From 54c4cd9a4a930deb7b382e0afcae2725cf9d19a4 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 9 Jul 2026 11:00:05 -0700 Subject: [PATCH] [planner] try bends=2 before bends=(2, 4) --- masque/builder/planner/planner.py | 42 +++++++++++---- masque/test/test_pather_trace_into.py | 77 +++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 2b68c45..ce9a62d 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -50,6 +50,7 @@ from .interface import ( PreparedRouteResult, RoutePlanningError, RoutePortContext, + route_error_is_fatal, ) @@ -908,6 +909,12 @@ class RoutingPlanner: TRACE_INTO_MAX_BENDS: int = 4 + def trace_into_bend_budgets(self, family: PrimitiveKind) -> tuple[int, ...]: + """Return staged trace_into bend budgets for the route rotation parity.""" + if family == 'bend': + return tuple(budget for budget in (1, 3) if budget <= self.TRACE_INTO_MAX_BENDS) + return tuple(budget for budget in (2, 4) if budget <= self.TRACE_INTO_MAX_BENDS) + def plan_leg( self, family: PrimitiveKind, @@ -1197,17 +1204,30 @@ class RoutingPlanner: desired.rotation = port_dst.rotation - pi desired.ptype = out_ptype family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) - leg = self.plan_leg( - family, - context_src, - length=length, - jog=jog, - ccw=ccw, - plug_into=portspec_dst if plug_destination else None, - constrain_jog=family == 'bend', - max_bends=self.TRACE_INTO_MAX_BENDS, - **(dict(kwargs) | {'out_ptype': out_ptype}), - ) + leg = None + last_error: Exception | None = None + for max_bends in self.trace_into_bend_budgets(family): + try: + leg = self.plan_leg( + family, + context_src, + length=length, + jog=jog, + ccw=ccw, + plug_into=portspec_dst if plug_destination else None, + constrain_jog=family == 'bend', + max_bends=max_bends, + **(dict(kwargs) | {'out_ptype': out_ptype}), + ) + break + except (BuildError, NotImplementedError) as err: + if route_error_is_fatal(err): + raise + last_error = err + if leg is None: + if last_error is not None: + raise last_error + raise BuildError('No legal primitive offer for trace_into route') renames = ((thru, context_src.portspec),) if thru is not None else () return self.prepared_result_from_legs( (leg,), diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index 7c5a362..ac51992 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -6,6 +6,8 @@ from numpy import pi from numpy.testing import assert_equal from masque import Library, PathTool, Port, Pather +from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner +from masque.builder.planner.planner import Candidate, RouteLeg from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool from masque.error import BuildError, PortError @@ -260,3 +262,78 @@ def test_pather_trace_into_rejects_reserved_route_kwargs( assert p.pattern.ports['B'].rotation is not None assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation) assert len(p._paths['A']) == 0 + + +class TraceIntoBudgetPlanner(RoutingPlanner): + def __init__(self, successes: set[int], fatal_at: set[int] | None = None) -> None: + self.successes = successes + self.fatal_at = set() if fatal_at is None else fatal_at + self.attempts: list[int | None] = [] + + def plan_leg( + self, + family: Any, + context: RoutePortContext, + *, + length: float | None = None, + jog: float | None = None, + ccw: Any = None, + plug_into: str | None = None, + constrain_jog: bool = False, + max_bends: int | None = None, + **kwargs: Any, + ) -> RouteLeg: + _ = family, length, jog, ccw, constrain_jog, kwargs + self.attempts.append(max_bends) + if max_bends in self.fatal_at: + raise RoutePlanningError('fatal', fatal=True) + if max_bends not in self.successes: + raise BuildError('try next budget') + return RouteLeg( + portspec=context.portspec, + start_port=context.port.copy(), + tool=context.tool, + candidate=Candidate((), context.port.copy(), 0.0, 0, 0.0), + plug_into=plug_into, + ) + + def prepared_result_from_legs( + self, + legs: Any, + *, + renames: tuple[tuple[str, str], ...] = (), + ) -> PreparedRouteResult: + _ = legs, renames + return PreparedRouteResult(()) + + +@pytest.mark.parametrize( + ('dst', 'successes', 'attempts'), + [ + (Port((-10, 0), rotation=pi, ptype='wire'), {2}, [2]), + (Port((-10, 0), rotation=pi, ptype='wire'), {4}, [2, 4]), + (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {1}, [1]), + (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {3}, [1, 3]), + ], +) +def test_trace_into_uses_staged_bend_budgets( + dst: Port, + successes: set[int], + attempts: list[int], + ) -> None: + planner = TraceIntoBudgetPlanner(successes) + context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) + + planner.plan_trace_into(context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None) + + assert planner.attempts == attempts + + +def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None: + planner = TraceIntoBudgetPlanner({4}, fatal_at={2}) + context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) + + with pytest.raises(RoutePlanningError, match='fatal'): + planner.plan_trace_into(context, 'dst', Port((-10, 0), rotation=pi, ptype='wire'), out_ptype=None, plug_destination=True, thru=None) + + assert planner.attempts == [2]