[Pather/Planner] Doc cleanup and more informative error enums

This commit is contained in:
Jan Petykiewicz 2026-07-12 17:25:13 -07:00
commit 49713896c3
13 changed files with 370 additions and 95 deletions

View file

@ -3,7 +3,7 @@ import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import Pather
from ..builder import MinimumStatus, Pather, RouteFailureDetails
from ..builder.utils import ell
from ..error import BuildError
from ..library import Library
@ -16,15 +16,48 @@ def test_builder_public_imports() -> None:
from masque import RenderStep as TopRenderStep
from masque import RouteError as TopRouteError
from masque import RouteFailureDetails as TopRouteFailureDetails
from masque import RouteFailurePolicy as TopRouteFailurePolicy
from masque import MinimumStatus as TopMinimumStatus
from masque.builder import PortPather as BuilderPortPather
from masque.builder import RenderStep as BuilderRenderStep
from masque.builder import RouteError as BuilderRouteError
from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails
from masque.builder import RouteFailurePolicy as BuilderRouteFailurePolicy
from masque.builder import MinimumStatus as BuilderMinimumStatus
assert TopPortPather is BuilderPortPather
assert TopRenderStep is BuilderRenderStep
assert TopRouteError is BuilderRouteError
assert TopRouteFailureDetails is BuilderRouteFailureDetails
assert TopRouteFailurePolicy is BuilderRouteFailurePolicy
assert TopMinimumStatus is BuilderMinimumStatus
def test_route_failure_details_enforces_minimum_status_invariants() -> None:
common = {
'operation': 'trace',
'portspec': 'A',
'in_ptype': 'wire',
'out_ptype': 'wide',
'request': {},
'resolved_length': 1,
'resolved_jog': None,
'cause': 'no route',
}
with pytest.raises(BuildError, match='FOUND requires minimum_length'):
RouteFailureDetails(
**common,
minimum_length=None,
minimum_status=MinimumStatus.FOUND,
)
with pytest.raises(BuildError, match='requires minimum_length=None'):
RouteFailureDetails(
**common,
minimum_length=2,
minimum_status=MinimumStatus.NO_ROUTE,
)
def test_builder_init() -> None:

View file

@ -5,8 +5,9 @@ import pytest
import numpy
from numpy import pi
from masque import Pather, Library, Port, RouteError
from masque import MinimumStatus, Pather, Library, Port, RouteError, RouteFailurePolicy
from masque.builder.planner import RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import NoLegalRouteError
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
from masque.error import BuildError
from masque.library import ILibrary
@ -216,8 +217,8 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None:
assert details.resolved_jog is None
# The length-2 route exists, but normal cost ranking prefers length 5.
assert details.minimum_length == 5
assert details.minimum_attempted
assert not details.minimum_exhausted
assert details.minimum_status is MinimumStatus.FOUND
assert exc_info.value.policy is RouteFailurePolicy.RECOVERABLE
assert details.minimum_cause is None
assert 'preferred_minimum_length: 5' in str(exc_info.value)
assert tool.commit_calls == 0
@ -260,13 +261,53 @@ def test_route_error_reports_no_route_at_any_length() -> None:
p.ccw('A', 10, out_ptype='optical')
details = exc_info.value.details
assert details.minimum_attempted
assert details.minimum_status is MinimumStatus.NO_ROUTE
assert details.minimum_length is None
assert details.minimum_exhausted
assert details.minimum_cause is not None
assert 'no legal route exists at any length' in str(exc_info.value)
def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None:
planner = RoutingPlanner()
context = RoutePortContext(
'A',
Port((0, 0), rotation=0, ptype='wire'),
PlanningOnlyTool(),
)
solve_count = 0
def solver_for_request(_request: Any) -> Any:
nonlocal solve_count
solve_count += 1
current_solve = solve_count
class FailingSolver:
def solve(self) -> Never:
if current_solve == 1:
raise NoLegalRouteError('requested length has no route')
raise RuntimeError('minimum diagnosis failed')
return FailingSolver()
monkeypatch.setattr(planner, 'solver_for_request', solver_for_request)
with pytest.raises(RouteError) as exc_info:
planner.plan_leg(
'bend',
context,
'trace_to',
{'ccw': True, 'length': 1},
length=1,
ccw=True,
)
details = exc_info.value.details
assert details.minimum_status is MinimumStatus.FAILED
assert details.minimum_length is None
assert details.minimum_cause == 'minimum diagnosis failed'
assert 'minimum-length calculation failed' in str(exc_info.value)
@pytest.mark.parametrize('length', [-1, numpy.nan, numpy.inf])
def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: float) -> None:
tool = RequestCountingTool()
@ -281,7 +322,8 @@ def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length:
p.ccw('A', length)
details = exc_info.value.details
assert not details.minimum_attempted
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert exc_info.value.policy is RouteFailurePolicy.FATAL
assert details.minimum_length is None
assert tool.offer_calls == 0
assert numpy.allclose(p.ports['A'].offset, (0, 0))
@ -302,7 +344,7 @@ def test_negative_position_length_reports_bound_without_offer_query() -> None:
details = exc_info.value.details
assert details.request == {'ccw': True, 'x': 1}
assert details.resolved_length == -1
assert not details.minimum_attempted
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0
@ -325,7 +367,7 @@ def test_negative_bundle_length_reports_failing_port_and_bound() -> None:
assert details.portspec == 'A'
assert details.request == {'ccw': True, 'emax': 0, 'spacing': 2}
assert details.resolved_length == -2
assert not details.minimum_attempted
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0
@ -348,7 +390,7 @@ def test_negative_each_length_reports_failing_port_without_offer_query() -> None
assert details.portspec == 'A'
assert details.request == {'ccw': None, 'each': -2}
assert details.resolved_length == -2
assert not details.minimum_attempted
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0
@ -887,7 +929,7 @@ def test_pather_uturn_failed_two_bend_route_is_atomic() -> None:
with pytest.raises(RouteError, match='U-turn') as exc_info:
p.uturn('A', 1.5, length=0)
assert exc_info.value.details.minimum_attempted
assert exc_info.value.details.minimum_status is MinimumStatus.NO_ROUTE
assert exc_info.value.details.minimum_length is None
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))

View file

@ -93,6 +93,30 @@ def test_pather_accepts_and_reuses_planner_instance() -> None:
assert planner.trace_to_calls == 2
def test_port_tool_policy_and_portpather_selection_follow_names() -> None:
default_tool = PathTool(layer=(1, 0), width=1, ptype='wire')
named_tool = PathTool(layer=(2, 0), width=1, ptype='wire')
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools={None: default_tool, 'A': named_tool},
render='deferred',
)
selected = p.at('A')
p.rename_ports({'A': 'B'})
assert selected.ports == ['A']
assert p.tools['A'] is named_tool
assert 'B' not in p.tools
p.straight('B', 1)
assert p._paths['B'][0].tool is default_tool
p.mkport('A', Port((10, 0), rotation=0, ptype='wire'))
p.straight('A', 1)
assert p._paths['A'][0].tool is named_tool
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.straight("start", 10)

View file

@ -101,6 +101,60 @@ def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, L
assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 0)]) == 1
def test_deferred_render_batches_tools_by_identity() -> None:
class CountingTool(PathTool):
def __init__(self, *args, **kwargs) -> None: # noqa: ANN002,ANN003
super().__init__(*args, **kwargs)
self.render_calls = 0
def render(self, *args, **kwargs): # noqa: ANN002,ANN003,ANN202
self.render_calls += 1
return super().render(*args, **kwargs)
lib = Library()
tool1 = CountingTool(layer=(1, 0), width=2, ptype='wire')
tool2 = CountingTool(layer=(1, 0), width=2, ptype='wire')
assert tool1 == tool2
assert tool1 is not tool2
p = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool1,
render='deferred',
)
p.straight('A', 5)
p.retool(tool2, 'A')
p.straight('A', 5)
p.render()
assert tool1.render_calls == 1
assert tool2.render_calls == 1
assert len(p.pattern.shapes[(1, 0)]) == 2
def test_deleted_name_reuse_does_not_retarget_pending_render_steps() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
p = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
p.straight('A', 5)
original_step = p._paths['A'][0]
p.rename_ports({'A': None})
p.mkport('A', Port((100, 0), rotation=0, ptype='wire'))
p.straight('A', 5)
assert len(p._paths['A']) == 2
assert_allclose(original_step.start_port.offset, (0, 0))
assert_allclose(original_step.end_port.offset, (-5, 0))
assert_allclose(p._paths['A'][1].start_port.offset, (100, 0))
def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
pp = rp.at("start")

View file

@ -5,9 +5,9 @@ import pytest
from numpy import pi
from numpy.testing import assert_equal
from masque import Library, PathTool, Port, Pather
from masque import Library, PathTool, Port, Pather, RouteFailurePolicy
from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import Candidate, RouteRequest
from masque.builder.planner.planner import Candidate, SolverRequest
from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
from masque.error import BuildError, PortError
@ -280,7 +280,7 @@ class TraceIntoBudgetSolver:
band = (min_bends, max_bends)
self.attempts.append(band)
if band in self.fatal_at:
raise RoutePlanningError('fatal', fatal=True)
raise RoutePlanningError('fatal', policy=RouteFailurePolicy.FATAL)
if band not in self.successes:
raise BuildError('try next budget')
return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0)
@ -291,7 +291,7 @@ class TraceIntoBudgetPlanner(RoutingPlanner):
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
self.solver_requests = 0
def solver_for_request(self, request: RouteRequest) -> Any:
def solver_for_request(self, request: SolverRequest) -> Any:
_ = request
self.solver_requests += 1
return self.solver