[Pather/Planner] Doc cleanup and more informative error enums
This commit is contained in:
parent
3a3cd854a5
commit
49713896c3
13 changed files with 370 additions and 95 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue