[Pather] add strategy arg

This commit is contained in:
Jan Petykiewicz 2026-07-10 14:20:04 -07:00
commit cf3f72b828
5 changed files with 188 additions and 29 deletions

View file

@ -58,6 +58,7 @@ from .planner.interface import (
route_error_is_fatal, route_error_is_fatal,
) )
from .planner import ( from .planner import (
RouteTieBreakStrategy,
RoutingPlanner, RoutingPlanner,
) )
from .planner.bounds import resolved_position_bound from .planner.bounds import resolved_position_bound
@ -517,6 +518,7 @@ class Pather(PortList):
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> Self: ) -> Self:
""" """
@ -530,16 +532,18 @@ class Pather(PortList):
For a single port with no length or bound, legal primitive-offer For a single port with no length or bound, legal primitive-offer
candidates are evaluated at their minimum legal length-like parameters, candidates are evaluated at their minimum legal length-like parameters,
then cost selects among those minimum-length candidates. `out_ptype`, then cost selects among those minimum-length candidates. `out_ptype`,
when provided, constrains only the final route endpoint. when provided, constrains only the final route endpoint. `strategy`
controls straight-first vs turn-first ordering only after cost and
structural tie-breakers.
`spacing` and `set_rotation` are only valid when using a bundle bound. `spacing` and `set_rotation` are only valid when using a bundle bound.
""" """
with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, **bounds): with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, strategy=strategy, **bounds):
if isinstance(portspec, str): if isinstance(portspec, str):
portspec = [portspec] portspec = [portspec]
contexts = self._route_contexts(portspec) contexts = self._route_contexts(portspec)
try: try:
result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, **bounds) result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if not self._dead or route_error_is_fatal(err): if not self._dead or route_error_is_fatal(err):
raise raise
@ -576,6 +580,7 @@ class Pather(PortList):
ccw: SupportsBool | None, ccw: SupportsBool | None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> Self: ) -> Self:
""" """
@ -587,13 +592,15 @@ class Pather(PortList):
With no positional or bundle bound, single-port `trace_to()` uses the With no positional or bundle bound, single-port `trace_to()` uses the
same omitted minimum-length primitive-offer behavior as `trace()`. same omitted minimum-length primitive-offer behavior as `trace()`.
`strategy` controls straight-first vs turn-first ordering only after
cost and structural tie-breakers.
""" """
with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds): with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, strategy=strategy, **bounds):
if isinstance(portspec, str): if isinstance(portspec, str):
portspec = [portspec] portspec = [portspec]
contexts = self._route_contexts(portspec) contexts = self._route_contexts(portspec)
try: try:
result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, **bounds) result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): if not self._dead or len(contexts) != 1 or route_error_is_fatal(err):
raise raise
@ -640,6 +647,7 @@ class Pather(PortList):
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> Self: ) -> Self:
""" """
@ -655,14 +663,16 @@ class Pather(PortList):
Multi-port jogs require `spacing`; the innermost first-bend port uses Multi-port jogs require `spacing`; the innermost first-bend port uses
the base `length` or omitted-length solve, and other ports derive exact the base `length` or omitted-length solve, and other ports derive exact
route lengths and offsets from that base route. `out_ptype`, when route lengths and offsets from that base route. `out_ptype`, when
provided, constrains only each final route endpoint. provided, constrains only each final route endpoint. `strategy`
controls straight-first vs S-first ordering only after cost and
structural tie-breakers.
""" """
with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, spacing=spacing, **bounds): with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, spacing=spacing, strategy=strategy, **bounds):
if isinstance(portspec, str): if isinstance(portspec, str):
portspec = [portspec] portspec = [portspec]
contexts = self._route_contexts(portspec) contexts = self._route_contexts(portspec)
try: try:
result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, **bounds) result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): if not self._dead or len(contexts) != 1 or route_error_is_fatal(err):
raise raise
@ -701,6 +711,7 @@ class Pather(PortList):
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> Self: ) -> Self:
""" """
@ -714,14 +725,16 @@ class Pather(PortList):
other ports derive exact lengths and offsets from it. Use `length=0` to other ports derive exact lengths and offsets from it. Use `length=0` to
request the old zero-public-length U-turn shape. Positional and request the old zero-public-length U-turn shape. Positional and
bundle-bound keywords are not supported for this operation. `out_ptype`, bundle-bound keywords are not supported for this operation. `out_ptype`,
when provided, constrains only each final route endpoint. when provided, constrains only each final route endpoint. `strategy`
controls straight-first vs U-first ordering only after cost and
structural tie-breakers.
""" """
with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, **bounds): with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, strategy=strategy, **bounds):
if isinstance(portspec, str): if isinstance(portspec, str):
portspec = [portspec] portspec = [portspec]
contexts = self._route_contexts(portspec) contexts = self._route_contexts(portspec)
try: try:
result = self.planner.plan_uturn_route(contexts, offset, length, spacing=spacing, **bounds) result = self.planner.plan_uturn_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if ( if (
not self._dead not self._dead
@ -752,6 +765,7 @@ class Pather(PortList):
out_ptype: str | None = None, out_ptype: str | None = None,
plug_destination: bool = True, plug_destination: bool = True,
thru: str | None = None, thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Self: ) -> Self:
""" """
@ -760,9 +774,10 @@ class Pather(PortList):
The current baseline searches bounded primitive-offer routes with up to The current baseline searches bounded primitive-offer routes with up to
four bend roles, including straight, single-bend, S-like, U-like, and four bend roles, including straight, single-bend, S-like, U-like, and
dogleg topologies. The lowest-cost legal bounded candidate is selected; dogleg topologies. The lowest-cost legal bounded candidate is selected;
bend count, step count, and search order are tie-breakers only. bend count, step count, and search order are tie-breakers only. `strategy`
controls straight-first vs turn-first search order within those ties.
Route-shape kwargs such as `length`, `offset`, `ccw`, positional bounds, Route-shape kwargs such as `length`, `offset`, `ccw`, positional bounds,
and bundle bounds are reserved for this internal solve; other tool bundle bounds, and `strategy` are reserved for this internal solve; other tool
kwargs are forwarded to primitive offer generation. kwargs are forwarded to primitive offer generation.
If `plug_destination` is `True`, the destination port is consumed by the final step. If `plug_destination` is `True`, the destination port is consumed by the final step.
@ -779,6 +794,7 @@ class Pather(PortList):
out_ptype=out_ptype, out_ptype=out_ptype,
plug_destination=plug_destination, plug_destination=plug_destination,
thru=thru, thru=thru,
strategy=strategy,
**kwargs, **kwargs,
): ):
result = self.planner.plan_trace_into( result = self.planner.plan_trace_into(
@ -788,6 +804,7 @@ class Pather(PortList):
out_ptype = out_ptype, out_ptype = out_ptype,
plug_destination = plug_destination, plug_destination = plug_destination,
thru = thru, thru = thru,
strategy = strategy,
**kwargs, **kwargs,
) )
self._apply_route_result(result) self._apply_route_result(result)

View file

@ -12,4 +12,5 @@ from .interface import (
RoutePortContext as RoutePortContext, RoutePortContext as RoutePortContext,
route_error_is_fatal as route_error_is_fatal, route_error_is_fatal as route_error_is_fatal,
) )
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
from .planner import RoutingPlanner as RoutingPlanner from .planner import RoutingPlanner as RoutingPlanner

View file

@ -53,6 +53,15 @@ from .interface import (
route_error_is_fatal, route_error_is_fatal,
) )
RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStrategy:
"""Return a supported route tie-break strategy or raise a routing error."""
if strategy in ('straight_first', 'turn_first'):
return strategy
raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first')
def is_close(a: float, b: float) -> bool: def is_close(a: float, b: float) -> bool:
"""Compare route-solver scalars with the planner tolerance.""" """Compare route-solver scalars with the planner tolerance."""
@ -245,6 +254,8 @@ class RouteRequest:
"""Whether bend-family trace_into routes must also match `jog`.""" """Whether bend-family trace_into routes must also match `jog`."""
max_bends: int | None = None max_bends: int | None = None
"""Optional override for grammar bend budget.""" """Optional override for grammar bend budget."""
strategy: RouteTieBreakStrategy = 'straight_first'
"""Discovery-order tie-break strategy for straight-vs-turn placement."""
@property @property
def route_name(self) -> str: def route_name(self) -> str:
@ -715,7 +726,10 @@ class Solver:
residual_jog: float, residual_jog: float,
) -> Iterable[tuple[SelectedPrimitive, ...]]: ) -> Iterable[tuple[SelectedPrimitive, ...]]:
"""Recursively enumerate normal/adapter/turn blocks within the bend budget.""" """Recursively enumerate normal/adapter/turn blocks within the bend budget."""
for normal in self.straight_options(steps): straight_options = self.straight_options(steps)
if self.request.strategy == 'straight_first':
straight_options = (*straight_options[1:], straight_options[0])
for normal in straight_options:
after_normal = (*steps, *normal) after_normal = (*steps, *normal)
suffix_options = ( suffix_options = (
self.adapter_options(after_normal, residual_jog=0) self.adapter_options(after_normal, residual_jog=0)
@ -949,6 +963,16 @@ class RoutingPlanner:
""" """
TRACE_INTO_MAX_BENDS: int = 4 TRACE_INTO_MAX_BENDS: int = 4
DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first'
def __init__(self, strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY) -> None:
self.strategy = validate_strategy(strategy)
def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy:
"""Return the per-route strategy or the planner default."""
if strategy is None:
return getattr(self, 'strategy', self.DEFAULT_STRATEGY)
return validate_strategy(strategy)
def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]: def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]:
"""Return non-overlapping trace_into bend-budget bands for staged solving.""" """Return non-overlapping trace_into bend-budget bands for staged solving."""
@ -972,6 +996,7 @@ class RoutingPlanner:
ccw: SupportsBool | None = None, ccw: SupportsBool | None = None,
constrain_jog: bool = False, constrain_jog: bool = False,
max_bends: int | None = None, max_bends: int | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> RouteRequest: ) -> RouteRequest:
"""Build normalized solver input for one route leg.""" """Build normalized solver input for one route leg."""
@ -986,6 +1011,7 @@ class RoutingPlanner:
out_ptype=kwargs.get('out_ptype'), out_ptype=kwargs.get('out_ptype'),
constrain_jog=constrain_jog, constrain_jog=constrain_jog,
max_bends=max_bends, max_bends=max_bends,
strategy=self.resolve_strategy(strategy),
) )
def solver_for_request(self, request: RouteRequest) -> Solver: def solver_for_request(self, request: RouteRequest) -> Solver:
@ -1019,6 +1045,7 @@ class RoutingPlanner:
plug_into: str | None = None, plug_into: str | None = None,
constrain_jog: bool = False, constrain_jog: bool = False,
max_bends: int | None = None, max_bends: int | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> RouteLeg: ) -> RouteLeg:
"""Solve one route leg and attach it to its source Pather context.""" """Solve one route leg and attach it to its source Pather context."""
@ -1030,6 +1057,7 @@ class RoutingPlanner:
ccw=ccw, ccw=ccw,
constrain_jog=constrain_jog, constrain_jog=constrain_jog,
max_bends=max_bends, max_bends=max_bends,
strategy=strategy,
**kwargs, **kwargs,
) )
try: try:
@ -1096,6 +1124,7 @@ class RoutingPlanner:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> PreparedRouteResult: ) -> PreparedRouteResult:
"""Plan straight or single-bend traces, including `each` and bundle-bound modes.""" """Plan straight or single-bend traces, including `each` and bundle-bound modes."""
@ -1104,21 +1133,21 @@ class RoutingPlanner:
planner_bounds.validate_trace_args(portspec, length=length, spacing=spacing, bounds=route_bounds) planner_bounds.validate_trace_args(portspec, length=length, spacing=spacing, bounds=route_bounds)
family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend'
if length is not None: if length is not None:
leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, **route_bounds) leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, strategy=strategy, **route_bounds)
return self.prepared_result_from_legs((leg,)) return self.prepared_result_from_legs((leg,))
if route_bounds.get('each') is not None: if route_bounds.get('each') is not None:
each = route_bounds.pop('each') each = route_bounds.pop('each')
return PreparedRouteResult(tuple( return PreparedRouteResult(tuple(
self.prepared_route_action_from_leg( self.prepared_route_action_from_leg(
self.plan_leg(family, context, length=each, ccw=ccw, **route_bounds), self.plan_leg(family, context, length=each, ccw=ccw, strategy=strategy, **route_bounds),
) )
for context in contexts for context in contexts
)) ))
bundle_bounds = planner_bounds.present_bundle_bounds(route_bounds) bundle_bounds = planner_bounds.present_bundle_bounds(route_bounds)
if not bundle_bounds: if not bundle_bounds:
leg = self.plan_leg(family, contexts[0], length=None, ccw=ccw, **route_bounds) leg = self.plan_leg(family, contexts[0], length=None, ccw=ccw, strategy=strategy, **route_bounds)
return self.prepared_result_from_legs((leg,)) return self.prepared_result_from_legs((leg,))
bound_type = bundle_bounds[0] bound_type = bundle_bounds[0]
@ -1135,7 +1164,7 @@ class RoutingPlanner:
actions = [] actions = []
for port_name, route_length in extensions.items(): for port_name, route_length in extensions.items():
context = next(context for context in contexts if context.portspec == port_name) context = next(context for context in contexts if context.portspec == port_name)
leg = self.plan_leg(family, context, length=route_length, ccw=ccw, **route_bounds) leg = self.plan_leg(family, context, length=route_length, ccw=ccw, strategy=strategy, **route_bounds)
actions.append(self.prepared_route_action_from_leg(leg)) actions.append(self.prepared_route_action_from_leg(leg))
return PreparedRouteResult(tuple(actions)) return PreparedRouteResult(tuple(actions))
@ -1145,6 +1174,7 @@ class RoutingPlanner:
ccw: SupportsBool | None, ccw: SupportsBool | None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> PreparedRouteResult: ) -> PreparedRouteResult:
"""Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes.""" """Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes."""
@ -1156,7 +1186,7 @@ class RoutingPlanner:
if any(route_bounds.get(key) is not None for key in planner_bounds.POSITION_KEYS): if any(route_bounds.get(key) is not None for key in planner_bounds.POSITION_KEYS):
raise BuildError('Position bounds only allowed with a single port') raise BuildError('Position bounds only allowed with a single port')
if resolved is None: if resolved is None:
return self.plan_trace_route(contexts, ccw, spacing=spacing, **route_bounds) return self.plan_trace_route(contexts, ccw, spacing=spacing, strategy=strategy, **route_bounds)
planner_bounds.validate_trace_to_positional_args(spacing=spacing, bounds=route_bounds) planner_bounds.validate_trace_to_positional_args(spacing=spacing, bounds=route_bounds)
_key, _value, length = resolved _key, _value, length = resolved
@ -1166,7 +1196,7 @@ class RoutingPlanner:
if key not in planner_bounds.POSITION_KEYS and key != 'length' if key not in planner_bounds.POSITION_KEYS and key != 'length'
} }
family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend'
leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, **other_bounds) leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, strategy=strategy, **other_bounds)
return self.prepared_result_from_legs((leg,)) return self.prepared_result_from_legs((leg,))
def plan_jog_route( def plan_jog_route(
@ -1176,11 +1206,12 @@ class RoutingPlanner:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> PreparedRouteResult: ) -> PreparedRouteResult:
"""Plan S-bend routes for single ports or spaced bundles.""" """Plan S-bend routes for single ports or spaced bundles."""
if numpy.isclose(offset, 0): if numpy.isclose(offset, 0):
return self.plan_trace_to_route(contexts, None, length=length, spacing=spacing, **bounds) return self.plan_trace_to_route(contexts, None, length=length, spacing=spacing, strategy=strategy, **bounds)
route_bounds = dict(bounds) route_bounds = dict(bounds)
portspec = tuple(context.portspec for context in contexts) portspec = tuple(context.portspec for context in contexts)
planner_bounds.validate_jog_args(portspec, length=length, spacing=spacing, bounds=route_bounds) planner_bounds.validate_jog_args(portspec, length=length, spacing=spacing, bounds=route_bounds)
@ -1193,9 +1224,9 @@ class RoutingPlanner:
if len(contexts) > 1: if len(contexts) > 1:
return PreparedRouteResult(tuple( return PreparedRouteResult(tuple(
self.prepared_route_action_from_leg(leg) self.prepared_route_action_from_leg(leg)
for leg in self.plan_su_bundle_routes('s', contexts, offset, length, spacing, **other_bounds) for leg in self.plan_su_bundle_routes('s', contexts, offset, length, spacing, strategy=strategy, **other_bounds)
)) ))
leg = self.plan_leg('s', contexts[0], length=length, jog=offset, **other_bounds) leg = self.plan_leg('s', contexts[0], length=length, jog=offset, strategy=strategy, **other_bounds)
return self.prepared_result_from_legs((leg,)) return self.prepared_result_from_legs((leg,))
def plan_uturn_route( def plan_uturn_route(
@ -1205,6 +1236,7 @@ class RoutingPlanner:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any, **bounds: Any,
) -> PreparedRouteResult: ) -> PreparedRouteResult:
"""Plan U-turn routes for single ports or spaced bundles.""" """Plan U-turn routes for single ports or spaced bundles."""
@ -1214,9 +1246,9 @@ class RoutingPlanner:
if len(contexts) > 1: if len(contexts) > 1:
return PreparedRouteResult(tuple( return PreparedRouteResult(tuple(
self.prepared_route_action_from_leg(leg) self.prepared_route_action_from_leg(leg)
for leg in self.plan_su_bundle_routes('u', contexts, offset, length, spacing, **route_bounds) for leg in self.plan_su_bundle_routes('u', contexts, offset, length, spacing, strategy=strategy, **route_bounds)
)) ))
leg = self.plan_leg('u', contexts[0], length=length, jog=offset, **route_bounds) leg = self.plan_leg('u', contexts[0], length=length, jog=offset, strategy=strategy, **route_bounds)
return self.prepared_result_from_legs((leg,)) return self.prepared_result_from_legs((leg,))
def plan_su_bundle_routes( def plan_su_bundle_routes(
@ -1226,6 +1258,8 @@ class RoutingPlanner:
offset: float, offset: float,
length: float | None, length: float | None,
spacing: float | ArrayLike | None, spacing: float | ArrayLike | None,
*,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[RouteLeg, ...]: ) -> tuple[RouteLeg, ...]:
""" """
@ -1236,27 +1270,34 @@ class RoutingPlanner:
and offset so all legs can be planned independently. and offset so all legs can be planned independently.
""" """
if len(contexts) == 1: if len(contexts) == 1:
return (self.plan_leg(kind, contexts[0], length=length, jog=offset, **kwargs),) return (self.plan_leg(kind, contexts[0], length=length, jog=offset, strategy=strategy, **kwargs),)
route_name = 'jog' if kind == 's' else 'uturn' route_name = 'jog' if kind == 's' else 'uturn'
if kind == 'u' and is_close(offset, 0): if kind == 'u' and is_close(offset, 0):
raise BuildError('multi-port uturn() requires nonzero offset to determine bundle ordering') raise BuildError('multi-port uturn() requires nonzero offset to determine bundle ordering')
contexts_by_name = {context.portspec: context for context in contexts} contexts_by_name = {context.portspec: context for context in contexts}
initial_specs = planner_bounds.su_bundle_specs(contexts, offset, 0, spacing, route_name=route_name) initial_specs = planner_bounds.su_bundle_specs(contexts, offset, 0, spacing, route_name=route_name)
anchor_portspec, _anchor_length, _anchor_offset = initial_specs[0] anchor_portspec, _anchor_length, _anchor_offset = initial_specs[0]
anchor = self.plan_leg(kind, contexts_by_name[anchor_portspec], length=length, jog=offset, **kwargs) anchor = self.plan_leg(kind, contexts_by_name[anchor_portspec], length=length, jog=offset, strategy=strategy, **kwargs)
base_length = anchor.candidate.public_length base_length = anchor.candidate.public_length
specs = planner_bounds.su_bundle_specs(contexts, offset, base_length, spacing, route_name=route_name) specs = planner_bounds.su_bundle_specs(contexts, offset, base_length, spacing, route_name=route_name)
first_portspec, _first_length, _first_offset = specs[0] first_portspec, _first_length, _first_offset = specs[0]
routes_by_name = {first_portspec: anchor} routes_by_name = {first_portspec: anchor}
for spec_portspec, spec_length, spec_offset in specs[1:]: for spec_portspec, spec_length, spec_offset in specs[1:]:
if kind == 's' and is_close(spec_offset, 0): if kind == 's' and is_close(spec_offset, 0):
routes_by_name[spec_portspec] = self.plan_leg('straight', contexts_by_name[spec_portspec], length=spec_length, **kwargs) routes_by_name[spec_portspec] = self.plan_leg(
'straight',
contexts_by_name[spec_portspec],
length=spec_length,
strategy=strategy,
**kwargs,
)
else: else:
routes_by_name[spec_portspec] = self.plan_leg( routes_by_name[spec_portspec] = self.plan_leg(
kind, kind,
contexts_by_name[spec_portspec], contexts_by_name[spec_portspec],
length=spec_length, length=spec_length,
jog=spec_offset, jog=spec_offset,
strategy=strategy,
**kwargs, **kwargs,
) )
return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs) return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs)
@ -1270,6 +1311,7 @@ class RoutingPlanner:
out_ptype: str | None, out_ptype: str | None,
plug_destination: bool, plug_destination: bool,
thru: str | None, thru: str | None,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> PreparedRouteResult: ) -> PreparedRouteResult:
"""Plan a bounded route from one source port into a destination port.""" """Plan a bounded route from one source port into a destination port."""
@ -1297,6 +1339,7 @@ class RoutingPlanner:
ccw=ccw, ccw=ccw,
constrain_jog=family == 'bend', constrain_jog=family == 'bend',
max_bends=self.TRACE_INTO_MAX_BENDS, max_bends=self.TRACE_INTO_MAX_BENDS,
strategy=strategy,
**(dict(kwargs) | {'out_ptype': out_ptype}), **(dict(kwargs) | {'out_ptype': out_ptype}),
) )
solver = self.solver_for_request(request) solver = self.solver_for_request(request)

View file

@ -6,6 +6,7 @@ import pytest
from numpy import pi from numpy import pi
from masque import Library, Path, Port, Pather from masque import Library, Path, Port, Pather
from masque.builder.planner import RoutingPlanner
from masque.builder.tools import ( from masque.builder.tools import (
BendOffer, BendOffer,
PathTool, PathTool,
@ -422,6 +423,103 @@ def test_pather_selects_lowest_cost_offer() -> None:
assert numpy.allclose(p.ports['A'].offset, (-7, 0)) assert numpy.allclose(p.ports['A'].offset, (-7, 0))
class StrategyTieTool(PlanningOnlyTool):
def __init__(self) -> None:
self.seen_kwargs: list[dict[str, Any]] = []
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
self.seen_kwargs.append(dict(kwargs))
endpoint_ptype = out_ptype or in_ptype
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length},
)),
),)
if kind == 's':
return (SOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((3, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog},
)),
),)
return ()
def pather_with_strategy_tool(
planner: RoutingPlanner | None = None,
) -> tuple[Pather, StrategyTieTool]:
tool = StrategyTieTool()
pather = Pather(Library(), tools=tool, planner=planner, render='deferred')
pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
return pather, tool
def test_pather_route_strategy_defaults_to_straight_first() -> None:
pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10)
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
assert pather._paths['A'][0].data['length'] == 7
def test_pather_route_strategy_uses_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10)
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
assert pather._paths['A'][1].data['length'] == 7
def test_pather_route_strategy_per_route_overrides_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10, strategy='straight_first')
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, strategy='turn_first')
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
def test_pather_route_strategy_is_not_forwarded_to_tool() -> None:
pather, tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, strategy='turn_first', marker='sentinel')
assert tool.seen_kwargs
assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs)
assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs)
def test_pather_route_strategy_rejects_invalid_values() -> None:
with pytest.raises(BuildError, match='Invalid route strategy'):
RoutingPlanner(strategy='sideways')
pather, _tool = pather_with_strategy_tool()
with pytest.raises(BuildError, match='Invalid route strategy'):
pather.jog('A', 4, length=10, strategy='sideways')
def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None: def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None:
invalid_parameters: list[float] = [] invalid_parameters: list[float] = []

View file

@ -76,7 +76,7 @@ def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_set
rp.render() rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -1], [1, -1], [3, -1], [4, -1], [4, -2], [4, -10]], atol=1e-10) assert_allclose(path_shape.vertices, [[0, 0], [0, -8], [0, -9], [1, -9], [3, -9], [4, -9], [4, -10]], atol=1e-10)
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: