From 22e645e527ac642136979de47902874b58e6cc06 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 8 Jul 2026 23:58:24 -0700 Subject: [PATCH] [builder] major Pather/Planner/Tool rework --- MIGRATION.md | 225 +- examples/tutorial/README.md | 4 +- examples/tutorial/pather.py | 350 ++- examples/tutorial/renderpather.py | 2 +- masque/__init__.py | 1 - masque/builder/__init__.py | 49 +- masque/builder/logging.py | 44 +- masque/builder/pather.py | 1155 ++++------ masque/builder/planner/__init__.py | 15 + masque/builder/planner/bounds.py | 257 +++ masque/builder/planner/interface.py | 83 + masque/builder/planner/planner.py | 1226 ++++++++++ masque/builder/tools.py | 2239 +++++++++---------- masque/pattern.py | 16 +- masque/ports.py | 28 +- masque/test/helpers.py | 119 + masque/test/test_autotool_planning.py | 987 ++++++-- masque/test/test_builder.py | 10 + masque/test/test_pather_autotool.py | 68 +- masque/test/test_pather_constraints.py | 502 ++++- masque/test/test_pather_core.py | 163 +- masque/test/test_pather_place_plug.py | 20 +- masque/test/test_pather_primitive_offers.py | 514 +++++ masque/test/test_pather_rendering.py | 195 +- masque/test/test_pather_trace_into.py | 121 +- masque/test/test_ports.py | 61 + masque/utils/__init__.py | 5 + masque/utils/ptypes.py | 22 + 28 files changed, 6025 insertions(+), 2456 deletions(-) create mode 100644 masque/builder/planner/__init__.py create mode 100644 masque/builder/planner/bounds.py create mode 100644 masque/builder/planner/interface.py create mode 100644 masque/builder/planner/planner.py create mode 100644 masque/test/test_pather_primitive_offers.py create mode 100644 masque/utils/ptypes.py diff --git a/MIGRATION.md b/MIGRATION.md index 818b133..dad5969 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -134,11 +134,8 @@ previously used `RenderPather`. ## `BasicTool` was replaced -`BasicTool` is no longer exported. Use: - -- `SimpleTool` for the simple "one straight generator + one bend cell" case -- `AutoTool` if you need transitions, multiple candidate straights/bends, or - S-bends/U-bends +`BasicTool` is no longer exported. Use `AutoTool` for reusable straight, bend, +transition, and S-bend primitives. ### Old `BasicTool` @@ -157,55 +154,32 @@ tool = BasicTool( ### New `AutoTool` ```python -from masque.builder.tools import AutoTool +from masque.builder import AutoTool -tool = AutoTool( - straights=[ - AutoTool.Straight( - ptype='m1wire', - fn=make_straight, - in_port_name='input', - out_port_name='output', - ), - ], - bends=[ - AutoTool.Bend( - abstract=lib.abstract('bend'), - in_port_name='input', - out_port_name='output', - clockwise=True, - ), - ], - sbends=[], - transitions={ - ('m2wire', 'm1wire'): AutoTool.Transition( - lib.abstract('via'), - 'top', - 'bottom', - ), - }, - default_out_ptype='m1wire', +tool = ( + AutoTool() + .add_straight('m1wire', make_straight, 'input') + .add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True) + .add_transition(lib.abstract('via'), 'top', 'bottom') ) ``` The key differences are: -- `BasicTool` -> `SimpleTool` or `AutoTool` -- `straight=(fn, in_name, out_name)` -> `straights=[AutoTool.Straight(...)]` -- `bend=(abstract, in_name, out_name)` -> `bends=[AutoTool.Bend(...)]` -- transition keys are now `(external_ptype, internal_ptype)` tuples -- transitions use `AutoTool.Transition(...)` instead of raw tuples - -If your old `BasicTool` usage did not rely on transitions or multiple routing -options, `SimpleTool` is the closest replacement. +- `BasicTool` -> `AutoTool` +- `straight=(fn, in_name, out_name)` -> `add_straight(ptype, fn, in_name)` +- `bend=(abstract, in_name, out_name)` -> `add_bend(abstract, in_name, out_name)` +- transitions are registered with `add_transition(abstract, external_port, internal_port)` +- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter ## Custom `Tool` subclasses If you maintain your own `Tool` subclass, the interface changed: -- `Tool.path(...)` became `Tool.traceL(...)` -- `Tool.traceS(...)` and `Tool.traceU(...)` were added for native S/U routes -- `planL()` / `planS()` / `planU()` remain the planning hooks used by deferred rendering +- `primitive_offers()` is now the planning boundary +- `render()` consumes committed primitive render tokens +- `Tool.path(...)`, `traceL()`, `traceS()`, `traceU()`, `planL()`, + `planS()`, and `planU()` are no longer part of the public `Tool` API In practice, a minimal old implementation like: @@ -218,14 +192,168 @@ class MyTool(Tool): should now become: ```python +from collections.abc import Sequence +from typing import Any + +from masque import Port +from masque.builder import RenderStep, StraightOffer, Tool + + class MyTool(Tool): - def traceL(self, ccw, length, **kwargs): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): + if kind != 'straight': + return () + + def endpoint(length): + ptype = out_ptype or in_ptype + return Port((length, 0), rotation=3.141592653589793, ptype=ptype) + + def commit(length): + return {'length': length} + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype or in_ptype, + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + def render(self, batch: Sequence[RenderStep], **kwargs: Any): ... ``` -If you do not implement `traceS()` or `traceU()`, the unified pather will -either fall back to the planning hooks or synthesize those routes from simpler -steps where possible. +If a tool does not provide a primitive kind, return `()` for that kind. `Pather` +will compose available primitive offers where the route family allows it. + +### Primitive offers + +Tools describe legal routing primitives through `Tool.primitive_offers()`. +`Pather` composes those primitive offers to implement `trace()`, `jog()`, +`uturn()`, and `trace_into()`. + +For custom tools, construct the concrete offer class that matches the primitive +you are exposing: + +- `StraightOffer` for non-turning length-parameterized primitives +- `BendOffer` for single-turn length-parameterized primitives +- `SOffer` for S-like jog-parameterized primitives +- `UOffer` for U-like jog-parameterized primitives + +`PrimitiveOffer` is the shared base type used for generic annotations and +common callback behavior. It is not the normal class users should instantiate. +The concrete offer classes carry the semantic fields (`length_domain`, +`jog_domain`, `ccw`) so tools do not need to encode primitive identity in +strings. + +Minimal straight-only example: + +```python +from collections.abc import Sequence +from typing import Literal + +from masque import Port +from masque.builder import RenderStep, StraightOffer, Tool + + +class MyTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype=None, + out_ptype=None, + **kwargs, + ): + if kind != 'straight': + return () + + def endpoint(length): + ptype = out_ptype or in_ptype + return Port((length, 0), rotation=3.141592653589793, ptype=ptype) + + def commit(length): + return {'length': length} + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype or in_ptype, + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + def render(self, batch: Sequence[RenderStep], **kwargs): + ... +``` + +Primitive offers are local planning objects: + +- `endpoint_at(parameter)` returns the local output `Port` +- `cost_at(parameter)` returns an additive scalar route-selection cost +- `bbox_at(parameter)` returns local primitive bounds when a footprint hook is supplied +- `parameterized_bbox` may carry opaque future-router footprint metadata +- `commit(parameter)` returns opaque render data consumed later by `render()` +- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton +- selected parameter values must be finite; domains may use infinite open bounds but not `NaN` +- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer + +Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype +adapters. Requested `out_ptype` constrains only the final route endpoint; any +intermediate ptypes are chosen by the route solver. + +`Tool` subclasses must override `primitive_offers()` and return `()` themselves +for recognized unsupported kinds. There is no route-level `plan*()` fallback. +Omitted-length S/U behavior comes from direct `SOffer` and `UOffer` endpoint +domains or from composed straight/bend primitives. + +Offer constructors accept split `endpoint_planner` and `commit_planner` +callbacks. Provide both callbacks or override the offer methods in a subclass; +partial callback configurations are rejected during offer construction. + +When writing direct primitive offers, declare the actual endpoint ptype +produced by the offer if it can differ from the requested value; `Pather` +validates evaluated endpoints against the declared offer ptype. + +Stable imports for custom tool authors live in `masque.builder`. The +`masque.builder.planner` module is an internal planner implementation; do not +import it from user code. + +`trace_into()` uses the same primitive-offer route selection and now searches +bounded route topologies with up to four bend roles. This preserves the common +straight, bend, S-like, U-like, and dogleg cases while allowing routes that +need an additional bounded bend pair. Among legal bounded candidates, +`trace_into()` selects the lowest total primitive-offer cost; bend count and +step count are used only to break exact cost ties. + +Explicit-length `jog()` routes may also be satisfied by composing a straight +primitive before or after an omitted-length native S primitive. `uturn()` routes +may compose a straight primitive before an omitted-length native U primitive. +These compositions are used when they are the lowest-cost legal route for the +explicit request. + +`AutoTool` can attach `bbox_at()` hooks to its primitive offers by rendering the +selected primitive into a temporary pattern and measuring it. If the rendered +primitive contains reusable refs, pass the source library as `bbox_library=...`; +normal routing does not require this. + +### Omitted-length routing + +Single-port omitted-length calls now evaluate legal primitive routes at their +minimum legal length-like parameter, or at their intrinsic endpoint length when +the requested offset fixes the primitive geometry. Cost then selects among +those minimum-length candidates: + +```python +pather.trace('A', None) # minimum straight-like route +pather.jog('A', offset=2) # minimum S-like route for that offset +pather.uturn('A', offset=4) # minimum U-like route for that offset +``` + +For U-turns, use explicit `length=0` to request the old zero-public-length +shape: + +```python +pather.uturn('A', offset=4, length=0) +``` ## Transform semantics changed @@ -279,7 +407,6 @@ If you install the DXF extra, the supported `ezdxf` baseline moved from These are additive, but available now from `masque` and `masque.builder`: - `PortPather` -- `SimpleTool` - `AutoTool` - `boolean` @@ -289,7 +416,7 @@ If your code uses the routing stack, do these first: 1. Replace `path`/`path_to`/`mpath`/`path_into` calls with `trace`/`trace_to`/multi-port `trace`/`trace_into`. -2. Replace `BasicTool` with `SimpleTool` or `AutoTool`. +2. Replace `BasicTool` with `AutoTool`. 3. Fix imports that still reference `masque.builder.builder` or `masque.builder.renderpather`. 4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`. diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index dfdb1ff..c03e4a0 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -26,8 +26,8 @@ Contents * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` - [pather](pather.py) * Use `Pather` to route individual wires and wire bundles - * Use `AutoTool` to generate paths - * Use `AutoTool` to automatically transition between path types + * Define a custom `Tool` that exposes primitive routing offers + * Use primitive offers to automatically transition between path types - [renderpather](renderpather.py) * Use `Pather(auto_render=False)` and `PathTool` to build a layout similar to the one in [pather](pather.py), but using `Path` shapes instead of `Polygon`s. diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index 5cc5a61..8b138a8 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,10 +1,18 @@ """ -Manual wire routing tutorial: Pather and AutoTool +Manual wire routing tutorial: Pather and primitive offers """ +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Literal + +import numpy from numpy import pi from masque import Pather, Library, Pattern, Port, layer_t -from masque.builder.tools import AutoTool, Tool +from masque.abstract import Abstract +from masque.builder import BendOffer, RenderStep, StraightOffer, Tool +from masque.error import BuildError from masque.file.gdsii import writefile +from masque.library import ILibrary, SINGLE_USE_PREFIX from basic_shapes import GDS_OPTS @@ -111,6 +119,259 @@ def map_layer(layer: layer_t) -> layer_t: return layer +@dataclass(frozen=True, slots=True) +class WireStraightData: + length: float + out_transition: 'WireTransitionSpec | None' = None + + +@dataclass(frozen=True, slots=True) +class WireBendData: + straight_length: float + ccw: bool + + +@dataclass(frozen=True, slots=True) +class WireTransitionSpec: + abstract: Abstract + in_port_name: str + out_port_name: str + + @property + def in_port(self) -> Port: + return self.abstract.ports[self.in_port_name] + + @property + def out_port(self) -> Port: + return self.abstract.ports[self.out_port_name] + + +@dataclass(frozen=True, slots=True) +class WireTransitionData: + spec: WireTransitionSpec + + +@dataclass +class PrimitiveWireTool(Tool): + """ + Minimal routing tool that exposes local routing primitives directly. + + The high-level `Pather` methods below still decide how to compose straights, + bends, and ptype transitions. This tool only describes which one-step + primitives it can draw and how selected primitives should be rendered. + """ + layer: layer_t + width: float + ptype: str + bend: Abstract + transitions: Sequence[WireTransitionSpec] + + def _straight_pattern(self, length: float) -> Pattern: + return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length) + + @staticmethod + def _transition_length(spec: WireTransitionSpec) -> float | None: + dxy, angle = spec.in_port.measure_travel(spec.out_port) + if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0): + return None + return float(dxy[0]) + + def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]: + offers: list[StraightOffer] = [] + for index, spec in enumerate(self.transitions): + if spec.out_port.ptype != self.ptype: + continue + if in_ptype not in (None, 'unk', spec.in_port.ptype): + continue + + length = self._transition_length(spec) + if length is None: + continue + + def endpoint_planner( + parameter: float, + *, + spec: WireTransitionSpec = spec, + length: float = length, + ) -> Port: + _ = parameter + return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype) + + def commit_planner( + parameter: float, + *, + spec: WireTransitionSpec = spec, + ) -> WireTransitionData: + _ = parameter + return WireTransitionData(spec) + + offers.append(StraightOffer( + in_ptype = spec.in_port.ptype, + out_ptype = spec.out_port.ptype, + priority_bias = index * 1e7, + length_domain = (length, length), + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + )) + return tuple(offers) + + def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]: + if out_ptype in ('unk', self.ptype): + return () + + offers: list[StraightOffer] = [] + for index, spec in enumerate(self.transitions): + if spec.in_port.ptype != self.ptype: + continue + if out_ptype is not None and spec.out_port.ptype != out_ptype: + continue + + transition_length = self._transition_length(spec) + if transition_length is None: + continue + + def endpoint_planner( + length: float, + *, + spec: WireTransitionSpec = spec, + transition_length: float = transition_length, + ) -> Port: + straight_length = length - transition_length + if straight_length < 0: + raise BuildError( + f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}' + ) + return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype) + + def commit_planner( + length: float, + *, + spec: WireTransitionSpec = spec, + transition_length: float = transition_length, + ) -> WireStraightData: + endpoint_planner(length) + return WireStraightData(length - transition_length, spec) + + offers.append(StraightOffer( + in_ptype = self.ptype, + out_ptype = spec.out_port.ptype, + priority_bias = index * 1e7, + length_domain = (transition_length, numpy.inf), + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + )) + return tuple(offers) + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes) + **kwargs: Any, + ) -> tuple[StraightOffer | BendOffer, ...]: + if kind == 'straight': + route_kwargs = 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 + return WireStraightData(length) + + native_offer = StraightOffer( + in_ptype = self.ptype, + out_ptype = self.ptype, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ) + return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype)) + + if kind == 'bend': + ccw = bool(kwargs.pop('ccw')) + bend_forward = self.width / 2 + bend_run = bend_forward if ccw else -bend_forward + bend_rotation = -pi / 2 if ccw else pi / 2 + + def endpoint_planner(length: float) -> Port: + straight_length = length - bend_forward + if straight_length < 0: + raise BuildError( + f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}' + ) + return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype) + + def commit_planner(length: float) -> WireBendData: + endpoint_planner(length) + return WireBendData(straight_length=length - bend_forward, ccw=ccw) + + return (BendOffer( + in_ptype = self.ptype, + out_ptype = self.ptype, + ccw = ccw, + length_domain = (bend_forward, numpy.inf), + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ),) + + if kind in ('s', 'u'): + return () + + raise BuildError(f'Unrecognized primitive offer kind {kind!r}') + + def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None: + if numpy.isclose(data.length, 0) and data.out_transition is None: + return + + if not numpy.isclose(data.length, 0): + tree.top_pattern().plug( + self._straight_pattern(data.length), + {port_names[1]: 'input'}, + append=True, + ) + + if data.out_transition is not None: + self._render_transition(tree, port_names, WireTransitionData(data.out_transition)) + + def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None: + self._render_straight(tree, port_names, WireStraightData(data.straight_length)) + tree.top_pattern().plug( + self.bend, + {port_names[1]: 'input'}, + mirrored=data.ccw, + ) + + @staticmethod + def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None: + tree.top_pattern().plug( + data.spec.abstract, + {port_names[1]: data.spec.in_port_name}, + ) + + def render( + self, + 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) + + for step in batch: + assert step.tool == self + if isinstance(step.data, WireTransitionData): + self._render_transition(tree, port_names, step.data) + elif isinstance(step.data, WireStraightData): + self._render_straight(tree, port_names, step.data) + elif isinstance(step.data, WireBendData): + self._render_bend(tree, port_names, step.data) + else: + raise BuildError(f'Unexpected primitive render data {type(step.data)}') + return tree + + def prepare_tools() -> tuple[Library, Tool, Tool]: """ Create some basic library elements and tools for drawing M1 and M2 @@ -133,70 +394,33 @@ def prepare_tools() -> tuple[Library, Tool, Tool]: # # Now, define two tools. - # M1_tool will route on M1, using wires with M1_WIDTH - # M2_tool will route on M2, using wires with M2_WIDTH - # Both tools are able to automatically transition from the other wire type (with a via) + # M1_tool will route on M1, using wires with M1_WIDTH. + # M2_tool will route on M2, using wires with M2_WIDTH. # - # Note that while we use AutoTool for this tutorial, you can define your own `Tool` - # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, - # transmission line geometry, or other features. + # Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers + # directly: it tells `Pather` about native straight/bend primitives and about + # via adapters that can transition between M1 and M2 port types. # - M1_tool = AutoTool( - # First, we need a function which takes in a length and spits out an M1 wire - straights = [ - AutoTool.Straight( - ptype = 'm1wire', - fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), - in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input - out_port_name = 'output', # and use the port named 'output' as the output - ), - ], - bends = [ - AutoTool.Bend( - abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier - in_port_name = 'input', - out_port_name = 'output', - clockwise = True, - ), - ], - transitions = { # We can automate transitions for different (normally incompatible) port types - ('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire' - library.abstract('v1_via'), # we can place a V1 via - 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) - 'bottom', # and using the port named 'bottom' as the output - ), - }, - sbends = [], - default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 + via = library.abstract('v1_via') + via_transitions = ( + WireTransitionSpec(via, 'top', 'bottom'), + WireTransitionSpec(via, 'bottom', 'top'), ) - M2_tool = AutoTool( - straights = [ - # Again, we use make_straight_wire, but this time we set parameters for M2 - AutoTool.Straight( - ptype = 'm2wire', - fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), - in_port_name = 'input', - out_port_name = 'output', - ), - ], - bends = [ - # and we use an M2 bend - AutoTool.Bend( - abstract = library.abstract('m2_bend'), - in_port_name = 'input', - out_port_name = 'output', - ), - ], - transitions = { - ('m1wire', 'm2wire'): AutoTool.Transition( - library.abstract('v1_via'), # We still use the same via, - 'bottom', # but the input port is now 'bottom' - 'top', # and the output port is now 'top' - ), - }, - sbends = [], - default_out_ptype = 'm2wire', # We default to trying to stay on M2 + M1_tool = PrimitiveWireTool( + layer = 'M1', + width = M1_WIDTH, + ptype = 'm1wire', + bend = library.abstract('m1_bend'), + transitions = via_transitions, + ) + + M2_tool = PrimitiveWireTool( + layer = 'M2', + width = M2_WIDTH, + ptype = 'm2wire', + bend = library.abstract('m2_bend'), + transitions = via_transitions, ) return library, M1_tool, M2_tool @@ -292,7 +516,7 @@ def main() -> None: pather.straight('GND', x=-50_000) # Save the pather's pattern into our library - library['Pather_and_AutoTool'] = pather.pattern + library['Pather_and_PrimitiveOffers'] = pather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file library.map_layers(map_layer) diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index 4b43b19..01fd352 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -2,7 +2,7 @@ Manual wire routing tutorial: deferred Pather and PathTool """ from masque import Pather, Library -from masque.builder.tools import PathTool +from masque.builder import PathTool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS diff --git a/masque/__init__.py b/masque/__init__.py index e240ae3..f64b180 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -81,7 +81,6 @@ from .builder import ( Tool as Tool, Pather as Pather, RenderStep as RenderStep, - SimpleTool as SimpleTool, AutoTool as AutoTool, PathTool as PathTool, PortPather as PortPather, diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index a8f4cc0..54c6c45 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,3 +1,42 @@ +""" +Builder helpers for port-based assembly and primitive-offer routing. + +A routing `Tool` describes the primitive route families it can provide by +returning `PrimitiveOffer` objects. Each offer is a parameterized planning +candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost, +optional footprint metadata, and a commit hook for producing tool-specific +render data after a concrete parameter has been selected. + +`Pather` owns user-facing route operations such as `trace()`, `jog()`, +`uturn()`, and `trace_into()`. For each operation, it asks the active `Tool` for +offers and passes those offers plus route constraints to the internal router. +The router selects a sequence of internal selected primitives, each pairing an +offer with a concrete parameter, endpoint, and cost. + +Route commit is separate from route selection. Once a route is selected, +`Pather` calls `offer.commit(parameter)` only for the selected primitives and +stores the returned opaque tool payload in `RenderStep.data`. Later, +`Pather.render()` batches compatible `RenderStep`s and calls `Tool.render()` to +turn those committed payloads into geometry. + +`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract. +`RenderStep` is `Pather`'s deferred-render record, and +`masque.builder.planner` is an internal planner implementation rather than a +stable public API. + +The practical layering is: +- user code drives `Pather` and chooses Tools per port or by default, +- Tools describe local legal motion primitives without touching Pather state, +- the internal router composes those primitives into high-level route shapes, +- Pather applies the prepared result to ports, deferred render queues, and the + target pattern/library. + +Code outside the builder package should prefer the exports here over importing +from `masque.builder.planner`. The planner package is intentionally available +for tests and internal maintenance, but it is not the compatibility boundary +for custom Tools. +""" + from .pather import ( Pather as Pather, PortPather as PortPather, @@ -5,9 +44,13 @@ from .pather import ( from .utils import ell as ell from .tools import ( Tool as Tool, - RenderStep as RenderStep, - SimpleTool as SimpleTool, AutoTool as AutoTool, PathTool as PathTool, + RenderStep as RenderStep, + PrimitiveKind as PrimitiveKind, + PrimitiveOffer as PrimitiveOffer, + StraightOffer as StraightOffer, + BendOffer as BendOffer, + SOffer as SOffer, + UOffer as UOffer, ) -from .logging import logged_op as logged_op diff --git a/masque/builder/logging.py b/masque/builder/logging.py index b4a113b..e7cfb16 100644 --- a/masque/builder/logging.py +++ b/masque/builder/logging.py @@ -1,19 +1,13 @@ -""" -Logging and operation decorators for Pather -""" +"""Logging helpers for Pather.""" from typing import TYPE_CHECKING, Any -from collections.abc import Iterator, Sequence, Callable +from collections.abc import Iterator, Sequence import logging -from functools import wraps -import inspect import numpy from contextlib import contextmanager if TYPE_CHECKING: from .pather import Pather -logger = logging.getLogger(__name__) - def _format_log_args(**kwargs) -> str: arg_strs = [] @@ -84,37 +78,3 @@ class PatherLogger: self.indent -= 1 self.depth -= 1 - - -def logged_op( - portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None, - ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """ - Decorator to wrap Pather methods with logging. - """ - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - sig = inspect.signature(func) - - @wraps(func) - def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any: - logger_obj = getattr(self, '_logger', None) - if logger_obj is None or not logger_obj.debug: - return func(self, *args, **kwargs) - - bound = sig.bind(self, *args, **kwargs) - bound.apply_defaults() - all_args = bound.arguments - # remove 'self' from logged args - logged_args = {k: v for k, v in all_args.items() if k != 'self'} - - ps = portspec_getter(all_args) if portspec_getter else None - - # Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation - logged_args.pop('portspec', None) - - with logger_obj.log_operation(self, func.__name__, ps, **logged_args): - if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'): - logger.warning(f"Skipping geometry for {func.__name__}() since device is dead") - return func(self, *args, **kwargs) - return wrapper - return decorator diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 8398e2e..1206d60 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1,15 +1,41 @@ """ -Unified Pattern assembly and routing (`Pather`) +Unified Pattern assembly and routing (`Pather`). + +`Pather` is the public object that owns layout state. It holds the working +Pattern, Library, per-port Tool assignments, pending `RenderStep`s, and routing +side effects such as plug consumption, port renames, and auto-render. The +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. + +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, +- selection/preparation: the planner validates the route mode, selects a legal + primitive-offer composition, commits only the chosen offers into opaque + `RenderStep.data`, and returns prepared actions, +- application: `Pather` appends the prepared steps to its pending queue, + replaces live output ports, consumes plug destinations, applies deferred + trace-thru renames, and batches auto-render around the whole route, +- rendering: pending steps are grouped by live port, Tool, and continuity before + `Tool.render()` builds geometry for each compatible batch. + +This split keeps selection failures largely transactional for live Pather +state: unsupported primitive combinations can fail before the Pattern, pending +step queue, or Library are touched. Once selected offers are committed and +prepared actions are applied, later commit, plug, rename, render, or insertion +failures may leave partial output; that mutation boundary belongs to Pather, +not to Tool implementations or the route solver. """ -from typing import Self, Literal, Any, overload -from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence, Callable +from typing import Self, Any, overload +from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence import copy import logging from collections import defaultdict from functools import wraps from pprint import pformat -from itertools import chain from contextlib import contextmanager +from itertools import chain import numpy from numpy import pi @@ -20,10 +46,21 @@ from ..library import ILibrary, TreeView, SINGLE_USE_PREFIX from ..error import BuildError, PortError from ..ports import PortList, Port from ..abstract import Abstract -from ..utils import SupportsBool -from .tools import Tool, RenderStep -from .utils import ell -from .logging import logged_op, PatherLogger +from ..utils import SupportsBool, ptypes_compatible +from .tools import ( + Tool, + RenderStep, + ) +from .planner.interface import ( + PreparedRouteResult, + RoutePortContext, + route_error_is_fatal, + ) +from .planner import ( + RoutingPlanner, + ) +from .planner.bounds import resolved_position_bound +from .logging import PatherLogger logger = logging.getLogger(__name__) @@ -38,9 +75,10 @@ class Pather(PortList): The `Pather` holds context in the form of a `Library`, its underlying pattern, and a set of `Tool`s for generating routing segments. - Routing operations (`trace`, `jog`, `uturn`, etc.) are rendered - incrementally by default. Set `auto_render=False` to defer geometry - generation until an explicit call to `render()`. + Routing operations (`trace`, `jog`, `uturn`, etc.) select primitive offers + from the active `Tool` and compose them into deferred `RenderStep`s. + Set `auto_render=False` to defer geometry generation until an explicit call + to `render()`. Examples: Creating a Pather =========================== @@ -60,7 +98,7 @@ class Pather(PortList): Set `auto_render=False` to defer and call `pather.render()` later. """ __slots__ = ( - 'pattern', 'library', 'tools', 'paths', + 'pattern', 'library', 'tools', 'planner', '_paths', '_dead', '_logger', '_auto_render', '_auto_render_append' ) @@ -76,8 +114,13 @@ class Pather(PortList): A key of `None` indicates the default `Tool`. """ - paths: defaultdict[str, list[RenderStep]] - """ Per-port list of planned operations, to be used by `render()` """ + planner: RoutingPlanner + """ + Stateless route-selection facade. + + Per-solve mutable state belongs in routing search/catalog objects rather + than on the planner instance. + """ _dead: bool """ If True, geometry generation is skipped (for debugging) """ @@ -88,16 +131,25 @@ class Pather(PortList): _auto_render: bool """ If True, routing operations call render() immediately """ - PROBE_LENGTH: float = 1e6 - """ Large length used when probing tools for their lateral displacement """ + _paths: defaultdict[str, list[RenderStep]] + """ Per-port pending render steps, consumed by `render()` """ - _POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position') - """ Single-port position bounds accepted by `trace_to()` and `jog()` """ + def _route_context(self, portspec: str) -> RoutePortContext: + """ + Snapshot the live port and selected Tool for planning. - _BUNDLE_BOUND_KEYS: tuple[str, ...] = ( - 'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest', - ) - """ Bounds accepted by `trace()` / `trace_to()` when solving bundle extensions """ + The port copy lets route-selection failures leave live Pather state + unchanged. Tool lookup prefers a port-specific Tool and falls back to + the `None` default. + """ + tool = self.tools.get(portspec, self.tools.get(None)) + if tool is None: + raise BuildError(f'No tool assigned for port {portspec}') + return RoutePortContext(portspec, self.pattern[portspec].copy(), tool) + + def _route_contexts(self, portspecs: Sequence[str]) -> tuple[RoutePortContext, ...]: + """Snapshot several ports in request order for bundle planning.""" + return tuple(self._route_context(portspec) for portspec in portspecs) @property def ports(self) -> dict[str, Port]: @@ -118,6 +170,7 @@ class Pather(PortList): debug: bool = False, auto_render: bool = True, auto_render_append: bool = True, + planner: RoutingPlanner | None = None, ) -> None: """ Args: @@ -131,6 +184,8 @@ class Pather(PortList): auto_render: If True, enables immediate rendering of routing steps. auto_render_append: If `auto_render` is True, determines whether to append geometry or add a reference. + planner: Optional stateless route-selection planner. If omitted, + a new `RoutingPlanner` is used. """ self._dead = False self._logger = PatherLogger(debug=debug) @@ -138,7 +193,8 @@ class Pather(PortList): self._auto_render_append = auto_render_append self.library = library self.pattern = pattern if pattern is not None else Pattern() - self.paths = defaultdict(list) + self.planner = RoutingPlanner() if planner is None else planner + self._paths = defaultdict(list) if ports is not None: if self.pattern.ports: @@ -158,8 +214,8 @@ class Pather(PortList): library[name] = self.pattern def __del__(self) -> None: - if any(self.paths.values()): - logger.warning(f'Pather {self} had unrendered paths', stack_info=True) + if any(self._paths.values()): + logger.warning(f'Pather {self} had pending render steps', stack_info=True) def __repr__(self) -> str: s = f'' @@ -168,33 +224,26 @@ class Pather(PortList): # # Core Pattern Operations (Immediate) # - def _prepare_break(self, name: str | None) -> tuple[str, RenderStep] | None: - """ Snapshot one batch-breaking step for a name with deferred geometry. """ - if self._dead or name is None: - return None - - steps = self.paths.get(name) - if not steps: - return None - - port = self.ports.get(name, steps[-1].end_port) - return name, RenderStep('P', None, port.copy(), port.copy(), None) - def _prepare_breaks(self, names: Iterable[str | None]) -> list[tuple[str, RenderStep]]: """ Snapshot break markers to be committed after a successful mutation. """ prepared: list[tuple[str, RenderStep]] = [] - for n in names: - step = self._prepare_break(n) - if step is not None: - prepared.append(step) + if self._dead: + return prepared + for name in names: + if name is None: + continue + steps = self._paths.get(name) + if not steps: + continue + port = self.ports.get(name, steps[-1].end_port) + prepared.append((name, RenderStep('P', None, port.copy(), port.copy(), None))) return prepared def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None: """ Append previously prepared break markers. """ for name, step in prepared: - self.paths[name].append(step) + self._paths[name].append(step) - @logged_op(lambda args: list(args['map_in'].keys())) def plug( self, other: Abstract | str | Pattern | TreeView, @@ -202,75 +251,80 @@ class Pather(PortList): map_out: dict[str, str | None] | None = None, **kwargs, ) -> Self: - other = self.library.resolve(other, append=kwargs.get('append', False)) + with self._logger.log_operation(self, 'plug', list(map_in.keys()), map_out=map_out, **kwargs): + other = self.library.resolve(other, append=kwargs.get('append', False)) - prepared_breaks: list[tuple[str, RenderStep]] = [] - if not self._dead: - other_ports = other.ports - affected = set(map_in.keys()) - plugged = set(map_in.values()) - for name in other_ports: - if name not in plugged: - new_name = (map_out or {}).get(name, name) - if new_name is not None: - affected.add(new_name) - prepared_breaks = self._prepare_breaks(affected) + prepared_breaks: list[tuple[str, RenderStep]] = [] + if not self._dead: + other_ports = other.ports + affected = set(map_in.keys()) + plugged = set(map_in.values()) + for name in other_ports: + if name not in plugged: + new_name = (map_out or {}).get(name, name) + if new_name is not None: + affected.add(new_name) + prepared_breaks = self._prepare_breaks(affected) + elif self._logger.debug: + logger.warning("Skipping geometry for plug() since device is dead") - self.pattern.plug(other=other, map_in=map_in, map_out=map_out, skip_geometry=self._dead, **kwargs) - self._commit_breaks(prepared_breaks) - return self + self.pattern.plug(other=other, map_in=map_in, map_out=map_out, skip_geometry=self._dead, **kwargs) + self._commit_breaks(prepared_breaks) + return self - @logged_op() def place( self, other: Abstract | str | Pattern | TreeView, port_map: dict[str, str | None] | None = None, **kwargs, ) -> Self: - other = self.library.resolve(other, append=kwargs.get('append', False)) + with self._logger.log_operation(self, 'place', None, port_map=port_map, **kwargs): + other = self.library.resolve(other, append=kwargs.get('append', False)) - prepared_breaks: list[tuple[str, RenderStep]] = [] - if not self._dead: - other_ports = other.ports - affected = set() - for name in other_ports: - new_name = (port_map or {}).get(name, name) - if new_name is not None: - affected.add(new_name) - prepared_breaks = self._prepare_breaks(affected) + prepared_breaks: list[tuple[str, RenderStep]] = [] + if not self._dead: + other_ports = other.ports + affected = set() + for name in other_ports: + new_name = (port_map or {}).get(name, name) + if new_name is not None: + affected.add(new_name) + prepared_breaks = self._prepare_breaks(affected) + elif self._logger.debug: + logger.warning("Skipping geometry for place() since device is dead") - self.pattern.place(other=other, port_map=port_map, skip_geometry=self._dead, **kwargs) - self._commit_breaks(prepared_breaks) - return self + self.pattern.place(other=other, port_map=port_map, skip_geometry=self._dead, **kwargs) + self._commit_breaks(prepared_breaks) + return self - @logged_op(lambda args: list(args['connections'].keys())) def plugged(self, connections: dict[str, str]) -> Self: - prepared_breaks = self._prepare_breaks(chain(connections.keys(), connections.values())) - self.pattern.plugged(connections) - self._commit_breaks(prepared_breaks) - return self + with self._logger.log_operation(self, 'plugged', list(connections.keys()), connections=connections): + prepared_breaks = self._prepare_breaks(chain(connections.keys(), connections.values())) + self.pattern.plugged(connections) + self._commit_breaks(prepared_breaks) + return self - @logged_op(lambda args: list(args['mapping'].keys())) def rename_ports(self, mapping: dict[str, str | None], overwrite: bool = False) -> Self: - winners = self.pattern._rename_ports_impl( - mapping, - overwrite=overwrite or self._dead, - allow_collisions=self._dead, - ) + with self._logger.log_operation(self, 'rename_ports', list(mapping.keys()), mapping=mapping, overwrite=overwrite): + winners = self.pattern._rename_ports_impl( + mapping, + overwrite=overwrite or self._dead, + allow_collisions=self._dead, + ) - moved_steps = {kk: self.paths.pop(kk) for kk in mapping if kk in self.paths} - for kk, steps in moved_steps.items(): - vv = mapping[kk] - # Preserve deferred geometry even if the live port is deleted. - # `render()` can still materialize the saved steps using their stored start/end ports. - # Current semantics intentionally keep deleted ports' queued steps under the old key, - # so if a new live port later reuses that name it does not retarget the old geometry; - # the old and new routes merely share a render bucket until `render()` consumes them. - target = kk if vv is None else vv - if self._dead and vv is not None and winners.get(vv) != kk: - target = kk - self.paths[target].extend(steps) - return self + moved_steps = {kk: self._paths.pop(kk) for kk in mapping if kk in self._paths} + for kk, steps in moved_steps.items(): + vv = mapping[kk] + # Preserve deferred geometry even if the live port is deleted. + # `render()` can still materialize the saved steps using their stored start/end ports. + # Current semantics intentionally keep deleted ports' queued steps under the old key, + # so if a new live port later reuses that name it does not retarget the old geometry; + # the old and new routes merely share a render bucket until `render()` consumes them. + target = kk if vv is None else vv + if self._dead and vv is not None and winners.get(vv) != kk: + target = kk + self._paths[target].extend(steps) + return self def set_dead(self) -> Self: self._dead = True @@ -304,452 +358,69 @@ class Pather(PortList): self.pattern.path(*args, **kwargs) return self - @logged_op(lambda args: list(args['self'].ports.keys())) def translate(self, offset: ArrayLike) -> Self: - offset_arr = numpy.asarray(offset) - self.pattern.translate_elements(offset_arr) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.transformed(offset_arr, 0, numpy.zeros(2)) - return self + with self._logger.log_operation(self, 'translate', list(self.ports.keys()), offset=offset): + offset_arr = numpy.asarray(offset) + self.pattern.translate_elements(offset_arr) + for steps in self._paths.values(): + for i, step in enumerate(steps): + steps[i] = step.transformed(offset_arr, 0, numpy.zeros(2)) + return self - @logged_op(lambda args: list(args['self'].ports.keys())) def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - pivot_arr = numpy.asarray(pivot) - self.pattern.rotate_around(pivot_arr, angle) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.transformed(numpy.zeros(2), angle, pivot_arr) - return self + with self._logger.log_operation(self, 'rotate_around', list(self.ports.keys()), pivot=pivot, angle=angle): + pivot_arr = numpy.asarray(pivot) + self.pattern.rotate_around(pivot_arr, angle) + for steps in self._paths.values(): + for i, step in enumerate(steps): + steps[i] = step.transformed(numpy.zeros(2), angle, pivot_arr) + return self - @logged_op(lambda args: list(args['self'].ports.keys())) def mirror(self, axis: int = 0) -> Self: - self.pattern.mirror(axis) - for steps in self.paths.values(): - for i, step in enumerate(steps): - steps[i] = step.mirrored(axis) - return self + with self._logger.log_operation(self, 'mirror', list(self.ports.keys()), axis=axis): + self.pattern.mirror(axis) + for steps in self._paths.values(): + for i, step in enumerate(steps): + steps[i] = step.mirrored(axis) + return self - @logged_op(lambda args: args['name']) def mkport(self, name: str, value: Port) -> Self: - super().mkport(name, value) - return self + with self._logger.log_operation(self, 'mkport', name, value=value): + super().mkport(name, value) + return self # # Routing Logic (Deferred / Incremental) # - def _apply_step( - self, - opcode: Literal['L', 'S', 'U'], - portspec: str, - out_port: Port, - data: Any, - tool: Tool, - plug_into: str | None = None, - ) -> None: - """ Common logic for applying a planned step to a port. """ - port = self.pattern[portspec] - port_rot = port.rotation - assert port_rot is not None - - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - - if not self._dead: - step = RenderStep(opcode, tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - - self.pattern.ports[portspec] = out_port.copy() - - if plug_into is not None: - self.plugged({portspec: plug_into}) - - if self._auto_render: - self.render(append=self._auto_render_append) - - def _transform_relative_port(self, start_port: Port, out_port: Port) -> Port: - """ Transform a tool-planned output port into layout coordinates without mutating state. """ - port_rot = start_port.rotation - assert port_rot is not None - - transformed = out_port.copy() - transformed.rotate_around((0, 0), pi + port_rot) - transformed.translate(start_port.offset) - return transformed - - def _resolved_position_bound( - self, - portspec: str, - bounds: Mapping[str, Any], - *, - allow_length: bool, - ) -> tuple[str, Any, float] | None: + def _apply_route_result(self, result: PreparedRouteResult) -> None: """ - Resolve a single positional bound for a single port into a travel length. + Apply every action and deferred rename in a prepared route result. + + Route actions may contain several primitive render steps and port + mutations. Temporarily disabling auto-render prevents each step from + becoming its own rendered cell. """ - present = [(key, bounds[key]) for key in self._POSITION_KEYS if bounds.get(key) is not None] - if not present: - return None - if len(present) > 1: - keys = ', '.join(key for key, _value in present) - raise BuildError(f'Provide exactly one positional bound; got {keys}') - 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] - port = self.pattern[portspec] - assert port.rotation is not None - is_horiz = numpy.isclose(port.rotation % pi, 0) - if is_horiz: - if key == 'y': - raise BuildError('Port is horizontal') - target = Port((value, port.offset[1]), rotation=None) - else: - if key == 'x': - raise BuildError('Port is vertical') - target = Port((port.offset[0], value), rotation=None) - (travel, _jog), _ = port.measure_travel(target) - return key, value, -float(travel) - - @staticmethod - def _format_route_key_list(keys: Sequence[str]) -> str: - return ', '.join(keys) - - @staticmethod - def _present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]: - return [key for key in keys if bounds.get(key) is not None] - - def _present_bundle_bounds(self, bounds: Mapping[str, Any]) -> list[str]: - return self._present_keys(bounds, self._BUNDLE_BOUND_KEYS) - - def _validate_trace_args( - self, - portspec: Sequence[str], - *, - length: float | None, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - bundle_bounds = self._present_bundle_bounds(bounds) - if len(bundle_bounds) > 1: - args = self._format_route_key_list(bundle_bounds) - raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}') - - invalid_with_length = self._present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds - invalid_with_each = self._present_keys(bounds, ('set_rotation',)) + bundle_bounds - - if length is not None: - if len(portspec) > 1: - raise BuildError('length only allowed with a single port') - if spacing is not None: - invalid_with_length.append('spacing') - if invalid_with_length: - args = self._format_route_key_list(invalid_with_length) - raise BuildError(f'length cannot be combined with other routing bounds: {args}') - return - - if bounds.get('each') is not None: - if spacing is not None: - invalid_with_each.append('spacing') - if invalid_with_each: - args = self._format_route_key_list(invalid_with_each) - raise BuildError(f'each cannot be combined with other routing bounds: {args}') - return - - if not bundle_bounds: - raise BuildError('No bound type specified for trace()') - - def _validate_trace_to_positional_args( - self, - *, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - invalid = self._present_keys(bounds, ('each', 'set_rotation')) + self._present_bundle_bounds(bounds) - if spacing is not None: - invalid.append('spacing') - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}') - - def _validate_jog_args(self, *, length: float | None, bounds: Mapping[str, Any]) -> None: - invalid = self._present_keys(bounds, ('each', 'set_rotation')) + self._present_bundle_bounds(bounds) - if length is not None: - invalid = self._present_keys(bounds, self._POSITION_KEYS) + invalid - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}') - return - - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'Unsupported routing bounds for jog(): {args}') - - def _validate_uturn_args(self, bounds: Mapping[str, Any]) -> None: - invalid = self._present_keys(bounds, self._POSITION_KEYS + ('each', 'set_rotation')) + self._present_bundle_bounds(bounds) - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'Unsupported routing bounds for uturn(): {args}') - - def _validate_fallback_endpoint( - self, - portspec: str, - actual_end: Port, - *, - length: float, - jog: float, - out_rotation: float, - requested_out_ptype: str | None, - route_name: str, - ) -> None: - """ - Ensure a synthesized fallback route still satisfies the public routing contract. - """ - start_port = self.pattern[portspec] - expected_local = Port((length, jog), rotation=out_rotation, ptype=actual_end.ptype) - expected_end = self._transform_relative_port(start_port, expected_local) - - offsets_match = bool(numpy.allclose(actual_end.offset, expected_end.offset)) - rotations_match = ( - actual_end.rotation is not None - and expected_end.rotation is not None - and bool(numpy.isclose(actual_end.rotation, expected_end.rotation)) - ) - ptype_matches = requested_out_ptype is None or actual_end.ptype == requested_out_ptype - - if offsets_match and rotations_match and ptype_matches: - return - - raise BuildError( - f'{route_name} fallback via two planL() steps is unsupported for this tool/kwargs combination. ' - f'Expected offset={tuple(expected_end.offset)}, rotation={expected_end.rotation}, ' - f'ptype={requested_out_ptype or actual_end.ptype}; got offset={tuple(actual_end.offset)}, ' - f'rotation={actual_end.rotation}, ptype={actual_end.ptype}' - ) - - def _apply_validated_double_l( - self, - portspec: str, - tool: Tool, - first: tuple[Port, Any], - second: tuple[Port, Any], - *, - length: float, - jog: float, - out_rotation: float, - requested_out_ptype: str | None, - route_name: str, - plug_into: str | None, - ) -> None: - out_port0, data0 = first - out_port1, data1 = second - staged_port0 = self._transform_relative_port(self.pattern[portspec], out_port0) - staged_port1 = self._transform_relative_port(staged_port0, out_port1) - self._validate_fallback_endpoint( - portspec, - staged_port1, - length = length, - jog = jog, - out_rotation = out_rotation, - requested_out_ptype = requested_out_ptype, - route_name = route_name, - ) - self._apply_step('L', portspec, out_port0, data0, tool) - self._apply_step('L', portspec, out_port1, data1, tool, plug_into) - - def _plan_s_fallback( - self, - tool: Tool, - portspec: str, - in_ptype: str, - length: float, - jog: float, - **kwargs: Any, - ) -> tuple[tuple[Port, Any], tuple[Port, Any]]: - ccw0 = jog > 0 - R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs) - R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs) - L1, L2 = length - R2, abs(jog) - R1 - if L1 < 0 or L2 < 0: - raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback") - - first = tool.planL(ccw0, L1, in_ptype = in_ptype, **(kwargs | {'out_ptype': None})) - second = tool.planL(not ccw0, L2, in_ptype = first[0].ptype, **kwargs) - return first, second - - def _plan_u_fallback( - self, - tool: Tool, - in_ptype: str, - length: float, - jog: float, - **kwargs: Any, - ) -> tuple[tuple[Port, Any], tuple[Port, Any]]: - ccw = jog > 0 - R = self._get_tool_R(tool, ccw, in_ptype, **kwargs) - L1, L2 = length + R, abs(jog) - R - first = tool.planL(ccw, L1, in_ptype = in_ptype, **(kwargs | {'out_ptype': None})) - second = tool.planL(ccw, L2, in_ptype = first[0].ptype, **kwargs) - return first, second - - def _run_route_transaction(self, callback: Callable[[], None]) -> None: - """ Run a routing mutation atomically, rendering once at the end if auto-render is enabled. """ - saved_ports = copy.deepcopy(self.pattern.ports) - saved_paths = defaultdict(list, copy.deepcopy(dict(self.paths))) saved_auto_render = self._auto_render self._auto_render = False try: - callback() - except Exception: - self.pattern.ports = saved_ports - self.paths = saved_paths - raise + for action in result.actions: + if not action.render_steps: + raise BuildError('Prepared route action has no render steps') + + if not self._dead: + self._paths[action.portspec].extend(action.render_steps) + + self.pattern.ports[action.portspec] = action.final_port.copy() + + if action.plug_into is not None: + self.plugged({action.portspec: action.plug_into}) + for old_name, new_name in result.renames: + self.rename_ports({old_name: new_name}) + self._auto_render = saved_auto_render + if saved_auto_render and any(self._paths.values()): + self.render(append = self._auto_render_append) finally: self._auto_render = saved_auto_render - if saved_auto_render and any(self.paths.values()): - self.render(append = self._auto_render_append) - - def _execute_route_op(self, op_name: str, kwargs: dict[str, Any]) -> None: - if op_name == 'trace_to': - self.trace_to(**kwargs) - elif op_name == 'jog': - self.jog(**kwargs) - elif op_name == 'uturn': - self.uturn(**kwargs) - elif op_name == 'rename_ports': - self.rename_ports(**kwargs) - else: - raise BuildError(f'Unrecognized routing op {op_name}') - - def _execute_route_ops(self, ops: Sequence[tuple[str, dict[str, Any]]]) -> None: - for op_name, op_kwargs in ops: - self._execute_route_op(op_name, op_kwargs) - - def _merge_trace_into_op_kwargs( - self, - op_name: str, - user_kwargs: Mapping[str, Any], - **reserved: Any, - ) -> dict[str, Any]: - """ Merge tool kwargs with internally computed op kwargs, rejecting collisions. """ - collisions = sorted(set(user_kwargs) & set(reserved)) - if collisions: - args = ', '.join(collisions) - raise BuildError(f'trace_into() kwargs cannot override {op_name}() arguments: {args}') - return {**user_kwargs, **reserved} - - def _plan_trace_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None, - plug_destination: bool, - thru: str | None, - **kwargs: Any, - ) -> list[tuple[str, dict[str, Any]]]: - port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst] - if out_ptype is None: - out_ptype = port_dst.ptype - if port_src.rotation is None or port_dst.rotation is None: - raise PortError('Ports must have rotation') - - src_horiz = numpy.isclose(port_src.rotation % pi, 0) - dst_horiz = numpy.isclose(port_dst.rotation % pi, 0) - xd, yd = port_dst.offset - angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - dst_args = {'out_ptype': out_ptype} - if plug_destination: - dst_args['plug_into'] = portspec_dst - - ops: list[tuple[str, dict[str, Any]]] = [] - if src_horiz and not dst_horiz: - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = angle > pi, - x = xd, - ))) - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = None, - y = yd, - **dst_args, - ))) - elif dst_horiz and not src_horiz: - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = angle > pi, - y = yd, - ))) - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = None, - x = xd, - **dst_args, - ))) - elif numpy.isclose(angle, pi): - (travel, jog), _ = port_src.measure_travel(port_dst) - if numpy.isclose(jog, 0): - ops.append(( - 'trace_to', - self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = None, - x = xd if src_horiz else None, - y = yd if not src_horiz else None, - **dst_args, - ), - )) - else: - ops.append(('jog', self._merge_trace_into_op_kwargs( - 'jog', - kwargs, - portspec = portspec_src, - offset = -jog, - length = -travel, - **dst_args, - ))) - elif numpy.isclose(angle, 0): - (travel, jog), _ = port_src.measure_travel(port_dst) - ops.append(('uturn', self._merge_trace_into_op_kwargs( - 'uturn', - kwargs, - portspec = portspec_src, - offset = -jog, - length = -travel, - **dst_args, - ))) - else: - raise BuildError(f"Cannot route relative angle {angle}") - - if thru: - ops.append(('rename_ports', {'mapping': {thru: portspec_src}})) - return ops - - def _get_tool_R(self, tool: Tool, ccw: SupportsBool, in_ptype: str | None, **kwargs) -> float: - """ Probe a tool to find the lateral displacement (radius) of its bend. """ - kwargs_no_out = kwargs | {'out_ptype': None} - probe_len = kwargs.get('probe_length', self.PROBE_LENGTH) - try: - out_port, _ = tool.planL(ccw, probe_len, in_ptype=in_ptype, **kwargs_no_out) - return abs(out_port.y) - except (BuildError, NotImplementedError): - # Fallback for tools without planL: use traceL and measure the result - port_names = ('A', 'B') - tree = tool.traceL(ccw, probe_len, in_ptype=in_ptype, port_names=port_names, **kwargs_no_out) - pat = tree.top_pattern() - (_, R), _ = pat[port_names[0]].measure_travel(pat[port_names[1]]) - return abs(R) def _apply_dead_fallback( self, @@ -763,6 +434,12 @@ class Pather(PortList): out_rot: float | None = None, out_ptype: str | None = None, ) -> None: + """ + Move a dead Pather port without generating geometry. + + Dead fallback is only for debugging or dry layout flow. Fatal route + errors bypass it because they indicate an invalid Tool offer contract. + """ if out_rot is None: if ccw is None: out_rot = pi @@ -773,7 +450,8 @@ class Pather(PortList): logger.warning(f"Tool planning failed for dead pather. Using dummy extension for {portspec}.") port = self.pattern[portspec] port_rot = port.rotation - assert port_rot is not None + if port_rot is None: + raise PortError('Ports must have rotation') out_port = Port((length, jog), rotation=out_rot, ptype=out_ptype or in_ptype) out_port.rotate_around((0, 0), pi + port_rot) out_port.translate(port.offset) @@ -781,117 +459,6 @@ class Pather(PortList): if plug_into is not None: self.plugged({portspec: plug_into}) - @logged_op(lambda args: args['portspec']) - def _traceL(self, portspec: str, ccw: SupportsBool | None, length: float, *, plug_into: str | None = None, **kwargs: Any) -> Self: - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - in_ptype = self.pattern[portspec].ptype - try: - out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback( - portspec, - length, - 0, - ccw, - in_ptype, - plug_into, - out_ptype=kwargs.get('out_ptype'), - ) - return self - if out_port is not None: - self._apply_step('L', portspec, out_port, data, tool, plug_into) - return self - - @logged_op(lambda args: args['portspec']) - def _traceS(self, portspec: str, length: float, jog: float, *, plug_into: str | None = None, **kwargs: Any) -> Self: - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - in_ptype = self.pattern[portspec].ptype - try: - out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - try: - first, second = self._plan_s_fallback(tool, portspec, in_ptype, length, jog, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback( - portspec, - length, - jog, - None, - in_ptype, - plug_into, - out_rot=pi, - out_ptype=kwargs.get('out_ptype'), - ) - return self - - self._apply_validated_double_l( - portspec, - tool, - first, - second, - length = length, - jog = jog, - out_rotation = pi, - requested_out_ptype = kwargs.get('out_ptype'), - route_name = 'S-bend', - plug_into = plug_into, - ) - return self - if out_port is not None: - self._apply_step('S', portspec, out_port, data, tool, plug_into) - return self - - @logged_op(lambda args: args['portspec']) - def _traceU(self, portspec: str, jog: float, *, length: float = 0, plug_into: str | None = None, **kwargs: Any) -> Self: - tool = self.tools.get(portspec, self.tools.get(None)) - if tool is None: - raise BuildError(f'No tool assigned for port {portspec}') - in_ptype = self.pattern[portspec].ptype - try: - out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs) - except (BuildError, NotImplementedError): - try: - first, second = self._plan_u_fallback(tool, in_ptype, length, jog, **kwargs) - except (BuildError, NotImplementedError): - if not self._dead: - raise - self._apply_dead_fallback( - portspec, - length, - jog, - None, - in_ptype, - plug_into, - out_rot=0, - out_ptype=kwargs.get('out_ptype'), - ) - return self - - self._apply_validated_double_l( - portspec, - tool, - first, - second, - length = length, - jog = jog, - out_rotation = 0, - requested_out_ptype = kwargs.get('out_ptype'), - route_name = 'U-turn', - plug_into = plug_into, - ) - return self - if out_port is not None: - self._apply_step('U', portspec, out_port, data, tool, plug_into) - return self - # # High-level Routing Methods # @@ -912,26 +479,47 @@ class Pather(PortList): - `each` to extend each selected port independently by the same amount, or - one bundle bound such as `xmin`, `emax`, or `min_past_furthest`. + For a single port with no length or bound, legal primitive-offer + candidates are evaluated at their minimum legal length-like parameters, + then cost selects among those minimum-length candidates. `out_ptype`, + when provided, constrains only the final route endpoint. + `spacing` and `set_rotation` are only valid when using a bundle bound. """ with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - self._validate_trace_args(portspec, length=length, spacing=spacing, bounds=bounds) - if length is not None: - return self._traceL(portspec[0], ccw, length, **bounds) - if bounds.get('each') is not None: - each = bounds.pop('each') - for p in portspec: - self._traceL(p, ccw, each, **bounds) - return self - # Bundle routing - bt = self._present_bundle_bounds(bounds)[0] - bval = bounds.pop(bt) - set_rot = bounds.pop('set_rotation', None) - exts = ell(self.pattern[tuple(portspec)], ccw, spacing=spacing, bound=bval, bound_type=bt, set_rotation=set_rot) - for p, length_val in exts.items(): - self._traceL(p, ccw, length_val, **bounds) + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, **bounds) + except (BuildError, NotImplementedError) as err: + if not self._dead or route_error_is_fatal(err): + raise + if length is not None and len(contexts) == 1: + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + 0, + ccw, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + if bounds.get('each') is not None: + each = bounds['each'] + for context in contexts: + self._apply_dead_fallback( + context.portspec, + each, + 0, + ccw, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + raise + self._apply_route_result(result) return self def trace_to( @@ -948,25 +536,42 @@ class Pather(PortList): Exactly one of `p`, `pos`, `position`, `x`, or `y` may be used as a positional bound. Positional bounds are only valid for a single port and may not be combined with `length`, `spacing`, `each`, or bundle-bound keywords such as `xmin`/`emax`. + + With no positional or bundle bound, single-port `trace_to()` uses the + same omitted minimum-length primitive-offer behavior as `trace()`. """ with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - if len(portspec) == 1: - resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=False) - else: - resolved = None - pos_count = sum(bounds.get(key) is not None for key in self._POSITION_KEYS) - if pos_count: - raise BuildError('Position bounds only allowed with a single port') - if resolved is not None: - if len(portspec) > 1: - raise BuildError('Position bounds only allowed with a single port') - self._validate_trace_to_positional_args(spacing=spacing, bounds=bounds) - _key, _value, length = resolved - other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS and bk != 'length'} - return self._traceL(portspec[0], ccw, length, **other_bounds) - return self.trace(portspec, ccw, spacing=spacing, **bounds) + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, **bounds) + except (BuildError, NotImplementedError) as err: + if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): + raise + if bounds.get('length') is not None: + length = bounds['length'] + else: + resolved = resolved_position_bound( + contexts[0].port, + bounds, + allow_length=False, + ) + if resolved is None: + raise + _key, _value, length = resolved + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + 0, + ccw, + context.port.ptype, + out_ptype = bounds.get('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) @@ -980,45 +585,115 @@ class Pather(PortList): def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: return self.bend(portspec, False, length, **bounds) - def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self: + def jog( + self, + portspec: str | Sequence[str], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + **bounds: Any, + ) -> Self: """ Route an S-bend. - `length` is the along-travel displacement. If omitted, exactly one positional - bound (`p`, `pos`, `position`, `x`, or `y`) must be provided for a single port, - and the required travel distance is derived from that bound. When `length` is - provided, no other routing-bound keywords are accepted. + `length` is the along-travel displacement. If omitted and no positional + bound is supplied, a single-port jog evaluates legal S-like candidates + at their minimum legal length or primitive endpoint length for the + requested offset, then cost selects among those candidates. If exactly + one positional bound (`p`, `pos`, `position`, `x`, or `y`) is supplied, + the required travel distance is derived from that bound. + + Multi-port jogs require `spacing`; the innermost first-bend port uses + the base `length` or omitted-length solve, and other ports derive exact + route lengths and offsets from that base route. `out_ptype`, when + provided, constrains only each final route endpoint. """ - with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds): + with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - self._validate_jog_args(length=length, bounds=bounds) - other_bounds = dict(bounds) - if length is None: - if len(portspec) != 1: - raise BuildError('Positional length solving for jog() is only allowed with a single port') - resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=True) - if resolved is None: - raise BuildError('jog() requires either length=... or exactly one positional bound') - _key, _value, length = resolved - other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS} - for p in portspec: - self._traceS(p, length, offset, **other_bounds) + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, **bounds) + except (BuildError, NotImplementedError) as err: + if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): + raise + if numpy.isclose(offset, 0): + if length is None: + raise + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + 0, + None, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + fallback_length = length if length is not None else 0 + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + fallback_length, + offset, + None, + context.port.ptype, + out_rot = pi, + out_ptype = bounds.get('out_ptype'), + ) + return self + self._apply_route_result(result) return self - def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self: + def uturn( + self, + portspec: str | Sequence[str], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + **bounds: Any, + ) -> Self: """ Route a U-turn. - `length` is the along-travel displacement to the final port. If omitted, it defaults - to 0. Positional and bundle-bound keywords are not supported for this operation. + `length` is the along-travel displacement to the final port. If omitted, + legal U-like candidates are evaluated at their minimum legal length or + primitive endpoint length for the requested offset, then cost selects + among those candidates. Multi-port U-turns require nonzero `offset` and + `spacing`; the innermost first-bend port supplies the base route and + other ports derive exact lengths and offsets from it. Use `length=0` to + request the old zero-public-length U-turn shape. Positional and + bundle-bound keywords are not supported for this operation. `out_ptype`, + when provided, constrains only each final route endpoint. """ - with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds): + with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - self._validate_uturn_args(bounds) - for p in portspec: - self._traceU(p, offset, length=length if length else 0, **bounds) + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_uturn_route(contexts, offset, length, spacing=spacing, **bounds) + except (BuildError, NotImplementedError) as err: + if ( + not self._dead + or len(contexts) != 1 + or length is None + or route_error_is_fatal(err) + ): + raise + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + offset, + None, + context.port.ptype, + out_rot = 0.0, + out_ptype = bounds.get('out_ptype'), + ) + return self + self._apply_route_result(result) return self def trace_into( @@ -1032,11 +707,22 @@ class Pather(PortList): **kwargs: Any, ) -> Self: """ - Route one port into another using the shortest supported combination of trace primitives. + Route one port into another using a bounded primitive-offer selection. + + The current baseline searches bounded primitive-offer routes with up to + four bend roles, including straight, single-bend, S-like, U-like, and + dogleg topologies. The lowest-cost legal bounded candidate is selected; + bend count, step count, and search order are tie-breakers only. + Route-shape kwargs such as `length`, `offset`, `ccw`, positional bounds, + and bundle bounds are reserved for this internal solve; other tool + kwargs are forwarded 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. - The operation is transactional for live port state and deferred routing steps. + `out_ptype` constrains only the final route endpoint. Route selection + failures occur before live port state and deferred routing steps are + mutated; failures during selected-route execution, including primitive + commit, plug/thru application, or render, may leave partial output. """ with self._logger.log_operation( self, @@ -1047,22 +733,29 @@ class Pather(PortList): thru=thru, **kwargs, ): - ops = self._plan_trace_into( - portspec_src, + result = self.planner.plan_trace_into( + self._route_context(portspec_src), portspec_dst, + self.pattern[portspec_dst].copy(), out_ptype = out_ptype, plug_destination = plug_destination, thru = thru, **kwargs, ) - self._run_route_transaction(lambda: self._execute_route_ops(ops)) + self._apply_route_result(result) return self # # Rendering # def render(self, append: bool = True) -> Self: - """ Generate geometry for all planned paths. """ + """ + Generate geometry for all pending render steps. + + Consecutive compatible `RenderStep`s are batched by port and Tool, then + passed to `Tool.render()`. After insertion, the rendered output port is + checked against the endpoint that planning selected. + """ with self._logger.log_operation(self, 'render', None, append=append): tool_port_names = ('A', 'B') pat = Pattern() @@ -1081,23 +774,54 @@ class Pather(PortList): f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}' ) + def validate_rendered_endpoint(portspec: str, batch: list[RenderStep]) -> None: + expected = batch[-1].end_port + actual = pat.ports.get(portspec) + tool_name = type(batch[0].tool).__name__ + if actual is None: + raise BuildError( + f'Tool {tool_name}.render() did not produce output port {portspec!r}; ' + f'expected {expected.describe()}' + ) + + offsets_match = bool(numpy.allclose(actual.offset, expected.offset)) + rotations_match = ( + actual.rotation is None + or expected.rotation is None + or bool(numpy.isclose(numpy.sin(actual.rotation - expected.rotation), 0)) + ) + ptypes_match = ptypes_compatible(actual.ptype, expected.ptype) + if offsets_match and rotations_match and ptypes_match: + return + + raise BuildError( + f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: ' + f'expected {expected.describe()}, got {actual.describe()}' + ) + def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: assert batch[0].tool is not None tree = batch[0].tool.render(batch, port_names=tool_port_names) validate_tree(portspec, batch, tree) name = self.library << tree - if portspec in pat.ports: - del pat.ports[portspec] - pat.ports[portspec] = batch[0].start_port.copy() - if append: - pat.plug(self.library[name], {portspec: tool_port_names[0]}, append=True) - del self.library[name] - else: - pat.plug(self.library.abstract(name), {portspec: tool_port_names[0]}, append=False) - if portspec not in pat.ports and tool_port_names[1] in pat.ports: - pat.rename_ports({tool_port_names[1]: portspec}, overwrite=True) + try: + if portspec in pat.ports: + del pat.ports[portspec] + pat.ports[portspec] = batch[0].start_port.copy() + if append: + pat.plug(self.library[name], {portspec: tool_port_names[0]}, append=True) + del self.library[name] + else: + pat.plug(self.library.abstract(name), {portspec: tool_port_names[0]}, append=False) + if portspec not in pat.ports and tool_port_names[1] in pat.ports: + pat.rename_ports({tool_port_names[1]: portspec}, overwrite=True) + validate_rendered_endpoint(portspec, batch) + except Exception: + if name in self.library: + del self.library[name] + raise - for portspec, steps in self.paths.items(): + for portspec, steps in self._paths.items(): if not steps: continue batch: list[RenderStep] = [] @@ -1114,7 +838,7 @@ class Pather(PortList): if batch: render_batch(portspec, batch, append) - self.paths.clear() + self._paths.clear() pat.ports.clear() self.pattern.append(pat) return self @@ -1233,10 +957,14 @@ class PortPather: return self.bend(False, length, **kw) 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) 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) return self @@ -1303,7 +1031,12 @@ class PortPather: else: name_map = dict(name) self.pather.rename_ports(name_map) - self.ports = list(dict.fromkeys(mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None)) + renamed_ports: list[str] = [] + for port in self.ports: + renamed = name_map.get(port, port) + if renamed is not None and renamed not in renamed_ports: + renamed_ports.append(renamed) + self.ports = renamed_ports return self def select(self, ports: str | Iterable[str]) -> Self: @@ -1374,7 +1107,7 @@ class PortPather: def drop(self) -> Self: """ Remove selected ports from the pattern and the PortPather. """ - self.pather.rename_ports({pp: None for pp in self.ports}) + self.pather.rename_ports(dict.fromkeys(self.ports)) self.ports = [] return self diff --git a/masque/builder/planner/__init__.py b/masque/builder/planner/__init__.py new file mode 100644 index 0000000..fc4d22a --- /dev/null +++ b/masque/builder/planner/__init__.py @@ -0,0 +1,15 @@ +""" +Simplified primitive-offer route planner used by `Pather`. + +This package is the Pather-facing route-selection implementation. It keeps +the public Tool contract narrow: offers are evaluated during planning, and +offer commits are deferred until after a complete route is selected. +""" +from .interface import ( + PreparedRouteAction as PreparedRouteAction, + PreparedRouteResult as PreparedRouteResult, + RoutePlanningError as RoutePlanningError, + RoutePortContext as RoutePortContext, + route_error_is_fatal as route_error_is_fatal, + ) +from .planner import RoutingPlanner as RoutingPlanner diff --git a/masque/builder/planner/bounds.py b/masque/builder/planner/bounds.py new file mode 100644 index 0000000..43224a0 --- /dev/null +++ b/masque/builder/planner/bounds.py @@ -0,0 +1,257 @@ +""" +Argument validation and bound resolution for Pather routing calls. + +This module keeps user-facing mode validation outside the solver. It converts +single-port positional bounds into local travel lengths and derives multi-port +S/U bundle specs before primitive offers are considered. + +The solver expects one coherent route intent at a time. This module enforces +that public routing modes are not mixed: explicit length, per-port `each`, +positional bounds, and bundle bounds are mutually constrained before any Tool +offers are queried. Multi-port S/U bundles are also normalized here into exact +per-port public lengths and offsets. +""" +from __future__ import annotations + +# ruff: noqa: TC001,TC002,TC003 +from typing import Any +from collections.abc import Mapping, Sequence +from pprint import pformat + +import numpy +from numpy import pi +from numpy.typing import ArrayLike + +from ...error import BuildError, PortError +from ...ports import Port +from ...utils import rotation_matrix_2d +from .interface import RoutePortContext + + +POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position') +BUNDLE_BOUND_KEYS: tuple[str, ...] = ( + 'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest', + ) + + +def resolved_position_bound( + port: Port, + bounds: Mapping[str, Any], + *, + allow_length: bool, + ) -> tuple[str, Any, float] | None: + """Resolve a single positional bound for a single port into a travel length.""" + present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None] + if not present: + return None + if len(present) > 1: + keys = ', '.join(key for key, _value in present) + raise BuildError(f'Provide exactly one positional bound; got {keys}') + 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] + 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)) + if is_horiz: + if key == 'y': + raise BuildError('Port is horizontal') + target = Port((value, port.offset[1]), rotation=None) + else: + if key == 'x': + raise BuildError('Port is vertical') + target = Port((port.offset[0], value), rotation=None) + (travel, _jog), _ = port.measure_travel(target) + return key, value, -float(travel) + + +def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]: + """Return keys whose bound value is explicitly present and non-None.""" + return [key for key in keys if bounds.get(key) is not None] + + +def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]: + """Return active multi-port trace bound keys.""" + return present_keys(bounds, BUNDLE_BOUND_KEYS) + + +def validate_trace_args( + portspec: Sequence[str], + *, + length: float | None, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """ + Validate mutually-exclusive `trace()` routing modes. + + A trace request is either an explicit single-port length, an `each` length + for all ports, a single-port omitted-length solve, or a bundle solve with + exactly one bundle bound. + """ + bundle_bounds = present_bundle_bounds(bounds) + if len(bundle_bounds) > 1: + args = ', '.join(bundle_bounds) + raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}') + + invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds + invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds + + if length is not None: + if len(portspec) > 1: + raise BuildError('length only allowed with a single port') + if spacing is not None: + invalid_with_length.append('spacing') + if invalid_with_length: + args = ', '.join(invalid_with_length) + raise BuildError(f'length cannot be combined with other routing bounds: {args}') + return + + if bounds.get('each') is not None: + if spacing is not None: + invalid_with_each.append('spacing') + if invalid_with_each: + args = ', '.join(invalid_with_each) + raise BuildError(f'each cannot be combined with other routing bounds: {args}') + return + + if not bundle_bounds and len(portspec) == 1: + if spacing is not None: + raise BuildError('spacing cannot be combined with omitted-length single-port trace()') + invalid = present_keys(bounds, ('set_rotation',)) + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}') + return + + if not bundle_bounds: + raise BuildError('No bound type specified for trace()') + + +def validate_trace_to_positional_args( + *, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """Reject bound combinations that cannot be mixed with a single positional `trace_to()` target.""" + invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds) + if spacing is not None: + invalid.append('spacing') + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}') + + +def validate_jog_args( + portspec: Sequence[str], + *, + length: float | None, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """ + Validate `jog()` mode constraints before S-route planning. + + Single-port jogs may derive length from a positional bound. Multi-port jogs + require spacing and cannot combine omitted length with positional bounds. + """ + invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds) + if len(portspec) == 1 and spacing is not None: + invalid.append('spacing') + if len(portspec) > 1 and length is None: + invalid += present_keys(bounds, POSITION_KEYS) + if length is not None: + invalid = present_keys(bounds, POSITION_KEYS) + invalid + if invalid: + args = ', '.join(invalid) + raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}') + return + + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Unsupported routing bounds for jog(): {args}') + + +def validate_uturn_args( + portspec: Sequence[str], + *, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """Validate `uturn()` arguments, which do not support positional or bundle-bound keywords.""" + invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds) + if len(portspec) == 1 and spacing is not None: + invalid.append('spacing') + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Unsupported routing bounds for uturn(): {args}') + + +def su_bundle_specs( + contexts: Sequence[RoutePortContext], + offset: float, + length: float, + spacing: float | ArrayLike | None, + *, + route_name: str, + ) -> tuple[tuple[str, float, float], ...]: + """ + Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs. + + Ports are ordered from the inside of the first bend outward. The first spec + receives the requested base route; later specs add cumulative spacing to + both route length and lateral offset so the bundle keeps the requested + separation. + """ + if spacing is None: + raise BuildError(f'Must provide spacing for multi-port {route_name}()') + + 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) + if not has_rotation.all(): + raise PortError(f'Ports must have rotation for multi-port {route_name}()') + + rotations = numpy.array([port.rotation for port in ports.values()], dtype=float) + if not numpy.allclose(rotations[0], rotations): + port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()} + raise BuildError( + f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n' + + pformat(port_rotations) + ) + + direction = rotations[0] + pi + rot_matrix = rotation_matrix_2d(-direction) + orig_offsets = numpy.array([port.offset for port in ports.values()]) + rot_offsets = (rot_matrix @ orig_offsets.T).T + + first_ccw = bool(offset > 0) + 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 spacing_arr.size == 1: + steps[1:] = spacing_arr[0] + elif spacing_arr.size == len(ports) - 1: + steps[1:] = spacing_arr + else: + raise BuildError( + f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; ' + f'got length {spacing_arr.size}' + ) + if not numpy.all(numpy.isfinite(steps)): + raise BuildError('spacing must contain only finite values') + + names = tuple(ports.keys()) + ordered_spacings = numpy.cumsum(steps) + anchor_y = float(rot_offsets[y_order[0], 1]) + specs: list[tuple[str, float, float]] = [] + for order_index, port_index in enumerate(y_order): + spacing_offset = float(ordered_spacings[order_index]) + start_y = float(rot_offsets[port_index, 1]) + specs.append(( + names[port_index], + float(length) + spacing_offset, + float(offset) - start_y + anchor_y + spacing_offset, + )) + return tuple(specs) diff --git a/masque/builder/planner/interface.py b/masque/builder/planner/interface.py new file mode 100644 index 0000000..58028df --- /dev/null +++ b/masque/builder/planner/interface.py @@ -0,0 +1,83 @@ +""" +Planner/Pather exchange types. + +`Pather` snapshots live routing state into these records before calling the +planner. The planner returns prepared actions that `Pather` can apply without +needing to know solver internals. +""" + +from __future__ import annotations + +# ruff: noqa: TC001 + +from dataclasses import dataclass + +from ...error import BuildError +from ...ports import Port +from ..tools import RenderStep, Tool + + +class RoutePlanningError(BuildError): + """Route-planning error with fallback policy metadata.""" + + fatal: bool + + def __init__(self, *args: object, fatal: bool = False) -> None: + super().__init__(*args) + self.fatal = fatal + + +def route_error_is_fatal(err: Exception) -> bool: + """Return true when a planning error should bypass dead-Pather fallback.""" + return bool(getattr(err, 'fatal', False)) + + +@dataclass(frozen=True, slots=True) +class RoutePortContext: + """ + Immutable planning view of one live Pather port. + + `port` is a copy of the live port so failed route selection leaves Pather + state unchanged. `tool` is the already-resolved routing Tool for this + portspec. + """ + portspec: str + """Live Pather port name being planned.""" + port: Port + """Copied live port used as immutable route input.""" + tool: Tool + """Resolved Tool for this port.""" + + +@dataclass(frozen=True, slots=True) +class PreparedRouteAction: + """ + Prepared mutation for one routed Pather port. + + The planner has already committed selected primitive offers into + `render_steps` and computed the final live port. `plug_into`, when set, + names the destination port to consume after the route endpoint is applied. + """ + portspec: str + """Live Pather port name to update.""" + render_steps: tuple[RenderStep, ...] + """Committed route steps to append to Pather's pending render queue.""" + final_port: Port + """Final live port value after all route steps.""" + plug_into: str | None = None + """Optional destination port to consume after the final port is applied.""" + + +@dataclass(frozen=True, slots=True) +class PreparedRouteResult: + """ + Complete prepared result for one Pather routing operation. + + `actions` are applied first. `renames` are deferred until after all route + actions so trace-into/thru behavior can be represented without exposing the + solver's selected primitive sequence to Pather. + """ + actions: tuple[PreparedRouteAction, ...] + """Prepared per-port route mutations.""" + renames: tuple[tuple[str, str], ...] = () + """Deferred `(old_name, new_name)` port renames applied after actions.""" diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py new file mode 100644 index 0000000..a9257ce --- /dev/null +++ b/masque/builder/planner/planner.py @@ -0,0 +1,1226 @@ +""" +Primitive-offer route selection for `Pather`. + +`RoutingPlanner` is the stateless boundary between `Pather` routing calls and +Tool primitive offers. `Pather` passes copied `RoutePortContext` snapshots here; +the planner returns `PreparedRouteResult` records that describe pending +mutations without applying them to the live Pattern. + +Public routing modes and bounds are normalized by `bounds.py` before the solver +sees them. This module plans one route intent at a time: it queries Tool offers, +enumerates bounded primitive compositions, inserts ptype adapters, solves +primitive parameters, ranks candidates, and commits only the selected offers +into `RenderStep` payloads. + +All search is performed in Tool-local route coordinates. The active input port +is at the origin, travel is along +x, and positive jog is to the left. After a +candidate is selected, committed steps are transformed back into layout-space +using the copied starting port. +""" +from __future__ import annotations + +# ruff: noqa: ANN401,PLR0912,PLR0913,PLR0915,TC001,TC002,TC003 + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from itertools import combinations +from math import isclose as math_isclose +from typing import Any, Literal + +import numpy +from numpy import pi +from numpy.typing import ArrayLike + +from ...error import BuildError, PortError +from ...ports import Port +from ...utils import PTypeMatch, SupportsBool, ptype_match, ptypes_compatible, rotation_matrix_2d +from ..tools import ( + BendOffer, + PrimitiveKind, + PrimitiveOffer, + RenderStep, + SOffer, + StraightOffer, + Tool, + ) +from ..utils import ell +from . import bounds as planner_bounds +from .interface import ( + PreparedRouteAction, + PreparedRouteResult, + RoutePlanningError, + RoutePortContext, + ) + + +def is_close(a: float, b: float) -> bool: + """Compare route-solver scalars with the planner tolerance.""" + return math_isclose(float(a), float(b), rel_tol=1e-5, abs_tol=1e-8) + + +def clean_parameter(value: float) -> float: + """Snap tiny solver noise in primitive parameters before domain checks.""" + rounded = round(float(value)) + if abs(float(value) - rounded) <= 1e-8: + return float(rounded) + if abs(float(value)) <= 1e-10: + return 0.0 + return float(value) + + +def minimum_parameter(offer: PrimitiveOffer, route_name: str) -> float: + """Return an offer's deterministic minimum legal parameter.""" + lower, upper = offer.parameter_domain + if lower != upper and lower >= upper: + raise BuildError(f'{route_name} primitive has an invalid parameter domain {offer.parameter_domain}') + if not numpy.isfinite(lower): + raise BuildError(f'{route_name} primitive has no finite minimum parameter') + return offer.canonicalize_parameter(lower) + + +def minimum_nonzero_parameters(offer: PrimitiveOffer) -> tuple[float, ...]: + """Return deterministic nonzero endpoint parameters near an offer domain edge.""" + lower, upper = offer.parameter_domain + if lower == upper: + try: + value = offer.canonicalize_parameter(lower) + except BuildError: + return () + return () if is_close(value, 0) else (value,) + + candidates: list[float] = [] + if lower > 0 and numpy.isfinite(lower): + candidates.append(float(lower)) + if upper < 0 and numpy.isfinite(upper): + candidates.append(float(numpy.nextafter(upper, -numpy.inf))) + + selected: list[float] = [] + for value in candidates: + try: + parameter = offer.canonicalize_parameter(value) + except BuildError: + continue + if not is_close(parameter, 0) and not any(is_close(parameter, prev) for prev in selected): + selected.append(parameter) + return tuple(selected) + + +def adapter_s_parameter(offer: PrimitiveOffer, residual_jog: float) -> float: + """Choose a deterministic S-adapter parameter, preferring residual-jog direction.""" + candidates = minimum_nonzero_parameters(offer) + if not candidates: + raise BuildError('S adapter has no finite deterministic parameter') + + residual_sign = numpy.sign(residual_jog) + + def key(item: tuple[int, float]) -> tuple[float, int, int]: + index, value = item + sign = numpy.sign(value) + sign_rank = 0 if is_close(residual_jog, 0) or sign == residual_sign else 1 + return round(abs(value), 9), sign_rank, index + + return min(enumerate(candidates), key=key)[1] + + +def is_adapter_offer(offer: PrimitiveOffer) -> bool: + """Return true for straight/S offers that intentionally change concrete ptype.""" + return ( + isinstance(offer, StraightOffer | SOffer) + and ptype_match(offer.in_ptype, offer.in_ptype) is PTypeMatch.EXACT + and ptype_match(offer.out_ptype, offer.out_ptype) is PTypeMatch.EXACT + and ptype_match(offer.in_ptype, offer.out_ptype) is PTypeMatch.MISMATCH + ) + + +def raise_if_fatal(err: Exception) -> None: + """Propagate fatal planning errors while allowing normal candidate rejection.""" + if getattr(err, 'fatal', False): + raise err + + +def solve_small_lstsq( + matrix: Sequence[Sequence[float]], + residual: Sequence[float], + ) -> tuple[float, ...] | None: + """ + Solve tiny least-squares systems without always paying NumPy setup cost. + + Route parameter solving only uses one or two constraints and one or two + adjustable primitive parameters. Closed forms keep common cases simple; + NumPy remains the fallback for degenerate or future larger systems. + """ + rows = len(matrix) + cols = len(matrix[0]) if rows else 0 + if rows == 1 and cols == 1: + a = matrix[0][0] + return None if a == 0 else (residual[0] / a,) + if rows == 1 and cols == 2: + a, b = matrix[0] + denom = a * a + b * b + return None if denom == 0 else (residual[0] * a / denom, residual[0] * b / denom) + if rows == 2 and cols == 1: + a = matrix[0][0] + b = matrix[1][0] + denom = a * a + b * b + return None if denom == 0 else ((a * residual[0] + b * residual[1]) / denom,) + if rows == 2 and cols == 2: + a, b = matrix[0] + c, d = matrix[1] + determinant = a * d - b * c + if determinant != 0: + return ( + (d * residual[0] - b * residual[1]) / determinant, + (-c * residual[0] + a * residual[1]) / determinant, + ) + matrix_array = numpy.array(matrix) + deltas, _residuals, _rank, _singular = numpy.linalg.lstsq(matrix_array, numpy.array(residual), rcond=None) + return tuple(float(delta) for delta in deltas) + + +@dataclass(frozen=True, slots=True) +class SelectedPrimitive: + """ + One evaluated primitive offer in a candidate route. + + `out_port` is still in route-local coordinates. `role` distinguishes + primitives that satisfy the requested route shape from ptype adapters that + the grammar may insert around those primitives. + """ + offer: PrimitiveOffer + """Offer selected for this primitive step.""" + parameter: float + """Canonicalized offer parameter used for endpoint, cost, and commit.""" + out_port: Port + """Route-local endpoint produced by the selected offer.""" + cost: float + """Finite additive planning cost reported by the offer.""" + role: Literal['main', 'adapter'] = 'main' + """Whether this step satisfies route geometry or adapts ptype.""" + route_kind: PrimitiveKind | None = None + """Primitive kind used when querying the Tool for this step.""" + + +@dataclass(frozen=True, slots=True) +class Candidate: + """A fully solved primitive sequence with its composed local endpoint.""" + steps: tuple[SelectedPrimitive, ...] + """Ordered primitive sequence selected by the grammar.""" + end_port: Port + """Composed route-local endpoint.""" + cost: float + """Sum of primitive costs.""" + order: int + """Deterministic discovery order used as the final tie-breaker.""" + public_length: float + """Length reported back to bundle planning for omitted-length anchors.""" + + +@dataclass(frozen=True, slots=True) +class RouteRequest: + """ + Normalized solver input for one route leg. + + 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. + """ + family: PrimitiveKind + """High-level route family being solved.""" + tool: Tool + """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.""" + length: float | None = None + """Requested local x displacement, when constrained.""" + jog: float | None = None + """Requested local y displacement, when constrained.""" + ccw: SupportsBool | None = None + """Requested bend direction for single-bend routes.""" + out_ptype: str | None = None + """Requested final endpoint ptype.""" + constrain_jog: bool = False + """Whether bend-family trace_into routes must also match `jog`.""" + max_bends: int | None = None + """Optional override for grammar bend budget.""" + + @property + def route_name(self) -> str: + if self.family in ('straight', 'bend'): + return 'trace' + if self.family == 's': + return 'S-bend' + return 'U-turn' + + @property + def out_rotation(self) -> float: + if self.family == 'straight': + return pi + if self.family == 'bend': + return -pi / 2 if bool(self.ccw) else pi / 2 + if self.family == 's': + return pi + return 0.0 + + @property + def bend_budget(self) -> int: + if self.max_bends is not None: + return self.max_bends + if self.family == 'straight': + return 0 + if self.family == 'bend': + return 1 + return 2 + + +class Solver: + """ + Bounded grammar solver for composed primitive routes. + + The grammar is `A? (N A? (B|S|U) A?)* N A?`, where `A` is a ptype adapter, + `N` is a normal straight-like primitive, and the middle term is either a + bend primitive, a Tool-provided S/U primitive, or a composed S/U route made + from bend primitives. Parameter solving happens after a sequence is + enumerated so fixed and adjustable offers share the same path. + """ + + def __init__(self, request: RouteRequest) -> None: + self.request = request + self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str, PrimitiveKind | None], SelectedPrimitive] = {} + self.order = 0 + + def solve(self) -> Candidate: + """ + Enumerate, finalize, deduplicate, and rank legal candidates. + + Non-fatal candidate errors are accumulated so the failure message can + preserve useful Tool feedback. Fatal offer-contract errors stop the + solve immediately. + """ + def endpoint_key(port: Port) -> tuple[float, float, float | None, str | None]: + return ( + round(float(port.x), 9), + round(float(port.y), 9), + None if port.rotation is None else round(float(port.rotation), 9), + port.ptype, + ) + + def candidate_key(candidate: Candidate) -> tuple[Any, ...]: + def offer_key(offer: PrimitiveOffer) -> tuple[Any, ...]: + return ( + type(offer).__qualname__, + offer.in_ptype, + offer.out_ptype, + round(float(offer.priority_bias), 9), + tuple(round(float(value), 9) for value in offer.parameter_domain), + getattr(offer, 'ccw', None), + id(offer.endpoint_planner), + id(offer.commit_planner), + ) + + return ( + endpoint_key(candidate.end_port), + tuple(( + offer_key(step.offer), + step.role, + step.route_kind, + round(float(step.parameter), 9), + endpoint_key(step.out_port), + ) for step in candidate.steps), + ) + + candidates: list[Candidate] = [] + errors: list[Exception] = [] + seen: set[tuple[Any, ...]] = set() + for steps in self.enumerate_grammar(): + if not steps: + continue + if any(first.role == 'adapter' and second.role == 'adapter' for first, second in zip(steps, steps[1:], strict=False)): + continue + try: + candidate = self.finalize(steps) + except (BuildError, NotImplementedError, PortError) as err: + raise_if_fatal(err) + errors.append(err) + continue + key = candidate_key(candidate) + if key in seen: + continue + seen.add(key) + candidates.append(candidate) + + if not candidates: + for err in errors: + if getattr(err, 'fatal', False): + raise err + if errors: + last_error = errors[-1] + if self.request.route_name in str(last_error): + raise last_error + raise BuildError(f'{self.request.route_name} route is unsupported: {last_error}') from last_error + raise BuildError(f'No legal primitive offer for {self.request.route_name}') + + return min( + candidates, + key=lambda candidate: ( + round(float(candidate.cost), 9), + sum(step.role == 'adapter' for step in candidate.steps), + len(candidate.steps), + candidate.order, + ), + ) + + def primitive_offers( + self, + kind: PrimitiveKind, + in_ptype: str | None, + *, + 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) + if extra: + kwargs.update(extra) + return self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + + def evaluate( + self, + offer: PrimitiveOffer, + parameter: float, + in_ptype: str | None, + *, + out_ptype: str | None, + role: Literal['main', 'adapter'], + route_kind: PrimitiveKind | None, + route_name: str | None = None, + ) -> SelectedPrimitive: + """ + Canonicalize and validate one offer evaluation. + + This is the single point where the solver checks ptype compatibility, + endpoint declarations, selected endpoint ptype, finite cost, and + zero-jog S rejection. + """ + route_name = self.request.route_name if route_name is None else route_name + selected = offer.canonicalize_parameter(clean_parameter(parameter)) + key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name, route_kind) + cached = self.eval_cache.get(key) + if cached is not None: + return cached + + if not ptypes_compatible(in_ptype, offer.in_ptype): + raise BuildError('primitive input ptype is incompatible') + if isinstance(offer, SOffer) and is_close(selected, 0): + raise BuildError('zero-jog S primitive candidates are not allowed') + out_port = offer.endpoint_at(selected) + if not ptypes_compatible(out_port.ptype, offer.out_ptype): + raise RoutePlanningError( + f'{route_name} primitive endpoint ptype does not match declared offer out_ptype', + fatal=True, + ) + if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype): + raise RoutePlanningError( + 'Requested out_ptype does not match primitive endpoint ptype', + fatal=True, + ) + cost = float(offer.cost_at(selected)) + if not numpy.isfinite(cost): + raise BuildError(f'{route_name} primitive returned non-finite cost') + if cost < 0: + raise BuildError(f'{route_name} primitive returned negative cost') + primitive = SelectedPrimitive( + offer, + selected, + out_port, + cost, + role=role, + route_kind=route_kind, + ) + self.eval_cache[key] = primitive + return primitive + + def compose_endpoint(self, steps: Sequence[SelectedPrimitive]) -> Port: + """ + Compose local primitive endpoints into one local route endpoint. + + Primitive output rotations follow Masque's port convention: the port + points back into the primitive, so each step advances orientation by + the primitive output rotation plus pi. + """ + offset = numpy.zeros(2) + angle = 0.0 + ptype: str | None = None + for step in steps: + out_port = step.out_port + if out_port.rotation is None: + raise BuildError('Primitive endpoints must have rotation') + offset += rotation_matrix_2d(angle) @ out_port.offset + angle += out_port.rotation + pi + ptype = out_port.ptype + return Port(offset, rotation=angle - pi, ptype=ptype) + + def current_ptype(self, steps: Sequence[SelectedPrimitive]) -> str | None: + return self.request.in_ptype if not steps else self.compose_endpoint(steps).ptype + + def adapter_options( + self, + steps: Sequence[SelectedPrimitive], + *, + residual_jog: float, + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return no-adapter plus single straight/S ptype adapter options.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [()] + for kind in ('straight', 's'): + try: + offers = self.primitive_offers(kind, current_ptype, out_ptype=None) + except NotImplementedError: + continue + for offer in offers: + if not is_adapter_offer(offer): + continue + try: + parameter = ( + minimum_parameter(offer, 'straight adapter') + if kind == 'straight' + else adapter_s_parameter(offer, residual_jog) + ) + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='adapter', + route_kind=kind, + route_name=f'{kind} adapter', + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def straight_options( + self, + steps: Sequence[SelectedPrimitive], + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return no-straight plus minimum-parameter non-adapter straight options.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [()] + try: + offers = self.primitive_offers('straight', current_ptype, out_ptype=None) + except NotImplementedError: + return tuple(options) + for offer in offers: + if is_adapter_offer(offer): + continue + try: + parameter = minimum_parameter(offer, 'trace') + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='main', + route_kind='straight', + route_name='trace', + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def bend_options( + self, + steps: Sequence[SelectedPrimitive], + ccw: SupportsBool, + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return legal fixed-direction bend options for the current ptype.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [] + try: + offers = self.primitive_offers('bend', current_ptype, out_ptype=None, extra={'ccw': ccw}) + except NotImplementedError: + return () + for offer in offers: + if not isinstance(offer, BendOffer): + continue + if bool(offer.ccw) != bool(ccw): + continue + try: + parameter = minimum_parameter(offer, 'trace') + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='main', + route_kind='bend', + route_name='trace', + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def su_primitive_options( + self, + steps: Sequence[SelectedPrimitive], + kind: Literal['s', 'u'], + jog: float | None = None, + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return Tool-provided S/U primitive options for candidate jogs.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [] + try: + offers = self.primitive_offers(kind, current_ptype, out_ptype=None) + except NotImplementedError: + return () + route_name = 'S-bend' if kind == 's' else 'U-turn' + for offer in offers: + if kind == 's' and not isinstance(offer, SOffer): + continue + for parameter in self.su_parameters(offer, kind, jog): + try: + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='main', + route_kind=kind, + route_name=route_name, + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def su_parameters( + self, + offer: PrimitiveOffer, + kind: Literal['s', 'u'], + requested_jog: float | None, + ) -> tuple[float, ...]: + """Build deterministic jog-parameter probes for Tool-provided S/U offers.""" + candidates: list[float] = [] + if requested_jog is not None and (kind == 'u' or not is_close(requested_jog, 0)): + candidates.append(float(requested_jog)) + if kind == 'u': + lower, _upper = offer.parameter_domain + if numpy.isfinite(lower): + candidates.append(float(lower)) + else: + candidates.append(0.0) + candidates.extend(minimum_nonzero_parameters(offer)) + + selected: list[float] = [] + for candidate in candidates: + try: + parameter = offer.canonicalize_parameter(candidate) + except BuildError: + continue + if kind == 's' and is_close(parameter, 0): + continue + if not any(is_close(parameter, existing) for existing in selected): + selected.append(parameter) + return tuple(selected) + + def turn_options( + self, + steps: Sequence[SelectedPrimitive], + remaining_bends: int, + ) -> tuple[tuple[tuple[SelectedPrimitive, ...], int], ...]: + """Return bend-family options paired with their consumed bend budget.""" + options: list[tuple[tuple[SelectedPrimitive, ...], int]] = [] + if remaining_bends >= 1: + for ccw in (False, True): + options.extend((turn, 1) for turn in self.bend_options(steps, ccw)) + if remaining_bends >= 2: + jog = self.request.jog + options.extend((turn, 2) for turn in self.su_primitive_options(steps, 's', jog)) + options.extend((turn, 2) for turn in self.su_primitive_options(steps, 'u', jog)) + return tuple(options) + + def enumerate_grammar(self) -> Iterable[tuple[SelectedPrimitive, ...]]: + """Yield raw primitive sequences allowed by the bounded route grammar.""" + residual_jog = 0.0 if self.request.jog is None else float(self.request.jog) + base: tuple[SelectedPrimitive, ...] = () + for prefix_adapter in self.adapter_options(base, residual_jog=residual_jog): + prefix = (*base, *prefix_adapter) + yield from self.enumerate_segments(prefix, self.request.bend_budget, residual_jog=residual_jog) + + def enumerate_segments( + self, + steps: tuple[SelectedPrimitive, ...], + remaining_bends: int, + *, + residual_jog: float, + ) -> Iterable[tuple[SelectedPrimitive, ...]]: + """Recursively enumerate normal/adapter/turn blocks within the bend budget.""" + for normal in self.straight_options(steps): + after_normal = (*steps, *normal) + suffix_options = ( + self.adapter_options(after_normal, residual_jog=0) + if self.request.out_ptype is not None + else ((),) + ) + for suffix in suffix_options: + yield (*after_normal, *suffix) + + if remaining_bends <= 0: + continue + for core_adapter in self.adapter_options(after_normal, residual_jog=residual_jog): + before_core = (*after_normal, *core_adapter) + for turn, bend_count in self.turn_options(before_core, remaining_bends): + after_turn = (*before_core, *turn) + for post_adapter in self.adapter_options(after_turn, residual_jog=residual_jog): + yield from self.enumerate_segments( + (*after_turn, *post_adapter), + remaining_bends - bend_count, + residual_jog=residual_jog, + ) + + def adjustable_indices(self, steps: Sequence[SelectedPrimitive]) -> tuple[int, ...]: + """Return non-adapter primitive indices whose parameter can still move.""" + adjustable: list[int] = [] + for index, step in enumerate(steps): + if step.role == 'adapter': + continue + parameter = step.parameter + lower, upper = step.offer.parameter_domain + probes = [ + parameter + max(1e-6, abs(parameter) * 1e-6), + parameter - max(1e-6, abs(parameter) * 1e-6), + ] + if numpy.isfinite(upper): + probes.append(numpy.nextafter(upper, -numpy.inf)) + if numpy.isfinite(lower): + probes.append(lower) + for probe in probes: + try: + step.offer.canonicalize_parameter(probe) + except BuildError: + continue + if abs(float(probe) - float(parameter)) > 1e-12: + adjustable.append(index) + break + return tuple(adjustable) + + def reevaluate(self, steps: Sequence[SelectedPrimitive], parameters: Sequence[float]) -> tuple[SelectedPrimitive, ...]: + """Re-evaluate a primitive sequence with new parameters and flowing ptypes.""" + selected: list[SelectedPrimitive] = [] + current_ptype = self.request.in_ptype + for step, parameter in zip(steps, parameters, strict=True): + selected_step = self.evaluate( + step.offer, + parameter, + current_ptype, + out_ptype=None, + role=step.role, + route_kind=step.route_kind, + ) + selected.append(selected_step) + current_ptype = selected_step.out_port.ptype + return tuple(selected) + + def solve_parameters( + self, + steps: Sequence[SelectedPrimitive], + solve_indices: Sequence[int], + constraints: Sequence[tuple[Literal['x', 'y'], float]], + ) -> tuple[tuple[SelectedPrimitive, ...], Port] | None: + """ + Adjust selected primitive parameters to satisfy endpoint constraints. + + The solver estimates each adjustable parameter's local linear effect by + probing the composed endpoint, solves the tiny least-squares system, + then re-evaluates with canonicalized parameters. + """ + parameters = [step.parameter for step in steps] + for _iteration in range(3): + selected_steps = self.reevaluate(steps, parameters) + base_end = self.compose_endpoint(selected_steps) + if not solve_indices: + return selected_steps, base_end + + matrix = [[0.0 for _index in solve_indices] for _constraint in constraints] + for column, solve_index in enumerate(solve_indices): + step = steps[solve_index] + parameter = parameters[solve_index] + probe = parameter + max(1e-6, abs(parameter) * 1e-6) + try: + probe = step.offer.canonicalize_parameter(probe) + except BuildError: + probe = numpy.nextafter(parameter, -numpy.inf) + try: + probe = step.offer.canonicalize_parameter(probe) + except BuildError: + return None + if abs(float(probe) - float(parameter)) <= 1e-12: + return None + probe_parameters = list(parameters) + probe_parameters[solve_index] = probe + probe_steps = self.reevaluate(steps, probe_parameters) + probe_end = self.compose_endpoint(probe_steps) + for row, (axis, _target) in enumerate(constraints): + matrix[row][column] = (float(getattr(probe_end, axis)) - float(getattr(base_end, axis))) / (probe - parameter) + + residual = [target - float(getattr(base_end, axis)) for axis, target in constraints] + if all(abs(value) <= 1e-9 for value in residual): + return selected_steps, base_end + deltas = solve_small_lstsq(matrix, residual) + if deltas is None: + return None + changed = False + for solve_index, delta in zip(solve_indices, deltas, strict=True): + parameter = steps[solve_index].offer.canonicalize_parameter( + clean_parameter(parameters[solve_index] + float(delta)), + ) + changed = changed or abs(parameter - parameters[solve_index]) > 1e-12 + parameters[solve_index] = parameter + if not changed: + return selected_steps, base_end + selected_steps = self.reevaluate(steps, parameters) + return selected_steps, self.compose_endpoint(selected_steps) + + def endpoint_matches( + self, + end_port: Port, + constraints: Sequence[tuple[Literal['x', 'y'], float]], + ) -> bool: + """Return true when a composed endpoint satisfies requested position, rotation, and ptype.""" + for axis, target in constraints: + if not is_close(getattr(end_port, axis), target): + return False + if end_port.rotation is None: + return False + rotation_delta = (float(end_port.rotation) - self.request.out_rotation) % (2 * pi) + if not (is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi)): + return False + return self.request.out_ptype is None or ptypes_compatible(end_port.ptype, self.request.out_ptype) + + def finalize(self, steps: Sequence[SelectedPrimitive]) -> Candidate: + """ + Try all small solve sets for one raw sequence and return the first match. + + Solve-set order is deterministic and becomes part of the candidate + ordering only after cost and structural tie-breakers. + """ + constraints: list[tuple[Literal['x', 'y'], float]] = [] + if self.request.length is not None: + constraints.append(('x', float(self.request.length))) + if self.request.family == 'straight': + constraints.append(('y', 0.0)) + elif self.request.family in ('s', 'u') or self.request.constrain_jog: + if self.request.jog is None: + raise BuildError(f'{self.request.route_name} route requires a jog constraint') + constraints.append(('y', float(self.request.jog))) + route_constraints = tuple(constraints) + adjustable = self.adjustable_indices(steps) + solve_sets: list[tuple[int, ...]] = [()] + max_solve = min(len(route_constraints), len(adjustable)) + for solve_size in range(1, max_solve + 1): + solve_sets.extend(combinations(adjustable, solve_size)) + + for solve_indices in solve_sets: + solved = self.solve_parameters(steps, solve_indices, route_constraints) + if solved is None: + continue + selected_steps, end_port = solved + if not self.endpoint_matches(end_port, route_constraints): + continue + order = self.order + self.order += 1 + public_length = float(end_port.x) if self.request.length is None else float(self.request.length) + return Candidate( + tuple(selected_steps), + end_port, + sum(step.cost for step in selected_steps), + order, + public_length, + ) + raise BuildError(f'{self.request.route_name} composed primitive route is unsupported') + + +@dataclass(frozen=True, slots=True) +class RouteLeg: + """ + One solved route leg tied to the Pather port it will update. + + `start_port` is the copied route start used for all layout transforms. + `tool` is stored with the leg so prepared render steps cannot be stamped + with a mismatched Tool after bundle ordering. + """ + portspec: str + """Pather port name this leg will update.""" + start_port: Port + """Copied layout-space route start.""" + tool: Tool + """Tool used for all render steps in this leg.""" + candidate: Candidate + """Solved local primitive candidate.""" + plug_into: str | None = None + """Optional destination port to consume after applying the final endpoint.""" + + +class RoutingPlanner: + """ + Pather-facing stateless route-selection facade. + + Public Pather methods call this class with copied port contexts. Returned + `PreparedRouteResult`s contain only committed render steps, final ports, + plug targets, and renames needed for Pather state mutation. + """ + + TRACE_INTO_MAX_BENDS: int = 4 + + def plan_leg( + self, + family: PrimitiveKind, + context: RoutePortContext, + *, + length: float | None = None, + jog: float | None = None, + ccw: SupportsBool | None = None, + plug_into: str | None = None, + constrain_jog: bool = False, + max_bends: int | None = None, + **kwargs: Any, + ) -> RouteLeg: + """Solve one route leg and attach it to its source Pather context.""" + request = RouteRequest( + family=family, + tool=context.tool, + in_ptype=context.port.ptype, + route_kwargs=kwargs, + length=length, + jog=jog, + ccw=ccw, + out_ptype=kwargs.get('out_ptype'), + constrain_jog=constrain_jog, + max_bends=max_bends, + ) + try: + candidate = Solver(request).solve() + except BuildError as err: + if family == 'u' and length is None and not getattr(err, 'fatal', False): + raise BuildError('No legal primitive offer for omitted-length U-turn') from err + raise + return RouteLeg( + portspec=context.portspec, + start_port=context.port.copy(), + tool=context.tool, + candidate=candidate, + plug_into=plug_into, + ) + + def prepared_route_action_from_leg( + self, + leg: RouteLeg, + ) -> PreparedRouteAction: + """ + Convert a solved leg into committed render steps and a final live port. + + Offer commits happen here, after route selection succeeds. Each + primitive endpoint is transformed from route-local coordinates using + the previous step's layout-space output port. + """ + current = leg.start_port.copy() + render_steps: list[RenderStep] = [] + for selected in leg.candidate.steps: + port_rot = current.rotation + if port_rot is None: + raise PortError('Ports must have rotation') + out_port = selected.out_port.copy() + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(current.offset) + render_steps.append(RenderStep( + selected.offer.opcode, + leg.tool, + current.copy(), + out_port.copy(), + selected.offer.commit(selected.parameter), + )) + current = out_port + if not render_steps: + raise BuildError('Route leg has no primitive steps') + return PreparedRouteAction( + portspec=leg.portspec, + render_steps=tuple(render_steps), + final_port=current.copy(), + plug_into=leg.plug_into, + ) + + def prepared_result_from_legs( + self, + legs: Sequence[RouteLeg], + *, + renames: tuple[tuple[str, str], ...] = (), + ) -> PreparedRouteResult: + """Build a prepared result from solved legs plus deferred port renames.""" + return PreparedRouteResult( + actions=tuple(self.prepared_route_action_from_leg(leg) for leg in legs), + renames=renames, + ) + + def plan_trace_route( + self, + contexts: Sequence[RoutePortContext], + ccw: SupportsBool | None, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan straight or single-bend traces, including `each` and bundle-bound modes.""" + route_bounds = dict(bounds) + portspec = tuple(context.portspec for context in contexts) + planner_bounds.validate_trace_args(portspec, length=length, spacing=spacing, bounds=route_bounds) + family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' + if length is not None: + leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, **route_bounds) + return self.prepared_result_from_legs((leg,)) + + if route_bounds.get('each') is not None: + each = route_bounds.pop('each') + return PreparedRouteResult(tuple( + self.prepared_route_action_from_leg( + self.plan_leg(family, context, length=each, ccw=ccw, **route_bounds), + ) + for context in contexts + )) + + bundle_bounds = planner_bounds.present_bundle_bounds(route_bounds) + if not bundle_bounds: + leg = self.plan_leg(family, contexts[0], length=None, ccw=ccw, **route_bounds) + return self.prepared_result_from_legs((leg,)) + + bound_type = bundle_bounds[0] + bound_value = route_bounds.pop(bound_type) + set_rotation = route_bounds.pop('set_rotation', None) + extensions = ell( + {context.portspec: context.port for context in contexts}, + ccw, + spacing=spacing, + bound=bound_value, + bound_type=bound_type, + set_rotation=set_rotation, + ) + actions = [] + for port_name, route_length in extensions.items(): + context = next(context for context in contexts if context.portspec == port_name) + leg = self.plan_leg(family, context, length=route_length, ccw=ccw, **route_bounds) + actions.append(self.prepared_route_action_from_leg(leg)) + return PreparedRouteResult(tuple(actions)) + + def plan_trace_to_route( + self, + contexts: Sequence[RoutePortContext], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes.""" + route_bounds = dict(bounds) + if len(contexts) == 1: + resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=False) + else: + resolved = None + if any(route_bounds.get(key) is not None for key in planner_bounds.POSITION_KEYS): + raise BuildError('Position bounds only allowed with a single port') + if resolved is None: + return self.plan_trace_route(contexts, ccw, spacing=spacing, **route_bounds) + + planner_bounds.validate_trace_to_positional_args(spacing=spacing, bounds=route_bounds) + _key, _value, length = resolved + other_bounds = { + key: value + for key, value in route_bounds.items() + if key not in planner_bounds.POSITION_KEYS and key != 'length' + } + family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' + leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, **other_bounds) + return self.prepared_result_from_legs((leg,)) + + def plan_jog_route( + self, + contexts: Sequence[RoutePortContext], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan S-bend routes for single ports or spaced bundles.""" + if numpy.isclose(offset, 0): + return self.plan_trace_to_route(contexts, None, length=length, spacing=spacing, **bounds) + route_bounds = dict(bounds) + portspec = tuple(context.portspec for context in contexts) + planner_bounds.validate_jog_args(portspec, length=length, spacing=spacing, bounds=route_bounds) + other_bounds = dict(route_bounds) + if length is None and len(contexts) == 1: + resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=True) + if resolved is not None: + _key, _value, length = resolved + other_bounds = {key: value for key, value in route_bounds.items() if key not in planner_bounds.POSITION_KEYS} + if len(contexts) > 1: + return PreparedRouteResult(tuple( + self.prepared_route_action_from_leg(leg) + for leg in self.plan_su_bundle_routes('s', contexts, offset, length, spacing, **other_bounds) + )) + leg = self.plan_leg('s', contexts[0], length=length, jog=offset, **other_bounds) + return self.prepared_result_from_legs((leg,)) + + def plan_uturn_route( + self, + contexts: Sequence[RoutePortContext], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan U-turn routes for single ports or spaced bundles.""" + route_bounds = dict(bounds) + portspec = tuple(context.portspec for context in contexts) + planner_bounds.validate_uturn_args(portspec, spacing=spacing, bounds=route_bounds) + if len(contexts) > 1: + return PreparedRouteResult(tuple( + self.prepared_route_action_from_leg(leg) + for leg in self.plan_su_bundle_routes('u', contexts, offset, length, spacing, **route_bounds) + )) + leg = self.plan_leg('u', contexts[0], length=length, jog=offset, **route_bounds) + return self.prepared_result_from_legs((leg,)) + + def plan_su_bundle_routes( + self, + kind: Literal['s', 'u'], + contexts: Sequence[RoutePortContext], + offset: float, + length: float | None, + spacing: float | ArrayLike | None, + **kwargs: Any, + ) -> tuple[RouteLeg, ...]: + """ + Solve the anchor S/U route and derive exact routes for the rest of a bundle. + + The anchor may determine the public length when omitted. Once known, + `su_bundle_specs()` normalizes every other port into an exact length + and offset so all legs can be planned independently. + """ + if len(contexts) == 1: + return (self.plan_leg(kind, contexts[0], length=length, jog=offset, **kwargs),) + route_name = 'jog' if kind == 's' else 'uturn' + if kind == 'u' and is_close(offset, 0): + raise BuildError('multi-port uturn() requires nonzero offset to determine bundle ordering') + contexts_by_name = {context.portspec: context for context in contexts} + initial_specs = planner_bounds.su_bundle_specs(contexts, offset, 0, spacing, route_name=route_name) + anchor_portspec, _anchor_length, _anchor_offset = initial_specs[0] + anchor = self.plan_leg(kind, contexts_by_name[anchor_portspec], length=length, jog=offset, **kwargs) + base_length = anchor.candidate.public_length + specs = planner_bounds.su_bundle_specs(contexts, offset, base_length, spacing, route_name=route_name) + first_portspec, _first_length, _first_offset = specs[0] + routes_by_name = {first_portspec: anchor} + for spec_portspec, spec_length, spec_offset in specs[1:]: + if kind == 's' and is_close(spec_offset, 0): + routes_by_name[spec_portspec] = self.plan_leg('straight', contexts_by_name[spec_portspec], length=spec_length, **kwargs) + else: + routes_by_name[spec_portspec] = self.plan_leg( + kind, + contexts_by_name[spec_portspec], + length=spec_length, + jog=spec_offset, + **kwargs, + ) + return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs) + + def plan_trace_into( + self, + context_src: RoutePortContext, + portspec_dst: str, + port_dst: Port, + *, + out_ptype: str | None, + plug_destination: bool, + thru: str | None, + **kwargs: Any, + ) -> 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: + raise PortError('Ports must have rotation') + desired = port_dst.copy() + desired.rotation = port_dst.rotation - pi + desired.ptype = out_ptype + family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) + leg = self.plan_leg( + family, + context_src, + length=length, + jog=jog, + ccw=ccw, + plug_into=portspec_dst if plug_destination else None, + constrain_jog=family == 'bend', + max_bends=self.TRACE_INTO_MAX_BENDS, + **(dict(kwargs) | {'out_ptype': out_ptype}), + ) + renames = ((thru, context_src.portspec),) if thru is not None else () + return self.prepared_result_from_legs( + (leg,), + renames=renames, + ) + + def trace_into_spec( + self, + start_port: Port, + end_port: Port, + ) -> tuple[PrimitiveKind, float, float, SupportsBool | None]: + """Convert source/destination geometry into a primitive route family and constraints.""" + def quarter_turn(rotation: float) -> int: + normalized = rotation % (2 * pi) + if is_close(normalized, 2 * pi): + normalized = 0.0 + quarter = int(round(normalized / (pi / 2))) % 4 + if not is_close(normalized, (quarter * pi / 2) % (2 * pi)): + raise BuildError('trace_into() only supports Manhattan port rotations') + return quarter + + travel_jog, _angle = start_port.measure_travel(end_port) + travel, jog = travel_jog + length = -float(travel) + offset = -float(jog) + if start_port.rotation is None or end_port.rotation is None: + raise PortError('Ports must have rotation') + relative_quarter = (quarter_turn(end_port.rotation) - quarter_turn(start_port.rotation)) % 4 + if relative_quarter == 0: + return ('straight', length, 0.0, None) if is_close(offset, 0) else ('s', length, offset, None) + if relative_quarter == 1: + return 'bend', length, offset, True + if relative_quarter == 2: + return 'u', length, offset, None + return 'bend', length, offset, False diff --git a/masque/builder/tools.py b/masque/builder/tools.py index af49d44..e4569bd 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,35 +1,558 @@ """ -Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) +Routing Tool contracts and built-in Tool implementations. -The `Tool` interface has two layers: +A `Tool` is the user-extensible side of Pather routing. It does not receive +Pather state and it does not choose high-level route topology. Instead, +`primitive_offers()` exposes the local primitive moves that are legal for the +Tool: parameter domains, endpoint ptypes and geometry, additive costs, +optional footprint bounds, and a commit hook for the selected parameter. +`masque.builder.planner` composes those offers into `trace()`, `jog()`, +`uturn()`, and `trace_into()` routes. -* `traceL`/`traceS`/`traceU` create concrete single-use geometry immediately. -* `planL`/`planS`/`planU` return an output `Port` plus tool-specific render - data, allowing `Pather(auto_render=False)` to defer geometry creation until - `Tool.render()` is called with a batch of `RenderStep`s. +Offer geometry is described in local route coordinates. The current input port +is `(0, 0)` with rotation `0`; length-like parameters advance along +x; positive +jog is left of travel; returned endpoint ports describe the primitive output in +that same local frame. The planner transforms selected endpoints into layout +coordinates only after a complete route has been chosen. -Plans are expressed in local tool coordinates: the input port is at `(0, 0)` -with rotation `0`, `length` is measured along the input axis, and positive -`jog` is left of the direction of travel. Concrete tools may implement native -planning/rendering for L, S, and U routes; otherwise the base planning methods -fall back to the corresponding `trace*()` methods. `Pather` may also synthesize -some routes from simpler primitives when a tool does not provide a native route. +Tool authors should treat offer planning callbacks as pure descriptions. The +solver may call `endpoint_at()`, `cost_at()`, and `bbox_at()` many times while +enumerating candidate compositions, ptype adapters, and parameter solutions. +Those callbacks should be deterministic and should not mutate a Library, +Pattern, or live Pather. `commit()` is the first selected-offer hook: it runs +only for primitives in the chosen route and returns the opaque value stored in +`RenderStep.data`. + +`render()` is the geometry-construction hook. Pather calls it later with a +compatible batch of committed `RenderStep`s, already expressed in layout +coordinates and grouped by Tool/continuity. The returned single-top tree is +plugged into the pending route by Pather, which also validates that the rendered +output port matches the endpoint selected during planning. + +Routing uses the Tool assigned to the routed input port. The solver does not +search across Tools or infer `Pather.retool()` boundaries; transitions, +cross-ptype routes, and adapter shapes must be exposed by the active Tool as +primitive offers. """ -from typing import Literal, Any, Self, cast -from collections.abc import Sequence, Callable, Iterator -from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? -from dataclasses import dataclass +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 math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan import numpy from numpy.typing import NDArray from numpy import pi -from ..utils import SupportsBool, rotation_matrix_2d, layer_t +from ..utils import ( + layer_t, + ptypes_compatible as ptypes_compatible, + rotation_matrix_2d, + ) from ..ports import Port from ..pattern import Pattern from ..abstract import Abstract from ..library import ILibrary, Library, SINGLE_USE_PREFIX from ..error import BuildError +from ..shapes import Path + + +def _canonicalize_domain_value( + value: float, + domain: tuple[float, float], + *, + rtol: float = 1e-9, + atol: float = 1e-12, + ) -> float: + """ + Canonicalize a solved primitive parameter against a route domain. + + Normal domains are half-open `[min, max)`. A `(value, value)` domain is a + special closed singleton used for fixed parameters. + """ + vv = float(value) + lower, upper = (float(domain[0]), float(domain[1])) + + if scalar_isnan(lower) or scalar_isnan(upper): + raise BuildError(f'Parameter domain must not contain NaN values: {domain}') + if lower > upper: + raise BuildError(f'Parameter domain lower bound must not exceed upper bound: {domain}') + if not scalar_isfinite(vv): + raise BuildError(f'Parameter {vv:g} must be finite') + + if lower == upper: + if not scalar_isfinite(lower): + raise BuildError(f'Singleton parameter domain must be finite: {domain}') + if scalar_isclose(vv, lower, rel_tol=rtol, abs_tol=atol): + return lower + raise BuildError(f'Parameter {vv:g} is outside singleton domain {{{lower:g}}}') + + if scalar_isclose(vv, lower, rel_tol=rtol, abs_tol=atol): + vv = lower + if vv < lower or vv >= upper: + raise BuildError(f'Parameter {vv:g} is outside half-open domain [{lower:g}, {upper:g})') + return vv + + +EndpointCallable = Callable[[float], Port] +CommitCallable = Callable[[float], Any] +BBoxCallable = Callable[[float], NDArray[numpy.float64]] +DataCallable = Callable[[float], Any] +BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]] +PrimitiveKind = Literal['straight', 'bend', 's', 'u'] +BUILTIN_PRIORITY_STEP = 1e7 + + +def _generated_offer_callbacks( + endpoint_at: EndpointCallable, + data_at: DataCallable, + bbox_for_data: BBoxForDataCallable | None, + ) -> tuple[EndpointCallable, CommitCallable, BBoxCallable | None]: + def commit(parameter: float) -> Any: + return data_at(parameter) + + if bbox_for_data is None: + return endpoint_at, commit, None + + def bbox(parameter: float) -> NDArray[numpy.float64]: + return bbox_for_data(data_at(parameter)) + + return endpoint_at, commit, bbox + + +def _prebuilt_offer_callbacks( + endpoint: Port, + data: Any, + bbox_for_data: BBoxForDataCallable | None, + ) -> tuple[EndpointCallable, CommitCallable, BBoxCallable | None]: + prebuilt_endpoint = endpoint.copy() + + def endpoint_at(parameter: float) -> Port: + _ = parameter + return prebuilt_endpoint.copy() + + def commit(parameter: float) -> Any: + _ = parameter + return data + + if bbox_for_data is None: + return endpoint_at, commit, None + + def bbox(parameter: float) -> NDArray[numpy.float64]: + _ = parameter + return bbox_for_data(data) + + return endpoint_at, commit, bbox + + +@dataclass(frozen=True, slots=True) +class PrimitiveOffer(ABC): + """ + Shared base type for local routing primitives made available by a `Tool`. + + Custom tools normally construct one of the concrete offer classes: + `StraightOffer`, `BendOffer`, `SOffer`, or `UOffer`. `PrimitiveOffer` + exists to hold common callback, ptype, cost, and footprint behavior and is + useful for annotations when handling offers generically. + + Offers are pure planning objects. `endpoint_at()` returns a local output + `Port`, `cost_at()` returns an additive scalar cost, `bbox_at()` returns + local primitive bounds when a footprint hook is available, and `commit()` + returns opaque render data only after an offer has been selected. These + methods should be deterministic and must not mutate the user's target + library. + + Parameter domains are half-open `[min, max)` ranges, except `(value, value)` + is a closed singleton for fixed-size primitives. `None` and `"unk"` ptypes + are wildcards; incompatible concrete ptypes are rejected by `Pather`. + """ + in_ptype: str | None + out_ptype: str | None + priority_bias: float = 0.0 + bbox_planner: BBoxCallable | None = None + parameterized_bbox: Any | None = None + endpoint_planner: EndpointCallable | None = None + commit_planner: CommitCallable | None = None + + def __post_init__(self) -> None: + has_endpoint = self.endpoint_planner is not None + has_commit = self.commit_planner is not None + + if has_endpoint != has_commit: + raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner') + if not numpy.isfinite(self.priority_bias) or self.priority_bias < 0: + raise BuildError(f'PrimitiveOffer priority_bias must be nonnegative and finite, got {self.priority_bias:g}') + + @property + @abstractmethod + def opcode(self) -> Literal['L', 'S', 'U']: + raise NotImplementedError + + @property + @abstractmethod + def parameter_domain(self) -> tuple[float, float]: + raise NotImplementedError + + def canonicalize_parameter(self, parameter: float) -> float: + """Return a finite selected parameter inside this offer's domain.""" + return _canonicalize_domain_value(parameter, self.parameter_domain) + + def endpoint_at(self, parameter: float) -> Port: + """ + Evaluate the local endpoint for a candidate parameter. + + The returned port is in Tool-local public route coordinates. It must + not depend on live Pather state or mutate the user's target library. + """ + selected = self.canonicalize_parameter(parameter) + if self.endpoint_planner is not None: + return self.endpoint_planner(selected) + raise NotImplementedError + + def cost_at(self, parameter: float) -> float: + """ + Return this primitive's additive planning cost. + + Lower cost is preferred before internal tie-breakers. The default cost + is based on local endpoint displacement plus `priority_bias`. + """ + selected = self.canonicalize_parameter(parameter) + if self.endpoint_planner is not None: + out_port = self.endpoint_planner(selected) + else: + out_port = self.endpoint_at(selected) + return self.priority_bias + abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y)) + + def bbox_at(self, parameter: float) -> NDArray[numpy.float64]: + """ + Return local primitive bounds for footprint-aware planning. + + Tools may omit this hook by raising `NotImplementedError`; when present + it must return a finite `(2, 2)` min/max array in local coordinates. + """ + if self.bbox_planner is None: + raise NotImplementedError + + bounds = numpy.asarray( + self.bbox_planner(self.canonicalize_parameter(parameter)), + dtype=float, + ) + if bounds.shape != (2, 2): + raise BuildError(f'Primitive bbox must have shape (2, 2), got {bounds.shape}') + if not numpy.all(numpy.isfinite(bounds)): + raise BuildError('Primitive bbox must contain only finite values') + if numpy.any(bounds[0, :] > bounds[1, :]): + raise BuildError('Primitive bbox minimum corner must not exceed maximum corner') + return bounds + + def commit(self, parameter: float) -> Any: + """ + Produce opaque render data for a selected primitive. + + `Pather` calls this only for selected primitives while preparing + `RenderStep.data`. Unselected candidates are evaluated by endpoint/cost + only and should not need commit-side work. + """ + selected = self.canonicalize_parameter(parameter) + if self.commit_planner is not None: + return self.commit_planner(selected) + raise NotImplementedError + + +@dataclass(frozen=True, slots=True) +class StraightOffer(PrimitiveOffer): + """Straight or straight-like primitive parameterized by public route length.""" + length_domain: tuple[float, float] = (0.0, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + data_at: DataCallable, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + length_domain: tuple[float, float] = (0.0, numpy.inf), + ) -> Self: + """ + Build a generated straight offer with the default route-frame endpoint. + """ + def endpoint_at(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=ptype) + + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + length_domain = length_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt straight-like offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + length_domain = (float(endpoint.x), float(endpoint.x)), + ) + + @property + def opcode(self) -> Literal['L']: + return 'L' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.length_domain + + +@dataclass(frozen=True, slots=True) +class BendOffer(PrimitiveOffer): + """Single-turn L-route primitive parameterized by public route length.""" + ccw: bool = True + length_domain: tuple[float, float] = (0.0, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + endpoint_at: EndpointCallable, + data_at: DataCallable, + *, + ccw: bool, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + length_domain: tuple[float, float] = (0.0, numpy.inf), + ) -> Self: + """ + Build a generated bend offer from endpoint and render-data callbacks. + """ + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ccw = ccw, + length_domain = length_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + ccw: bool, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt bend offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ccw = ccw, + length_domain = (float(endpoint.x), float(endpoint.x)), + ) + + @property + def opcode(self) -> Literal['L']: + return 'L' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.length_domain + + +@dataclass(frozen=True, slots=True) +class SOffer(PrimitiveOffer): + """Non-turning S-route primitive parameterized by jog for a fixed route length.""" + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + endpoint_at: EndpointCallable, + data_at: DataCallable, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf), + ) -> Self: + """ + Build a generated S-like offer from endpoint and render-data callbacks. + """ + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = jog_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt S-like offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = (float(endpoint.y), float(endpoint.y)), + ) + + @property + def opcode(self) -> Literal['S']: + return 'S' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.jog_domain + + +@dataclass(frozen=True, slots=True) +class UOffer(PrimitiveOffer): + """U-turn-like primitive parameterized by jog for a fixed route length.""" + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + endpoint_at: EndpointCallable, + data_at: DataCallable, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf), + ) -> Self: + """ + Build a generated U-like offer from endpoint and render-data callbacks. + """ + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = jog_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt U-like offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = (float(endpoint.y), float(endpoint.y)), + ) + + @property + def opcode(self) -> Literal['U']: + return 'U' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.jog_domain @dataclass(frozen=True, slots=True) @@ -43,9 +566,9 @@ class RenderStep: """ opcode: Literal['L', 'S', 'U', 'P'] """ What operation is being performed. - L: planL (straight, optionally with a single bend) - S: planS (s-bend) - U: planU (u-bend) + L: straight or single-bend primitive + S: S-like primitive + U: U-like primitive P: plug """ @@ -115,312 +638,47 @@ class RenderStep: ) -def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]: - """ - Measure generated geometry for the base `Tool.plan*()` fallbacks. - - Returns the calculated output port and the original tree as render data. - """ - pat = tree.top_pattern() - in_p = pat[port_names[0]] - out_p = pat[port_names[1]] - (travel, jog), rot = in_p.measure_travel(out_p) - return Port((travel, jog), rotation=rot, ptype=out_p.ptype), tree - - -class Tool: +class Tool(ABC): """ Interface for path (e.g. wire or waveguide) generation. - Subclasses may implement immediate `trace*()` methods, deferred - `plan*()`/`render()` methods, or both. The base `plan*()` implementations - call the matching `trace*()` method and measure the resulting ports, so a - simple immediate-rendering tool can implement only `traceL`, `traceS`, or - `traceU` as needed. Tools that support deferred rendering should return - compact, tool-specific data from `plan*()` and consume it in `render()`. + Subclasses must override `primitive_offers()` and explicitly return `()` + for recognized primitive kinds they do not support. + + Custom tools should return concrete offer objects (`StraightOffer`, + `BendOffer`, `SOffer`, or `UOffer`) rather than parsing offer identity from + strings after construction. """ - def traceL( + @abstractmethod + def primitive_offers( self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port. - - Used by `Pather`. - - The output port must be exactly `length` away along the input port's axis, but - may be placed an additional (unspecified) distance away along the perpendicular - direction. The output port should be rotated (or not) based on the value of - `ccw`. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested L-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'traceL() not implemented for {type(self)}') - - def traceS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port, and `jog` distance on the perpendicular axis. - `jog` is positive when moving left of the direction of travel (from input to output port). - - Used by `Pather`. - - The output port should be rotated to face the input port (i.e. plugging the device - into a port will move that port but keep its orientation). - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total distance from input to output, along the second axis. Positive indicates - a leftward shift when moving from input to output port. - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested S-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'traceS() not implemented for {type(self)}') - - def planL( - self, - ccw: SupportsBool | None, - length: float, + kind: PrimitiveKind, *, in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> tuple[Port, Any]: + ) -> tuple[PrimitiveOffer, ...]: """ - Plan a wire or waveguide that travels exactly `length` distance along the axis - of its input port. + Return local primitive offers available for the requested route role. - Used by `Pather` when `auto_render=False`. + Tools override this to expose multiple legal primitive variants with explicit domains and costs. + Direct offer implementations should declare the actual endpoint ptype produced by the offer when it + can differ from the requested value. - The output port must be exactly `length` away along the input port's axis, but - may be placed an additional (unspecified) distance away along the perpendicular - direction. The output port should be rotated (or not) based on the value of - `ccw`. + `kind` is one of: + - `'straight'`: a non-turning `StraightOffer` + - `'bend'`: a 90-degree `BendOffer`; `ccw` is supplied in `kwargs` + - `'s'`: a non-turning `SOffer` + - `'u'`: an `UOffer` - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. + `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 + adapters when legal. """ - # Fallback implementation using traceL - port_names = kwargs.pop('port_names', ('A', 'B')) - tree = self.traceL( - ccw, - length, - in_ptype=in_ptype, - out_ptype=out_ptype, - port_names=port_names, - **kwargs, - ) - return measure_tool_plan(tree, port_names) - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - Plan a wire or waveguide that travels exactly `length` distance along the axis - of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend). - - Used by `Pather` when `auto_render=False`. - - The output port must have an orientation rotated by pi from the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftward shift (i.e. counterclockwise bend followed - by a clockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - # Fallback implementation using traceS - port_names = kwargs.pop('port_names', ('A', 'B')) - tree = self.traceS( - length, - jog, - in_ptype=in_ptype, - out_ptype=out_ptype, - port_names=port_names, - **kwargs, - ) - return measure_tool_plan(tree, port_names) - - def traceU( - self, - jog: float, - *, - length: float = 0, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide whose output is displaced by `length` along - the input axis and `jog` along the perpendicular axis, while preserving - the input orientation (i.e. a U-bend or jogged U-turn). - - Used by `Pather`. Tools may leave this unimplemented if they - do not support a native U-bend primitive. - - The output port must have an orientation identical to the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise bend - followed by a clockwise bend) - length: The total offset from the input to output, along the input axis. - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested U-shaped wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'traceU() not implemented for {type(self)}') - - def planU( - self, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - Plan a wire or waveguide whose output is displaced by optional `length` - along the input axis and `jog` along the perpendicular axis, while - preserving the input orientation (i.e. a U-bend or jogged U-turn). - - Used by `Pather` when `auto_render=False`. This is an optional native-planning hook: tools may - implement it when they can represent a U-turn directly, otherwise they may rely - on `traceU()` or let `Pather` synthesize the route from simpler primitives. - - The output port must have an orientation identical to the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise_bend - followed by a clockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. `length` may be supplied here to - request a U-turn whose final port is displaced along both axes. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - # Fallback implementation using traceU - kwargs = dict(kwargs) - length = kwargs.pop('length', 0) - port_names = kwargs.pop('port_names', ('A', 'B')) - tree = self.traceU( - jog, - length=length, - in_ptype=in_ptype, - out_ptype=out_ptype, - port_names=port_names, - **kwargs, - ) - return measure_tool_plan(tree, port_names) + raise NotImplementedError + @abstractmethod def render( self, batch: Sequence[RenderStep], @@ -429,880 +687,468 @@ class Tool: **kwargs, ) -> ILibrary: """ - Render the provided `batch` of `RenderStep`s into geometry, returning a tree - (a Library with a single topcell). + Render a compatible batch of selected route steps into geometry. - The base implementation is intended for steps whose plan data came from - the base fallback planners, where `RenderStep.data` is already an - `ILibrary`. Subclasses with native `plan*()` data should generally - override this method. + `Pather.render()` passes batches that share one Tool and are continuous + in layout coordinates. The returned tree must have one top cell whose + input and output ports are named by `port_names`; `Pather` plugs the + input port into the pending route start and validates the output port + against the planned final endpoint. Args: - batch: A sequence of `RenderStep` objects containing the ports and data - provided by this tool's `planL`/`planS`/`planU` functions. + batch: A sequence of `RenderStep` objects containing committed + 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. """ - assert not batch or batch[0].tool == self - # Fallback: render each step individually - lib, pat = Library.mktree(SINGLE_USE_PREFIX + 'batch') - pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') - - for step in batch: - if step.opcode == 'L': - if isinstance(step.data, ILibrary): - seg_tree = step.data - else: - # extract parameters from kwargs or data - seg_tree = self.traceL( - ccw=step.data.get('ccw') if isinstance(step.data, dict) else None, - length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0, - port_names=port_names, - **kwargs, - ) - elif step.opcode == 'S': - if isinstance(step.data, ILibrary): - seg_tree = step.data - else: - seg_tree = self.traceS( - length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0, - jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0, - port_names=port_names, - **kwargs, - ) - elif step.opcode == 'U': - if isinstance(step.data, ILibrary): - seg_tree = step.data - else: - seg_tree = self.traceU( - jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0, - length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0, - port_names=port_names, - **kwargs, - ) - else: - continue - - seg_name = lib << seg_tree - pat.plug(lib[seg_name], {port_names[1]: port_names[0]}, append=True) - del lib[seg_name] - - return lib + raise NotImplementedError -abstract_tuple_t = tuple[Abstract, str, str] +GeneratedPrimitiveFn = Callable[..., Pattern | Library] @dataclass -class SimpleTool(Tool, metaclass=ABCMeta): - """ - Minimal L-route tool built from one straight generator and one bend. - - `SimpleTool` supports straight segments and single-bend L routes through - `planL`/`traceL`/`render`. It does not perform automatic port-type - transitions and does not provide native S or U routes. Use `AutoTool` when - routes need multiple candidate primitives, transitions, S-bends, or U-turns. - """ - straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str] - """ `(create_straight, in_port_name, out_port_name)` for straight segments. """ - - bend: abstract_tuple_t # Assumed to be clockwise - """ `(clockwise_bend_abstract, in_port_name, out_port_name)` for L turns. """ - - default_out_ptype: str - """ Default value for out_ptype """ - - mirror_bend: bool = True - """ Whether a clockwise bend should be mirrored (vs rotated) to get a ccw bend """ - - @dataclass(frozen=True, slots=True) - class LData: - """ Deferred render data returned by `planL()`. """ - straight_length: float - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, # noqa: ARG002 (unused) - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, LData]: - if ccw is not None: - bend, bport_in, bport_out = self.bend - - angle_in = bend.ports[bport_in].rotation - angle_out = bend.ports[bport_out].rotation - assert angle_in is not None - assert angle_out is not None - - bend_dxy = rotation_matrix_2d(-angle_in) @ ( - bend.ports[bport_out].offset - - bend.ports[bport_in].offset - ) - - bend_angle = angle_out - angle_in - - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - else: - bend_dxy = numpy.zeros(2) - bend_angle = pi - - if ccw is not None: - out_ptype_actual = bend.ports[bport_out].ptype - else: - out_ptype_actual = self.default_out_ptype - - straight_length = length - bend_dxy[0] - bend_run = bend_dxy[1] - - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' - ) - - data = self.LData(straight_length, kwargs, ccw) - out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) - return out_port, data - - def _renderL( - self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - gen_straight, sport_in, _sport_out = self.straight - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: sport_in} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.ccw is not None: - bend, bport_in, bport_out = self.bend - mirrored = self.mirror_bend and bool(data.ccw) - inport = bport_in if (self.mirror_bend or not data.ccw) else bport_out - pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) - return tree - - def traceL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.add_port_pair(names=(port_names[0], port_names[1])) - - for step in batch: - assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - -@dataclass -class AutoTool(Tool, metaclass=ABCMeta): +class AutoTool(Tool): """ A routing tool assembled from reusable path primitives. `AutoTool` chooses among prioritized straight generators, pre-rendered bends, - optional native S-bend generators, and pre-rendered transitions to satisfy the - `Tool` planning/rendering interface used by `Pather`. + optional generated S-bend primitives, pre-rendered U-turns, and + pre-rendered transitions registered through `add_straight()`, + `add_bend()`, `add_sbend()`, `add_uturn()`, and `add_transition()`. - Route selection is greedy in the order supplied by `straights`, `bends`, and - `sbends`. For each route, the planner subtracts any transition and bend - overhead from the requested distance, then uses the first candidate whose - remaining straight or jog length falls within that candidate's range. + Registration call order defines primitive priority. - `planL` uses one straight and, if `ccw` is not `None`, one bend. `planS` - first tries a straight plus a native S-bend, then a pure native S-bend, and - falls back to a two-L route when no native S-bend candidate fits. `planU` - is implemented as a two-L route. + Straight and bend offers use one straight and, if turning, one bend. + `add_sbend()` exposes generated S-bend primitives. `add_uturn()` exposes + reusable U-turn primitives; otherwise U-turns are left to `Pather`'s + composed-route planning. - Transition keys are `(external_ptype, internal_ptype)`. For example, a - transition keyed by `('m2wire', 'm1wire')` is used when the route is being - attached to an external `m2wire` port but the selected primitive is `m1wire`. - Call `add_complementary_transitions()` to automatically add reversed entries - for any missing opposite directions. + Transitions are bidirectional by default: `add_transition(external, + internal)` exposes adapter offers in both directions. Pass `one_way=True` + 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 `trace*()` or - `render()` are forwarded to those generators, along with any keyword - arguments captured during `plan*()`. + single-top `Library`. Extra keyword arguments passed to `render()` are + forwarded to those generators. """ @dataclass(frozen=True, slots=True) - class Straight: - """ - Description of a straight-path generator. - - `fn(length, **kwargs)` must return a path whose `in_port_name` and - `out_port_name` ports are separated by `length` along the input axis. - The planner considers this generator only when the required length is in - `length_range`, with an inclusive lower bound and exclusive upper bound. - """ - ptype: str - """ Port type produced by this straight segment. """ - - fn: Callable[[float], Pattern] | Callable[[float], Library] - """ Generator function called as `fn(length, **kwargs)`. """ - - in_port_name: str - """ Name of the input port on the generated pattern. """ - - out_port_name: str - """ Name of the output port on the generated pattern. """ - - length_range: tuple[float, float] = (0, numpy.inf) - """ Valid generated lengths, as `(inclusive_min, exclusive_max)`. """ + class GeneratedData: + """ Deferred render data for one generated primitive offer. """ + fn: GeneratedPrimitiveFn + port_name: str + parameter: float + mirrored: bool = False @dataclass(frozen=True, slots=True) - class SBend: - """ - Description of a native S-bend generator. - - `fn(jog, **kwargs)` is called with a non-negative jog magnitude and must - return a path whose output port faces back toward the input port. For a - negative requested jog, `AutoTool` mirrors the generated S-bend during - rendering. - """ - ptype: str - """ Port type produced by this S-bend. """ - - fn: Callable[[float], Pattern] | Callable[[float], Library] - """ - Generator function called as `fn(abs(jog), **kwargs)`. The generated - geometry is assumed to jog left, i.e. counterclockwise relative to the - direction of travel. This function is not called when the residual jog is - zero. - """ - - in_port_name: str - """ Name of the input port on the generated pattern. """ - - out_port_name: str - """ Name of the output port on the generated pattern. """ - - jog_range: tuple[float, float] = (0, numpy.inf) - """ Valid residual jog magnitudes, as `(inclusive_min, exclusive_max)`. """ - - @dataclass(frozen=True, slots=True) - class Bend: - """ - Description of a pre-rendered L-bend. - - `abstract` must contain `in_port_name` and `out_port_name`. The - `clockwise` flag describes the in-to-out turn direction of that stored - bend. If `mirror` is true, `AutoTool` mirrors the stored bend to realize - the opposite turn direction; otherwise it plugs the bend from the - opposite port where possible. - """ + class ReusableData: + """ Deferred render data for one reusable abstract primitive offer. """ abstract: Abstract - """ Abstract for the reusable bend pattern. """ + port_name: str + mirrored: bool = False - in_port_name: str - """ Name of the bend input port. """ + bbox_library: Mapping[str, Pattern] | None = None + """ Optional source library used to resolve reusable refs during `bbox_at()` measurement. """ - out_port_name: str - """ Name of the bend output port. """ + _straight_offers: list[PrimitiveOffer] = field( + default_factory = list, + init = False, + repr = False, + ) + _bend_offers: tuple[list[PrimitiveOffer], list[PrimitiveOffer]] = field( + default_factory = lambda: ([], []), + init = False, + repr = False, + ) + _s_offers: list[PrimitiveOffer] = field( + default_factory = list, + init = False, + repr = False, + ) + _u_offers: list[PrimitiveOffer] = field( + default_factory = list, + init = False, + repr = False, + ) + _transition_adapter_offers_by_key: dict[tuple[Literal['straight', 's'], str], list[PrimitiveOffer]] = field( + default_factory=dict, + init = False, + repr = False, + ) + _transition_adapter_offer_keys: set[tuple[int, str, str]] = field( + default_factory=set, + init = False, + repr = False, + ) - clockwise: bool = True # Is in-to-out clockwise? - """ Whether the stored bend turns clockwise from input to output. """ - - mirror: bool = True # Should we mirror to get the other rotation? - """ Whether to mirror the stored bend to produce the opposite turn. """ - - @property - def in_port(self) -> Port: - return self.abstract.ports[self.in_port_name] - - @property - def out_port(self) -> Port: - return self.abstract.ports[self.out_port_name] - - @dataclass(frozen=True, slots=True) - class Transition: - """ - Description of a pre-rendered port-type transition. - - `their_port_name` is the external side of the transition and - `our_port_name` is the side compatible with the selected internal - primitive. The transition table key should match that direction: - `(their_ptype, our_ptype)`. - """ - abstract: Abstract - """ Abstract for the reusable transition pattern. """ - - their_port_name: str - """ Name of the external-side port. """ - - our_port_name: str - """ Name of the internal primitive-side port. """ - - @property - def our_port(self) -> Port: - return self.abstract.ports[self.our_port_name] - - @property - def their_port(self) -> Port: - return self.abstract.ports[self.their_port_name] - - def reversed(self) -> Self: - return type(self)(self.abstract, self.our_port_name, self.their_port_name) - - @dataclass(frozen=True, slots=True) - class LPlan: - """ Candidate L-route configuration before final straight length is known. """ - straight: 'AutoTool.Straight' - bend: 'AutoTool.Bend | None' - in_trans: 'AutoTool.Transition | None' - b_trans: 'AutoTool.Transition | None' - out_trans: 'AutoTool.Transition | None' - overhead_x: float - overhead_y: float - bend_angle: float - out_ptype: str - - @dataclass(frozen=True, slots=True) - class LData: - """ Deferred render data returned by `planL()`. """ - straight_length: float - straight: 'AutoTool.Straight' - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None - bend: 'AutoTool.Bend | None' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - def _iter_l_plans( + def add_straight( self, - ccw: SupportsBool | None, - in_ptype: str | None, - out_ptype: str | None, - ) -> Iterator[LPlan]: + ptype: str, + fn: GeneratedPrimitiveFn, + in_port_name: str, + *, + length_range: tuple[float, float] = (0, numpy.inf), + ) -> Self: """ - Iterate over all possible combinations of straights and bends that - could form an L-path. + Register a generated straight primitive. """ - bends = cast('list[AutoTool.Bend | None]', self.bends) - if ccw is None and not bends: - bends = [None] + priority_bias = len(self._straight_offers) * BUILTIN_PRIORITY_STEP - for straight in self.straights: - for bend in bends: - bend_dxy, bend_angle = self._bend2dxy(bend, ccw) + def data_at(length: float) -> AutoTool.GeneratedData: + return self.GeneratedData(fn, in_port_name, length) - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) + self._straight_offers.append(StraightOffer.generated( + ptype, + data_at, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + length_domain = length_range, + )) + return self - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else cast('AutoTool.Bend', bend).out_port.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, bend_angle) + def add_bend( + self, + abstract: Abstract, + in_port_name: str, + out_port_name: str, + *, + clockwise: bool = True, + mirror: bool = True, + ) -> Self: + """ + Register a reusable L-bend primitive. + """ + priority_bias = len(self._bend_offers[0]) * BUILTIN_PRIORITY_STEP + in_port = abstract.ports[in_port_name] + out_port = abstract.ports[out_port_name] + out_ptype = out_port.ptype - b_transition = None - if ccw is not None: - assert bend is not None - if bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) + for ccw in (False, True): + bend_dxy, bend_angle = self._bend2dxy(in_port, out_port, ccw) + bend_dx = float(bend_dxy[0]) + bend_dy = float(bend_dxy[1]) + mirrored = mirror and (ccw == clockwise) + port_name = in_port_name if (mirror or ccw != clockwise) else out_port_name + reusable_data = self.ReusableData(abstract, port_name, mirrored) + endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=out_ptype) - overhead_x = bend_dxy[0] + itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0] - overhead_y = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] + self._bend_offers[int(ccw)].append(BendOffer.prebuilt( + in_ptype = in_port.ptype, + out_ptype = out_ptype, + endpoint = endpoint, + data = reusable_data, + ccw = ccw, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) + return self - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif ccw is not None: - assert bend is not None - out_ptype_actual = bend.out_port.ptype - else: - out_ptype_actual = straight.ptype + def add_sbend( + self, + ptype: str, + fn: GeneratedPrimitiveFn, + in_port_name: str, + out_port_name: str, + *, + jog_range: tuple[float, float] = (0, numpy.inf), + ) -> Self: + """ + Register a generated S-bend primitive. + """ + for jog_domain in self._signed_jog_domains(jog_range): + priority_bias = len(self._s_offers) * BUILTIN_PRIORITY_STEP - yield self.LPlan( - straight = straight, - bend = bend, - in_trans = in_transition, - b_trans = b_transition, - out_trans = out_transition, - overhead_x = overhead_x, - overhead_y = overhead_y, - bend_angle = bend_angle, - out_ptype = out_ptype_actual, + def endpoint_s( + jog: float, + *, + fn: GeneratedPrimitiveFn = fn, + in_port_name: str = in_port_name, + out_port_name: str = out_port_name, + ) -> Port: + jog_magnitude = abs(jog) + sbend_dxy = self._sbend2dxy(fn, in_port_name, out_port_name, jog_magnitude) + return Port((float(sbend_dxy[0]), float(jog)), rotation=pi, ptype=ptype) + + def data_at(jog: float) -> AutoTool.GeneratedData: + return self.GeneratedData( + fn, + in_port_name, + abs(jog), + mirrored = jog < 0, ) - @dataclass(frozen=True, slots=True) - class SData: - """ Deferred render data for native-S routes returned by `planS()`. """ - straight_length: float - straight: 'AutoTool.Straight' - gen_kwargs: dict[str, Any] - jog_remaining: float - sbend: 'AutoTool.SBend' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' + self._s_offers.append(SOffer.generated( + ptype, + endpoint_s, + data_at, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + jog_domain = jog_domain, + )) + return self - @dataclass(frozen=True, slots=True) - class UData: - """ Deferred render data for `planU()` or double-L `planS()` routes. """ - ldata0: 'AutoTool.LData' - ldata1: 'AutoTool.LData' - straight2: 'AutoTool.Straight' - l2_length: float - mid_transition: 'AutoTool.Transition | None' - - def _solve_double_l( + def add_uturn( self, - length: float, - jog: float, - ccw1: SupportsBool, - ccw2: SupportsBool, - in_ptype: str | None, - out_ptype: str | None, - **kwargs, - ) -> tuple[Port, UData]: + abstract: Abstract, + in_port_name: str, + out_port_name: str, + *, + mirror: bool = True, + ) -> Self: """ - Solve for a path consisting of two L-bends connected by a straight segment. - Used for both U-turns (ccw1 == ccw2) and S-bends (ccw1 != ccw2). + Register a reusable U-turn primitive. """ - is_u = bool(ccw1) == bool(ccw2) - out_rot = 0 if is_u else pi + in_port = abstract.ports[in_port_name] + out_port = abstract.ports[out_port_name] + dxy, angle = in_port.measure_travel(out_port) + if angle is None: + raise BuildError('U-turn primitive output port must have the same route-frame rotation as its input port') + normalized_angle = angle % (2 * pi) + if not (numpy.isclose(normalized_angle, 0) or numpy.isclose(normalized_angle, 2 * pi)): + raise BuildError('U-turn primitive output port must have the same route-frame rotation as its input port') - for plan1 in self._iter_l_plans(ccw1, in_ptype, None): - rot_mid = rotation_matrix_2d(pi + plan1.bend_angle) - mid_axis = rot_mid @ numpy.array((1.0, 0.0)) - if not numpy.isclose(mid_axis[0], 0) or numpy.isclose(mid_axis[1], 0): - continue + length = float(dxy[0]) + jog = float(dxy[1]) + out_ptype = out_port.ptype - for straight_mid in self.straights: - mid_ptype_pair = (plan1.out_ptype, straight_mid.ptype) - mid_trans = self.transitions.get(mid_ptype_pair, None) - mid_trans_dxy = self._itransition2dxy(mid_trans) + def add_offer( + offer_jog: float, + *, + mirrored: bool, + ) -> None: + reusable_data = self.ReusableData(abstract, in_port_name, mirrored) + priority_bias = len(self._u_offers) * BUILTIN_PRIORITY_STEP + endpoint = Port((length, offer_jog), rotation=0, ptype=out_ptype) - for plan2 in self._iter_l_plans(ccw2, straight_mid.ptype, out_ptype): - fixed_dxy = numpy.array((plan1.overhead_x, plan1.overhead_y)) - fixed_dxy += rot_mid @ ( - mid_trans_dxy - + numpy.array((plan2.overhead_x, plan2.overhead_y)) - ) + self._u_offers.append(UOffer.prebuilt( + in_ptype = in_port.ptype, + out_ptype = out_ptype, + endpoint = endpoint, + data = reusable_data, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) - l1_straight = length - fixed_dxy[0] - l2_straight = (jog - fixed_dxy[1]) / mid_axis[1] + add_offer(jog, mirrored=False) + if mirror and not numpy.isclose(jog, 0): + add_offer(-jog, mirrored=True) + return self - if plan1.straight.length_range[0] <= l1_straight < plan1.straight.length_range[1] \ - and straight_mid.length_range[0] <= l2_straight < straight_mid.length_range[1]: - l3_straight = 0 - if plan2.straight.length_range[0] <= l3_straight < plan2.straight.length_range[1]: - ldata0 = self.LData( - l1_straight, plan1.straight, kwargs, ccw1, plan1.bend, - plan1.in_trans, plan1.b_trans, plan1.out_trans, - ) - ldata1 = self.LData( - l3_straight, plan2.straight, kwargs, ccw2, plan2.bend, - plan2.in_trans, plan2.b_trans, plan2.out_trans, - ) - - data = self.UData(ldata0, ldata1, straight_mid, l2_straight, mid_trans) - out_port = Port((length, jog), rotation=out_rot, ptype=plan2.out_ptype) - return out_port, data - raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}") - - straights: list[Straight] - """ Straight generators to choose from, in priority order. """ - - bends: list[Bend] - """ L-bend primitives to choose from, in priority order. """ - - sbends: list[SBend] - """ Native S-bend generators to choose from, in priority order. """ - - transitions: dict[tuple[str, str], Transition] - """ Mapping from `(external_ptype, internal_ptype)` to transition primitive. """ - - default_out_ptype: str - """ Output port type used when a zero-length route provides no primitive ptype. """ - - def add_complementary_transitions(self) -> Self: + def add_transition( + self, + abstract: Abstract, + their_port_name: str, + our_port_name: str, + *, + one_way: bool = False, + ) -> Self: """ - Add reversed transition entries for any missing opposite directions. - - Existing explicit entries are preserved. The method mutates - `self.transitions` and returns `self` for fluent construction. + Register a reusable port-type transition and expose it as router-visible adapter offers. """ - for iioo in list(self.transitions.keys()): - ooii = (iioo[1], iioo[0]) - self.transitions.setdefault(ooii, self.transitions[iioo].reversed()) + self._add_transition_direction(abstract, their_port_name, our_port_name) + if not one_way: + self._add_transition_direction(abstract, our_port_name, their_port_name) return self @staticmethod - def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: - if ccw is None: - return numpy.zeros(2), pi - assert bend is not None - bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) + def _bend2dxy(in_port: Port, out_port: Port, ccw: bool) -> tuple[NDArray[numpy.float64], float]: + bend_dxy, bend_angle = in_port.measure_travel(out_port) assert bend_angle is not None - if bool(ccw): + if ccw: bend_dxy[1] *= -1 bend_angle *= -1 return bend_dxy, bend_angle @staticmethod - def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]: - if numpy.isclose(jog, 0): + def _wildcard_ptype_key(ptype: str | None) -> str: + return 'unk' if ptype in (None, 'unk') else ptype + + @staticmethod + def _sbend2dxy( + fn: GeneratedPrimitiveFn, + in_port_name: str, + out_port_name: str, + jog_magnitude: float, + ) -> NDArray[numpy.float64]: + if numpy.isclose(jog_magnitude, 0): return numpy.zeros(2) - sbend_pat_or_tree = sbend.fn(abs(jog)) + sbend_pat_or_tree = fn(jog_magnitude) sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) + dxy, _ = sbpat[in_port_name].measure_travel(sbpat[out_port_name]) return dxy - @staticmethod - def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: - if in_transition is None: - return numpy.zeros(2) - dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port) - return dxy + def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], None]) -> NDArray[numpy.float64]: + port_names = ('A', 'B') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_bbox') + pat.add_port_pair(names=port_names) + render(tree, port_names) - @staticmethod - def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: - if out_transition is None: - return numpy.zeros(2) - orot = out_transition.our_port.rotation - assert orot is not None - otrans_dxy = rotation_matrix_2d(bend_angle - orot - pi) @ (out_transition.their_port.offset - out_transition.our_port.offset) - return otrans_dxy - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, LData]: - - for plan in self._iter_l_plans(ccw, in_ptype, out_ptype): - straight_length = length - plan.overhead_x - if plan.straight.length_range[0] <= straight_length < plan.straight.length_range[1]: - data = self.LData( - straight_length = straight_length, - straight = plan.straight, - straight_kwargs = kwargs, - ccw = ccw, - bend = plan.bend, - in_transition = plan.in_trans, - b_transition = plan.b_trans, - out_transition = plan.out_trans, - ) - out_port = Port((length, plan.overhead_y), rotation=plan.bend_angle, ptype=plan.out_ptype) - return out_port, data - - raise BuildError(f'Failed to find a valid L-path configuration for {length=:,g}, {ccw=}, {in_ptype=}, {out_ptype=}') - - def _renderL( - self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into an existing tree. - """ - pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - pat.plug(straight_pat_or_tree, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if data.ccw is not None: - bend = data.bend - assert bend is not None - mirrored = bend.mirror and (bool(data.ccw) == bend.clockwise) - inport = bend.in_port_name if (bend.mirror or bool(data.ccw) != bend.clockwise) else bend.out_port_name - pat.plug(bend.abstract, {port_names[1]: inport}, mirrored=mirrored) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def traceL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - - success = False - for straight in self.straights: - for sbend in self.sbends: - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if numpy.isclose(jog, 0) else sbend.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, pi) - - # Assume we'll need a straight segment with transitions, then discard them if they don't fit - # We do this before generating the s-bend because the transitions might have some dy component - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - b_transition = None - if not numpy.isclose(jog, 0) and sbend.ptype != straight.ptype: - b_transition = self.transitions.get((sbend.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - if length > itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]: - # `if` guard to avoid unnecessary calls to `_sbend2dxy()`, which calls `sbend.fn()` - # note some S-bends may have 0 length, so we can't be more restrictive - jog_remaining = jog - itrans_dxy[1] - btrans_dxy[1] - otrans_dxy[1] - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - straight_length = length - sbend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - - # Straight didn't work, see if just the s-bend is enough - if sbend.ptype != straight.ptype: - # Need to use a different in-transition for sbend (vs straight) - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, sbend.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] - if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[0] + otrans_dxy[0]) - if success: - b_transition = None - straight_length = 0 - break - if success: - break - - if not success: - ccw0 = jog > 0 - return self._solve_double_l(length, jog, ccw0, not ccw0, in_ptype, out_ptype, **kwargs) - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif not numpy.isclose(jog_remaining, 0): - out_ptype_actual = sbend.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype + if self.bbox_library is None: + library: Mapping[str, Pattern] = tree else: - out_ptype_actual = self.default_out_ptype + library = ChainMap(dict(tree), self.bbox_library) - data = self.SData(straight_length, straight, kwargs, jog_remaining, sbend, in_transition, b_transition, out_transition) - out_port = Port((length, jog), rotation=pi, ptype=out_ptype_actual) - return out_port, data + try: + bounds = pat.get_bounds(library=library) + except KeyError as err: + raise NotImplementedError( + 'AutoTool bbox_at() requires bbox_library to resolve reusable primitive refs' + ) from err + if bounds is None: + return numpy.zeros((2, 2), dtype=float) + return numpy.asarray(bounds, dtype=float) - def _renderS( + def _bbox_for_data(self, data: Any) -> NDArray[numpy.float64]: + return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names, {})) + + def _add_transition_direction( self, - data: SData, + abstract: Abstract, + their_port_name: str, + our_port_name: str, + ) -> None: + their_port = abstract.ports[their_port_name] + our_port = abstract.ports[our_port_name] + transition_data = self.ReusableData(abstract, their_port_name) + + key = ( + id(abstract), + their_port_name, + our_port_name, + ) + if key in self._transition_adapter_offer_keys: + return + self._transition_adapter_offer_keys.add(key) + + dxy, angle = their_port.measure_travel(our_port) + if angle is None or not numpy.isclose(angle, pi): + return + + dx = float(dxy[0]) + dy = float(dxy[1]) + kind: Literal['straight', 's'] = 'straight' if numpy.isclose(dy, 0) else 's' + in_key = self._wildcard_ptype_key(their_port.ptype) + endpoint = Port((dx, dy), rotation=pi, ptype=our_port.ptype) + + offers = self._transition_adapter_offers_by_key.setdefault((kind, in_key), []) + priority_bias = len(offers) * BUILTIN_PRIORITY_STEP + if kind == 'straight': + offers.append(StraightOffer.prebuilt( + in_ptype = their_port.ptype, + out_ptype = our_port.ptype, + endpoint = endpoint, + data = transition_data, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) + return + + offers.append(SOffer.prebuilt( + in_ptype = their_port.ptype, + out_ptype = our_port.ptype, + endpoint = endpoint, + data = transition_data, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) + + @staticmethod + def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]: + lower, upper = (float(magnitude_range[0]), float(magnitude_range[1])) + if lower < 0 or lower > upper: + return () + + if lower == upper: + if lower == 0: + return ((0.0, 0.0),) + return ((lower, lower), (-lower, -lower)) + + positive = (lower, upper) + neg_lower = -numpy.inf if numpy.isinf(upper) else float(numpy.nextafter(-upper, numpy.inf)) + negative = (neg_lower, -lower) + domains: list[tuple[float, float]] = [positive] + if neg_lower < -lower: + domains.append(negative) + if lower > 0: + domains.append((-lower, -lower)) + return tuple(domains) + + def primitive_offers( + self, + kind: PrimitiveKind, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[PrimitiveOffer, ...]: + _ = out_ptype + 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, + ) + + if kind == 'bend': + return tuple(self._bend_offers[int(bool(kwargs['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, + ) + + if kind == 'u': + return tuple(self._u_offers) + raise BuildError(f'Unrecognized primitive offer kind {kind!r}') + + def _render_generated( + self, + data: GeneratedData, tree: ILibrary, port_names: tuple[str, str], gen_kwargs: dict[str, Any], ) -> ILibrary: - """ - Render a native-S step into an existing tree. - """ + if numpy.isclose(data.parameter, 0): + return tree + pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if not numpy.isclose(data.jog_remaining, 0): - sbend_pat_or_tree = data.sbend.fn(abs(data.jog_remaining), **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.sbend.in_port_name} - if isinstance(sbend_pat_or_tree, Pattern): - pat.plug(sbend_pat_or_tree, pmap, append=True, mirrored=data.jog_remaining < 0) - else: - sbend_tree = sbend_pat_or_tree - top = sbend_tree.top() - sbend_tree.flatten(top, dangling_ok=True) - pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def traceS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planS( - length, - jog, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - if isinstance(data, self.UData): - self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + generated = data.fn(data.parameter, **gen_kwargs) + pmap = {port_names[1]: data.port_name} + if isinstance(generated, Pattern): + pat.plug(generated, pmap, append=True, mirrored=data.mirrored) else: - self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + top = generated.top() + generated.flatten(top, dangling_ok=True) + pat.plug(generated[top], pmap, append=True, mirrored=data.mirrored) return tree - def planU( + def _render_reusable( self, - jog: float, - *, - length: float = 0, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, UData]: - ccw = jog > 0 - return self._solve_double_l(length, jog, ccw, ccw, in_ptype, out_ptype, **kwargs) + data: ReusableData, + tree: ILibrary, + port_names: tuple[str, str], + ) -> ILibrary: + pat = tree.top_pattern() + pat.plug(data.abstract, {port_names[1]: data.port_name}, mirrored=data.mirrored) + return tree - def _renderU( + def _render_data( self, - data: UData, + data: Any, tree: ILibrary, port_names: tuple[str, str], gen_kwargs: dict[str, Any], ) -> ILibrary: - pat = tree.top_pattern() - # 1. First L-bend - self._renderL(data.ldata0, tree, port_names, gen_kwargs) - # 2. Connecting straight - if data.mid_transition: - pat.plug(data.mid_transition.abstract, {port_names[1]: data.mid_transition.their_port_name}) - if not numpy.isclose(data.l2_length, 0): - s2_pat_or_tree = data.straight2.fn(data.l2_length, **(gen_kwargs | data.ldata0.straight_kwargs)) - pmap = {port_names[1]: data.straight2.in_port_name} - if isinstance(s2_pat_or_tree, Pattern): - pat.plug(s2_pat_or_tree, pmap, append=True) - else: - s2_tree = s2_pat_or_tree - top = s2_tree.top() - s2_tree.flatten(top, dangling_ok=True) - pat.plug(s2_tree[top], pmap, append=True) - # 3. Second L-bend - self._renderL(data.ldata1, tree, port_names, gen_kwargs) - return tree - - def traceU( - self, - jog: float, - *, - length: float = 0, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planU( - jog, - length = length, - in_ptype = in_ptype, - out_ptype = out_ptype, - **kwargs, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceU') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - return tree + if isinstance(data, self.GeneratedData): + return self._render_generated(data=data, tree=tree, port_names=port_names, gen_kwargs=gen_kwargs) + 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__}') def render( self, @@ -1317,25 +1163,16 @@ class AutoTool(Tool, metaclass=ABCMeta): for step in batch: assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - elif step.opcode == 'S': - if isinstance(step.data, self.UData): - self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - else: - self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - elif step.opcode == 'U': - self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + self._render_data(step.data, tree, port_names, kwargs) return tree @dataclass -class PathTool(Tool, metaclass=ABCMeta): +class PathTool(Tool): """ Tool that renders routes directly as `Pattern.path()` geometry. - `PathTool` supports L and S routes. Immediate `traceL()` and `traceS()` - create one path element per route, while deferred `render()` combines a + `PathTool` supports L and S primitive offers. `render()` combines a compatible batch of L/S `RenderStep`s into one multi-vertex path. U routes are left to `Pather` synthesis or to a different tool. """ @@ -1348,20 +1185,6 @@ class PathTool(Tool, metaclass=ABCMeta): ptype: str = 'unk' """ Port type for generated input and output ports. """ - #@dataclass(frozen=True, slots=True) - #class LData: - # dxy: NDArray[numpy.float64] - - #def __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None: - # Tool.__init__(self) - # self.layer = layer - # self.width = width - # self.ptype: str - - def _check_out_ptype(self, out_ptype: str | None) -> None: - if out_ptype and out_ptype != self.ptype: - raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') - def _bend_radius(self) -> float: return self.width / 2 @@ -1391,114 +1214,100 @@ class PathTool(Tool, metaclass=ABCMeta): ] return numpy.array(vertices, dtype=float) - def traceL( + def _path_bbox(self, vertices: NDArray[numpy.float64]) -> NDArray[numpy.float64]: + return Path(vertices=vertices, width=self.width).get_bounds_single() + + def primitive_offers( self, - ccw: SupportsBool | None, - length: float, + kind: PrimitiveKind, *, in_ptype: str | None = None, out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, # noqa: ARG002 (unused) - ) -> Library: - out_port, data = self.planL( - ccw, - length, - in_ptype=in_ptype, - out_ptype=out_ptype, - ) + **kwargs, + ) -> tuple[PrimitiveOffer, ...]: + if kind == 'u': + return () - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - pat.path(layer=self.layer, width=self.width, vertices=self._plan_l_vertices(length, float(out_port.y))) + if not ptypes_compatible(out_ptype, self.ptype): + raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') + ptype = self.ptype - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 + if kind == 'straight': + def straight_data(length: float) -> NDArray[numpy.float64]: + return numpy.array((length, 0.0)) - pat.ports = { - port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: Port(out_port.offset, rotation=out_rot, ptype=self.ptype), - } + def endpoint_straight(length: float) -> Port: + data = straight_data(length) + return Port(data, rotation=pi, ptype=ptype) - return tree + def bbox_straight(length: float) -> NDArray[numpy.float64]: + data = straight_data(length) + return self._path_bbox(self._plan_l_vertices(float(data[0]), float(data[1]))) - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, NDArray[numpy.float64]]: - # TODO check all the math for L-shaped bends + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + bbox_planner=bbox_straight, + endpoint_planner=endpoint_straight, + commit_planner=straight_data, + ),) - self._check_out_ptype(out_ptype) + if kind == 'bend': + ccw = kwargs['ccw'] + radius = self._bend_radius() + bend_run = radius if bool(ccw) else -radius + bend_angle = -pi / 2 if bool(ccw) else pi / 2 - if ccw is not None: - bend_dxy = numpy.array([1, -1]) * self._bend_radius() - bend_angle = pi / 2 + def bend_data(length: float) -> NDArray[numpy.float64]: + _ = length + return numpy.array((length, bend_run)) - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - else: - bend_dxy = numpy.zeros(2) - bend_angle = pi + def endpoint_bend(length: float) -> Port: + data = bend_data(length) + return Port(data, rotation=bend_angle, ptype=ptype) - straight_length = length - bend_dxy[0] - bend_run = bend_dxy[1] + def bbox_bend(length: float) -> NDArray[numpy.float64]: + data = bend_data(length) + return self._path_bbox(self._plan_l_vertices(float(data[0]), float(data[1]))) - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' - ) - data = numpy.array((length, bend_run)) - out_port = Port(data, rotation=bend_angle, ptype=self.ptype) - return out_port, data + return (BendOffer( + in_ptype=in_ptype, + out_ptype=ptype, + ccw=bool(ccw), + length_domain=(radius, radius), + bbox_planner=bbox_bend, + endpoint_planner=endpoint_bend, + commit_planner=bend_data, + ),) - def traceS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, # noqa: ARG002 (unused) - ) -> Library: - out_port, _data = self.planS( - length, - jog, - in_ptype=in_ptype, - out_ptype=out_ptype, - ) + if kind == 's': + def minimum_length(jog: float) -> float: + if numpy.isclose(jog, 0): + return 0.0 + return self.width - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS') - pat.path(layer=self.layer, width=self.width, vertices=self._plan_s_vertices(length, jog)) - pat.ports = { - port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: out_port, - } - return tree + def s_data(jog: float) -> NDArray[numpy.float64]: + length = minimum_length(jog) + self._plan_s_vertices(length, jog) + return numpy.array((length, jog)) - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, NDArray[numpy.float64]]: - self._check_out_ptype(out_ptype) - self._plan_s_vertices(length, jog) - data = numpy.array((length, jog)) - out_port = Port((length, jog), rotation=pi, ptype=self.ptype) - return out_port, data + def endpoint_s(jog: float) -> Port: + data = s_data(jog) + return Port(data, rotation=pi, ptype=ptype) + + def bbox_s(jog: float) -> NDArray[numpy.float64]: + data = s_data(jog) + return self._path_bbox(self._plan_s_vertices(float(data[0]), float(data[1]))) + + return (SOffer( + in_ptype=in_ptype, + out_ptype=ptype, + bbox_planner=bbox_s, + endpoint_planner=endpoint_s, + commit_planner=s_data, + ),) + + raise BuildError(f'Unrecognized primitive offer kind {kind!r}') def render( self, diff --git a/masque/pattern.py b/masque/pattern.py index e882795..daf78e8 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1398,10 +1398,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) if annotation_conflicts: raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') - else: - if isinstance(other, Pattern): - raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' - 'Use `append=True` if you intended to append the full geometry.') + elif isinstance(other, Pattern): + raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' + 'Use `append=True` if you intended to append the full geometry.') ports = {} for name, port in other.ports.items(): @@ -1527,11 +1526,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. + ok_connections: Set of additional allowed ptype combinations. + Ptypes accepted by the shared compatibility policy are always + allowed. Non-allowed ptype connections will emit a warning. + Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. skip_geometry: If `True`, only ports are updated and geometry is skipped. If a valid transform cannot be found (e.g. due to misaligned ports), a 'best-effort' dummy transform is used diff --git a/masque/ports.py b/masque/ports.py index e745880..bbd18b7 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -12,7 +12,7 @@ from numpy import pi from numpy.typing import ArrayLike, NDArray from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable -from .utils import rotate_offsets_around, rotation_matrix_2d +from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace @@ -542,7 +542,7 @@ class PortList(metaclass=ABCMeta): a_types = [pp.ptype for pp in a_ports] b_types = [pp.ptype for pp in b_ports] - type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt) + type_conflicts = numpy.array([not ptypes_compatible(at, bt) for at, bt in zip(a_types, b_types, strict=True)]) if type_conflicts.any(): @@ -641,11 +641,10 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `other` should be rotated. Otherwise, `set_rotation` must remain `None`. - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will log a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. + ok_connections: Set of additional allowed ptype combinations. + Ptypes accepted by the shared compatibility policy are always + allowed. Non-allowed ptype connections will log a warning. + Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -694,11 +693,10 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `o_ports` should be rotated. Otherwise, `set_rotation` must remain `None`. - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will log a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. + ok_connections: Set of additional allowed ptype combinations. + Ptypes accepted by the shared compatibility policy are always + allowed. Non-allowed ptype connections will log a warning. + Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -725,8 +723,10 @@ class PortList(metaclass=ABCMeta): o_rotations *= -1 ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]} - type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs) - for st, ot in zip(s_types, o_types, strict=True)]) + type_conflicts = numpy.array([ + not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs + for st, ot in zip(s_types, o_types, strict=True) + ]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' for nn, (kk, vv) in enumerate(map_in.items()): diff --git a/masque/test/helpers.py b/masque/test/helpers.py index 32b2f66..2ccea1a 100644 --- a/masque/test/helpers.py +++ b/masque/test/helpers.py @@ -1,9 +1,14 @@ from typing import Any +from collections.abc import Callable +from copy import deepcopy import numpy from numpy.typing import ArrayLike, NDArray from numpy.testing import assert_allclose +from masque import Pather, Port +from masque.builder.tools import RenderStep + def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]: """ @@ -25,3 +30,117 @@ def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: flo Assert that an object's single-shape bounds match `expected`. """ assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol) + + +def normalized_route_data(data: Any) -> Any: + """ + Return a deterministic, comparison-friendly representation of route data. + """ + if isinstance(data, dict): + return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0]))) + if isinstance(data, list | tuple): + return tuple(normalized_route_data(value) for value in data) + if isinstance(data, numpy.ndarray): + return tuple(normalized_route_data(value) for value in data.tolist()) + if isinstance(data, numpy.generic): + return data.item() + try: + hash(data) + except TypeError: + return repr(data) + return data + + +def route_step_signature(step: RenderStep) -> tuple[Any, ...]: + """ + Return the stable planning-relevant portion of one rendered route step. + """ + return ( + step.opcode, + tuple(round(float(value), 9) for value in step.start_port.offset), + None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9), + step.start_port.ptype, + tuple(round(float(value), 9) for value in step.end_port.offset), + None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9), + step.end_port.ptype, + normalized_route_data(step.data), + ) + + +def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]: + """ + Return a deterministic signature for a pather route. + """ + return tuple(route_step_signature(step) for step in pather._paths[portspec]) + + +def route_endpoint(pather: Pather, portspec: str) -> Port: + """ + Return the endpoint of a routed port, falling back to the live port for empty routes. + """ + steps = pather._paths[portspec] + if not steps: + return pather.pattern[portspec] + return steps[-1].end_port + + +def assert_route_endpoint( + pather: Pather, + portspec: str, + expected: Port, + *, + atol: float = 1e-8, + ) -> None: + """ + Assert that a route endpoint matches an expected port pose and ptype. + """ + actual = route_endpoint(pather, portspec) + assert_allclose(actual.offset, expected.offset, atol=atol) + if expected.rotation is None: + assert actual.rotation is None + else: + assert actual.rotation is not None + assert numpy.isclose(actual.rotation, expected.rotation, atol=atol) + assert actual.ptype == expected.ptype + + +def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None: + """ + Assert a simple render-step bend budget for route signatures. + """ + bend_count = sum(1 for step in pather._paths[portspec] if step.opcode == 'L' and step.start_port.rotation != step.end_port.rotation) + assert bend_count <= max_bends + + +def assert_route_deterministic( + make_pather: Callable[[], Pather], + route: Callable[[Pather], None], + portspec: str, + ) -> None: + """ + Assert that the same route operation produces the same route signature twice. + """ + first = make_pather() + route(first) + first_signature = route_signature(first, portspec) + + second = make_pather() + route(second) + assert route_signature(second, portspec) == first_signature + + +def assert_route_failure_does_not_mutate( + pather: Pather, + route: Callable[[], None], + expected_exception: type[BaseException], + ) -> BaseException: + """ + Assert that a failing route operation leaves pending route steps untouched. + """ + before = deepcopy(dict(pather._paths)) + try: + route() + except expected_exception as err: + assert dict(pather._paths) == before + return err + raise AssertionError(f'Expected {expected_exception.__name__}') diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py index 1c98cde..db7e8f4 100644 --- a/masque/test/test_autotool_planning.py +++ b/masque/test/test_autotool_planning.py @@ -1,12 +1,64 @@ +from contextlib import suppress +from typing import Any + import pytest from numpy import pi from numpy.testing import assert_allclose -from masque.builder.tools import AutoTool +from masque.builder.tools import AutoTool, PrimitiveOffer, RenderStep, UOffer from masque.builder.pather import Pather from masque.library import Library from masque.pattern import Pattern from masque.ports import Port +from masque.error import BuildError + + +def commit_offer(offer: PrimitiveOffer, parameter: float) -> tuple[Port, Any]: + return offer.endpoint_at(parameter), offer.commit(parameter) + + +def selected_offer(tool: AutoTool, kind: str, parameter: float, **kwargs: Any) -> tuple[PrimitiveOffer, Port, Any]: + valid = [] + for offer in tool.primitive_offers(kind, **kwargs): + try: + out_port, data = commit_offer(offer, parameter) + except BuildError: + continue + valid.append((offer, out_port, data)) + + assert valid + return min(valid, key=lambda item: item[0].cost_at(parameter)) + + +def selected_matching_offer( + tool: AutoTool, + kind: str, + parameter: float, + predicate: Any, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, Port, Any]: + valid = [] + for offer in tool.primitive_offers(kind, **kwargs): + try: + out_port, data = commit_offer(offer, parameter) + except BuildError: + continue + if predicate(offer, out_port, data): + valid.append((offer, out_port, data)) + + assert valid + return min(valid, key=lambda item: item[0].cost_at(parameter)) + + +def rendered_offer_tree( + tool: AutoTool, + offer: PrimitiveOffer, + parameter: float, + source_ptype: str | None = None, + ) -> Library: + start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk") + end, data = commit_offer(offer, parameter) + return tool.render((RenderStep(offer.opcode, tool, start, end, data),)) def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: @@ -33,15 +85,15 @@ def autotool_setup() -> tuple[Pather, AutoTool, Library]: lib["via"] = via_pat via_abs = lib.abstract("via") - tool_m1 = AutoTool( - straights=[ - AutoTool.Straight(ptype="wire_m1", fn=lambda length: _make_transition_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out") - ], - bends=[], - sbends=[], - transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")}, - default_out_ptype="wire_m1", - ) + tool_m1 = ( + AutoTool(bbox_library=lib) + .add_straight( + "wire_m1", + lambda length: _make_transition_straight(length, ptype="wire_m1"), + "in", + ) + .add_transition(via_abs, "m2", "m1") + ) p = Pather(lib, tools=tool_m1) p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") @@ -57,30 +109,152 @@ def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) - assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) assert p.ports["start"].ptype == "wire_m1" -def make_straight(length, width=2, ptype="wire"): + +def test_autotool_route_level_methods_removed(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: + _p, tool, _lib = autotool_setup + + for name in ("planL", "planS", "planU", "traceL", "traceS", "traceU"): + assert not hasattr(tool, name) + + +def test_autotool_straight_offer_supports_requested_output_transition( + autotool_setup: tuple[Pather, AutoTool, Library], + ) -> None: + _p, tool, lib = autotool_setup + + p = Pather(lib, tools=tool, auto_render=False) + p.ports["start"] = Port((0, 0), pi, ptype="wire_m1") + p.straight("start", 10, out_ptype="wire_m2") + + assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) + assert p.ports["start"].ptype == "wire_m2" + assert [type(step.data) for step in p._paths["start"]] == [AutoTool.GeneratedData, AutoTool.ReusableData] + assert p._paths["start"][0].data.parameter == 9 + assert p._paths["start"][1].data.port_name == "m1" + + +def test_pather_straight_topology_allows_transition_offset_cancellation() -> None: + lib = Library() + + trans_in = Pattern() + trans_in.ports["EXT"] = Port((0, 0), 0, ptype="ext_in") + trans_in.ports["CORE"] = Port((1, 1), pi, ptype="core") + lib["trans_in"] = trans_in + + trans_out = Pattern() + trans_out.ports["EXT"] = Port((0, 0), 0, ptype="ext_out") + trans_out.ports["CORE"] = Port((1, -1), pi, ptype="core") + lib["trans_out"] = trans_out + + tool = ( + AutoTool(bbox_library=lib) + .add_straight( + "core", + lambda length: _make_transition_straight(length, ptype="core"), + "in", + length_range=(0, 1e8), + ) + .add_transition(lib.abstract("trans_in"), "EXT", "CORE") + .add_transition(lib.abstract("trans_out"), "EXT", "CORE") + ) + + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), pi, ptype="ext_in") + p.trace("A", None, length=10, out_ptype="ext_out") + + assert_allclose(p.ports["A"].offset, [10, 0], atol=1e-10) + assert p.ports["A"].ptype == "ext_out" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.ReusableData, + AutoTool.GeneratedData, + AutoTool.ReusableData, + ] + assert p._paths["A"][1].data.parameter == 8 + + +def test_autotool_add_transition_dedupes_bidirectional_adapter_offers() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + trans_abs = lib.abstract("trans") + + tool = AutoTool(bbox_library=lib) + tool.add_transition(trans_abs, "EXT", "CORE") + tool.add_transition(trans_abs, "EXT", "CORE") + + ext_offers = tool.primitive_offers("straight", in_ptype="ext") + core_offers = tool.primitive_offers("straight", in_ptype="core") + + assert len(ext_offers) == 1 + assert len(core_offers) == 1 + + _ext_port, ext_data = commit_offer(ext_offers[0], 2) + _core_port, core_data = commit_offer(core_offers[0], 2) + assert isinstance(ext_data, AutoTool.ReusableData) + assert isinstance(core_data, AutoTool.ReusableData) + assert ext_data.port_name == "EXT" + assert core_data.port_name == "CORE" + + +def test_autotool_add_transition_one_way_inhibits_reverse_adapter_offer() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + trans_abs = lib.abstract("trans") + + tool = AutoTool(bbox_library=lib) + tool.add_transition(trans_abs, "EXT", "CORE", one_way=True) + + ext_offers = tool.primitive_offers("straight", in_ptype="ext") + core_offers = tool.primitive_offers("straight", in_ptype="core") + + assert len(ext_offers) == 1 + assert core_offers == () + + _ext_port, ext_data = commit_offer(ext_offers[0], 2) + assert isinstance(ext_data, AutoTool.ReusableData) + assert ext_data.port_name == "EXT" + + +def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: pat = Pattern() pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) pat.ports["B"] = Port((length, 0), pi, ptype=ptype) return pat -def make_bend(R, width=2, ptype="wire", clockwise=True): + +def make_bend(R: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern: pat = Pattern() # Rectangular approximation of a 90 degree bend. if clockwise: pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype) + pat.ports["B"] = Port((R, -R), pi / 2, ptype=ptype) else: pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype) + pat.ports["B"] = Port((R, R), -pi / 2, ptype=ptype) return pat + +def make_sbend(jog: float, ptype: str = "wire") -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((10, jog), pi, ptype=ptype) + return pat + + @pytest.fixture -def multi_bend_tool(): +def multi_bend_tool() -> tuple[AutoTool, Library]: lib = Library() lib["b1"] = make_bend(2, ptype="wire") @@ -88,19 +262,13 @@ def multi_bend_tool(): lib["b2"] = make_bend(5, ptype="wire") b2_abs = lib.abstract("b2") - tool = AutoTool( - straights=[ - AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)), - AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8)) - ], - bends=[ - AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True), - AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True) - ], - sbends=[], - transitions={}, - default_out_ptype="wire" - ) + tool = ( + AutoTool(bbox_library=lib) + .add_straight("wire", make_straight, "A", length_range=(0, 10)) + .add_straight("wire", lambda length: make_straight(length, width=4), "A", length_range=(10, 1e8)) + .add_bend(b1_abs, "A", "B", clockwise=True, mirror=True) + .add_bend(b2_abs, "A", "B", clockwise=True, mirror=True) + ) return tool, lib @pytest.fixture @@ -117,32 +285,36 @@ def asymmetric_transition_tool() -> AutoTool: trans_pat.ports["MID"] = Port((3, 1), pi, ptype="mid") lib["core_mid"] = trans_pat - return AutoTool( - straights=[ - AutoTool.Straight( - ptype="core", - fn=lambda length: make_straight(length, ptype="core"), - in_port_name="A", - out_port_name="B", - length_range=(0, 3), - ), - AutoTool.Straight( - ptype="mid", - fn=lambda length: make_straight(length, ptype="mid"), - in_port_name="A", - out_port_name="B", - length_range=(0, 1e8), - ), - ], - bends=[ - AutoTool.Bend(lib.abstract("core_bend"), "in", "out", clockwise=True, mirror=True), - ], - sbends=[], - transitions={ - ("mid", "core"): AutoTool.Transition(lib.abstract("core_mid"), "MID", "CORE"), - }, - default_out_ptype="core", - ).add_complementary_transitions() + return ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 3)) + .add_straight("mid", lambda length: make_straight(length, ptype="mid"), "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("core_bend"), "in", "out", clockwise=True, mirror=True) + .add_transition(lib.abstract("core_mid"), "MID", "CORE") + ) + +@pytest.fixture +def wildcard_transition_tool() -> tuple[AutoTool, Library]: + lib = Library() + + def make_core_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + trans_pat = Pattern() + trans_pat.ports["WILD"] = Port((0, 0), 0, ptype="unk") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["wild_core"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 1e8)) + .add_sbend("core", make_core_sbend, "A", "B", jog_range=(-1e8, 1e8)) + .add_transition(lib.abstract("wild_core"), "WILD", "CORE") + ) + return tool, lib def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None: pat = tree.top_pattern() @@ -154,23 +326,451 @@ def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[ assert_allclose(rot, plan_port.rotation) assert out_port.ptype == plan_port.ptype -def test_autotool_planL_selection(multi_bend_tool) -> None: - tool, _ = multi_bend_tool - p, data = tool.planL(True, 5) - assert data.straight.length_range == (0, 10) - assert data.straight_length == 3 - assert data.bend.abstract.name == "b1" - assert_allclose(p.offset, [5, 2]) +def assert_rendered_offer_endpoint_matches_plan( + tool: AutoTool, + offer: PrimitiveOffer, + parameter: float, + source_ptype: str | None = None, + ) -> None: + out_port = offer.endpoint_at(parameter) + tree = rendered_offer_tree(tool, offer, parameter, source_ptype) + assert_trace_matches_plan(out_port, tree) - p, data = tool.planL(True, 15) - assert data.straight.length_range == (10, 1e8) - assert data.straight_length == 13 - assert_allclose(p.offset, [15, 2]) + +def assert_offer_bbox_matches_trace(offer: PrimitiveOffer, parameter: float, tree: Library, source_lib: Library) -> None: + expected = tree.top_pattern().get_bounds(library=source_lib) + assert expected is not None + assert_allclose(offer.bbox_at(parameter), expected) + + +def assert_offer_bbox_matches_rendered_offer( + tool: AutoTool, + offer: PrimitiveOffer, + parameter: float, + source_lib: Library, + ) -> None: + tree = rendered_offer_tree(tool, offer, parameter) + assert_offer_bbox_matches_trace(offer, parameter, tree, source_lib) + + +def make_sbend_tool(jog_range: tuple[float, float]) -> AutoTool: + def make_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + return ( + AutoTool() + .add_straight("core", make_straight, "A", length_range=(0, 1e8)) + .add_sbend("core", make_sbend, "A", "B", jog_range=jog_range) + ) + + +def make_uturn_pattern(length: float = 10, jog: float = 4, ptype: str = "core") -> Pattern: + pat = Pattern() + y0, y1 = sorted((0, jog)) + pat.rect((2, 0), xmin=0, xmax=length, ymin=y0 - 1, ymax=y1 + 1) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((length, jog), 0, ptype=ptype) + return pat + + +def make_uturn_tool() -> tuple[AutoTool, Library]: + lib = Library() + lib["u"] = make_uturn_pattern() + tool = AutoTool(bbox_library=lib).add_uturn(lib.abstract("u"), "A", "B") + return tool, lib + + +@pytest.mark.parametrize("in_ptype", [None, "unk"]) +def test_autotool_transition_offer_wildcard_input_key_treats_none_and_unk_equivalently( + wildcard_transition_tool: tuple[AutoTool, Library], + in_ptype: str | None, + ) -> None: + tool, _lib = wildcard_transition_tool + + valid = [] + for offer in tool.primitive_offers("straight", in_ptype=in_ptype, out_ptype="core"): + try: + out_port, data = commit_offer(offer, 2) + except BuildError: + continue + valid.append((offer, out_port, data)) + + assert valid + _offer, out_port, data = min(valid, key=lambda item: item[0].cost_at(2)) + assert isinstance(data, AutoTool.ReusableData) + assert_allclose(out_port.offset, [2, 0]) + assert out_port.ptype == "core" + + +@pytest.mark.parametrize("out_ptype", [None, "unk"]) +def test_autotool_transition_offer_wildcard_output_key_treats_none_and_unk_equivalently( + wildcard_transition_tool: tuple[AutoTool, Library], + out_ptype: str | None, + ) -> None: + tool, _lib = wildcard_transition_tool + + _offer, out_port, data = selected_matching_offer( + tool, + "straight", + 2, + lambda _offer, out_port, data: out_port.ptype == "unk" and isinstance(data, AutoTool.ReusableData), + in_ptype="core", + out_ptype=out_ptype, + ) + + assert_allclose(out_port.offset, [2, 0]) + assert out_port.ptype == "unk" + + +def test_autotool_l_offer_selection_uses_primitive_cost_and_domains( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, _lib = multi_bend_tool + + small_straight_offer, small_straight_port, small_straight_data = selected_offer(tool, "straight", 5) + assert small_straight_offer.parameter_domain == (0, 10) + assert small_straight_data.parameter == 5 + assert_allclose(small_straight_port.offset, [5, 0]) + + large_straight_offer, large_straight_port, large_straight_data = selected_offer(tool, "straight", 15) + assert large_straight_offer.parameter_domain == (10, 1e8) + assert large_straight_data.parameter == 15 + assert_allclose(large_straight_port.offset, [15, 0]) + + _small_bend_offer, small_bend_port, small_bend_data = selected_offer(tool, "bend", 2, ccw=True) + assert small_bend_data.abstract.name == "b1" + assert_allclose(small_bend_port.offset, [2, 2]) + + large_bend_offer, large_bend_port, large_bend_data = selected_offer(tool, "bend", 5, ccw=True) + assert large_bend_data.abstract.name == "b2" + assert_allclose(large_bend_port.offset, [5, 5]) + valid_costs = [] + for offer in tool.primitive_offers("straight"): + with suppress(BuildError): + valid_costs.append(offer.cost_at(15)) + assert large_straight_offer.cost_at(15) == min(valid_costs) + assert large_bend_offer.parameter_domain == (5, 5) + + +def test_autotool_l_offer_bbox_matches_rendered_primitive(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, lib = multi_bend_tool + offer, _out_port, _data = selected_offer(tool, "bend", 2, ccw=True) + + assert_offer_bbox_matches_rendered_offer(tool, offer, 2, lib) + + +def test_autotool_generated_straight_endpoint_matches_rendered_offer( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, _lib = multi_bend_tool + offer, _out_port, data = selected_offer(tool, "straight", 7, in_ptype="wire") + + assert isinstance(data, AutoTool.GeneratedData) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 7, "wire") @pytest.mark.parametrize("ccw", [False, True]) -def test_autotool_traceL_matches_plan_with_post_bend_transition(ccw: bool) -> None: +def test_autotool_reusable_bend_endpoint_matches_rendered_offer( + multi_bend_tool: tuple[AutoTool, Library], + ccw: bool, + ) -> None: + tool, _lib = multi_bend_tool + offer, _out_port, data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=ccw) + + assert isinstance(data, AutoTool.ReusableData) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 2, "wire") + + +def test_autotool_transition_offer_bbox_matches_rendered_primitive() -> None: + lib = Library() + + lib["core_bend"] = make_bend(2, ptype="core") + trans_pat = Pattern() + trans_pat.rect((2, 0), xmin=0, xmax=3, yctr=2, ly=2) + trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core") + trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") + lib["out_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("out_trans"), "EXT", "CORE") + ) + offer, _out_port, data = selected_offer(tool, "s", 1, in_ptype="core", out_ptype="ext") + + assert isinstance(data, AutoTool.ReusableData) + assert_offer_bbox_matches_rendered_offer(tool, offer, 1, lib) + + +def test_autotool_transition_endpoint_matches_rendered_offer( + autotool_setup: tuple[Pather, AutoTool, Library], + ) -> None: + _pather, tool, _lib = autotool_setup + offer, _out_port, data = selected_matching_offer( + tool, + "straight", + 1, + lambda _offer, _out_port, data: isinstance(data, AutoTool.ReusableData), + in_ptype="wire_m1", + out_ptype="wire_m2", + ) + + assert isinstance(data, AutoTool.ReusableData) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 1, "wire_m1") + + +def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None: + lib = Library() + + def make_wide_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.rect((2, 0), xmin=0, xmax=10, yctr=jog / 2, ly=abs(jog) + 2) + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + trans_pat = Pattern() + trans_pat.rect((2, 0), xmin=0, xmax=5, yctr=0, ly=2) + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") + lib["xin"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", make_straight, "A", length_range=(1, 1e8)) + .add_sbend("core", make_wide_sbend, "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") + + assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) + + pather = Pather(lib, tools=tool, auto_render=False) + pather.ports["A"] = Port((0, 0), 0, ptype="ext") + pather.jog("A", 4) + + assert_allclose(pather.ports["A"].offset, [-15, -4]) + assert [step.opcode for step in pather._paths["A"]] == ['L', 'S'] + assert isinstance(pather._paths["A"][0].data, AutoTool.ReusableData) + assert isinstance(pather._paths["A"][1].data, AutoTool.GeneratedData) + + +def test_autotool_s_offer_uses_absolute_jog_range_for_both_signs() -> None: + tool = make_sbend_tool((0, 1e8)) + offers = tool.primitive_offers('s', in_ptype="core") + + valid_positive = [] + valid_negative = [] + for offer in offers: + try: + if offer.endpoint_at(4).y == 4: + valid_positive.append(offer) + except BuildError: + pass + try: + if offer.endpoint_at(-4).y == -4: + valid_negative.append(offer) + except BuildError: + pass + + assert valid_positive + assert valid_negative + + pather = Pather(Library(), tools=tool, auto_render=False) + pather.ports["A"] = Port((0, 0), 0, ptype="core") + pather.jog("A", -4) + assert_allclose(pather.ports["A"].offset, [-10, 4]) + assert isinstance(pather._paths["A"][0].data, AutoTool.GeneratedData) + + +def test_autotool_sbend_registration_order_sets_priority() -> None: + def first_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((20, jog), pi, ptype="core") + return pat + + def second_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((5, jog), pi, ptype="core") + return pat + + tool = ( + AutoTool() + .add_sbend("core", first_sbend, "A", "B", jog_range=(0, 1e8)) + .add_sbend("core", second_sbend, "A", "B", jog_range=(0, 1e8)) + ) + + _offer, out_port, data = selected_offer(tool, "s", 4, in_ptype="core") + + assert isinstance(data, AutoTool.GeneratedData) + assert data.fn is first_sbend + assert_allclose(out_port.offset, [20, 4]) + + +def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None: + tool = make_sbend_tool((4, 4)) + offers = tool.primitive_offers('s', in_ptype="core") + + assert sorted(offer.jog_domain for offer in offers) == [(-4.0, -4.0), (4.0, 4.0)] + assert [offer.endpoint_at(4).y for offer in offers if offer.jog_domain == (4.0, 4.0)] == [4] + assert [offer.endpoint_at(-4).y for offer in offers if offer.jog_domain == (-4.0, -4.0)] == [-4] + + for jog, expected_y in ((4, -4), (-4, 4)): + pather = Pather(Library(), tools=tool, auto_render=False) + pather.ports["A"] = Port((0, 0), 0, ptype="core") + pather.jog("A", jog) + assert_allclose(pather.ports["A"].offset, [-10, expected_y]) + + +def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None: + tool = make_sbend_tool((-4, 4)) + + assert tool.primitive_offers('s', in_ptype="core") == () + + +def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None: + tool, lib = make_uturn_tool() + offer, out_port, data = selected_offer(tool, "u", 4, in_ptype="core") + + assert isinstance(offer, UOffer) + assert isinstance(data, AutoTool.ReusableData) + assert data.abstract.name == "u" + assert not data.mirrored + assert_allclose(out_port.offset, [10, 4]) + assert out_port.rotation is not None + assert_allclose(out_port.rotation, 0) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 4, "core") + assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) + + +def test_autotool_uturn_offer_mirror_exposes_both_signs() -> None: + tool, _lib = make_uturn_tool() + offers = tool.primitive_offers('u', in_ptype="core") + + assert sorted(offer.jog_domain for offer in offers if isinstance(offer, UOffer)) == [ + (-4.0, -4.0), + (4.0, 4.0), + ] + for offer in offers: + assert isinstance(offer, UOffer) + jog = offer.jog_domain[0] + data = offer.commit(jog) + assert isinstance(data, AutoTool.ReusableData) + assert data.mirrored == (jog < 0) + assert_allclose(offer.endpoint_at(jog).offset, [10, jog]) + + +def test_pather_autotool_uses_prebaked_uturn_offer() -> None: + tool, lib = make_uturn_tool() + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="core") + + p.uturn("A", 4, length=10) + + assert_allclose(p.ports["A"].offset, [-10, -4]) + assert p.ports["A"].rotation is not None + assert_allclose(p.ports["A"].rotation, pi) + assert [step.opcode for step in p._paths["A"]] == ['U'] + assert isinstance(p._paths["A"][0].data, AutoTool.ReusableData) + + +def test_autotool_uturn_rejects_non_uturn_orientation() -> None: + lib = Library() + pat = make_uturn_pattern() + pat.ports["B"].rotation = pi + lib["bad_u"] = pat + + with pytest.raises(BuildError, match="U-turn primitive output port"): + AutoTool().add_uturn(lib.abstract("bad_u"), "A", "B") + + +def test_pather_autotool_omitted_uturn_composes_l_offers( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, lib = multi_bend_tool + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.uturn("A", 20) + + assert_allclose(p.ports["A"].offset, [0, -20]) + assert p.ports["A"].rotation is not None + assert_allclose(p.ports["A"].rotation, pi) + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.ReusableData, + AutoTool.GeneratedData, + AutoTool.ReusableData, + ] + + +def test_pather_autotool_explicit_uturn_composes_l_offers( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, lib = multi_bend_tool + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.uturn("A", 20, length=10) + + assert_allclose(p.ports["A"].offset, [-10, -20]) + assert p.ports["A"].rotation is not None + assert_allclose(p.ports["A"].rotation, pi) + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.GeneratedData, + AutoTool.ReusableData, + ] + + +def test_autotool_offer_bbox_rejects_invalid_parameter(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, _lib = multi_bend_tool + offer = tool.primitive_offers('bend', ccw=True)[0] + + with pytest.raises(BuildError, match='outside singleton domain'): + offer.bbox_at(offer.parameter_domain[0] - 1) + +def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, lib = multi_bend_tool + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.trace("A", True, length=15) + + assert_allclose(p.ports["A"].offset, [-15, -2]) + straight_step, bend_step = p._paths["A"] + assert isinstance(straight_step.data, AutoTool.GeneratedData) + assert isinstance(bend_step.data, AutoTool.ReusableData) + assert straight_step.data.parameter == 13 + assert bend_step.data.abstract.name == "b1" + + +def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None: + markers: list[str | None] = [] + + def make_marked_straight(length: float, marker: str | None = None) -> Pattern: + markers.append(marker) + return make_straight(length, ptype="wire") + + tool = AutoTool().add_straight("wire", make_marked_straight, "A", length_range=(0, 1e8)) + p = Pather(Library(), tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.straight("A", 5, marker="route") + p.render() + + assert markers == [None] + + +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_bend_offer_supports_requested_output_transition(ccw: bool) -> None: lib = Library() bend_pat = Pattern() @@ -183,88 +783,145 @@ def test_autotool_traceL_matches_plan_with_post_bend_transition(ccw: bool) -> No trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") lib["out_trans"] = trans_pat - tool = AutoTool( - straights=[ - AutoTool.Straight( - ptype="core", - fn=lambda length: make_straight(length, ptype="core"), - in_port_name="A", - out_port_name="B", - length_range=(0, 1e8), - ), - ], - bends=[ - AutoTool.Bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True), - ], - sbends=[], - transitions={ - ("ext", "core"): AutoTool.Transition(lib.abstract("out_trans"), "EXT", "CORE"), - }, - default_out_ptype="core", + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("out_trans"), "EXT", "CORE") ) - plan_port, data = tool.planL(ccw, 10, out_ptype="ext") + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", ccw, length=10, out_ptype="ext") - assert data.out_transition is not None - - tree = tool.traceL(ccw, 10, out_ptype="ext") - assert_trace_matches_plan(plan_port, tree) + assert p.ports["A"].ptype == "ext" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + assert p._paths["A"][2].data.port_name == "CORE" -def test_autotool_planU_consistency(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - - p, data = tool.planU(20, length=10) - assert data.ldata0.straight_length == 7 - assert data.ldata0.bend.abstract.name == "b2" - assert data.l2_length == 13 - assert data.ldata1.straight_length == 0 - assert data.ldata1.bend.abstract.name == "b1" - -def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None: - tool = asymmetric_transition_tool - - plan_port, data = tool.planU(12, length=0, in_ptype="core") - - assert data.ldata1.in_transition is not None - assert data.ldata1.b_transition is not None - - tree = tool.traceU(12, length=0, in_ptype="core") - assert_trace_matches_plan(plan_port, tree) - -def test_autotool_planS_double_L(multi_bend_tool) -> None: - tool, lib = multi_bend_tool - - p, data = tool.planS(20, 10) - assert_allclose(p.offset, [20, 10]) - assert_allclose(p.rotation, pi) - - assert data.ldata0.straight_length == 16 - assert data.ldata1.straight_length == 0 - assert data.l2_length == 6 - -def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None: - tool = asymmetric_transition_tool - - plan_port, data = tool.planS(4, 10, in_ptype="core") - - assert isinstance(data, AutoTool.UData) - assert data.ldata1.in_transition is not None - assert data.ldata1.b_transition is not None - - tree = tool.traceS(4, 10, in_ptype="core") - assert_trace_matches_plan(plan_port, tree) - -def test_autotool_planS_pure_sbend_with_transition_dx() -> None: +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_bend_offer_supports_bend_input_transition(ccw: bool) -> None: lib = Library() - def make_straight(length: float) -> Pattern: + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") + bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") + lib["mid_bend"] = bend_pat + + trans_pat = Pattern() + trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + trans_pat.ports["CORE"] = Port((1, 0), pi, ptype="core") + lib["bend_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("bend_trans"), "MID", "CORE") + ) + + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", ccw, length=10, out_ptype="mid") + + assert p.ports["A"].ptype == "mid" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + assert p._paths["A"][1].data.port_name == "CORE" + + +def test_pather_accepts_bend_offer_with_zero_lateral_endpoint() -> None: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") + bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") + lib["mid_bend"] = bend_pat + + trans_pat = Pattern() + trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + trans_pat.ports["CORE"] = Port((1, -2), pi, ptype="core") + lib["bend_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("bend_trans"), "MID", "CORE") + ) + + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", True, length=10, out_ptype="mid") + + assert_allclose(p.ports["A"].offset, [-10, 0], atol=1e-10) + assert p.ports["A"].ptype == "mid" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + + +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_bend_offer_supports_bend_and_output_transitions(ccw: bool) -> None: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") + bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") + lib["mid_bend"] = bend_pat + + bend_trans_pat = Pattern() + bend_trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + bend_trans_pat.ports["CORE"] = Port((1, 0), pi, ptype="core") + lib["bend_trans"] = bend_trans_pat + + out_trans_pat = Pattern() + out_trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + out_trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") + lib["out_trans"] = out_trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight("core", lambda length: make_straight(length, ptype="core"), "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("bend_trans"), "MID", "CORE") + .add_transition(lib.abstract("out_trans"), "EXT", "MID") + ) + + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", ccw, length=12, out_ptype="ext") + + assert p.ports["A"].ptype == "ext" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + assert p._paths["A"][1].data.port_name == "CORE" + assert p._paths["A"][3].data.port_name == "MID" + + +def test_pather_autotool_pure_sbend_with_transition_dx() -> None: + lib = Library() + + def make_core_straight(length: float) -> Pattern: pat = Pattern() pat.ports["A"] = Port((0, 0), 0, ptype="core") pat.ports["B"] = Port((length, 0), pi, ptype="core") return pat - def make_sbend(jog: float) -> Pattern: + def make_core_sbend(jog: float) -> Pattern: pat = Pattern() pat.ports["A"] = Port((0, 0), 0, ptype="core") pat.ports["B"] = Port((10, jog), pi, ptype="core") @@ -275,36 +932,34 @@ def test_autotool_planS_pure_sbend_with_transition_dx() -> None: trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") lib["xin"] = trans_pat - tool = AutoTool( - straights=[ - AutoTool.Straight( - ptype="core", - fn=make_straight, - in_port_name="A", - out_port_name="B", - length_range=(1, 1e8), - ) - ], - bends=[], - sbends=[ - AutoTool.SBend( - ptype="core", - fn=make_sbend, - in_port_name="A", - out_port_name="B", - jog_range=(0, 1e8), - ) - ], - transitions={ - ("ext", "core"): AutoTool.Transition(lib.abstract("xin"), "EXT", "CORE"), - }, - default_out_ptype="core", + tool = ( + AutoTool() + .add_straight("core", make_core_straight, "A", length_range=(1, 1e8)) + .add_sbend("core", make_core_sbend, "A", "B", jog_range=(0, 1e8)) + .add_transition(lib.abstract("xin"), "EXT", "CORE") ) - p, data = tool.planS(15, 4, in_ptype="ext") + transition_offer, trans_port, trans_data = selected_offer( + tool, + "straight", + 5, + in_ptype="ext", + out_ptype="core", + ) + assert transition_offer.out_ptype == "core" + assert_allclose(trans_port.offset, [5, 0]) + assert isinstance(trans_data, AutoTool.ReusableData) - assert_allclose(p.offset, [15, 4]) - assert_allclose(p.rotation, pi) - assert data.straight_length == 0 - assert data.jog_remaining == 4 - assert data.in_transition is not None + s_offer, s_port, s_data = selected_offer(tool, "s", 4, length=15, in_ptype="core") + assert_allclose(s_port.offset, [10, 4]) + assert isinstance(s_data, AutoTool.GeneratedData) + assert s_offer.out_ptype == "core" + + p = Pather(lib, tools=tool, auto_render=False) + p.ports["A"] = Port((0, 0), 0, ptype="ext") + p.jog("A", 4) + + assert_allclose(p.ports["A"].offset, [-15, -4]) + assert [step.opcode for step in p._paths["A"]] == ['L', 'S'] + assert isinstance(p._paths["A"][0].data, AutoTool.ReusableData) + assert isinstance(p._paths["A"][1].data, AutoTool.GeneratedData) diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py index 9f73d2b..53c0a8c 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -11,6 +11,16 @@ from ..pattern import Pattern from ..ports import Port +def test_builder_public_imports() -> None: + from masque import PortPather as TopPortPather + from masque import RenderStep as TopRenderStep + from masque.builder import PortPather as BuilderPortPather + from masque.builder import RenderStep as BuilderRenderStep + + assert TopPortPather is BuilderPortPather + assert TopRenderStep is BuilderRenderStep + + def test_builder_init() -> None: lib = Library() b = Pather(lib, name="mypat") diff --git a/masque/test/test_pather_autotool.py b/masque/test/test_pather_autotool.py index 6ad553a..98be1a1 100644 --- a/masque/test/test_pather_autotool.py +++ b/masque/test/test_pather_autotool.py @@ -7,30 +7,30 @@ from masque import Pather, Library, Pattern, Port from masque.builder.tools import AutoTool -def make_straight(length, width=2, ptype="wire"): +def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: pat = Pattern() pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) pat.ports["B"] = Port((length, 0), pi, ptype=ptype) return pat -def make_bend(R, width=2, ptype="wire", clockwise=True): +def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern: pat = Pattern() # Rectangular approximation of a 90 degree bend. if clockwise: - pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) - pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) + pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width) + pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype) + pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype) else: - pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) - pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) + pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width) + pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius) pat.ports["A"] = Port((0, 0), 0, ptype=ptype) - pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype) + pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype) return pat @pytest.fixture -def multi_bend_tool(): +def multi_bend_tool() -> tuple[AutoTool, Library]: lib = Library() lib["b1"] = make_bend(2, ptype="wire") @@ -38,19 +38,13 @@ def multi_bend_tool(): lib["b2"] = make_bend(5, ptype="wire") b2_abs = lib.abstract("b2") - tool = AutoTool( - straights=[ - AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)), - AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8)) - ], - bends=[ - AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True), - AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True) - ], - sbends=[], - transitions={}, - default_out_ptype="wire" - ) + tool = ( + AutoTool() + .add_straight("wire", make_straight, "A", length_range=(0, 10)) + .add_straight("wire", lambda length: make_straight(length, width=4), "A", length_range=(10, 1e8)) + .add_bend(b1_abs, "A", "B", clockwise=True, mirror=True) + .add_bend(b2_abs, "A", "B", clockwise=True, mirror=True) + ) return tool, lib def test_autotool_uturn() -> None: @@ -70,13 +64,11 @@ def test_autotool_uturn() -> None: bend_pat.ports['out'] = Port((500, -500), pi/2) lib['bend'] = bend_pat - tool = AutoTool( - straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')], - bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)], - sbends=[], - transitions={}, - default_out_ptype='wire' - ) + tool = ( + AutoTool() + .add_straight('wire', make_straight, 'in') + .add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True) + ) p = Pather(lib, tools=tool, auto_render=False) p.pattern.ports['A'] = Port((0, 0), 0) @@ -88,7 +80,7 @@ def test_autotool_uturn() -> None: assert p.pattern.ports['A'].rotation is not None assert numpy.isclose(p.pattern.ports['A'].rotation, pi) -def test_deferred_render_autotool_double_L(multi_bend_tool) -> None: +def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None: tool, lib = multi_bend_tool rp = Pather(lib, tools=tool, auto_render=False) rp.ports["A"] = Port((0,0), 0, ptype="wire") @@ -101,22 +93,10 @@ def test_deferred_render_autotool_double_L(multi_bend_tool) -> None: rp.render() assert len(rp.pattern.refs) > 0 -def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None: +def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None: tool, lib = multi_bend_tool - class BasicTool(AutoTool): - def planU(self, *args, **kwargs): - raise NotImplementedError() - - tool_basic = BasicTool( - straights=tool.straights, - bends=tool.bends, - sbends=tool.sbends, - transitions=tool.transitions, - default_out_ptype=tool.default_out_ptype - ) - - p = Pather(lib, tools=tool_basic) + p = Pather(lib, tools=tool) p.ports["A"] = Port((0,0), 0, ptype="wire") p.uturn("A", 10, length=5) diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index 1e74bd6..e1d77ba 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -1,26 +1,131 @@ -from typing import Any +from collections.abc import Sequence +from typing import Any, Literal, Never import pytest import numpy from numpy import pi -from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError +from masque import Pather, Library, Port +from masque.builder.planner import RoutePortContext, RoutingPlanner +from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool +from masque.error import BuildError +from masque.library import ILibrary -def test_pather_jog_failed_fallback_is_atomic() -> None: +class PlanningOnlyTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[Any, ...]: + _ = kind, in_ptype, out_ptype, kwargs + return () + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree, pat = Library.mktree('planning_only_tool') + pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') + return tree + + +class FirstPortOnlyTraceTool(PlanningOnlyTool): + def __init__(self) -> None: + self.render_calls = 0 + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[StraightOffer | BendOffer, ...]: + _ = out_ptype + if in_ptype != 'wire': + return () + + if kind == 'straight': + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + def commit(length: float) -> dict[str, float | str]: + return {'kind': 'straight', 'length': length} + + return (StraightOffer( + in_ptype='wire', + out_ptype='wire', + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + if kind == 'bend': + ccw = bool(kwargs['ccw']) + + def endpoint(length: float) -> Port: + return Port( + (length, 1 if ccw else -1), + rotation=-pi / 2 if ccw else pi / 2, + ptype='wire', + ) + + def commit(length: float) -> dict[str, float | str]: + return {'kind': 'bend', 'length': length} + + return (BendOffer( + in_ptype='wire', + out_ptype='wire', + ccw=ccw, + length_domain=(1, numpy.inf), + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + return () + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, + ) -> Library: + _ = batch, port_names, kwargs + self.render_calls += 1 + tree, pat = Library.mktree('trace') + pat.add_port_pair(names=port_names, ptype='wire') + return tree + + +class CountingPathTool(PathTool): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.render_calls = 0 + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, + ) -> ILibrary: + self.render_calls += 1 + return super().render(batch, port_names=port_names, **kwargs) + + +def test_pather_jog_failed_two_bend_route_is_atomic() -> None: lib = Library() tool = PathTool(layer='M1', width=2, ptype='wire') p = Pather(lib, tools=tool) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='shorter than required bend'): + with pytest.raises(BuildError, match='S-bend'): p.jog('A', 1.5, length=1.5) assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None: lib = Library() @@ -32,7 +137,19 @@ def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5)) assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 + +def test_pather_auto_render_batches_multi_step_selected_route_once() -> None: + lib = Library() + tool = CountingPathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, auto_render=True) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 4, length=10) + + assert tool.render_calls == 1 + assert len(p._paths['A']) == 0 + assert p.pattern.has_shapes() def test_pather_jog_length_solved_from_single_position_bound() -> None: lib = Library() @@ -50,18 +167,59 @@ def test_pather_jog_length_solved_from_single_position_bound() -> None: q.jog('A', 2, p=-6) assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2)) -def test_pather_jog_requires_length_or_one_position_bound() -> None: + +def test_pather_positional_bound_requires_port_rotation() -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=None, ptype='wire') + + with pytest.raises(BuildError, match='Ports must have rotation'): + p.trace_to('A', None, x=-5) + + +def test_pather_jog_omitted_length_uses_minimum_length_route() -> None: lib = Library() tool = PathTool(layer='M1', width=1, ptype='wire') - p = Pather(lib, tools=tool) + p = Pather(lib, tools=tool, auto_render=False) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='requires either length'): - p.jog('A', 2) + p.jog('A', 2) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -2)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert [step.opcode for step in p._paths['A']] == ['L', 'L', 'L'] with pytest.raises(BuildError, match='exactly one positional bound'): p.jog('A', 2, x=-6, p=-6) +def test_pather_trace_omitted_length_uses_minimum_offer() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.trace('A', None) + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + + p.trace('A', True) + assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -1)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) + +def test_pather_trace_to_without_bound_uses_single_port_trace_minimum() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.trace_to('A', False) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-1, 1)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 3 * pi / 2) + def test_pather_trace_to_rejects_conflicting_position_bounds() -> None: tool = PathTool(layer='M1', width=1, ptype='wire') @@ -83,7 +241,7 @@ def test_pather_trace_rejects_length_with_bundle_bound() -> None: with pytest.raises(BuildError, match='length cannot be combined'): p.trace('A', None, length=5, xmin=-100) -@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10})) +@pytest.mark.parametrize('kwargs', [{'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}]) # noqa: PT007 def test_pather_trace_rejects_multiple_bundle_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') @@ -92,6 +250,116 @@ def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> with pytest.raises(BuildError, match='exactly one bundle bound'): p.trace(['A', 'B'], None, **kwargs) + +def test_planner_constrained_bend_requires_jog() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + context = RoutePortContext('A', Port((0, 0), rotation=0, ptype='wire'), tool) + + with pytest.raises(BuildError, match='trace route requires a jog constraint'): + RoutingPlanner().plan_leg('bend', context, length=5, constrain_jog=True) + + +def test_pather_trace_each_plans_all_ports_before_mutation() -> None: + tool = FirstPortOnlyTraceTool() + p = Pather(Library(), tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-2, 5), rotation=0, ptype='blocked') + + with pytest.raises(BuildError, match='No legal primitive offer for trace'): + p.trace(['A', 'B'], None, each=5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (-2, 5)) + assert p.pattern.ports['A'].ptype == 'wire' + assert p.pattern.ports['B'].ptype == 'blocked' + assert len(p._paths['A']) == 0 + assert len(p._paths['B']) == 0 + +def test_pather_bundle_trace_plans_all_ports_before_mutation_or_render() -> None: + tool = FirstPortOnlyTraceTool() + p = Pather(Library(), tools=tool, auto_render=True) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='blocked') + + with pytest.raises(BuildError, match='No legal primitive offer for trace'): + p.trace(['A', 'B'], True, xmin=-10, spacing=2) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4)) + assert p.pattern.ports['A'].rotation == 0 + assert p.pattern.ports['B'].rotation == 0 + assert len(p._paths['A']) == 0 + assert len(p._paths['B']) == 0 + assert tool.render_calls == 0 + assert not p.pattern.has_shapes() + + +def test_pather_route_commit_failure_is_atomic_for_multi_port_trace() -> None: + class CommitFailureTool(PlanningOnlyTool): + def __init__(self) -> None: + self.committed: list[str | None] = [] + self.render_calls = 0 + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[StraightOffer, ...]: + _ = out_ptype, kwargs + if kind != 'straight': + return () + + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=in_ptype) + + def commit(length: float) -> dict[str, float | str | None]: + _ = length + self.committed.append(in_ptype) + if in_ptype == 'bad': + raise BuildError('selected commit failed') + return {'ptype': in_ptype, 'length': length} + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=in_ptype, + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, + ) -> Library: + _ = batch, port_names, kwargs + self.render_calls += 1 + tree, pat = Library.mktree('commit_failure_tool') + pat.add_port_pair(names=port_names) + return tree + + tool = CommitFailureTool() + p = Pather(Library(), tools=tool, auto_render=True) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='bad') + + with pytest.raises(BuildError, match='selected commit failed'): + p.trace(['A', 'B'], None, each=5) + + assert tool.committed == ['wire', 'bad'] + assert tool.render_calls == 0 + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4)) + assert p.pattern.ports['A'].ptype == 'wire' + assert p.pattern.ports['B'].ptype == 'bad' + assert len(p._paths['A']) == 0 + assert len(p._paths['B']) == 0 + assert not p.pattern.has_shapes() + def test_pather_jog_rejects_length_with_position_bound() -> None: p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') @@ -99,7 +367,7 @@ def test_pather_jog_rejects_length_with_position_bound() -> None: with pytest.raises(BuildError, match='length cannot be combined'): p.jog('A', 2, length=5, x=-999) -@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10})) +@pytest.mark.parametrize('kwargs', [{'x': -999}, {'xmin': -10}]) # noqa: PT007 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') @@ -107,7 +375,7 @@ def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None: with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'): p.uturn('A', 4, **kwargs) -def test_pather_uturn_none_length_defaults_to_zero() -> None: +def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None: lib = Library() tool = PathTool(layer='M1', width=1, ptype='wire') p = Pather(lib, tools=tool) @@ -119,9 +387,82 @@ def test_pather_uturn_none_length_defaults_to_zero() -> None: assert p.pattern.ports['A'].rotation is not None assert numpy.isclose(p.pattern.ports['A'].rotation, pi) -def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None: - class OutPtypeSensitiveTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): +def test_pather_uturn_explicit_zero_length_preserves_old_shape() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.uturn('A', 4, length=0) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi) + +def test_pather_uturn_does_not_use_direct_planl_fallback() -> None: + class PlanLOnlyTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> Never: + del kind, in_ptype, out_ptype, kwargs + raise NotImplementedError + + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + del out_ptype + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = 1 + else: + rotation = pi / 2 + jog = -1 + return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='No legal primitive offer for omitted-length U-turn'): + p.uturn('A', 5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + + with pytest.raises((BuildError, NotImplementedError)): + p.uturn('A', 5, length=10) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + +def test_pather_su_topology_rejects_out_ptype_sensitive_planl_jog() -> None: + class OutPtypeSensitiveTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: radius = 1 if out_ptype is None else 2 if ccw is None: rotation = pi @@ -135,19 +476,94 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> N ptype = out_ptype or in_ptype or 'wire' return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} - p = Pather(Library(), tools=OutPtypeSensitiveTool()) + p = Pather(Library(), tools=OutPtypeSensitiveTool(), auto_render=False) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='fallback via two planL'): + with pytest.raises((BuildError, NotImplementedError)): p.jog('A', 5, length=10, out_ptype='wide') assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 -def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None: - class OutPtypeSensitiveTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): +def test_pather_two_l_planl_only_uturn_is_not_supported() -> None: + class PlanLOnlyTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + del out_ptype + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = 1 + else: + rotation = pi / 2 + jog = -1 + return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises((BuildError, NotImplementedError)): + p.uturn('A', 5, length=10) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + +def test_pather_two_l_planl_only_jog_is_not_supported() -> None: + class PlanLOnlyTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + del out_ptype + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = 1 + else: + rotation = pi / 2 + jog = -1 + return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises((BuildError, NotImplementedError)): + p.jog('A', 5, length=10, out_ptype='unk') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].ptype == 'wire' + assert len(p._paths['A']) == 0 + +def test_pather_su_topology_rejects_out_ptype_sensitive_planl_uturn() -> None: + class OutPtypeSensitiveTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: radius = 1 if out_ptype is None else 2 if ccw is None: rotation = pi @@ -164,50 +580,22 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> p = Pather(Library(), tools=OutPtypeSensitiveTool()) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='fallback via two planL'): + with pytest.raises((BuildError, NotImplementedError)): p.uturn('A', 5, length=10, out_ptype='wide') assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 -def test_tool_planL_fallback_accepts_custom_port_names() -> None: - class DummyTool(Tool): - def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: - lib = Library() - pat = Pattern() - pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') - pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire') - lib['top'] = pat - return lib - - out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y')) - assert numpy.allclose(out_port.offset, (5, 0)) - assert numpy.isclose(out_port.rotation, pi) - -def test_tool_planS_fallback_accepts_custom_port_names() -> None: - class DummyTool(Tool): - def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: - lib = Library() - pat = Pattern() - pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire') - pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire') - lib['top'] = pat - return lib - - out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y')) - assert numpy.allclose(out_port.offset, (5, 2)) - assert numpy.isclose(out_port.rotation, pi) - -def test_pather_uturn_failed_fallback_is_atomic() -> None: +def test_pather_uturn_failed_two_bend_route_is_atomic() -> None: lib = Library() tool = PathTool(layer='M1', width=2, ptype='wire') p = Pather(lib, tools=tool) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='shorter than required bend'): + with pytest.raises(BuildError, match='U-turn'): p.uturn('A', 1.5, length=0) assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert p.pattern.ports['A'].rotation == 0 - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py index 1d67b90..96162af 100644 --- a/masque/test/test_pather_core.py +++ b/masque/test/test_pather_core.py @@ -1,10 +1,13 @@ +from typing import Any + import pytest import numpy from numpy import pi from numpy.testing import assert_allclose, assert_equal from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool +from masque.builder import PathTool, PrimitiveOffer, StraightOffer +from masque.builder.planner import RoutingPlanner from masque.error import BuildError, PortError @@ -17,6 +20,79 @@ def pather_setup() -> tuple[Pather, PathTool, Library]: p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") return p, tool, lib + +def test_builder_tool_symbol_exports() -> None: + import masque + import masque.builder + import masque.builder.tools as builder_tools + + package_root_exports = ( + 'RenderStep', + 'PortPather', + ) + builder_tool_names = ( + 'PrimitiveOffer', + 'StraightOffer', + 'BendOffer', + 'SOffer', + 'UOffer', + ) + internal_names = ( + 'PTypeMatch', + 'canonicalize_domain_value', + 'ptype_match', + 'ptypes_compatible', + ) + + for name in (*package_root_exports, *builder_tool_names): + assert hasattr(masque.builder, name) + + for name in package_root_exports: + assert hasattr(masque, name) + + for name in (*builder_tool_names, *internal_names): + assert not hasattr(masque, name) + + for name in internal_names: + assert not hasattr(masque.builder, name) + + for name in builder_tool_names: + assert hasattr(masque.builder, name) + assert hasattr(builder_tools, name) + + +def test_pather_pending_render_steps_are_private() -> None: + p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1), auto_render=False) + + assert not hasattr(p, 'paths') + assert hasattr(p, '_paths') + + +def test_pather_accepts_and_reuses_planner_instance() -> None: + class CountingPlanner(RoutingPlanner): + def __init__(self) -> None: + self.trace_to_calls = 0 + + def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any: + self.trace_to_calls += 1 + return super().plan_trace_to_route(*args, **kwargs) + + planner = CountingPlanner() + p = Pather( + Library(), + tools=PathTool(layer=(1, 0), width=1), + auto_render=False, + planner=planner, + ) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 1) + p.straight('A', 2) + + assert p.planner is planner + assert planner.trace_to_calls == 2 + + def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = pather_setup p.straight("start", 10) @@ -322,7 +398,90 @@ def test_pather_dead_fallback_preserves_out_ptype() -> None: assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0)) assert p.pattern.ports['A'].ptype == 'other' - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 + +def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None: + class MismatchedEndpointTool(PathTool): + def primitive_offers( + self, + kind, # noqa: ANN001 + *, + in_ptype=None, # noqa: ANN001 + out_ptype=None, # noqa: ANN001,ARG002 + **kwargs, # noqa: ANN003 + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype='other', + endpoint_planner=endpoint, + commit_planner=lambda length: {'length': length}, + ),) + + p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.set_dead() + + with pytest.raises(BuildError, match='does not match declared offer out_ptype'): + p.straight('A', 1000) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].ptype == 'wire' + assert len(p._paths['A']) == 0 + + +def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None: + class PartlyMismatchedEndpointTool(PathTool): + def primitive_offers( + self, + kind, # noqa: ANN001 + *, + in_ptype=None, # noqa: ANN001 + out_ptype=None, # noqa: ANN001,ARG002 + **kwargs, # noqa: ANN003 + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def mismatched_endpoint(length: float) -> Port: + ptype = 'wire' if numpy.isclose(length, 0) else 'other' + return Port((length, 0), rotation=pi, ptype=ptype) + + def valid_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + return ( + StraightOffer( + in_ptype=in_ptype, + out_ptype='wire', + endpoint_planner=mismatched_endpoint, + commit_planner=lambda length: {'kind': 'mismatched', 'length': length}, + ), + StraightOffer( + in_ptype=in_ptype, + out_ptype='wire', + endpoint_planner=valid_endpoint, + commit_planner=lambda length: {'kind': 'valid', 'length': length}, + ), + ) + + p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='does not match declared offer out_ptype'): + p.straight('A', 1000) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].ptype == 'wire' + assert len(p._paths['A']) == 0 + def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None: lib = Library() diff --git a/masque/test/test_pather_place_plug.py b/masque/test/test_pather_place_plug.py index 0bd6a3c..11e9bd2 100644 --- a/masque/test/test_pather_place_plug.py +++ b/masque/test/test_pather_place_plug.py @@ -1,12 +1,10 @@ -from typing import Any - import pytest import numpy from numpy import pi from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError +from masque.builder.tools import PathTool +from masque.error import PortError, PatternError def test_pather_place_treeview_resolves_once() -> None: @@ -46,7 +44,7 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None: p.pattern.ports['A'] = Port((0, 0), rotation=0) p.at('A').trace(None, 5000) - assert [step.opcode for step in p.paths['A']] == ['L'] + assert [step.opcode for step in p._paths['A']] == ['L'] other = Pattern( annotations={'k': [2]}, @@ -56,7 +54,7 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None: with pytest.raises(PatternError, match='Annotation keys overlap'): p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True) - assert [step.opcode for step in p.paths['A']] == ['L'] + assert [step.opcode for step in p._paths['A']] == ['L'] assert set(p.pattern.ports) == {'A'} def test_pather_place_reused_deleted_name_keeps_break_marker() -> None: @@ -72,7 +70,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None: p.place(other, port_map={'X': 'A'}, append=True) p.at('A').straight(2000) - assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L'] + assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L'] p.render() assert p.pattern.has_shapes() @@ -98,7 +96,7 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None: p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True) p.at('A').straight(2000) - assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L'] + assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L'] p.render() assert p.pattern.has_shapes() @@ -113,10 +111,10 @@ def test_pather_failed_plugged_does_not_add_break_marker() -> None: p.pattern.ports['A'] = Port((0, 0), rotation=0) p.at('A').straight(5000) - assert [step.opcode for step in p.paths['A']] == ['L'] + assert [step.opcode for step in p._paths['A']] == ['L'] with pytest.raises(PortError, match='Connection destination ports were not found'): p.plugged({'A': 'missing'}) - assert [step.opcode for step in p.paths['A']] == ['L'] - assert set(p.paths) == {'A'} + assert [step.opcode for step in p._paths['A']] == ['L'] + assert set(p._paths) == {'A'} diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py new file mode 100644 index 0000000..ab8facf --- /dev/null +++ b/masque/test/test_pather_primitive_offers.py @@ -0,0 +1,514 @@ +from collections.abc import Callable +from typing import Any, Literal + +import numpy +import pytest +from numpy import pi + +from masque import Library, Path, Port, Pather +from masque.builder.tools import ( + BendOffer, + PathTool, + PrimitiveOffer, + SOffer, + StraightOffer, + Tool, + UOffer, +) +from masque.error import BuildError +from masque.utils import PTypeMatch, ptype_match + + +def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]: + return { + 'endpoint_planner': lambda parameter: planner(parameter)[0], + 'commit_planner': lambda parameter: planner(parameter)[1], + } + + +class PlanningOnlyTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kind, in_ptype, out_ptype, kwargs + return () + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree, pat = Library.mktree('planning_only_tool') + pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') + return tree + + +def test_tool_requires_primitive_offers_override() -> None: + class RenderOnlyTool(Tool): + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + return Library() + + with pytest.raises(TypeError): + RenderOnlyTool() + + +def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None: + class BaseCallingTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + return Library() + + with pytest.raises(NotImplementedError): + BaseCallingTool().primitive_offers('straight') + + +def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float: + offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) + return offer.canonicalize_parameter(value) + + +def test_offer_canonicalize_parameter_half_open_and_singleton() -> None: + assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0 + assert canonicalize_offer_parameter(3, (0, 10)) == 3 + + with pytest.raises(BuildError, match='outside half-open domain'): + canonicalize_offer_parameter(10, (0, 10)) + + assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5 + + with pytest.raises(BuildError, match='outside singleton domain'): + canonicalize_offer_parameter(5.1, (5, 5)) + + +@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf]) +def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None: + with pytest.raises(BuildError, match='must be finite'): + canonicalize_offer_parameter(value, (0, 10)) + + +def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None: + with pytest.raises(BuildError, match='lower bound must not exceed upper bound'): + canonicalize_offer_parameter(3, (10, 0)) + + +def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None: + assert ptype_match('wire', 'wire') is PTypeMatch.EXACT + assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD + assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD + assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH + + +def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None: + endpoint_calls: list[float] = [] + commit_calls: list[float] = [] + + def endpoint(length: float) -> Port: + endpoint_calls.append(length) + return Port((length, 2), rotation=pi, ptype='wire') + + def commit(length: float) -> dict[str, float]: + commit_calls.append(length) + return {'length': length} + + offer = StraightOffer( + in_ptype='wire', + out_ptype='wire', + length_domain=(5, 5), + endpoint_planner=endpoint, + commit_planner=commit, + ) + + assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2)) + assert offer.cost_at(5 + 1e-13) == 5 + pi + assert commit_calls == [] + + assert offer.commit(5 + 1e-13) == {'length': 5} + assert endpoint_calls == [5, 5] + assert commit_calls == [5] + + +def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None: + def data_at(length: float) -> dict[str, float]: + return {'length': length} + + offer = StraightOffer.generated( + 'wire', + data_at, + length_domain=(2, 8), + priority_bias=3, + bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]), + ) + + endpoint = offer.endpoint_at(4) + + assert offer.length_domain == (2, 8) + assert offer.priority_bias == 3 + assert numpy.allclose(endpoint.offset, [4, 0]) + assert endpoint.rotation == pi + assert endpoint.ptype == 'wire' + assert offer.commit(4) == {'length': 4} + assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]]) + + +def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None: + def endpoint_at(jog: float) -> Port: + return Port((10, jog), rotation=pi, ptype='wire') + + def data_at(jog: float) -> dict[str, Any]: + return {'jog': abs(jog), 'mirrored': jog < 0} + + offer = SOffer.generated( + 'wire', + endpoint_at, + data_at, + jog_domain=(-5, 5), + bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]), + ) + + endpoint = offer.endpoint_at(-3) + + assert numpy.allclose(endpoint.offset, [10, -3]) + assert offer.commit(-3) == {'jog': 3, 'mirrored': True} + assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]]) + + +def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None: + def endpoint_at(length: float) -> Port: + return Port((length, length), rotation=-pi / 2, ptype='wire') + + def data_at(length: float) -> dict[str, float]: + return {'length': length} + + offer = BendOffer.generated( + 'wire', + endpoint_at, + data_at, + ccw=True, + length_domain=(4, 4), + bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]), + ) + + endpoint = offer.endpoint_at(4) + + assert offer.ccw + assert offer.length_domain == (4, 4) + assert numpy.allclose(endpoint.offset, [4, 4]) + assert endpoint.rotation == 3 * pi / 2 + assert offer.commit(4) == {'length': 4} + assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]]) + + +def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None: + def endpoint_at(jog: float) -> Port: + return Port((12, jog), rotation=0, ptype='wire') + + def data_at(jog: float) -> dict[str, float]: + return {'jog': jog} + + offer = UOffer.generated( + 'wire', + endpoint_at, + data_at, + jog_domain=(-6, 6), + bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]), + ) + + endpoint = offer.endpoint_at(-4) + + assert offer.jog_domain == (-6, 6) + assert numpy.allclose(endpoint.offset, [12, -4]) + assert endpoint.rotation == 0 + assert offer.commit(-4) == {'jog': -4} + assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]]) + + +def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None: + data = {'kind': 'prebuilt'} + bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731 + endpoint = Port((5, 2), rotation=pi / 2, ptype='out') + cases = [ + (StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5), + (BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5), + (SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2), + (UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2), + ] + + for offer, parameter in cases: + first = offer.endpoint_at(parameter) + first.offset[:] = 99 + second = offer.endpoint_at(parameter) + + assert numpy.allclose(second.offset, [5, 2]) + assert second.rotation == pi / 2 + assert second.ptype == 'out' + assert offer.commit(parameter) is data + assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]]) + + +def test_offer_rejects_negative_priority_bias() -> None: + with pytest.raises(BuildError, match='priority_bias must be nonnegative'): + StraightOffer(in_ptype='wire', out_ptype='wire', priority_bias=-1) + + +def test_offer_rejects_one_sided_split_callbacks() -> None: + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + with pytest.raises(BuildError, match='require both'): + StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint) + + +def test_offer_bbox_at_validates_bounds() -> None: + offer = StraightOffer( + in_ptype='wire', + out_ptype='wire', + bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]), + **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})), + ) + + assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]]) + + bad = StraightOffer( + in_ptype='wire', + out_ptype='wire', + bbox_planner=lambda _length: numpy.array([0, 1]), + **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})), + ) + with pytest.raises(BuildError, match='shape'): + bad.bbox_at(3) + + +def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + offer = tool.primitive_offers('straight', in_ptype='wire')[0] + + bounds = offer.bbox_at(10) + expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single() + + assert numpy.allclose(bounds, expected) + + +def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0] + + bounds = offer.bbox_at(1) + expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single() + + assert isinstance(offer, BendOffer) + assert offer.length_domain == (1, 1) + assert numpy.allclose(bounds, expected) + + +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] + + bounds = offer.bbox_at(3) + expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single() + + assert isinstance(offer, SOffer) + assert numpy.allclose(bounds, expected) + + +def test_pathtool_u_offers_remain_unsupported() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + + assert tool.primitive_offers('u', in_ptype='wire') == () + + +@pytest.mark.parametrize( + ('kind', 'kwargs'), + [ + ('straight', {}), + ('bend', {'ccw': True}), + ('s', {'length': 6}), + ], +) +def test_pathtool_out_ptype_unk_is_wildcard( + kind: Literal['straight', 'bend', 's'], + kwargs: dict[str, Any], + ) -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + + offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs) + + assert offers + assert offers[0].out_ptype == 'wire' + + +def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None: + class NoOfferTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kind, in_ptype, out_ptype, kwargs + raise NotImplementedError + + p = Pather(Library(), tools=NoOfferTool(), auto_render=False) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='No legal primitive offer for trace'): + p.straight('A', 5) + + +@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError]) +def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None: + class BrokenTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kind, in_ptype, out_ptype, kwargs + raise err_type('offer query failed') + + p = Pather(Library(), tools=BrokenTool(), auto_render=False) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(err_type): + p.straight('A', 5) + + +def test_pather_selects_lowest_cost_offer() -> None: + class MultiOfferTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def high(length: float) -> tuple[Port, dict[str, str | float]]: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'} + + def low(length: float) -> tuple[Port, dict[str, str | float]]: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'} + + return ( + StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=10, **offer_callbacks(high)), + StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=0, **offer_callbacks(low)), + ) + + p = Pather(Library(), tools=MultiOfferTool(), auto_render=False) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 7) + + assert p._paths['A'][0].data == {'kind': 'low'} + assert numpy.allclose(p.ports['A'].offset, (-7, 0)) + + +def test_pather_commits_only_selected_offer() -> None: + committed: list[str] = [] + + class RecordingTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def make(label: str, priority: float) -> StraightOffer: + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype) + + def commit(length: float) -> dict[str, float | str]: + committed.append(label) + return {'kind': label, 'length': length} + + return StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype, + priority_bias=priority, + endpoint_planner=endpoint, + commit_planner=commit, + ) + + return (make('expensive', 100), make('cheap', 0)) + + p = Pather(Library(), tools=RecordingTool(), auto_render=False) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 5) + + assert committed == ['cheap'] + assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5} + + +def test_pather_routes_with_offer_only_s_and_u_tool() -> None: + class OfferOnlyTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + endpoint_ptype = out_ptype or in_ptype + if kind == 'straight': + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda length: ( + Port((length, 0), rotation=pi, ptype=endpoint_ptype), + {'kind': 'straight', 'length': length}, + )), + ),) + if kind == 's': + return (SOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda jog: ( + Port((5, jog), rotation=pi, ptype=endpoint_ptype), + {'kind': 's', 'jog': jog}, + )), + ),) + if kind == 'u': + return (UOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda jog: ( + Port((2, jog), rotation=0, ptype=endpoint_ptype), + {'kind': 'u', 'jog': jog}, + )), + ),) + return () + + p = Pather(Library(), tools=OfferOnlyTool(), auto_render=False) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.jog('A', 3) + p.uturn('A', 4) + + assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u'] diff --git a/masque/test/test_pather_rendering.py b/masque/test/test_pather_rendering.py index 2f7360e..f539a51 100644 --- a/masque/test/test_pather_rendering.py +++ b/masque/test/test_pather_rendering.py @@ -6,7 +6,7 @@ from numpy import pi from numpy.testing import assert_allclose from ..builder import Pather -from ..builder.tools import PathTool, Tool +from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool from ..error import BuildError from ..library import Library from ..pattern import Pattern @@ -29,7 +29,7 @@ def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup rp.at("start").straight(10).straight(10) assert not rp.pattern.has_shapes() - assert len(rp.paths["start"]) == 2 + assert len(rp._paths["start"]) == 2 rp.render() assert rp.pattern.has_shapes() @@ -46,21 +46,19 @@ def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Lib rp.render() path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # Clockwise bend adds the bend endpoint after the straight segment vertex. - assert len(path_shape.vertices) == 4 - assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) + # The bend route is explicit straight run plus a fixed-size bend. + assert len(path_shape.vertices) == 5 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10) -def test_deferred_render_jog_uses_native_pathtool_planS(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: +def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: rp, tool, lib = deferred_render_setup rp.at("start").jog(4, length=10) - assert len(rp.paths["start"]) == 1 - assert rp.paths["start"][0].opcode == "S" + assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"] rp.render() path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - # Native PathTool S-bends place the jog width/2 before the route end. - assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10) + assert_allclose(path_shape.vertices, [[0, 0], [0, -1], [1, -1], [3, -1], [4, -1], [4, -2], [4, -10]], atol=1e-10) assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: @@ -71,7 +69,7 @@ def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_ rp.render() path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10) + assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10) def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: rp, tool1, lib = deferred_render_setup @@ -110,7 +108,7 @@ def test_deferred_render_dead_ports() -> None: assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10) - assert len(rp.paths["in"]) == 0 + assert len(rp._paths["in"]) == 0 rp.render() assert not rp.pattern.has_shapes() @@ -121,8 +119,8 @@ def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTo rp.rename_ports({"start": "new_start"}) rp.at("new_start").straight(10) - assert "start" not in rp.paths - assert len(rp.paths["new_start"]) == 2 + assert "start" not in rp._paths + assert len(rp._paths["new_start"]) == 2 rp.render() assert rp.pattern.has_shapes() @@ -137,7 +135,7 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende rp.at("start").straight(10).drop() assert "start" not in rp.ports - assert len(rp.paths["start"]) == 1 + assert len(rp._paths["start"]) == 1 rp.render() assert rp.pattern.has_shapes() @@ -145,26 +143,33 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10) -def test_pathtool_traceL_bend_geometry_matches_ports() -> None: +def test_pathtool_bend_offer_render_geometry_matches_ports() -> None: tool = PathTool(layer=(1, 0), width=2, ptype="wire") - tree = tool.traceL(True, 10) + offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0] + start = Port((0, 0), rotation=pi, ptype="wire") + end = offer.endpoint_at(1) + tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),)) pat = tree.top_pattern() path_shape = cast("Path", pat.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10) + assert offer.length_domain == (1, 1) + assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10) -def test_pathtool_traceS_geometry_matches_ports() -> None: +def test_pathtool_s_offer_render_geometry_matches_ports() -> None: tool = PathTool(layer=(1, 0), width=2, ptype="wire") - tree = tool.traceS(10, 4) + offer = tool.primitive_offers("s", in_ptype="wire")[0] + start = Port((0, 0), rotation=pi, ptype="wire") + end = offer.endpoint_at(4) + tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),)) pat = tree.top_pattern() path_shape = cast("Path", pat.shapes[(1, 0)][0]) - assert_allclose(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10) - assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10) - assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10) + assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10) + assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10) def test_deferred_render_uturn_fallback() -> None: lib = Library() @@ -174,9 +179,8 @@ def test_deferred_render_uturn_fallback() -> None: rp.at('A').uturn(offset=10000, length=5000) - assert len(rp.paths['A']) == 2 - assert rp.paths['A'][0].opcode == 'L' - assert rp.paths['A'][1].opcode == 'L' + assert len(rp._paths['A']) == 4 + assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L'] rp.render() assert rp.pattern.ports['A'].rotation is not None @@ -184,15 +188,23 @@ def test_deferred_render_uturn_fallback() -> None: def test_pather_render_auto_renames_single_use_tool_children() -> None: class FullTreeTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () ptype = out_ptype or in_ptype or 'wire' - return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((1, 0), pi, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), }) child = Pattern(annotations={'batch': [len(batch)]}) top.ref('_seg') @@ -215,13 +227,24 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None: assert all(name.startswith('_seg') for name in lib) assert p.pattern.referenced_patterns() <= set(lib.keys()) -def test_tool_render_fallback_preserves_segment_subtrees() -> None: +def test_custom_tool_render_preserves_segment_subtrees() -> None: class TraceTreeTool(Tool): - def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001 + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: self._trace(length), + ),) + + def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001 tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), pi, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), }) child = Pattern(annotations={'length': [length]}) top.ref('_seg') @@ -229,6 +252,11 @@ def test_tool_render_fallback_preserves_segment_subtrees() -> None: tree['_seg'] = child return tree + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + assert len(batch) == 1 + assert isinstance(batch[0].data, Library) + return batch[0].data + lib = Library() p = Pather(lib, tools=TraceTreeTool(), auto_render=False) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') @@ -242,11 +270,18 @@ def test_tool_render_fallback_preserves_segment_subtrees() -> None: def test_pather_render_rejects_missing_single_use_tool_refs() -> None: class MissingSingleUseTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () ptype = out_ptype or in_ptype or 'wire' - return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), @@ -270,15 +305,23 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None: def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: class SharedRefTool(Tool): - def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202 + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () ptype = out_ptype or in_ptype or 'wire' - return Port((length, 0), rotation=pi, ptype=ptype), {'length': length} + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) - def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202 + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((1, 0), pi, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), }) top.ref('shared') tree['_top'] = top @@ -295,6 +338,74 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: assert 'shared' in p.pattern.refs assert p.pattern.referenced_patterns() <= set(lib.keys()) +@pytest.mark.parametrize('append', [True, False]) +def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None: + class WrongEndpointTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] + tree = Library() + tree['_top'] = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length + 1, 0), pi, ptype='wire'), + }) + return tree + + lib = Library() + p = Pather(lib, tools=WrongEndpointTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(BuildError, match='does not match planned endpoint'): + p.render(append=append) + + assert not p.pattern.refs + assert not lib + +@pytest.mark.parametrize('append', [True, False]) +def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None: + class WrongPtypeTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] + tree = Library() + tree['_top'] = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='metal'), + }) + return tree + + lib = Library() + p = Pather(lib, tools=WrongPtypeTool(), auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(BuildError, match='does not match planned endpoint'): + p.render(append=append) + + assert not p.pattern.refs + assert not lib + def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None: lib = Library() tool = PathTool(layer='M1', width=1000) @@ -305,7 +416,7 @@ def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> rp.rename_ports({'A': None}) assert 'A' not in rp.pattern.ports - assert len(rp.paths['A']) == 1 + assert len(rp._paths['A']) == 1 rp.render() assert rp.pattern.has_shapes() diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index b5e8aed..7c5a362 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -1,13 +1,13 @@ from typing import Any -import pytest import numpy +import pytest from numpy import pi from numpy.testing import assert_equal -from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError +from masque import Library, PathTool, Port, Pather +from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool +from masque.error import BuildError, PortError @pytest.fixture @@ -17,6 +17,7 @@ def trace_into_setup() -> tuple[Pather, PathTool, Library]: p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False) return p, tool, lib + def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: p, _tool, _lib = trace_into_setup p.ports["src"] = Port((0, 0), 0, ptype="wire") @@ -28,6 +29,7 @@ def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) assert "dst" not in p.ports assert len(p.pattern.refs) == 1 + def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: p, _tool, _lib = trace_into_setup p.ports["src"] = Port((0, 0), 0, ptype="wire") @@ -37,10 +39,9 @@ def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> N assert "src" not in p.ports assert "dst" not in p.ports - # `trace_into()` batches internal legs before auto-rendering so the operation - # rolls back cleanly on later failures. assert len(p.pattern.refs) == 1 + def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: p, _tool, _lib = trace_into_setup p.ports["src"] = Port((0, 0), 0, ptype="wire") @@ -51,6 +52,7 @@ def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> assert "src" not in p.ports assert "dst" not in p.ports + def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: p, _tool, _lib = trace_into_setup p.ports["src"] = Port((0, 0), 0, ptype="wire") @@ -63,7 +65,8 @@ def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> N assert_equal(p.ports["src"].offset, [10, 10]) assert "other" not in p.ports -def test_pather_trace_into() -> None: + +def test_pather_trace_into_shapes() -> None: lib = Library() tool = PathTool(layer='M1', width=1000) p = Pather(lib, tools=tool, auto_render=False) @@ -76,7 +79,7 @@ def test_pather_trace_into() -> None: assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) p.pattern.ports['C'] = Port((0, 0), rotation=0) - p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2) + p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2) p.at('C').trace_into('D', plug_destination=False) assert 'D' in p.pattern.ports assert 'C' in p.pattern.ports @@ -107,6 +110,79 @@ def test_pather_trace_into() -> None: assert p.pattern.ports['I'].rotation is not None assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) + +def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None: + class TransitionTool(Tool): + def primitive_offers( + self, + kind, # noqa: ANN001 + *, + in_ptype=None, # noqa: ANN001 + out_ptype=None, # noqa: ANN001 + **kwargs, # noqa: ANN003 + ) -> tuple[PrimitiveOffer, ...]: + _ = in_ptype + if kind == 'straight': + def native_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='m1wire') + + native = StraightOffer( + in_ptype='m1wire', + out_ptype='m1wire', + endpoint_planner=native_endpoint, + commit_planner=lambda length: {'kind': 'straight', 'length': length}, + ) + if out_ptype in ('unk', 'm1wire'): + return (native,) + + def transition_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='m2wire') + + transition = StraightOffer( + in_ptype='m1wire', + out_ptype='m2wire', + length_domain=(2500, numpy.inf), + endpoint_planner=transition_endpoint, + commit_planner=lambda length: {'kind': 'transition', 'length': length}, + ) + return native, transition + + if kind == 'bend': + ccw = bool(kwargs['ccw']) + + def endpoint(length: float) -> Port: + return Port( + (length, 500 if ccw else -500), + rotation=-pi / 2 if ccw else pi / 2, + ptype='m1wire', + ) + + return (BendOffer( + in_ptype='m1wire', + out_ptype='m1wire', + ccw=ccw, + length_domain=(500, numpy.inf), + endpoint_planner=endpoint, + commit_planner=lambda length: {'kind': 'bend', 'length': length}, + ),) + + return () + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree, pat = Library.mktree('transition_tool') + pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire') + return tree + + p = Pather(Library(), tools=TransitionTool(), auto_render=False) + p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire') + p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire') + + p.trace_into('src', 'dst') + + assert 'src' not in p.pattern.ports + assert 'dst' not in p.pattern.ports + + def test_pather_trace_into_dead_updates_ports_without_geometry() -> None: lib = Library() tool = PathTool(layer='M1', width=1000, ptype='wire') @@ -121,27 +197,29 @@ def test_pather_trace_into_dead_updates_ports_without_geometry() -> None: assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) assert p.pattern.ports['A'].rotation is not None assert numpy.isclose(p.pattern.ports['A'].rotation, 0) - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 assert not p.pattern.has_shapes() assert not p.pattern.has_refs() -def test_pather_trace_into_failure_rolls_back_ports_and_paths() -> None: + +def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None: lib = Library() tool = PathTool(layer='M1', width=1, ptype='wire') p = Pather(lib, tools=tool) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire') - with pytest.raises(BuildError, match='does not match path ptype'): + with pytest.raises(BuildError): p.trace_into('A', 'B', plug_destination=False, out_ptype='other') assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert numpy.isclose(p.pattern.ports['A'].rotation, 0) assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5)) assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2) - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 -def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None: + +def test_pather_trace_into_rename_failure_propagates() -> None: lib = Library() tool = PathTool(layer='M1', width=1, ptype='wire') p = Pather(lib, tools=tool) @@ -152,19 +230,14 @@ def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None: with pytest.raises(PortError, match='overwritten'): p.trace_into('A', 'B', plug_destination=False, thru='other') - assert set(p.pattern.ports) == {'A', 'B', 'other'} - assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) - assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0)) - assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4)) - assert len(p.paths['A']) == 0 @pytest.mark.parametrize( ('dst', 'kwargs', 'match'), - ( - (Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'), - (Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'), - (Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'), - ), + [ + (Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'), + (Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'), + (Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'), + ], ) def test_pather_trace_into_rejects_reserved_route_kwargs( dst: Port, @@ -186,4 +259,4 @@ def test_pather_trace_into_rejects_reserved_route_kwargs( assert dst.rotation is not None assert p.pattern.ports['B'].rotation is not None assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation) - assert len(p.paths['A']) == 0 + assert len(p._paths['A']) == 0 diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index 6d24879..f17921f 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -169,6 +169,36 @@ def test_port_list_plugged() -> None: assert not pl.ports # Both should be removed +def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level("WARNING", logger="masque.ports") + + compatible_cases = [ + ("wire", "wire"), + ("unk", "wire"), + (None, "wire"), + ] + for left, right in compatible_cases: + caplog.clear() + pl = Pattern(ports={ + "A": Port((10, 10), 0, ptype=left), # type: ignore[arg-type] + "B": Port((10, 10), pi, ptype=right), # type: ignore[arg-type] + }) + + pl.plugged({"A": "B"}) + + assert not any("conflicting types" in record.message for record in caplog.records) + + caplog.clear() + pl = Pattern(ports={ + "A": Port((10, 10), 0, ptype="wire"), + "B": Port((10, 10), pi, ptype="metal"), + }) + + pl.plugged({"A": "B"}) + + assert any("conflicting types" in record.message for record in caplog.records) + + def test_port_list_plugged_empty_raises() -> None: class MyPorts(PortList): def __init__(self) -> None: @@ -291,3 +321,34 @@ def test_find_transform_requires_connection_map() -> None: with pytest.raises(PortError, match="at least one port connection"): Pattern.find_port_transform({}, {}, {}) + + +def test_find_transform_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level("WARNING", logger="masque.ports") + + compatible_cases = [ + ("wire", "wire"), + ("wire", "unk"), + ("wire", None), + ] + for left, right in compatible_cases: + caplog.clear() + host = Pattern(ports={"A": Port((0, 0), 0, ptype=left)}) # type: ignore[arg-type] + other = Pattern(ports={"X": Port((0, 0), pi, ptype=right)}) # type: ignore[arg-type] + + host.find_transform(other, {"A": "X"}) + + assert not any("conflicting types" in record.message for record in caplog.records) + + caplog.clear() + host = Pattern(ports={"A": Port((0, 0), 0, ptype="wire")}) + other = Pattern(ports={"X": Port((0, 0), pi, ptype="metal")}) + + host.find_transform(other, {"A": "X"}) + + assert any("conflicting types" in record.message for record in caplog.records) + + caplog.clear() + host.find_transform(other, {"A": "X"}, ok_connections={("wire", "metal")}) + + assert not any("conflicting types" in record.message for record in caplog.records) diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index f33142f..8e10ca1 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -10,6 +10,11 @@ from .array import is_scalar as is_scalar from .autoslots import AutoSlots as AutoSlots from .deferreddict import DeferredDict as DeferredDict from .decorators import oneshot as oneshot +from .ptypes import ( + PTypeMatch as PTypeMatch, + ptype_match as ptype_match, + ptypes_compatible as ptypes_compatible, + ) from .bitwise import ( get_bit as get_bit, diff --git a/masque/utils/ptypes.py b/masque/utils/ptypes.py new file mode 100644 index 0000000..e5953b2 --- /dev/null +++ b/masque/utils/ptypes.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class PTypeMatch(Enum): + """Result of comparing two port types.""" + EXACT = 'exact' + WILDCARD = 'wildcard' + MISMATCH = 'mismatch' + + +def ptype_match(left: str | None, right: str | None) -> PTypeMatch: + """Compare ptypes, treating `None` and `"unk"` as wildcards.""" + if left in (None, 'unk') or right in (None, 'unk'): + return PTypeMatch.WILDCARD + if left == right: + return PTypeMatch.EXACT + return PTypeMatch.MISMATCH + + +def ptypes_compatible(left: str | None, right: str | None) -> bool: + """Return true when two ptypes may connect under normal compatibility rules.""" + return ptype_match(left, right) is not PTypeMatch.MISMATCH