[Pather / AutoTool] rework route kwargs into tool_options, and actualyl pass them through AutoTool

This commit is contained in:
Jan Petykiewicz 2026-07-12 20:32:59 -07:00
commit 67afee7704
11 changed files with 865 additions and 130 deletions

View file

@ -22,13 +22,13 @@ The biggest migration point is that the old routing verbs were renamed:
| `Pather.pathU(...)` | `Pather.uturn(...)` |
| `Pather.path_into(...)` | `Pather.trace_into(...)` |
| `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` |
| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` |
| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` |
| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` |
| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` |
| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` |
| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` |
| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` |
| `RenderPather.path(...)` | `Pather(..., render='deferred').trace(...)` |
| `RenderPather.path_to(...)` | `Pather(..., render='deferred').trace_to(...)` |
| `RenderPather.mpath(...)` | `Pather(..., render='deferred').trace(...)` / `Pather(..., render='deferred').trace_to(...)` |
| `RenderPather.pathS(...)` | `Pather(..., render='deferred').jog(...)` |
| `RenderPather.pathU(...)` | `Pather(..., render='deferred').uturn(...)` |
| `RenderPather.path_into(...)` | `Pather(..., render='deferred').trace_into(...)` |
| `RenderPather.path_from(src, dst)` | `Pather(..., render='deferred').at(src).trace_into(dst)` |
There are also new convenience wrappers:
@ -123,13 +123,13 @@ from masque.builder.renderpather import RenderPather
from masque.builder import Pather
builder = Pather(...)
deferred = Pather(..., auto_render=False)
deferred = Pather(..., render='deferred')
```
Top-level imports from `masque` also continue to work.
`Pather` now defaults to `auto_render=True`, so plain construction replaces the
old `Builder` behavior. Use `Pather(..., auto_render=False)` where you
`Pather` now defaults to `render='auto'`, so plain construction replaces the
old `Builder` behavior. Use `Pather(..., render='deferred')` where you
previously used `RenderPather`.
## `BasicTool` was replaced
@ -218,7 +218,7 @@ class MyTool(Tool):
commit_planner=commit,
),)
def render(self, batch: Sequence[RenderStep], **kwargs: Any):
def render(self, batch: Sequence[RenderStep]):
...
```
@ -281,10 +281,32 @@ class MyTool(Tool):
commit_planner=commit,
),)
def render(self, batch: Sequence[RenderStep], **kwargs):
def render(self, batch: Sequence[RenderStep]):
...
```
Routing entry points now name every supported route argument explicitly.
Custom per-route planning values must be placed under `tool_options`:
```python
pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'})
```
The mapping is unpacked only for `Tool.primitive_offers()`. Route arguments do
not leak into that namespace, and tool options are not forwarded to
`Tool.render()`. An offer that needs route-specific render behavior must capture
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
does this automatically for generated straight and S-bend primitives: the flat
mapping is snapshotted per selected primitive and passed to its generator as
keyword arguments during rendering. AutoTool options must not change generated
ports or endpoint geometry. `PathTool` defines no tool options and rejects
nonempty mappings.
AutoTool does not validate generator keyword signatures during planning. A bad
keyword therefore raises when the generator runs, normally during rendering.
Generators that require route options must provide explicit port metadata at
registration; S-bend generators must also provide an explicit `endpoint`.
Primitive offers are local planning objects:
- `endpoint_at(parameter)` returns the local output `Port`

View file

@ -271,13 +271,13 @@ class PrimitiveWireTool(Tool):
**kwargs: Any,
) -> tuple[StraightOffer | BendOffer, ...]:
if kind == 'straight':
route_kwargs = dict(kwargs)
tool_options = dict(kwargs)
def endpoint_planner(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=self.ptype)
def commit_planner(length: float) -> WireStraightData:
_ = route_kwargs
_ = tool_options
return WireStraightData(length)
native_offer = StraightOffer(
@ -354,7 +354,6 @@ class PrimitiveWireTool(Tool):
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any, # noqa: ARG002 (no per-render options in this example tool)
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype)

View file

@ -8,6 +8,10 @@ planner package is intentionally internal: custom route generators should
extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on
planner classes or search details.
Public routing arguments are explicit. Custom per-route planning values belong
in `tool_options`; Pather forwards those values 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:
- snapshot: `Pather` resolves the active Tool for each requested port and
passes copied ports to the planner as `RoutePortContext` values,
@ -84,6 +88,29 @@ from .logging import PatherLogger
logger = logging.getLogger(__name__)
RenderPolicy = Literal['auto', 'immediate', 'deferred', 'warn', 'error', 'ignore']
RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'warn', 'error', 'ignore')
RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
def _present_route_args(**kwargs: Any) -> dict[str, Any]:
"""Return explicitly supplied route arguments for planner validation."""
return {key: value for key, value in kwargs.items() if value is not None}
def _validated_tool_options(tool_options: Mapping[str, Any] | None) -> dict[str, Any]:
"""Copy and validate custom planning options before any Tool is queried."""
if tool_options is None:
return {}
try:
options = dict(tool_options)
except (TypeError, ValueError) as err:
raise BuildError('tool_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'tool_options keys must be strings; got {nonstring!r}')
collisions = sorted(RESERVED_TOOL_OPTION_KEYS & options.keys())
if collisions:
raise BuildError(f'tool_options cannot override Tool arguments: {", ".join(collisions)}')
return options
class Pather(PortList):
@ -548,7 +575,19 @@ class Pather(PortList):
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
"""
Route one or more ports using straight segments or single 90-degree bends.
@ -567,12 +606,33 @@ class Pather(PortList):
`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, strategy=strategy, **bounds):
bounds = _present_route_args(
out_ptype=out_ptype,
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,
)
options = _validated_tool_options(tool_options)
with self._logger.log_operation(
self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing,
strategy=strategy, tool_options=options, **bounds,
):
if isinstance(portspec, str):
portspec = [portspec]
contexts = self._route_contexts(portspec)
try:
result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, strategy=strategy, **bounds)
result = self.planner.plan_trace_route(
contexts, ccw, length, spacing=spacing, strategy=strategy,
tool_options=options, **bounds,
)
except (BuildError, NotImplementedError) as err:
if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL:
raise
@ -584,7 +644,7 @@ class Pather(PortList):
0,
ccw,
context.port.ptype,
out_ptype = bounds.get('out_ptype'),
out_ptype = out_ptype,
)
return self
if bounds.get('each') is not None:
@ -596,7 +656,7 @@ class Pather(PortList):
0,
ccw,
context.port.ptype,
out_ptype = bounds.get('out_ptype'),
out_ptype = out_ptype,
)
return self
raise
@ -608,9 +668,27 @@ class Pather(PortList):
portspec: str | Sequence[str],
ccw: SupportsBool | None,
*,
length: float | None = None,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
"""
Route until a single positional bound is reached, or delegate to `trace()` for length/bundle bounds.
@ -624,12 +702,39 @@ class Pather(PortList):
`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, strategy=strategy, **bounds):
bounds = _present_route_args(
length=length,
out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest,
)
options = _validated_tool_options(tool_options)
with self._logger.log_operation(
self, 'trace_to', portspec, ccw=ccw, spacing=spacing,
strategy=strategy, tool_options=options, **bounds,
):
if isinstance(portspec, str):
portspec = [portspec]
contexts = self._route_contexts(portspec)
try:
result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, strategy=strategy, **bounds)
result = self.planner.plan_trace_to_route(
contexts, ccw, spacing=spacing, strategy=strategy,
tool_options=options, **bounds,
)
except (BuildError, NotImplementedError) as err:
if (
not self._dead
@ -655,23 +760,148 @@ class Pather(PortList):
0,
ccw,
context.port.ptype,
out_ptype = bounds.get('out_ptype'),
out_ptype = out_ptype,
)
return self
self._apply_route_result(result)
return self
def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
return self.trace_to(portspec, None, length=length, **bounds)
def straight(
self,
portspec: str | Sequence[str],
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.trace_to(
portspec, None, length=length, spacing=spacing, strategy=strategy,
out_ptype=out_ptype, 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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self:
return self.trace_to(portspec, ccw, length=length, **bounds)
def bend(
self,
portspec: str | Sequence[str],
ccw: SupportsBool,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.trace_to(
portspec, ccw, length=length, spacing=spacing, strategy=strategy,
out_ptype=out_ptype, 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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
return self.bend(portspec, True, length, **bounds)
def ccw(
self,
portspec: str | Sequence[str],
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.bend(
portspec, True, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
return self.bend(portspec, False, length, **bounds)
def cw(
self,
portspec: str | Sequence[str],
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.bend(
portspec, False, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def jog(
self,
@ -681,7 +911,13 @@ class Pather(PortList):
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any,
out_ptype: str | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
"""
Route an S-bend.
@ -700,12 +936,20 @@ class Pather(PortList):
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, strategy=strategy, **bounds):
bounds = _present_route_args(out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y)
options = _validated_tool_options(tool_options)
with self._logger.log_operation(
self, 'jog', portspec, offset=offset, length=length, spacing=spacing,
strategy=strategy, tool_options=options, **bounds,
):
if isinstance(portspec, str):
portspec = [portspec]
contexts = self._route_contexts(portspec)
try:
result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds)
result = self.planner.plan_jog_route(
contexts, offset, length, spacing=spacing, strategy=strategy,
tool_options=options, **bounds,
)
except (BuildError, NotImplementedError) as err:
if (
not self._dead
@ -723,7 +967,7 @@ class Pather(PortList):
0,
None,
context.port.ptype,
out_ptype = bounds.get('out_ptype'),
out_ptype = out_ptype,
)
return self
fallback_length = length if length is not None else 0
@ -735,7 +979,7 @@ class Pather(PortList):
None,
context.port.ptype,
out_rot = pi,
out_ptype = bounds.get('out_ptype'),
out_ptype = out_ptype,
)
return self
self._apply_route_result(result)
@ -749,7 +993,8 @@ class Pather(PortList):
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**bounds: Any,
out_ptype: str | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
"""
Route a U-turn.
@ -766,12 +1011,20 @@ class Pather(PortList):
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, strategy=strategy, **bounds):
bounds = _present_route_args(out_ptype=out_ptype)
options = _validated_tool_options(tool_options)
with self._logger.log_operation(
self, 'uturn', portspec, offset=offset, length=length, spacing=spacing,
strategy=strategy, tool_options=options, **bounds,
):
if isinstance(portspec, str):
portspec = [portspec]
contexts = self._route_contexts(portspec)
try:
result = self.planner.plan_uturn_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds)
result = self.planner.plan_uturn_route(
contexts, offset, length, spacing=spacing, strategy=strategy,
tool_options=options, **bounds,
)
except (BuildError, NotImplementedError) as err:
if (
not self._dead
@ -788,7 +1041,7 @@ class Pather(PortList):
None,
context.port.ptype,
out_rot = 0.0,
out_ptype = bounds.get('out_ptype'),
out_ptype = out_ptype,
)
return self
self._apply_route_result(result)
@ -803,7 +1056,7 @@ class Pather(PortList):
plug_destination: bool = True,
thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
"""
Route one port into another using a bounded primitive-offer selection.
@ -813,9 +1066,8 @@ class Pather(PortList):
dogleg topologies. The lowest-cost legal bounded candidate is selected;
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,
bundle bounds, and `strategy` are reserved for this internal solve; other tool
kwargs are forwarded to primitive offer generation.
Custom planning options may be supplied through `tool_options`; they are
forwarded only to primitive offer generation.
If `plug_destination` is `True`, the destination port is consumed by the final step.
If `thru` is provided, that port is renamed to the source name after the route is complete.
@ -824,6 +1076,7 @@ class Pather(PortList):
mutated; failures during selected-route execution, including primitive
commit, plug/thru application, or render, may leave partial output.
"""
options = _validated_tool_options(tool_options)
with self._logger.log_operation(
self,
'trace_into',
@ -832,7 +1085,7 @@ class Pather(PortList):
plug_destination=plug_destination,
thru=thru,
strategy=strategy,
**kwargs,
tool_options=options,
):
result = self.planner.plan_trace_into(
self._route_context(portspec_src),
@ -842,7 +1095,7 @@ class Pather(PortList):
plug_destination = plug_destination,
thru = thru,
strategy = strategy,
**kwargs,
tool_options = options,
)
self._apply_route_result(result)
return self
@ -1045,46 +1298,259 @@ class PortPather:
with self.pather.toolctx(tool, keys=self.ports):
yield self
def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> Self:
if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None:
kw['spacing'] = self.default_spacing
self.pather.trace(self.ports, ccw, length, **kw)
def trace(
self,
ccw: SupportsBool | None,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
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
self.pather.trace(
self.ports, ccw, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
tool_options=tool_options,
)
return self
def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> Self:
if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None:
kw['spacing'] = self.default_spacing
self.pather.trace_to(self.ports, ccw, **kw)
def trace_to(
self,
ccw: SupportsBool | None,
*,
length: float | None = None,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
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
self.pather.trace_to(
self.ports, ccw, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
return self
def straight(self, length: float | None = None, **kw: Any) -> Self:
return self.trace_to(None, length=length, **kw)
def straight(
self,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.trace_to(
None, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def bend(self, ccw: SupportsBool, length: float | None = None, **kw: Any) -> Self:
return self.trace_to(ccw, length=length, **kw)
def bend(
self,
ccw: SupportsBool,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.trace_to(
ccw, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def ccw(self, length: float | None = None, **kw: Any) -> Self:
return self.bend(True, length, **kw)
def ccw(
self,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.bend(
True, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def cw(self, length: float | None = None, **kw: Any) -> Self:
return self.bend(False, length, **kw)
def cw(
self,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
each: float | None = None,
set_rotation: float | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
emin: float | None = None,
emax: float | None = None,
pmin: float | None = None,
pmax: float | None = None,
xmin: float | None = None,
xmax: float | None = None,
ymin: float | None = None,
ymax: float | None = None,
min_past_furthest: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
return self.bend(
False, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
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,
min_past_furthest=min_past_furthest, tool_options=tool_options,
)
def jog(self, offset: float, length: float | None = None, **kw: Any) -> Self:
if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0):
kw['spacing'] = self.default_spacing
self.pather.jog(self.ports, offset, length, **kw)
def jog(
self,
offset: float,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
p: float | None = None,
pos: float | None = None,
position: float | None = None,
x: float | None = None,
y: float | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
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
self.pather.jog(
self.ports, offset, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype,
p=p, pos=pos, position=position, x=x, y=y, tool_options=tool_options,
)
return self
def uturn(self, offset: float, length: float | None = None, **kw: Any) -> Self:
if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1:
kw['spacing'] = self.default_spacing
self.pather.uturn(self.ports, offset, length, **kw)
def uturn(
self,
offset: float,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
out_ptype: str | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
if spacing is None and self.default_spacing is not None and len(self.ports) > 1:
spacing = self.default_spacing
self.pather.uturn(
self.ports, offset, length, spacing=spacing, strategy=strategy,
out_ptype=out_ptype, tool_options=tool_options,
)
return self
def trace_into(self, target_port: str, **kwargs) -> Self:
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit trace_into() with {len(self.ports)} (>1) ports.')
self.pather.trace_into(self.ports[0], target_port, **kwargs)
def trace_into(
self,
target_port: str,
*,
out_ptype: str | None = None,
plug_destination: bool = True,
thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
if len(self.ports) != 1:
raise BuildError(f'Unable to use implicit trace_into() with {len(self.ports)} ports; expected exactly one.')
self.pather.trace_into(
self.ports[0], target_port, out_ptype=out_ptype, plug_destination=plug_destination,
thru=thru, strategy=strategy, tool_options=tool_options,
)
return self
def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self:

View file

@ -20,7 +20,7 @@ from pprint import pformat
import numpy
from numpy import pi
from numpy.typing import ArrayLike
from numpy.typing import ArrayLike, NDArray
from ...error import BuildError, PortError
from ...ports import Port
@ -34,6 +34,22 @@ BUNDLE_BOUND_KEYS: tuple[str, ...] = (
)
def finite_scalar(value: Any, name: str, *, nonnegative: bool = False) -> float:
"""Return a finite scalar, preserving duck-typed numeric inputs."""
try:
array = numpy.asarray(value, dtype=float)
except (TypeError, ValueError) as err:
raise BuildError(f'{name} must be a finite numeric scalar') from err
if array.size != 1:
raise BuildError(f'{name} must be a scalar; got {array.size} values')
result = float(array.reshape(-1)[0])
if not numpy.isfinite(result):
raise BuildError(f'{name} must be finite')
if nonnegative and result < 0:
raise BuildError(f'{name} must be nonnegative')
return result
def resolved_position_bound(
port: Port,
bounds: Mapping[str, Any],
@ -50,7 +66,8 @@ def resolved_position_bound(
if not allow_length and bounds.get('length') is not None:
raise BuildError('length cannot be combined with a positional bound')
key, value = present[0]
key, raw_value = present[0]
value = finite_scalar(raw_value, f'{key} positional bound')
if port.rotation is None:
raise BuildError('Ports must have rotation')
is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9))
@ -206,6 +223,8 @@ def su_bundle_specs(
"""
if spacing is None:
raise BuildError(f'Must provide spacing for multi-port {route_name}()')
finite_scalar(offset, 'offset')
finite_scalar(length, 'length', nonnegative=True)
ports = {context.portspec: context.port for context in contexts}
has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool)
@ -229,7 +248,9 @@ def su_bundle_specs(
y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
steps = numpy.zeros(len(ports), dtype=float)
if numpy.any(spacing_arr < 0):
raise BuildError('spacing must be nonnegative')
steps: NDArray[numpy.float64] = numpy.zeros(len(ports), dtype=float)
if spacing_arr.size == 1:
steps[1:] = spacing_arr[0]
elif spacing_arr.size == len(ports) - 1:

View file

@ -251,7 +251,7 @@ class SolverRequest:
Public Pather calls are converted into this smaller shape before grammar
enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints;
`route_kwargs` are forwarded to Tool primitive-offer generation.
`tool_options` are forwarded to Tool primitive-offer generation.
"""
family: PrimitiveKind
"""High-level route family being solved."""
@ -259,8 +259,8 @@ class SolverRequest:
"""Tool queried for primitive offers."""
in_ptype: str | None
"""Input ptype at the start of the route."""
route_kwargs: Mapping[str, Any]
"""Tool kwargs forwarded to primitive-offer generation."""
tool_options: Mapping[str, Any]
"""Custom Tool kwargs forwarded to primitive-offer generation."""
length: float | None = None
"""Requested local x displacement, when constrained."""
jog: float | None = None
@ -439,9 +439,8 @@ class Solver:
out_ptype: str | None = None,
extra: Mapping[str, Any] | None = None,
) -> tuple[PrimitiveOffer, ...]:
"""Query the active Tool with route kwargs and per-query overrides."""
kwargs = dict(self.request.route_kwargs)
kwargs.pop('out_ptype', None)
"""Query the active Tool with custom planning options and internal overrides."""
kwargs = dict(self.request.tool_options)
if extra:
kwargs.update(extra)
try:
@ -1018,6 +1017,7 @@ class RoutingPlanner:
constrain_jog: bool = False,
max_bends: int | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> SolverRequest:
"""Build normalized solver input for one route leg."""
@ -1025,7 +1025,7 @@ class RoutingPlanner:
family=family,
tool=context.tool,
in_ptype=context.port.ptype,
route_kwargs=kwargs,
tool_options={} if tool_options is None else dict(tool_options),
length=length,
jog=jog,
ccw=ccw,
@ -1070,6 +1070,7 @@ class RoutingPlanner:
constrain_jog: bool = False,
max_bends: int | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> RouteLeg:
"""Solve one route leg and attach it to its source Pather context."""
@ -1107,6 +1108,7 @@ class RoutingPlanner:
constrain_jog=constrain_jog,
max_bends=max_bends,
strategy=strategy,
tool_options=tool_options,
**kwargs,
)
try:
@ -1212,6 +1214,7 @@ class RoutingPlanner:
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**bounds: Any,
) -> PreparedRouteResult:
"""Plan straight or single-bend traces, including `each` and bundle-bound modes."""
@ -1225,6 +1228,8 @@ class RoutingPlanner:
request_details['spacing'] = spacing
if strategy is not None:
request_details['strategy'] = strategy
if tool_options:
request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'trace'
diagnostic_request = request_details
return self._plan_trace_route(
@ -1235,6 +1240,7 @@ class RoutingPlanner:
diagnostic_request,
spacing=spacing,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
@ -1249,6 +1255,7 @@ class RoutingPlanner:
*,
spacing: float | ArrayLike | None,
strategy: RouteTieBreakStrategy | str | None,
tool_options: Mapping[str, Any] | None,
**route_bounds: Any,
) -> PreparedRouteResult:
portspec = tuple(context.portspec for context in contexts)
@ -1263,6 +1270,7 @@ class RoutingPlanner:
length=length,
ccw=ccw,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
return self.prepared_result_from_legs((leg,))
@ -1279,6 +1287,7 @@ class RoutingPlanner:
length=each,
ccw=ccw,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
),
)
@ -1295,6 +1304,7 @@ class RoutingPlanner:
length=None,
ccw=ccw,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
return self.prepared_result_from_legs((leg,))
@ -1321,6 +1331,7 @@ class RoutingPlanner:
length=route_length,
ccw=ccw,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
actions.append(self.prepared_route_action_from_leg(leg))
@ -1333,6 +1344,7 @@ class RoutingPlanner:
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**bounds: Any,
) -> PreparedRouteResult:
"""Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes."""
@ -1344,6 +1356,8 @@ class RoutingPlanner:
request_details['spacing'] = spacing
if strategy is not None:
request_details['strategy'] = strategy
if tool_options:
request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'trace_to'
diagnostic_request = request_details
return self._plan_trace_to_route(
@ -1353,6 +1367,7 @@ class RoutingPlanner:
diagnostic_request,
spacing=spacing,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
@ -1366,6 +1381,7 @@ class RoutingPlanner:
*,
spacing: float | ArrayLike | None,
strategy: RouteTieBreakStrategy | str | None,
tool_options: Mapping[str, Any] | None,
**route_bounds: Any,
) -> PreparedRouteResult:
if len(contexts) == 1:
@ -1383,6 +1399,7 @@ class RoutingPlanner:
diagnostic_request,
spacing=spacing,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
@ -1402,6 +1419,7 @@ class RoutingPlanner:
length=length,
ccw=ccw,
strategy=strategy,
tool_options=tool_options,
**other_bounds,
)
return self.prepared_result_from_legs((leg,))
@ -1414,9 +1432,11 @@ class RoutingPlanner:
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**bounds: Any,
) -> PreparedRouteResult:
"""Plan S-bend routes for single ports or spaced bundles."""
offset = planner_bounds.finite_scalar(offset, 'offset')
request_details = {'offset': offset, **{
key: value for key, value in bounds.items() if value is not None
}}
@ -1426,6 +1446,8 @@ class RoutingPlanner:
request_details['spacing'] = spacing
if strategy is not None:
request_details['strategy'] = strategy
if tool_options:
request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'jog'
diagnostic_request = request_details
if numpy.isclose(offset, 0):
@ -1437,6 +1459,7 @@ class RoutingPlanner:
length=length,
spacing=spacing,
strategy=strategy,
tool_options=tool_options,
**bounds,
)
route_bounds = dict(bounds)
@ -1460,6 +1483,7 @@ class RoutingPlanner:
operation,
diagnostic_request,
strategy=strategy,
tool_options=tool_options,
**other_bounds,
)
))
@ -1471,6 +1495,7 @@ class RoutingPlanner:
length=length,
jog=offset,
strategy=strategy,
tool_options=tool_options,
**other_bounds,
)
return self.prepared_result_from_legs((leg,))
@ -1483,9 +1508,11 @@ class RoutingPlanner:
*,
spacing: float | ArrayLike | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**bounds: Any,
) -> PreparedRouteResult:
"""Plan U-turn routes for single ports or spaced bundles."""
offset = planner_bounds.finite_scalar(offset, 'offset')
route_bounds = dict(bounds)
request_details = {'offset': offset, **{
key: value for key, value in route_bounds.items() if value is not None
@ -1496,6 +1523,8 @@ class RoutingPlanner:
request_details['spacing'] = spacing
if strategy is not None:
request_details['strategy'] = strategy
if tool_options:
request_details['tool_options'] = dict(tool_options)
operation: RouteOperation = 'uturn'
diagnostic_request = request_details
portspec = tuple(context.portspec for context in contexts)
@ -1512,6 +1541,7 @@ class RoutingPlanner:
operation,
diagnostic_request,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
))
@ -1523,6 +1553,7 @@ class RoutingPlanner:
length=length,
jog=offset,
strategy=strategy,
tool_options=tool_options,
**route_bounds,
)
return self.prepared_result_from_legs((leg,))
@ -1539,6 +1570,7 @@ class RoutingPlanner:
/,
*,
strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> tuple[RouteLeg, ...]:
"""
@ -1557,6 +1589,7 @@ class RoutingPlanner:
length=length,
jog=offset,
strategy=strategy,
tool_options=tool_options,
**kwargs,
),)
route_name = 'jog' if kind == 's' else 'uturn'
@ -1573,6 +1606,7 @@ class RoutingPlanner:
length=length,
jog=offset,
strategy=strategy,
tool_options=tool_options,
**kwargs,
)
base_length = anchor.candidate.public_length
@ -1589,6 +1623,7 @@ class RoutingPlanner:
length=spec_length,
jog=spec_offset,
strategy=strategy,
tool_options=tool_options,
**kwargs,
)
else:
@ -1600,6 +1635,7 @@ class RoutingPlanner:
length=spec_length,
jog=spec_offset,
strategy=strategy,
tool_options=tool_options,
**kwargs,
)
return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs)
@ -1614,17 +1650,9 @@ class RoutingPlanner:
plug_destination: bool,
thru: str | None,
strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any,
tool_options: Mapping[str, Any] | None = None,
) -> PreparedRouteResult:
"""Plan a bounded route from one source port into a destination port."""
reserved = {
'portspec', 'ccw', 'length', 'offset', 'plug_into', 'spacing', 'each', 'set_rotation',
*planner_bounds.POSITION_KEYS,
*planner_bounds.BUNDLE_BOUND_KEYS,
}
collisions = sorted(set(kwargs) & reserved)
if collisions:
raise BuildError(f'trace_into() kwargs cannot override route arguments: {", ".join(collisions)}')
if out_ptype is None:
out_ptype = port_dst.ptype
if context_src.port.rotation is None or port_dst.rotation is None:
@ -1642,7 +1670,8 @@ class RoutingPlanner:
constrain_jog=family == 'bend',
max_bends=self.TRACE_INTO_MAX_BENDS,
strategy=strategy,
**(dict(kwargs) | {'out_ptype': out_ptype}),
tool_options=tool_options,
out_ptype=out_ptype,
)
solver = self.solver_for_request(request)
candidate = None

View file

@ -49,8 +49,9 @@ from typing import Literal, Any, Self
from collections import ChainMap
from collections.abc import Sequence, Callable, Mapping
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt
from types import MappingProxyType
import numpy
from numpy.typing import NDArray
@ -693,6 +694,10 @@ class Tool(ABC):
- `'s'`: a non-turning `SOffer`
- `'u'`: an `UOffer`
Other `kwargs` come only from the Pather call's explicit
`tool_options` mapping. Tools are responsible for validating the keys
they support.
`Pather` applies any requested `out_ptype` to the final route endpoint,
not to every primitive in the route. Intermediate ptypes are
solver-selected, and heterogeneous straight/S offers may be used as
@ -706,7 +711,6 @@ class Tool(ABC):
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> ILibrary:
"""
Render a compatible batch of selected route steps into geometry.
@ -722,7 +726,6 @@ class Tool(ABC):
primitive render data.
port_names: The topcell's input and output ports should be named
`port_names[0]` and `port_names[1]` respectively.
kwargs: Custom tool-specific parameters.
"""
raise NotImplementedError
@ -778,17 +781,20 @@ class AutoTool(Tool):
when only the declared direction should be available.
Straight and S-bend generator functions may return either a `Pattern` or a
single-top `Library`. Extra keyword arguments passed to `render()` are
forwarded to those generators.
single-top `Library`. Route `tool_options` are snapshotted into committed
generated data and passed to each selected generator as keyword arguments.
These options are render-only: they may change geometry, layers, or
annotations, but must not change generated port topology or endpoints.
"""
@dataclass(frozen=True, slots=True)
class GeneratedData:
""" Deferred render data for one generated primitive offer. """
"""Deferred generator call, including its route-specific option snapshot."""
fn: GeneratedPrimitiveFn
port_name: str
parameter: float
mirrored: bool = False
tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
@dataclass(frozen=True, slots=True)
class ReusableData:
@ -972,7 +978,7 @@ class AutoTool(Tool):
If `ptype` or `in_port_name` is omitted, one in-domain example is
generated and the missing metadata is inferred from an equivalent
two-port straight.
two-port straight. Metadata inference does not receive route options.
"""
ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name)
priority_bias = len(self._straight_offers) * BUILTIN_PRIORITY_STEP
@ -1062,6 +1068,7 @@ class AutoTool(Tool):
If `ptype` or port names are omitted, one in-domain example is generated
and the missing metadata is inferred from an equivalent two-port S-bend.
Metadata inference and endpoint planning do not receive route options.
"""
ptype, in_port_name, out_port_name = self._infer_sbend_metadata(
fn,
@ -1225,7 +1232,7 @@ class AutoTool(Tool):
dxy, _ = sbpat[in_port_name].measure_travel(sbpat[out_port_name])
return dxy
def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], None]) -> NDArray[numpy.float64]:
def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], Any]) -> NDArray[numpy.float64]:
port_names = ('A', 'B')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_bbox')
pat.add_port_pair(names=port_names)
@ -1247,7 +1254,7 @@ class AutoTool(Tool):
return numpy.asarray(bounds, dtype=float)
def _bbox_for_data(self, data: Any) -> NDArray[numpy.float64]:
return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names, {}))
return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names))
def _add_transition_direction(
self,
@ -1330,21 +1337,45 @@ class AutoTool(Tool):
**kwargs,
) -> tuple[PrimitiveOffer, ...]:
_ = out_ptype
ccw = kwargs.pop('ccw', None)
tool_options = MappingProxyType(dict(kwargs))
def configured(offer: PrimitiveOffer) -> PrimitiveOffer:
if not tool_options:
return offer
def commit(parameter: float) -> Any:
data = offer.commit(parameter)
if not isinstance(data, self.GeneratedData):
raise BuildError('AutoTool generated offer returned unexpected commit data')
return replace(data, tool_options=tool_options)
def bbox(parameter: float) -> NDArray[numpy.float64]:
return self._bbox_for_data(commit(parameter))
return replace(
offer,
commit_planner=commit,
bbox_planner=bbox if offer.bbox_planner is not None else None,
)
if kind == 'straight':
in_key = self._wildcard_ptype_key(in_ptype)
return (
*self._transition_adapter_offers_by_key.get(('straight', in_key), ()),
*self._straight_offers,
*(configured(offer) for offer in self._straight_offers),
)
if kind == 'bend':
return tuple(self._bend_offers[int(bool(kwargs['ccw']))])
if ccw is None:
raise BuildError('AutoTool bend offer discovery requires ccw')
return tuple(self._bend_offers[int(bool(ccw))])
if kind == 's':
in_key = self._wildcard_ptype_key(in_ptype)
return (
*self._transition_adapter_offers_by_key.get(('s', in_key), ()),
*self._s_offers,
*(configured(offer) for offer in self._s_offers),
)
if kind == 'u':
@ -1356,13 +1387,12 @@ class AutoTool(Tool):
data: GeneratedData,
tree: ILibrary,
port_names: tuple[str, str],
gen_kwargs: dict[str, Any],
) -> ILibrary:
if numpy.isclose(data.parameter, 0):
return tree
pat = tree.top_pattern()
generated = data.fn(data.parameter, **gen_kwargs)
generated = data.fn(data.parameter, **data.tool_options)
pmap = {port_names[1]: data.port_name}
if isinstance(generated, Pattern):
pat.plug(generated, pmap, append=True, mirrored=data.mirrored)
@ -1387,10 +1417,9 @@ class AutoTool(Tool):
data: Any,
tree: ILibrary,
port_names: tuple[str, str],
gen_kwargs: dict[str, Any],
) -> ILibrary:
if isinstance(data, self.GeneratedData):
return self._render_generated(data=data, tree=tree, port_names=port_names, gen_kwargs=gen_kwargs)
return self._render_generated(data=data, tree=tree, port_names=port_names)
if isinstance(data, self.ReusableData):
return self._render_reusable(data=data, tree=tree, port_names=port_names)
raise BuildError(f'Unexpected AutoTool render data {type(data).__name__}')
@ -1400,7 +1429,6 @@ class AutoTool(Tool):
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
@ -1408,7 +1436,7 @@ class AutoTool(Tool):
for step in batch:
assert step.tool == self
self._render_data(step.data, tree, port_names, kwargs)
self._render_data(step.data, tree, port_names)
return tree
@ -1470,6 +1498,10 @@ class PathTool(Tool):
out_ptype: str | None = None,
**kwargs,
) -> tuple[PrimitiveOffer, ...]:
allowed = {'ccw'} if kind == 'bend' else set()
unsupported = sorted(set(kwargs) - allowed)
if unsupported:
raise BuildError(f'PathTool does not support tool options: {", ".join(unsupported)}')
if kind == 'u':
return ()
@ -1559,7 +1591,6 @@ class PathTool(Tool):
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs, # noqa: ARG002 (unused)
) -> ILibrary:
# Transform the batch so the first port is local (at 0,0) but retains its global rotation.

View file

@ -131,6 +131,8 @@ def ell(
else:
if set_rotation is None:
raise BuildError('set_rotation must be specified if no ports have rotations!')
if not numpy.isfinite(set_rotation):
raise BuildError('set_rotation must be finite')
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
is_horizontal = numpy.isclose(rotations[0] % pi, 0)
@ -156,7 +158,11 @@ def ell(
ch_offsets = numpy.zeros_like(y_order)
else:
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
steps = numpy.zeros_like(y_order)
if not numpy.all(numpy.isfinite(spacing_arr)):
raise BuildError('spacing must contain only finite values')
if numpy.any(spacing_arr < 0):
raise BuildError('spacing must be nonnegative')
steps: NDArray[numpy.float64] = numpy.zeros(len(y_order), dtype=float)
if spacing_arr.size == 1:
steps[1:] = spacing_arr[0]
elif spacing_arr.size == len(ports) - 1:

View file

@ -800,7 +800,7 @@ def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None:
.add_sbend(make_wide_sbend, "core", "A", "B", jog_range=(0, 1e8))
.add_transition(lib.abstract("xin"), "EXT", "CORE")
)
offer, _out_port, _data = selected_offer(tool, "s", 4, length=15, in_ptype="core")
offer, _out_port, _data = selected_offer(tool, "s", 4, in_ptype="core")
assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib)
@ -1006,7 +1006,7 @@ def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, L
assert bend_step.data.abstract.name == "b1"
def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None:
def test_autotool_generated_primitives_snapshot_route_options() -> None:
markers: list[str | None] = []
def make_marked_straight(length: float, marker: str | None = None) -> Pattern:
@ -1017,10 +1017,90 @@ def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None:
p = Pather(Library(), tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
p.straight("A", 5, marker="route")
first_options = {'marker': 'first'}
p.straight("A", 5, tool_options=first_options)
first_options['marker'] = 'mutated'
p.straight("A", 6, tool_options={'marker': 'second'})
first_data, second_data = (step.data for step in p._paths['A'])
assert isinstance(first_data, AutoTool.GeneratedData)
assert isinstance(second_data, AutoTool.GeneratedData)
assert dict(first_data.tool_options) == {'marker': 'first'}
assert dict(second_data.tool_options) == {'marker': 'second'}
p.render()
assert markers == [None]
assert markers == ['first', 'second']
def test_autotool_route_options_do_not_attach_to_reusable_bends(
multi_bend_tool: tuple[AutoTool, Library],
) -> None:
tool, _lib = multi_bend_tool
offers = tool.primitive_offers('bend', in_ptype='wire', ccw=True, marker='ignored')
assert offers
assert all(isinstance(offer.commit(offer.parameter_domain[0]), AutoTool.ReusableData) for offer in offers)
def test_autotool_sbend_generator_receives_route_options() -> None:
markers: list[str] = []
def make_marked_sbend(jog: float, *, marker: str) -> Pattern:
markers.append(marker)
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0, ptype='wire')
pat.ports['B'] = Port((3, jog), pi, ptype='wire')
return pat
tool = AutoTool().add_sbend(
make_marked_sbend,
'wire',
'A',
'B',
endpoint=lambda jog: Port((3, jog), pi, ptype='wire'),
)
p = Pather(Library(), tools=tool, render='deferred')
p.ports['A'] = Port((0, 0), 0, ptype='wire')
p.jog('A', 4, length=3, tool_options={'marker': 'sbend'})
p.render()
assert markers == ['sbend']
def test_autotool_generated_bbox_uses_route_options() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A')
offer = tool.primitive_offers('straight', width=6)[0]
assert_allclose(offer.bbox_at(10), [[0, -3], [10, 3]])
def test_autotool_unsupported_generator_option_fails_during_render() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A')
p = Pather(Library(), tools=tool, render='deferred')
p.ports['A'] = Port((0, 0), 0, ptype='wire')
p.straight('A', 5, tool_options={'unknown_generator_option': True})
with pytest.raises(TypeError, match='unknown_generator_option'):
p.render()
def test_autotool_generator_options_must_preserve_planned_endpoint() -> None:
def shifted_straight(length: float, endpoint_shift: float = 0) -> Pattern:
pat = make_straight(length)
pat.ports['B'].x += endpoint_shift
return pat
tool = AutoTool().add_straight(shifted_straight, 'wire', 'A')
p = Pather(Library(), tools=tool, render='deferred')
p.ports['A'] = Port((0, 0), 0, ptype='wire')
p.straight('A', 5, tool_options={'endpoint_shift': 1})
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render()
@pytest.mark.parametrize("ccw", [False, True])
@ -1204,7 +1284,7 @@ def test_pather_autotool_pure_sbend_with_transition_dx() -> None:
assert_allclose(trans_port.offset, [5, 0])
assert isinstance(trans_data, AutoTool.ReusableData)
s_offer, s_port, s_data = selected_offer(tool, "s", 4, length=15, in_ptype="core")
s_offer, s_port, s_data = selected_offer(tool, "s", 4, in_ptype="core")
assert_allclose(s_port.offset, [10, 4])
assert isinstance(s_data, AutoTool.GeneratedData)
assert s_offer.out_ptype == "core"

View file

@ -1,11 +1,12 @@
from collections.abc import Sequence
from typing import Any, Literal, Never
import inspect
import pytest
import numpy
from numpy import pi
from masque import MinimumStatus, Pather, Library, Port, RouteError, RouteFailurePolicy
from masque import MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy
from masque.builder.planner import RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import NoLegalRouteError
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
@ -199,7 +200,7 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None:
)
with pytest.raises(RouteError) as exc_info:
p.ccw('A', 1, out_ptype='wide', diagnostic_context='tool-value')
p.ccw('A', 1, out_ptype='wide', tool_options={'diagnostic_context': 'tool-value'})
details = exc_info.value.details
assert isinstance(exc_info.value, BuildError)
@ -211,7 +212,7 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None:
'ccw': True,
'length': 1,
'out_ptype': 'wide',
'diagnostic_context': 'tool-value',
'tool_options': {'diagnostic_context': 'tool-value'},
}
assert details.resolved_length == 1
assert details.resolved_jog is None
@ -705,7 +706,7 @@ def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.uturn('A', 4, **kwargs)
def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None:
@ -935,3 +936,74 @@ def test_pather_uturn_failed_two_bend_route_is_atomic() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p._paths['A']) == 0
@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_have_no_catch_all_kwargs(route_type: type, name: str) -> None:
parameters = inspect.signature(getattr(route_type, name)).parameters.values()
assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters)
def test_routing_typo_fails_before_tool_lookup() -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.straight('A', lenght=10) # type: ignore[call-arg]
assert tool.offer_calls == 0
@pytest.mark.parametrize('tool_options', [{1: 'bad'}, {'ccw': False}, {'out_ptype': 'bad'}])
def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, Any]) -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(BuildError, match='tool_options'):
p.trace('A', None, tool_options=tool_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize(
'operation',
[
lambda p: p.jog('A', numpy.nan, length=1),
lambda p: p.uturn('A', numpy.inf, length=1),
lambda p: p.straight('A', x=numpy.nan),
],
ids=['jog-offset', 'uturn-offset', 'position-bound'],
)
def test_nonfinite_route_geometry_fails_before_offer_query(operation: 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='finite'):
operation(p)
assert tool.offer_calls == 0
@pytest.mark.parametrize('spacing', [-1, numpy.nan, numpy.inf])
def test_invalid_bundle_spacing_fails_before_offer_query(spacing: float) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={
'A': Port((0, 0), rotation=0, ptype='wire'),
'B': Port((0, 4), rotation=0, ptype='wire'),
},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='spacing'):
p.trace(['A', 'B'], True, xmin=-10, spacing=spacing)
assert tool.offer_calls == 0

View file

@ -313,7 +313,7 @@ def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None:
def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('s', in_ptype='wire', length=6)[0]
offer = tool.primitive_offers('s', in_ptype='wire')[0]
bounds = offer.bbox_at(3)
expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single()
@ -333,7 +333,7 @@ def test_pathtool_u_offers_remain_unsupported() -> None:
[
('straight', {}),
('bend', {'ccw': True}),
('s', {'length': 6}),
('s', {}),
],
)
def test_pathtool_out_ptype_unk_is_wildcard(
@ -348,6 +348,12 @@ def test_pathtool_out_ptype_unk_is_wildcard(
assert offers[0].out_ptype == 'wire'
def test_pathtool_rejects_unsupported_planning_options() -> None:
tool = PathTool(layer='M1', width=1)
with pytest.raises(BuildError, match='does not support tool options: marker'):
tool.primitive_offers('straight', marker='sentinel')
def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
class NoOfferTool(PlanningOnlyTool):
def primitive_offers(
@ -437,13 +443,14 @@ class StrategyTieTool(PlanningOnlyTool):
) -> tuple[PrimitiveOffer, ...]:
self.seen_kwargs.append(dict(kwargs))
endpoint_ptype = out_ptype or in_ptype
marker = kwargs.get('marker')
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},
{'kind': 'straight', 'length': length, 'marker': marker},
)),
),)
if kind == 's':
@ -452,7 +459,7 @@ class StrategyTieTool(PlanningOnlyTool):
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((3, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog},
{'kind': 's', 'jog': jog, 'marker': marker},
)),
),)
return ()
@ -504,11 +511,12 @@ def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
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')
pather.jog('A', 4, length=10, strategy='turn_first', tool_options={'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)
assert all(step.data['marker'] == 'sentinel' for step in pather._paths['A'])
def test_pather_route_strategy_rejects_invalid_values() -> None:

View file

@ -252,7 +252,8 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = dst
with pytest.raises(BuildError, match=match):
_ = match
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.trace_into('A', 'B', plug_destination=False, **kwargs)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))