Compare commits

..

4 commits

12 changed files with 692 additions and 127 deletions

View file

@ -349,7 +349,7 @@ class MyTool(Tool):
``` ```
Routing entry points now name every supported route argument explicitly. Routing entry points now name every supported route argument explicitly.
Custom per-route planning values must be placed under `tool_options`: Custom per-route Tool values must be placed under `tool_options`:
```python ```python
pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'}) pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'})
@ -450,15 +450,25 @@ Stable imports for custom tool authors live in `masque.builder`. The
`masque.builder.planner` module is an internal planner implementation; do not `masque.builder.planner` module is an internal planner implementation; do not
import it from user code. import it from user code.
`trace_into()` uses the same primitive-offer route selection and now searches `trace_into()` uses the same primitive-offer route selection and defaults to
bounded route topologies with up to four bend roles. This preserves the common the minimal main-route bend count required by the endpoint relationship: zero
straight, bend, S-like, U-like, and dogleg cases while allowing routes that for a straight, one for a quarter-turn, and two for S- and U-like connections.
need an additional bounded bend pair. Bend-family requests search one-bend Ptype adapters do not consume this bend budget. Set
routes before three-bend routes; other families search zero-to-two-bend routes `plan_options={'bend_policy': 'flexible'}` to search bounded route topologies
before four-bend routes. The first band with a legal route wins. Within that with up to four bend roles, including dogleg and loop-like fallbacks.
band, candidates are ordered by total primitive-offer cost, adapter count, step Bend-family requests then search one-bend routes before three-bend routes;
count, and deterministic discovery order. The route `strategy` affects only other families search zero-to-two-bend routes before four-bend routes. The
that final discovery-order tie-break. first band with a legal route wins. Within that band, candidates are ordered
by total primitive-offer cost, adapter count, step count, and deterministic
discovery order. The default planner's `strategy` option affects only that
final discovery-order tie-break and is also supplied through `plan_options`.
`plan_options` is reserved for planner-specific per-route policy, while
`tool_options` is forwarded only to `Tool.primitive_offers()`. For example:
```python
pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'})
```
Explicit-length `jog()` routes may also be satisfied by composing a straight Explicit-length `jog()` routes may also be satisfied by composing a straight
primitive before or after an omitted-length native S primitive. `uturn()` routes primitive before or after an omitted-length native S primitive. `uturn()` routes

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
from pprint import pformat from pprint import pformat
from types import MappingProxyType from types import MappingProxyType
import traceback
from ..error import BuildError from ..error import BuildError
@ -68,10 +69,11 @@ class RouteFailureDetails:
class RouteError(BuildError): class RouteError(BuildError):
"""A route-selection failure with structured request diagnostics.""" """A route-selection failure with structured request and saved call-stack diagnostics."""
details: RouteFailureDetails details: RouteFailureDetails
policy: RouteFailurePolicy policy: RouteFailurePolicy
_call_stack: tuple[traceback.FrameSummary, ...]
def __init__( def __init__(
self, self,
@ -103,4 +105,5 @@ class RouteError(BuildError):
] ]
if details.minimum_cause is not None: if details.minimum_cause is not None:
lines.append(f' minimum_failure: {details.minimum_cause}') lines.append(f' minimum_failure: {details.minimum_cause}')
self._call_stack = tuple(traceback.extract_stack()[:-1])
super().__init__('\n'.join(lines)) super().__init__('\n'.join(lines))

View file

@ -8,9 +8,11 @@ planner package is intentionally internal: custom route generators should
extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on
planner classes or search details. planner classes or search details.
Public routing arguments are explicit. Custom per-route planning values belong Public routing arguments are explicit. Planner-specific per-route settings
in `tool_options`; Pather forwards those values only to offer discovery. A Tool belong in `plan_options`, while custom Tool offer values belong in
must capture any selected render-time value in its offer's committed data. `tool_options`. Pather keeps those namespaces separate and forwards Tool
options only to offer discovery. A Tool must capture any selected render-time
value in its offer's committed data.
Routing is split into four ownership phases: Routing is split into four ownership phases:
- snapshot: `Pather` resolves the active Tool for each requested port and - snapshot: `Pather` resolves the active Tool for each requested port and
@ -77,11 +79,8 @@ from .planner.interface import (
RoutePortContext, RoutePortContext,
route_failure_policy, route_failure_policy,
) )
from .error import RouteFailurePolicy, ToolContractError from .error import RouteError, RouteFailurePolicy, ToolContractError
from .planner import ( from .planner import RoutingPlanner
RouteTieBreakStrategy,
RoutingPlanner,
)
from .planner.bounds import resolved_position_bound from .planner.bounds import resolved_position_bound
from .logging import PatherLogger from .logging import PatherLogger
from ._tolerances import angles_equal, array_close from ._tolerances import angles_equal, array_close
@ -128,6 +127,20 @@ def _validated_tool_options(tool_options: Mapping[str, Any] | None) -> dict[str,
return options return options
def _validated_plan_options(plan_options: Mapping[str, Any] | None) -> dict[str, Any]:
"""Copy generic per-route planner options without interpreting their keys."""
if plan_options is None:
return {}
try:
options = dict(plan_options)
except (TypeError, ValueError) as err:
raise BuildError('plan_options must be a mapping with string keys') from err
nonstring = [key for key in options if not isinstance(key, str)]
if nonstring:
raise BuildError(f'plan_options keys must be strings; got {nonstring!r}')
return options
class Pather(PortList): class Pather(PortList):
""" """
A `Pather` is a helper object used for snapping together multiple A `Pather` is a helper object used for snapping together multiple
@ -626,7 +639,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -652,9 +665,8 @@ 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. `strategy` when provided, constrains only the final route endpoint. Planner-specific
controls straight-first vs turn-first ordering only after cost and per-route settings belong in `plan_options`.
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.
""" """
@ -672,20 +684,23 @@ class Pather(PortList):
ymax=ymax, ymax=ymax,
min_past_furthest=min_past_furthest, min_past_furthest=min_past_furthest,
) )
options = _validated_tool_options(tool_options) plan_opts = _validated_plan_options(plan_options)
tool_opts = _validated_tool_options(tool_options)
with self._logger.log_operation( with self._logger.log_operation(
self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing,
strategy=strategy, tool_options=options, **bounds, plan_options=plan_opts, tool_options=tool_opts, **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( result = self.planner.plan_trace_route(
contexts, ccw, length, spacing=spacing, strategy=strategy, contexts, ccw, length, spacing=spacing, plan_options=plan_opts,
tool_options=options, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL:
raise raise
if length is not None and len(contexts) == 1: if length is not None and len(contexts) == 1:
@ -726,7 +741,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -755,8 +770,7 @@ 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 Planner-specific per-route settings belong in `plan_options`.
cost and structural tie-breakers.
""" """
bounds = _present_route_args( bounds = _present_route_args(
length=length, length=length,
@ -778,20 +792,23 @@ class Pather(PortList):
ymax=ymax, ymax=ymax,
min_past_furthest=min_past_furthest, min_past_furthest=min_past_furthest,
) )
options = _validated_tool_options(tool_options) plan_opts = _validated_plan_options(plan_options)
tool_opts = _validated_tool_options(tool_options)
with self._logger.log_operation( with self._logger.log_operation(
self, 'trace_to', portspec, ccw=ccw, spacing=spacing, self, 'trace_to', portspec, ccw=ccw, spacing=spacing,
strategy=strategy, tool_options=options, **bounds, plan_options=plan_opts, tool_options=tool_opts, **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( result = self.planner.plan_trace_to_route(
contexts, ccw, spacing=spacing, strategy=strategy, contexts, ccw, spacing=spacing, plan_options=plan_opts,
tool_options=options, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if ( if (
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
@ -829,7 +846,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -850,7 +867,7 @@ class Pather(PortList):
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.trace_to( return self.trace_to(
portspec, None, length=length, spacing=spacing, strategy=strategy, portspec, None, length=length, spacing=spacing, plan_options=plan_options,
out_ptype=out_ptype, each=each, set_rotation=set_rotation, out_ptype=out_ptype, each=each, set_rotation=set_rotation,
p=p, pos=pos, position=position, x=x, y=y, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, emin=emin, emax=emax, pmin=pmin, pmax=pmax,
@ -865,7 +882,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -886,7 +903,7 @@ class Pather(PortList):
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.trace_to( return self.trace_to(
portspec, ccw, length=length, spacing=spacing, strategy=strategy, portspec, ccw, length=length, spacing=spacing, plan_options=plan_options,
out_ptype=out_ptype, each=each, set_rotation=set_rotation, out_ptype=out_ptype, each=each, set_rotation=set_rotation,
p=p, pos=pos, position=position, x=x, y=y, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, emin=emin, emax=emax, pmin=pmin, pmax=pmax,
@ -900,7 +917,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -921,7 +938,7 @@ class Pather(PortList):
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.bend( return self.bend(
portspec, True, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, portspec, True, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -933,7 +950,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -954,7 +971,7 @@ class Pather(PortList):
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.bend( return self.bend(
portspec, False, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, portspec, False, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -967,7 +984,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
p: float | None = None, p: float | None = None,
pos: float | None = None, pos: float | None = None,
@ -989,25 +1006,27 @@ 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. `strategy` provided, constrains only each final route endpoint. Planner-specific
controls straight-first vs S-first ordering only after cost and per-route settings belong in `plan_options`.
structural tie-breakers.
""" """
bounds = _present_route_args(out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y) bounds = _present_route_args(out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y)
options = _validated_tool_options(tool_options) plan_opts = _validated_plan_options(plan_options)
tool_opts = _validated_tool_options(tool_options)
with self._logger.log_operation( with self._logger.log_operation(
self, 'jog', portspec, offset=offset, length=length, spacing=spacing, self, 'jog', portspec, offset=offset, length=length, spacing=spacing,
strategy=strategy, tool_options=options, **bounds, plan_options=plan_opts, tool_options=tool_opts, **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( result = self.planner.plan_jog_route(
contexts, offset, length, spacing=spacing, strategy=strategy, contexts, offset, length, spacing=spacing, plan_options=plan_opts,
tool_options=options, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if ( if (
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
@ -1051,7 +1070,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, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
@ -1066,25 +1085,27 @@ 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. `strategy` when provided, constrains only each final route endpoint. Planner-specific
controls straight-first vs U-first ordering only after cost and per-route settings belong in `plan_options`.
structural tie-breakers.
""" """
bounds = _present_route_args(out_ptype=out_ptype) bounds = _present_route_args(out_ptype=out_ptype)
options = _validated_tool_options(tool_options) plan_opts = _validated_plan_options(plan_options)
tool_opts = _validated_tool_options(tool_options)
with self._logger.log_operation( with self._logger.log_operation(
self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, self, 'uturn', portspec, offset=offset, length=length, spacing=spacing,
strategy=strategy, tool_options=options, **bounds, plan_options=plan_opts, tool_options=tool_opts, **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( result = self.planner.plan_uturn_route(
contexts, offset, length, spacing=spacing, strategy=strategy, contexts, offset, length, spacing=spacing, plan_options=plan_opts,
tool_options=options, **bounds, tool_options=tool_opts, **bounds,
) )
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if isinstance(err, RouteError):
err.__traceback__ = None
if ( if (
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
@ -1115,21 +1136,26 @@ 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, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
""" """
Route one port into another using a bounded primitive-offer selection. Route one port into another using a bounded primitive-offer selection.
The current baseline searches bounded primitive-offer routes with up to By default, searches only the exact main-route bend count required by
four bend roles, including straight, single-bend, S-like, U-like, and the endpoint relationship: zero for a straight, one for a quarter-turn,
dogleg topologies. Bend-family requests try one-bend routes before and two for S- and U-like connections. This rejects extra dogleg and
three-bend routes; other families try zero-to-two-bend routes before loop-like fallback routes without inspecting primitive geometry. Ptype
four-bend routes. The first band with a legal candidate wins. Within a adapters do not consume this bend budget.
band, candidates are ordered by total cost, adapter count, step count,
the requested straight-vs-turn topology preference, and deterministic Set `plan_options={'bend_policy': 'flexible'}` to search bounded
discovery order. `strategy` therefore affects only otherwise tied primitive-offer routes with up to four bend roles. Bend-family requests try one-bend routes
candidates. before three-bend routes; other families try zero-to-two-bend routes
before four-bend routes. The first band with a legal candidate wins.
Within a band, candidates are ordered by total cost, adapter count,
step count, the requested straight-vs-turn topology preference, and
deterministic discovery order. The default planner's `strategy` option
therefore affects only otherwise tied candidates.
Custom planning options may be supplied through `tool_options`; they Custom planning options may be supplied through `tool_options`; they
are forwarded only to primitive offer generation. are forwarded only to primitive offer generation.
@ -1140,7 +1166,8 @@ class Pather(PortList):
mutated; failures during selected-route execution, including primitive mutated; failures during selected-route execution, including primitive
commit, plug/thru application, or render, may leave partial output. commit, plug/thru application, or render, may leave partial output.
""" """
options = _validated_tool_options(tool_options) plan_opts = _validated_plan_options(plan_options)
tool_opts = _validated_tool_options(tool_options)
with self._logger.log_operation( with self._logger.log_operation(
self, self,
'trace_into', 'trace_into',
@ -1148,9 +1175,10 @@ 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, plan_options=plan_opts,
tool_options=options, tool_options=tool_opts,
): ):
try:
result = self.planner.plan_trace_into( result = self.planner.plan_trace_into(
self._route_context(portspec_src), self._route_context(portspec_src),
portspec_dst, portspec_dst,
@ -1158,9 +1186,12 @@ 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, plan_options = plan_opts,
tool_options = options, tool_options = tool_opts,
) )
except RouteError as err:
err.__traceback__ = None
raise
self._apply_route_result(result) self._apply_route_result(result)
return self return self
@ -1376,7 +1407,7 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -1394,7 +1425,7 @@ class PortPather:
if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None:
spacing = self.default_spacing spacing = self.default_spacing
self.pather.trace( self.pather.trace(
self.ports, ccw, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, self.ports, ccw, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, emin=emin, emax=emax, pmin=pmin, pmax=pmax, each=each, set_rotation=set_rotation, emin=emin, emax=emax, pmin=pmin, pmax=pmax,
xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest,
tool_options=tool_options, tool_options=tool_options,
@ -1407,7 +1438,7 @@ class PortPather:
*, *,
length: float | None = None, length: float | None = None,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -1430,7 +1461,7 @@ class PortPather:
if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None:
spacing = self.default_spacing spacing = self.default_spacing
self.pather.trace_to( self.pather.trace_to(
self.ports, ccw, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, self.ports, ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -1442,7 +1473,7 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -1463,7 +1494,7 @@ class PortPather:
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.trace_to( return self.trace_to(
None, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, None, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -1475,7 +1506,7 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -1496,7 +1527,7 @@ class PortPather:
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.trace_to( return self.trace_to(
ccw, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -1507,7 +1538,7 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -1528,7 +1559,7 @@ class PortPather:
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.bend( return self.bend(
True, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, True, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -1539,7 +1570,7 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
each: float | None = None, each: float | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
@ -1560,7 +1591,7 @@ class PortPather:
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
return self.bend( return self.bend(
False, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, False, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y,
emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
min_past_furthest=min_past_furthest, tool_options=tool_options, min_past_furthest=min_past_furthest, tool_options=tool_options,
@ -1572,7 +1603,7 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
p: float | None = None, p: float | None = None,
pos: float | None = None, pos: float | None = None,
@ -1584,7 +1615,7 @@ class PortPather:
if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0): if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0):
spacing = self.default_spacing spacing = self.default_spacing
self.pather.jog( self.pather.jog(
self.ports, offset, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, self.ports, offset, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype,
p=p, pos=pos, position=position, x=x, y=y, tool_options=tool_options, p=p, pos=pos, position=position, x=x, y=y, tool_options=tool_options,
) )
return self return self
@ -1595,14 +1626,14 @@ class PortPather:
length: float | None = None, length: float | None = None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
if spacing is None and self.default_spacing is not None and len(self.ports) > 1: if spacing is None and self.default_spacing is not None and len(self.ports) > 1:
spacing = self.default_spacing spacing = self.default_spacing
self.pather.uturn( self.pather.uturn(
self.ports, offset, length, spacing=spacing, strategy=strategy, self.ports, offset, length, spacing=spacing, plan_options=plan_options,
out_ptype=out_ptype, tool_options=tool_options, out_ptype=out_ptype, tool_options=tool_options,
) )
return self return self
@ -1614,13 +1645,13 @@ class PortPather:
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, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
port = self._single_port('trace_into') port = self._single_port('trace_into')
self.pather.trace_into( self.pather.trace_into(
port, target_port, out_ptype=out_ptype, plug_destination=plug_destination, port, target_port, out_ptype=out_ptype, plug_destination=plug_destination,
thru=thru, strategy=strategy, tool_options=tool_options, thru=thru, plan_options=plan_options, tool_options=tool_options,
) )
return self return self

View file

@ -13,4 +13,5 @@ from .interface import (
route_failure_policy as route_failure_policy, route_failure_policy as route_failure_policy,
) )
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
from .planner import TraceIntoBendPolicy as TraceIntoBendPolicy
from .planner import RoutingPlanner as RoutingPlanner from .planner import RoutingPlanner as RoutingPlanner

View file

@ -72,6 +72,7 @@ from .interface import (
) )
RouteTieBreakStrategy = Literal['straight_first', 'turn_first'] RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
TraceIntoBendPolicy = Literal['flexible', 'minimal']
COST_RTOL = 1e-10 COST_RTOL = 1e-10
COST_ATOL = 1e-8 COST_ATOL = 1e-8
@ -87,6 +88,17 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr
raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first') raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first')
def validate_trace_into_bend_policy(
bend_policy: TraceIntoBendPolicy | str,
) -> TraceIntoBendPolicy:
"""Return a supported trace-into bend policy or raise a routing error."""
if bend_policy in ('flexible', 'minimal'):
return bend_policy
raise BuildError(
f'Invalid trace_into bend policy {bend_policy!r}; expected flexible or minimal'
)
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."""
return scalar_close(a, b) return scalar_close(a, b)
@ -1057,9 +1069,16 @@ class RoutingPlanner:
TRACE_INTO_MAX_BENDS: int = 4 TRACE_INTO_MAX_BENDS: int = 4
DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first' DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first'
DEFAULT_TRACE_INTO_BEND_POLICY: TraceIntoBendPolicy = 'minimal'
def __init__(self, strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY) -> None: def __init__(
self,
strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY,
*,
bend_policy: TraceIntoBendPolicy = DEFAULT_TRACE_INTO_BEND_POLICY,
) -> None:
self.strategy = validate_strategy(strategy) self.strategy = validate_strategy(strategy)
self.bend_policy = validate_trace_into_bend_policy(bend_policy)
def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy: def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy:
"""Return the per-route strategy or the planner default.""" """Return the per-route strategy or the planner default."""
@ -1067,9 +1086,56 @@ class RoutingPlanner:
return getattr(self, 'strategy', self.DEFAULT_STRATEGY) return getattr(self, 'strategy', self.DEFAULT_STRATEGY)
return validate_strategy(strategy) return validate_strategy(strategy)
def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]: def resolve_trace_into_bend_policy(
"""Return non-overlapping trace_into bend-budget bands for staged solving.""" self,
bend_policy: TraceIntoBendPolicy | str | None,
) -> TraceIntoBendPolicy:
"""Return the per-route trace-into bend policy or the planner default."""
if bend_policy is None:
return getattr(self, 'bend_policy', self.DEFAULT_TRACE_INTO_BEND_POLICY)
return validate_trace_into_bend_policy(bend_policy)
def validate_plan_options(
self,
plan_options: Mapping[str, Any] | None,
*,
allow_bend_policy: bool = False,
) -> dict[str, Any]:
"""Copy and validate the default planner's per-route option mapping."""
if plan_options is None:
return {}
try:
options = dict(plan_options)
except (TypeError, ValueError) as err:
raise BuildError('plan_options must be a mapping with string keys') from err
nonstring = [key for key in options if not isinstance(key, str)]
if nonstring:
raise BuildError(f'plan_options keys must be strings; got {nonstring!r}')
supported = {'strategy'}
if allow_bend_policy:
supported.add('bend_policy')
unsupported = sorted(options.keys() - supported)
if unsupported:
raise BuildError(
f'RoutingPlanner plan_options contains unsupported keys: {", ".join(unsupported)}'
)
if options.get('strategy') is not None:
options['strategy'] = validate_strategy(options['strategy'])
if options.get('bend_policy') is not None:
options['bend_policy'] = validate_trace_into_bend_policy(options['bend_policy'])
return options
def trace_into_bend_bands(
self,
family: PrimitiveKind,
*,
bend_policy: TraceIntoBendPolicy | str | None = None,
) -> tuple[tuple[int, int], ...]:
"""Return trace_into bend-budget bands for the requested detour policy."""
max_bends = self.TRACE_INTO_MAX_BENDS max_bends = self.TRACE_INTO_MAX_BENDS
if self.resolve_trace_into_bend_policy(bend_policy) == 'minimal':
required_bends = 0 if family == 'straight' else 1 if family == 'bend' else 2
return ((required_bends, required_bends),) if required_bends <= max_bends else ()
if family == 'bend': if family == 'bend':
return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends) return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends)
bands: list[tuple[int, int]] = [] bands: list[tuple[int, int]] = []
@ -1286,11 +1352,13 @@ 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, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | 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."""
plan_opts = self.validate_plan_options(plan_options)
strategy = plan_opts.get('strategy')
route_bounds = dict(bounds) route_bounds = dict(bounds)
request_details = {'ccw': ccw, **{ request_details = {'ccw': ccw, **{
key: value for key, value in route_bounds.items() if value is not None key: value for key, value in route_bounds.items() if value is not None
@ -1299,8 +1367,8 @@ class RoutingPlanner:
request_details['length'] = length request_details['length'] = length
if spacing is not None: if spacing is not None:
request_details['spacing'] = spacing request_details['spacing'] = spacing
if strategy is not None: if plan_opts:
request_details['strategy'] = strategy request_details['plan_options'] = dict(plan_opts)
if tool_options: if tool_options:
request_details['tool_options'] = dict(tool_options) request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'trace' operation: RouteOperation = 'trace'
@ -1416,19 +1484,21 @@ class RoutingPlanner:
ccw: SupportsBool | None, ccw: SupportsBool | None,
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | 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."""
plan_opts = self.validate_plan_options(plan_options)
strategy = plan_opts.get('strategy')
route_bounds = dict(bounds) route_bounds = dict(bounds)
request_details = {'ccw': ccw, **{ request_details = {'ccw': ccw, **{
key: value for key, value in route_bounds.items() if value is not None key: value for key, value in route_bounds.items() if value is not None
}} }}
if spacing is not None: if spacing is not None:
request_details['spacing'] = spacing request_details['spacing'] = spacing
if strategy is not None: if plan_opts:
request_details['strategy'] = strategy request_details['plan_options'] = dict(plan_opts)
if tool_options: if tool_options:
request_details['tool_options'] = dict(tool_options) request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'trace_to' operation: RouteOperation = 'trace_to'
@ -1504,11 +1574,13 @@ 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, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | 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."""
plan_opts = self.validate_plan_options(plan_options)
strategy = plan_opts.get('strategy')
offset = planner_bounds.finite_scalar(offset, 'offset') offset = planner_bounds.finite_scalar(offset, 'offset')
request_details = {'offset': offset, **{ request_details = {'offset': offset, **{
key: value for key, value in bounds.items() if value is not None key: value for key, value in bounds.items() if value is not None
@ -1517,8 +1589,8 @@ class RoutingPlanner:
request_details['length'] = length request_details['length'] = length
if spacing is not None: if spacing is not None:
request_details['spacing'] = spacing request_details['spacing'] = spacing
if strategy is not None: if plan_opts:
request_details['strategy'] = strategy request_details['plan_options'] = dict(plan_opts)
if tool_options: if tool_options:
request_details['tool_options'] = dict(tool_options) request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'jog' operation: RouteOperation = 'jog'
@ -1580,11 +1652,13 @@ 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, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | 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."""
plan_opts = self.validate_plan_options(plan_options)
strategy = plan_opts.get('strategy')
offset = planner_bounds.finite_scalar(offset, 'offset') offset = planner_bounds.finite_scalar(offset, 'offset')
route_bounds = dict(bounds) route_bounds = dict(bounds)
request_details = {'offset': offset, **{ request_details = {'offset': offset, **{
@ -1594,8 +1668,8 @@ class RoutingPlanner:
request_details['length'] = length request_details['length'] = length
if spacing is not None: if spacing is not None:
request_details['spacing'] = spacing request_details['spacing'] = spacing
if strategy is not None: if plan_opts:
request_details['strategy'] = strategy request_details['plan_options'] = dict(plan_opts)
if tool_options: if tool_options:
request_details['tool_options'] = dict(tool_options) request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'uturn' operation: RouteOperation = 'uturn'
@ -1722,10 +1796,14 @@ 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, plan_options: Mapping[str, Any] | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> 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."""
plan_opts = self.validate_plan_options(plan_options, allow_bend_policy=True)
strategy = plan_opts.get('strategy')
bend_policy = plan_opts.get('bend_policy')
resolved_bend_policy = self.resolve_trace_into_bend_policy(bend_policy)
if out_ptype is None: if out_ptype is None:
out_ptype = port_dst.ptype out_ptype = port_dst.ptype
if context_src.port.rotation is None or port_dst.rotation is None: if context_src.port.rotation is None or port_dst.rotation is None:
@ -1734,6 +1812,8 @@ class RoutingPlanner:
desired.rotation = port_dst.rotation - pi desired.rotation = port_dst.rotation - pi
desired.ptype = out_ptype desired.ptype = out_ptype
family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) family, length, jog, ccw = self.trace_into_spec(context_src.port, desired)
bend_bands = self.trace_into_bend_bands(family, bend_policy=resolved_bend_policy)
max_bends = max((band[1] for band in bend_bands), default=0)
request = self.solver_request( request = self.solver_request(
family, family,
context_src, context_src,
@ -1741,7 +1821,7 @@ class RoutingPlanner:
jog=jog, jog=jog,
ccw=ccw, ccw=ccw,
constrain_jog=family == 'bend', constrain_jog=family == 'bend',
max_bends=self.TRACE_INTO_MAX_BENDS, max_bends=max_bends,
strategy=strategy, strategy=strategy,
tool_options=tool_options, tool_options=tool_options,
out_ptype=out_ptype, out_ptype=out_ptype,
@ -1749,7 +1829,7 @@ class RoutingPlanner:
solver = self.solver_for_request(request) solver = self.solver_for_request(request)
candidate = None candidate = None
last_error: Exception | None = None last_error: Exception | None = None
for min_bends, max_bends in self.trace_into_bend_bands(family): for min_bends, max_bends in bend_bands:
try: try:
candidate = solver.solve(min_bends=min_bends, max_bends=max_bends) candidate = solver.solve(min_bends=min_bends, max_bends=max_bends)
break break

View file

@ -996,7 +996,13 @@ def test_autotool_strategy_orders_main_steps_across_adapters(jog: float) -> None
for strategy in ('straight_first', 'turn_first'): for strategy in ('straight_first', 'turn_first'):
pather = Pather(library, tools=tool, render='deferred') pather = Pather(library, tools=tool, render='deferred')
pather.ports['A'] = Port((0, 0), 0, ptype=primary) pather.ports['A'] = Port((0, 0), 0, ptype=primary)
pather.jog('A', jog, length=route_length, out_ptype=primary, strategy=strategy) pather.jog(
'A',
jog,
length=route_length,
out_ptype=primary,
plan_options={'strategy': strategy},
)
selected_kinds[strategy] = [step.kind for step in pather._paths['A']] selected_kinds[strategy] = [step.kind for step in pather._paths['A']]
assert selected_kinds['straight_first'] == ['straight', 'straight', 'straight', 's'] assert selected_kinds['straight_first'] == ['straight', 'straight', 'straight', 's']

View file

@ -1,8 +1,10 @@
from collections.abc import Iterator from collections.abc import Iterator
from types import FunctionType
import traceback
import pytest import pytest
from ..builder import Pather from ..builder import Pather, PathTool, RouteError
from ..error import BuildError, LibraryError from ..error import BuildError, LibraryError
from ..library import ( from ..library import (
INameView, INameView,
@ -28,6 +30,17 @@ def _owned_by(report: BuildReport, owner: str) -> set[str]:
} }
def _external_failing_route_recipe(lib: ILibrary) -> Pattern:
pather = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
pather.ccw('A', 10, out_ptype='optical')
return pather.pattern
class _MetadataSource(ILibraryView): class _MetadataSource(ILibraryView):
def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None: def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None:
self.mapping = mapping self.mapping = mapping
@ -263,6 +276,34 @@ def test_build_library_validate_is_retryable_after_failure() -> None:
assert report.dependency_graph["parent"] == frozenset({"child"}) assert report.dependency_graph["parent"] == frozenset({"child"})
def test_build_library_error_preserves_route_call_site() -> None:
builder = LibraryBuilder()
external_recipe = FunctionType(
_external_failing_route_recipe.__code__.replace(co_filename='/project/user_recipe.py'),
globals(),
)
builder.cells.top = cell(external_recipe)(builder.library)
with pytest.raises(BuildError) as exc_info:
builder.validate()
message = str(exc_info.value)
assert 'Cause: Unable to plan trace_to route' in message
assert 'Stack trace:' not in message
route_error = exc_info.value.__cause__
assert isinstance(route_error, RouteError)
assert route_error.__traceback__ is not None
route_frames = traceback.extract_tb(route_error.__traceback__)
assert sum(frame.filename == '/project/user_recipe.py' for frame in route_frames) == 1
assert not any('/builder/planner/' in frame.filename for frame in route_frames)
assert route_error.__cause__ is not None
assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack)
native = ''.join(traceback.format_exception(exc_info.value))
assert native.count('/project/user_recipe.py') == 1
def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None:
builder = LibraryBuilder() builder = LibraryBuilder()
builder["child"] = Pattern() builder["child"] = Pattern()

View file

@ -60,6 +60,10 @@ def test_route_failure_details_enforces_minimum_status_invariants() -> None:
) )
def test_plain_build_error_does_not_include_stacktrace() -> None:
assert str(BuildError('plain builder failure')) == 'plain builder failure'
def test_builder_init() -> None: def test_builder_init() -> None:
lib = Library() lib = Library()
b = Pather(lib, name="mypat") b = Pather(lib, name="mypat")

View file

@ -1,6 +1,8 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any, Literal, Never from typing import Any, Literal, Never
from types import FunctionType
import inspect import inspect
import traceback
import pytest import pytest
import numpy import numpy
@ -34,6 +36,18 @@ class PlanningOnlyTool(Tool):
return tree return tree
def _external_failing_ccw(pather: Pather) -> None:
pather.ccw('A', 10, out_ptype='optical')
def _external_failing_trace_to(pather: Pather) -> None:
pather.trace_to('A', True, length=10, out_ptype='optical')
def _external_failing_portpather_ccw(pather: Pather) -> None:
pather.at('A').ccw(10, out_ptype='optical')
class FirstPortOnlyTraceTool(PlanningOnlyTool): class FirstPortOnlyTraceTool(PlanningOnlyTool):
def __init__(self) -> None: def __init__(self) -> None:
self.render_calls = 0 self.render_calls = 0
@ -270,6 +284,90 @@ def test_route_error_reports_no_route_at_any_length() -> None:
assert 'no legal route exists at any length' in str(exc_info.value) assert 'no legal route exists at any length' in str(exc_info.value)
def test_route_error_reports_external_pather_call_site() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_ccw.__code__.replace(co_filename='/project/user_layout.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
message = str(exc_info.value)
assert 'Stack trace:' not in message
route_error = exc_info.value
native_frames = traceback.extract_tb(route_error.__traceback__)
assert native_frames
assert [frame.name for frame in native_frames if frame.filename.endswith('/builder/pather.py')] == [
'ccw',
'bend',
]
assert not any('/builder/planner/' in frame.filename for frame in native_frames)
native = ''.join(traceback.format_exception(route_error))
assert native.count('/project/user_layout.py') == 1
assert '\nStack trace:\n' not in native
assert route_error.__cause__ is not None
assert isinstance(route_error._call_stack, tuple)
assert sum(frame.filename == '/project/user_layout.py' for frame in route_error._call_stack) == 1
assert any(frame.filename.endswith('/builder/pather.py') for frame in route_error._call_stack)
assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack)
def test_route_error_direct_trace_to_starts_native_traceback_at_caller() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_trace_to.__code__.replace(co_filename='/project/direct_route.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
frames = traceback.extract_tb(exc_info.value.__traceback__)
assert sum(frame.filename == '/project/direct_route.py' for frame in frames) == 1
assert not any(frame.filename.endswith('/builder/pather.py') for frame in frames)
assert not any('/builder/planner/' in frame.filename for frame in frames)
def test_route_error_portpather_keeps_only_forwarding_frames() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_portpather_ccw.__code__.replace(co_filename='/project/selected_route.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
frames = traceback.extract_tb(exc_info.value.__traceback__)
assert sum(frame.filename == '/project/selected_route.py' for frame in frames) == 1
assert [frame.name for frame in frames if frame.filename.endswith('/builder/pather.py')] == [
'ccw',
'bend',
'trace_to',
]
assert not any('/builder/planner/' in frame.filename for frame in frames)
def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None:
planner = RoutingPlanner() planner = RoutingPlanner()
context = RoutePortContext( context = RoutePortContext(
@ -986,6 +1084,16 @@ def test_routing_entry_points_have_no_catch_all_kwargs(route_type: type, name: s
assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters) assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters)
@pytest.mark.parametrize('route_type', [Pather, PortPather])
@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into'])
def test_routing_entry_points_use_generic_plan_options(route_type: type, name: str) -> None:
parameters = inspect.signature(getattr(route_type, name)).parameters
assert 'plan_options' in parameters
assert 'strategy' not in parameters
assert 'bend_policy' not in parameters
def test_routing_typo_fails_before_tool_lookup() -> None: def test_routing_typo_fails_before_tool_lookup() -> None:
tool = RequestCountingTool() tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred') p = Pather(Library(), tools=tool, render='deferred')
@ -1007,6 +1115,41 @@ def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, An
assert tool.offer_calls == 0 assert tool.offer_calls == 0
@pytest.mark.parametrize('plan_options', [7, {1: 'bad'}])
def test_plan_options_reject_invalid_mapping_shape(plan_options: Any) -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(BuildError, match='plan_options'):
p.trace('A', None, plan_options=plan_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize(
'plan_options',
[
{'bend_policy': 'flexible'},
{'unknown': True},
],
)
def test_default_planner_rejects_unsupported_plan_options_before_tool_lookup(
plan_options: dict[str, Any],
) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='unsupported keys'):
p.trace('A', None, length=1, plan_options=plan_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize( @pytest.mark.parametrize(
'operation', 'operation',
[ [

View file

@ -93,6 +93,41 @@ def test_pather_accepts_and_reuses_planner_instance() -> None:
assert planner.trace_to_calls == 2 assert planner.trace_to_calls == 2
def test_pather_copies_and_forwards_plan_options_to_planner() -> None:
class RecordingPlanner(RoutingPlanner):
def __init__(self) -> None:
super().__init__()
self.received_plan_options: Any = None
def plan_trace_to_route(
self,
*args: Any,
plan_options: Any = None,
**kwargs: Any,
) -> Any:
self.received_plan_options = plan_options
return super().plan_trace_to_route(
*args,
plan_options=plan_options,
**kwargs,
)
planner = RecordingPlanner()
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
render='deferred',
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0)
supplied = {'strategy': 'turn_first'}
p.straight('A', 1, plan_options=supplied)
assert planner.received_plan_options == supplied
assert planner.received_plan_options is not supplied
def test_port_tool_policy_and_portpather_selection_follow_names() -> None: def test_port_tool_policy_and_portpather_selection_follow_names() -> None:
default_tool = PathTool(layer=(1, 0), width=1, ptype='wire') default_tool = PathTool(layer=(1, 0), width=1, ptype='wire')
named_tool = PathTool(layer=(2, 0), width=1, ptype='wire') named_tool = PathTool(layer=(2, 0), width=1, ptype='wire')

View file

@ -732,7 +732,7 @@ def test_pather_route_strategy_uses_planner_default() -> None:
def test_pather_route_strategy_per_route_overrides_planner_default() -> None: def test_pather_route_strategy_per_route_overrides_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first')) pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10, strategy='straight_first') pather.jog('A', 4, length=10, plan_options={'strategy': 'straight_first'})
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's'] assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
@ -740,15 +740,21 @@ def test_pather_route_strategy_per_route_overrides_planner_default() -> None:
def test_pather_route_strategy_per_route_can_request_turn_first() -> None: def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
pather, _tool = pather_with_strategy_tool() pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, strategy='turn_first') pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'})
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight'] assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
def test_pather_route_strategy_is_not_forwarded_to_tool() -> None: def test_pather_plan_options_are_not_forwarded_to_tool() -> None:
pather, tool = pather_with_strategy_tool() pather, tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, strategy='turn_first', tool_options={'marker': 'sentinel'}) pather.jog(
'A',
4,
length=10,
plan_options={'strategy': 'turn_first'},
tool_options={'marker': 'sentinel'},
)
assert tool.seen_kwargs assert tool.seen_kwargs
assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs) assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs)
@ -762,7 +768,7 @@ def test_pather_route_strategy_rejects_invalid_values() -> None:
pather, _tool = pather_with_strategy_tool() pather, _tool = pather_with_strategy_tool()
with pytest.raises(BuildError, match='Invalid route strategy'): with pytest.raises(BuildError, match='Invalid route strategy'):
pather.jog('A', 4, length=10, strategy='sideways') pather.jog('A', 4, length=10, plan_options={'strategy': 'sideways'})
def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None: def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None:

View file

@ -113,6 +113,78 @@ def test_pather_trace_into_shapes() -> None:
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
@pytest.mark.parametrize(
'dst',
[
Port((-10_000, 0), rotation=pi),
Port((-10_000, 2_000), rotation=pi),
Port((-5_000, 5_000), rotation=pi / 2),
Port((-10_000, 2_000), rotation=0),
],
)
def test_pather_trace_into_minimal_policy_accepts_required_topologies(dst: Port) -> None:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1_000),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0)
pather.ports['dst'] = dst
pather.trace_into(
'src',
'dst',
plug_destination=False,
plan_options={'bend_policy': 'minimal'},
)
assert numpy.allclose(pather.ports['src'].offset, dst.offset)
assert pather.ports['src'].rotation is not None
assert numpy.isclose((pather.ports['src'].rotation - dst.rotation) % (2 * pi), pi)
def test_pather_trace_into_bend_policy_changes_real_solver_fallback() -> None:
def make_pather() -> Pather:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((2, 0), rotation=pi, ptype='wire')
return pather
flexible = make_pather()
flexible.at('src').trace_into(
'dst',
plug_destination=False,
plan_options={'bend_policy': 'flexible'},
)
assert_equal(flexible.ports['src'].offset, (2, 0))
assert flexible.ports['src'].rotation is not None
assert numpy.isclose(flexible.ports['src'].rotation, 0)
bend_roles = sum(
1 if step.kind == 'bend' else 2 if step.kind in ('s', 'u') else 0
for step in flexible._paths['src']
)
assert bend_roles == 4
minimal = make_pather()
with pytest.raises(BuildError):
minimal.at('src').trace_into(
'dst',
plug_destination=False,
)
assert set(minimal.ports) == {'src', 'dst'}
assert_equal(minimal.ports['src'].offset, (0, 0))
assert numpy.isclose(minimal.ports['src'].rotation, 0)
assert_equal(minimal.ports['dst'].offset, (2, 0))
assert numpy.isclose(minimal.ports['dst'].rotation, pi)
assert not minimal._paths
def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None: def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None:
p = Pather( p = Pather(
Library(), Library(),
@ -305,6 +377,7 @@ class TraceIntoBudgetSolver:
class TraceIntoBudgetPlanner(RoutingPlanner): class TraceIntoBudgetPlanner(RoutingPlanner):
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None: def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
super().__init__()
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at) self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
self.solver_requests = 0 self.solver_requests = 0
@ -340,7 +413,15 @@ def test_trace_into_reuses_solver_across_staged_bend_bands(
planner = TraceIntoBudgetPlanner(successes) planner = TraceIntoBudgetPlanner(successes)
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) 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) planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert planner.solver.attempts == attempts assert planner.solver.attempts == attempts
assert planner.solver_requests == 1 assert planner.solver_requests == 1
@ -351,7 +432,15 @@ def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None:
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
with pytest.raises(RoutePlanningError, match='fatal'): 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) planner.plan_trace_into(
context,
'dst',
Port((-10, 0), rotation=pi, ptype='wire'),
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert planner.solver.attempts == [(0, 2)] assert planner.solver.attempts == [(0, 2)]
assert planner.solver_requests == 1 assert planner.solver_requests == 1
@ -361,8 +450,124 @@ def test_trace_into_bend_bands_respect_max_bends() -> None:
class OneBendPlanner(RoutingPlanner): class OneBendPlanner(RoutingPlanner):
TRACE_INTO_MAX_BENDS = 1 TRACE_INTO_MAX_BENDS = 1
planner = OneBendPlanner() planner = OneBendPlanner(bend_policy='flexible')
assert planner.trace_into_bend_bands('straight') == ((0, 0),) assert planner.trace_into_bend_bands('straight') == ((0, 0),)
assert planner.trace_into_bend_bands('s') == ((0, 0),) assert planner.trace_into_bend_bands('s') == ((0, 0),)
assert planner.trace_into_bend_bands('bend') == ((1, 1),) assert planner.trace_into_bend_bands('bend') == ((1, 1),)
@pytest.mark.parametrize(
('family', 'expected'),
[
('straight', ((0, 0),)),
('bend', ((1, 1),)),
('s', ((2, 2),)),
('u', ((2, 2),)),
],
)
def test_trace_into_default_minimal_bend_bands(family: str, expected: tuple[tuple[int, int], ...]) -> None:
planner = RoutingPlanner()
assert planner.trace_into_bend_bands(family) == expected
assert planner.trace_into_bend_bands(family, bend_policy='flexible') == (
((1, 1), (3, 3)) if family == 'bend' else ((0, 2), (4, 4))
)
@pytest.mark.parametrize(
('dst', 'required_band'),
[
(Port((-10, 0), rotation=pi, ptype='wire'), (0, 0)),
(Port((-10, -5), rotation=pi, ptype='wire'), (2, 2)),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), (1, 1)),
(Port((-10, -5), rotation=0, ptype='wire'), (2, 2)),
],
)
def test_trace_into_minimal_policy_uses_orientation_required_band(
dst: Port,
required_band: tuple[int, int],
) -> None:
planner = TraceIntoBudgetPlanner({required_band})
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,
plan_options={'bend_policy': 'minimal'},
)
assert planner.solver.attempts == [required_band]
def test_trace_into_minimal_policy_rejects_fallback_without_mutation() -> None:
planner = TraceIntoBudgetPlanner({(4, 4)})
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
planner=planner,
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='try next budget'):
pather.trace_into('src', 'dst', plan_options={'bend_policy': 'minimal'})
assert planner.solver.attempts == [(0, 0)]
assert set(pather.ports) == {'src', 'dst'}
assert_equal(pather.ports['src'].offset, (0, 0))
assert_equal(pather.ports['dst'].offset, (-10, 0))
assert not pather._paths
def test_trace_into_bend_policy_planner_default_and_route_override() -> None:
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
dst = Port((-10, 0), rotation=pi, ptype='wire')
minimal_planner = TraceIntoBudgetPlanner({(4, 4)})
with pytest.raises(BuildError, match='try next budget'):
minimal_planner.plan_trace_into(
context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None,
)
assert minimal_planner.solver.attempts == [(0, 0)]
flexible_planner = TraceIntoBudgetPlanner({(4, 4)})
flexible_planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert flexible_planner.solver.attempts == [(0, 2), (4, 4)]
def test_trace_into_rejects_invalid_bend_policy() -> None:
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
RoutingPlanner(bend_policy='sideways') # type: ignore[arg-type]
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
pather.trace_into('src', 'dst', plan_options={'bend_policy': 'sideways'})