[builder] major Pather/Planner/Tool rework
This commit is contained in:
parent
0f39ce8816
commit
22e645e527
28 changed files with 6036 additions and 2467 deletions
225
MIGRATION.md
225
MIGRATION.md
|
|
@ -134,11 +134,8 @@ previously used `RenderPather`.
|
||||||
|
|
||||||
## `BasicTool` was replaced
|
## `BasicTool` was replaced
|
||||||
|
|
||||||
`BasicTool` is no longer exported. Use:
|
`BasicTool` is no longer exported. Use `AutoTool` for reusable straight, bend,
|
||||||
|
transition, and S-bend primitives.
|
||||||
- `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
|
|
||||||
|
|
||||||
### Old `BasicTool`
|
### Old `BasicTool`
|
||||||
|
|
||||||
|
|
@ -157,55 +154,32 @@ tool = BasicTool(
|
||||||
### New `AutoTool`
|
### New `AutoTool`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from masque.builder.tools import AutoTool
|
from masque.builder import AutoTool
|
||||||
|
|
||||||
tool = AutoTool(
|
tool = (
|
||||||
straights=[
|
AutoTool()
|
||||||
AutoTool.Straight(
|
.add_straight('m1wire', make_straight, 'input')
|
||||||
ptype='m1wire',
|
.add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True)
|
||||||
fn=make_straight,
|
.add_transition(lib.abstract('via'), 'top', 'bottom')
|
||||||
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',
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The key differences are:
|
The key differences are:
|
||||||
|
|
||||||
- `BasicTool` -> `SimpleTool` or `AutoTool`
|
- `BasicTool` -> `AutoTool`
|
||||||
- `straight=(fn, in_name, out_name)` -> `straights=[AutoTool.Straight(...)]`
|
- `straight=(fn, in_name, out_name)` -> `add_straight(ptype, fn, in_name)`
|
||||||
- `bend=(abstract, in_name, out_name)` -> `bends=[AutoTool.Bend(...)]`
|
- `bend=(abstract, in_name, out_name)` -> `add_bend(abstract, in_name, out_name)`
|
||||||
- transition keys are now `(external_ptype, internal_ptype)` tuples
|
- transitions are registered with `add_transition(abstract, external_port, internal_port)`
|
||||||
- transitions use `AutoTool.Transition(...)` instead of raw tuples
|
- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter
|
||||||
|
|
||||||
If your old `BasicTool` usage did not rely on transitions or multiple routing
|
|
||||||
options, `SimpleTool` is the closest replacement.
|
|
||||||
|
|
||||||
## Custom `Tool` subclasses
|
## Custom `Tool` subclasses
|
||||||
|
|
||||||
If you maintain your own `Tool` subclass, the interface changed:
|
If you maintain your own `Tool` subclass, the interface changed:
|
||||||
|
|
||||||
- `Tool.path(...)` became `Tool.traceL(...)`
|
- `primitive_offers()` is now the planning boundary
|
||||||
- `Tool.traceS(...)` and `Tool.traceU(...)` were added for native S/U routes
|
- `render()` consumes committed primitive render tokens
|
||||||
- `planL()` / `planS()` / `planU()` remain the planning hooks used by deferred rendering
|
- `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:
|
In practice, a minimal old implementation like:
|
||||||
|
|
||||||
|
|
@ -218,14 +192,168 @@ class MyTool(Tool):
|
||||||
should now become:
|
should now become:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from masque import Port
|
||||||
|
from masque.builder import RenderStep, StraightOffer, Tool
|
||||||
|
|
||||||
|
|
||||||
class MyTool(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
|
If a tool does not provide a primitive kind, return `()` for that kind. `Pather`
|
||||||
either fall back to the planning hooks or synthesize those routes from simpler
|
will compose available primitive offers where the route family allows it.
|
||||||
steps where possible.
|
|
||||||
|
### 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
|
## 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`:
|
These are additive, but available now from `masque` and `masque.builder`:
|
||||||
|
|
||||||
- `PortPather`
|
- `PortPather`
|
||||||
- `SimpleTool`
|
|
||||||
- `AutoTool`
|
- `AutoTool`
|
||||||
- `boolean`
|
- `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
|
1. Replace `path`/`path_to`/`mpath`/`path_into` calls with
|
||||||
`trace`/`trace_to`/multi-port `trace`/`trace_into`.
|
`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
|
3. Fix imports that still reference `masque.builder.builder` or
|
||||||
`masque.builder.renderpather`.
|
`masque.builder.renderpather`.
|
||||||
4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`.
|
4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`.
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ Contents
|
||||||
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
||||||
- [pather](pather.py)
|
- [pather](pather.py)
|
||||||
* Use `Pather` to route individual wires and wire bundles
|
* Use `Pather` to route individual wires and wire bundles
|
||||||
* Use `AutoTool` to generate paths
|
* Define a custom `Tool` that exposes primitive routing offers
|
||||||
* Use `AutoTool` to automatically transition between path types
|
* Use primitive offers to automatically transition between path types
|
||||||
- [renderpather](renderpather.py)
|
- [renderpather](renderpather.py)
|
||||||
* Use `Pather(auto_render=False)` and `PathTool` to build a layout similar to the one in [pather](pather.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.
|
but using `Path` shapes instead of `Polygon`s.
|
||||||
|
|
|
||||||
|
|
@ -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 numpy import pi
|
||||||
from masque import Pather, Library, Pattern, Port, layer_t
|
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.file.gdsii import writefile
|
||||||
|
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||||
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
||||||
|
|
@ -111,6 +119,259 @@ def map_layer(layer: layer_t) -> layer_t:
|
||||||
return layer
|
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]:
|
def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||||
"""
|
"""
|
||||||
Create some basic library elements and tools for drawing M1 and M2
|
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.
|
# Now, define two tools.
|
||||||
# M1_tool will route on M1, using wires with M1_WIDTH
|
# M1_tool will route on M1, using wires with M1_WIDTH.
|
||||||
# M2_tool will route on M2, using wires with M2_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)
|
|
||||||
#
|
#
|
||||||
# Note that while we use AutoTool for this tutorial, you can define your own `Tool`
|
# Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers
|
||||||
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
|
# directly: it tells `Pather` about native straight/bend primitives and about
|
||||||
# transmission line geometry, or other features.
|
# via adapters that can transition between M1 and M2 port types.
|
||||||
#
|
#
|
||||||
M1_tool = AutoTool(
|
via = library.abstract('v1_via')
|
||||||
# First, we need a function which takes in a length and spits out an M1 wire
|
via_transitions = (
|
||||||
straights = [
|
WireTransitionSpec(via, 'top', 'bottom'),
|
||||||
AutoTool.Straight(
|
WireTransitionSpec(via, 'bottom', 'top'),
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
M2_tool = AutoTool(
|
M1_tool = PrimitiveWireTool(
|
||||||
straights = [
|
layer = 'M1',
|
||||||
# Again, we use make_straight_wire, but this time we set parameters for M2
|
width = M1_WIDTH,
|
||||||
AutoTool.Straight(
|
ptype = 'm1wire',
|
||||||
|
bend = library.abstract('m1_bend'),
|
||||||
|
transitions = via_transitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
M2_tool = PrimitiveWireTool(
|
||||||
|
layer = 'M2',
|
||||||
|
width = M2_WIDTH,
|
||||||
ptype = 'm2wire',
|
ptype = 'm2wire',
|
||||||
fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
bend = library.abstract('m2_bend'),
|
||||||
in_port_name = 'input',
|
transitions = via_transitions,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
return library, M1_tool, M2_tool
|
return library, M1_tool, M2_tool
|
||||||
|
|
||||||
|
|
@ -292,7 +516,7 @@ def main() -> None:
|
||||||
pather.straight('GND', x=-50_000)
|
pather.straight('GND', x=-50_000)
|
||||||
|
|
||||||
# Save the pather's pattern into our library
|
# 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
|
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||||
library.map_layers(map_layer)
|
library.map_layers(map_layer)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Manual wire routing tutorial: deferred Pather and PathTool
|
Manual wire routing tutorial: deferred Pather and PathTool
|
||||||
"""
|
"""
|
||||||
from masque import Pather, Library
|
from masque import Pather, Library
|
||||||
from masque.builder.tools import PathTool
|
from masque.builder import PathTool
|
||||||
from masque.file.gdsii import writefile
|
from masque.file.gdsii import writefile
|
||||||
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ from .builder import (
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
Pather as Pather,
|
Pather as Pather,
|
||||||
RenderStep as RenderStep,
|
RenderStep as RenderStep,
|
||||||
SimpleTool as SimpleTool,
|
|
||||||
AutoTool as AutoTool,
|
AutoTool as AutoTool,
|
||||||
PathTool as PathTool,
|
PathTool as PathTool,
|
||||||
PortPather as PortPather,
|
PortPather as PortPather,
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
from .pather import (
|
||||||
Pather as Pather,
|
Pather as Pather,
|
||||||
PortPather as PortPather,
|
PortPather as PortPather,
|
||||||
|
|
@ -5,9 +44,13 @@ from .pather import (
|
||||||
from .utils import ell as ell
|
from .utils import ell as ell
|
||||||
from .tools import (
|
from .tools import (
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
RenderStep as RenderStep,
|
|
||||||
SimpleTool as SimpleTool,
|
|
||||||
AutoTool as AutoTool,
|
AutoTool as AutoTool,
|
||||||
PathTool as PathTool,
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
"""
|
"""Logging helpers for Pather."""
|
||||||
Logging and operation decorators for Pather
|
|
||||||
"""
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from collections.abc import Iterator, Sequence, Callable
|
from collections.abc import Iterator, Sequence
|
||||||
import logging
|
import logging
|
||||||
from functools import wraps
|
|
||||||
import inspect
|
|
||||||
import numpy
|
import numpy
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .pather import Pather
|
from .pather import Pather
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_log_args(**kwargs) -> str:
|
def _format_log_args(**kwargs) -> str:
|
||||||
arg_strs = []
|
arg_strs = []
|
||||||
|
|
@ -84,37 +78,3 @@ class PatherLogger:
|
||||||
|
|
||||||
self.indent -= 1
|
self.indent -= 1
|
||||||
self.depth -= 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
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
15
masque/builder/planner/__init__.py
Normal file
15
masque/builder/planner/__init__.py
Normal file
|
|
@ -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
|
||||||
257
masque/builder/planner/bounds.py
Normal file
257
masque/builder/planner/bounds.py
Normal file
|
|
@ -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)
|
||||||
83
masque/builder/planner/interface.py
Normal file
83
masque/builder/planner/interface.py
Normal file
|
|
@ -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."""
|
||||||
1226
masque/builder/planner/planner.py
Normal file
1226
masque/builder/planner/planner.py
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1398,8 +1398,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
|
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
|
||||||
if annotation_conflicts:
|
if annotation_conflicts:
|
||||||
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||||
else:
|
elif isinstance(other, Pattern):
|
||||||
if isinstance(other, Pattern):
|
|
||||||
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
||||||
'Use `append=True` if you intended to append the full geometry.')
|
'Use `append=True` if you intended to append the full geometry.')
|
||||||
|
|
||||||
|
|
@ -1527,11 +1526,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
Note that this does not flatten `other`, so its refs will still
|
Note that this does not flatten `other`, so its refs will still
|
||||||
be refs (now inside `self`).
|
be refs (now inside `self`).
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
ok_connections: Set of additional allowed ptype combinations.
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
Ptypes accepted by the shared compatibility policy are always
|
||||||
any other ptypte. Non-allowed ptype connections will emit a
|
allowed. Non-allowed ptype connections will emit a warning.
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||||
`(b, a)`.
|
|
||||||
skip_geometry: If `True`, only ports are updated and geometry is
|
skip_geometry: If `True`, only ports are updated and geometry is
|
||||||
skipped. If a valid transform cannot be found (e.g. due to
|
skipped. If a valid transform cannot be found (e.g. due to
|
||||||
misaligned ports), a 'best-effort' dummy transform is used
|
misaligned ports), a 'best-effort' dummy transform is used
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from numpy import pi
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
|
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
|
from .error import PortError, format_stacktrace
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -542,7 +542,7 @@ class PortList(metaclass=ABCMeta):
|
||||||
|
|
||||||
a_types = [pp.ptype for pp in a_ports]
|
a_types = [pp.ptype for pp in a_ports]
|
||||||
b_types = [pp.ptype for pp in b_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)])
|
for at, bt in zip(a_types, b_types, strict=True)])
|
||||||
|
|
||||||
if type_conflicts.any():
|
if type_conflicts.any():
|
||||||
|
|
@ -641,11 +641,10 @@ class PortList(metaclass=ABCMeta):
|
||||||
port with `rotation=None`), `set_rotation` must be provided
|
port with `rotation=None`), `set_rotation` must be provided
|
||||||
to indicate how much `other` should be rotated. Otherwise,
|
to indicate how much `other` should be rotated. Otherwise,
|
||||||
`set_rotation` must remain `None`.
|
`set_rotation` must remain `None`.
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
ok_connections: Set of additional allowed ptype combinations.
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
Ptypes accepted by the shared compatibility policy are always
|
||||||
any other ptypte. Non-allowed ptype connections will log a
|
allowed. Non-allowed ptype connections will log a warning.
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||||
`(b, a)`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- The (x, y) translation (performed last)
|
- The (x, y) translation (performed last)
|
||||||
|
|
@ -694,11 +693,10 @@ class PortList(metaclass=ABCMeta):
|
||||||
port with `rotation=None`), `set_rotation` must be provided
|
port with `rotation=None`), `set_rotation` must be provided
|
||||||
to indicate how much `o_ports` should be rotated. Otherwise,
|
to indicate how much `o_ports` should be rotated. Otherwise,
|
||||||
`set_rotation` must remain `None`.
|
`set_rotation` must remain `None`.
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
ok_connections: Set of additional allowed ptype combinations.
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
Ptypes accepted by the shared compatibility policy are always
|
||||||
any other ptypte. Non-allowed ptype connections will log a
|
allowed. Non-allowed ptype connections will log a warning.
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||||
`(b, a)`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- The (x, y) translation (performed last)
|
- The (x, y) translation (performed last)
|
||||||
|
|
@ -725,8 +723,10 @@ class PortList(metaclass=ABCMeta):
|
||||||
o_rotations *= -1
|
o_rotations *= -1
|
||||||
|
|
||||||
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[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)
|
type_conflicts = numpy.array([
|
||||||
for st, ot in zip(s_types, o_types, strict=True)])
|
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():
|
if type_conflicts.any():
|
||||||
msg = 'Ports have conflicting types:\n'
|
msg = 'Ports have conflicting types:\n'
|
||||||
for nn, (kk, vv) in enumerate(map_in.items()):
|
for nn, (kk, vv) in enumerate(map_in.items()):
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from collections.abc import Callable
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
from numpy.testing import assert_allclose
|
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]:
|
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 that an object's single-shape bounds match `expected`.
|
||||||
"""
|
"""
|
||||||
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
|
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__}')
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,16 @@ from ..pattern import Pattern
|
||||||
from ..ports import Port
|
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:
|
def test_builder_init() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
b = Pather(lib, name="mypat")
|
b = Pather(lib, name="mypat")
|
||||||
|
|
|
||||||
|
|
@ -7,30 +7,30 @@ from masque import Pather, Library, Pattern, Port
|
||||||
from masque.builder.tools import AutoTool
|
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 = Pattern()
|
||||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||||
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
|
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
|
||||||
return pat
|
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()
|
pat = Pattern()
|
||||||
# Rectangular approximation of a 90 degree bend.
|
# Rectangular approximation of a 90 degree bend.
|
||||||
if clockwise:
|
if clockwise:
|
||||||
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
|
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||||
pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0)
|
pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0)
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
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:
|
else:
|
||||||
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
|
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||||
pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R)
|
pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius)
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
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
|
return pat
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def multi_bend_tool():
|
def multi_bend_tool() -> tuple[AutoTool, Library]:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
||||||
lib["b1"] = make_bend(2, ptype="wire")
|
lib["b1"] = make_bend(2, ptype="wire")
|
||||||
|
|
@ -38,18 +38,12 @@ def multi_bend_tool():
|
||||||
lib["b2"] = make_bend(5, ptype="wire")
|
lib["b2"] = make_bend(5, ptype="wire")
|
||||||
b2_abs = lib.abstract("b2")
|
b2_abs = lib.abstract("b2")
|
||||||
|
|
||||||
tool = AutoTool(
|
tool = (
|
||||||
straights=[
|
AutoTool()
|
||||||
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
|
.add_straight("wire", make_straight, "A", 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))
|
.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)
|
||||||
bends=[
|
.add_bend(b2_abs, "A", "B", clockwise=True, mirror=True)
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
return tool, lib
|
return tool, lib
|
||||||
|
|
||||||
|
|
@ -70,12 +64,10 @@ def test_autotool_uturn() -> None:
|
||||||
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
||||||
lib['bend'] = bend_pat
|
lib['bend'] = bend_pat
|
||||||
|
|
||||||
tool = AutoTool(
|
tool = (
|
||||||
straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')],
|
AutoTool()
|
||||||
bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)],
|
.add_straight('wire', make_straight, 'in')
|
||||||
sbends=[],
|
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
|
||||||
transitions={},
|
|
||||||
default_out_ptype='wire'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
|
@ -88,7 +80,7 @@ def test_autotool_uturn() -> None:
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
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
|
tool, lib = multi_bend_tool
|
||||||
rp = Pather(lib, tools=tool, auto_render=False)
|
rp = Pather(lib, tools=tool, auto_render=False)
|
||||||
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
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()
|
rp.render()
|
||||||
assert len(rp.pattern.refs) > 0
|
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
|
tool, lib = multi_bend_tool
|
||||||
|
|
||||||
class BasicTool(AutoTool):
|
p = Pather(lib, tools=tool)
|
||||||
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.ports["A"] = Port((0,0), 0, ptype="wire")
|
p.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||||
|
|
||||||
p.uturn("A", 10, length=5)
|
p.uturn("A", 10, length=5)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,131 @@
|
||||||
from typing import Any
|
from collections.abc import Sequence
|
||||||
|
from typing import Any, Literal, Never
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from masque import Pather, Library, Pattern, Port
|
from masque import Pather, Library, Port
|
||||||
from masque.builder.tools import PathTool, Tool
|
from masque.builder.planner import RoutePortContext, RoutingPlanner
|
||||||
from masque.error import BuildError, PortError, PatternError
|
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()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||||
p = Pather(lib, tools=tool)
|
p = Pather(lib, tools=tool)
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
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)
|
p.jog('A', 1.5, length=1.5)
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
assert p.pattern.ports['A'].rotation == 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:
|
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
|
||||||
lib = Library()
|
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 numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
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:
|
def test_pather_jog_length_solved_from_single_position_bound() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
@ -50,18 +167,59 @@ def test_pather_jog_length_solved_from_single_position_bound() -> None:
|
||||||
q.jog('A', 2, p=-6)
|
q.jog('A', 2, p=-6)
|
||||||
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
|
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()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
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')
|
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'):
|
with pytest.raises(BuildError, match='exactly one positional bound'):
|
||||||
p.jog('A', 2, x=-6, p=-6)
|
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:
|
def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
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'):
|
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||||
p.trace('A', None, length=5, xmin=-100)
|
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:
|
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 = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, 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'):
|
with pytest.raises(BuildError, match='exactly one bundle bound'):
|
||||||
p.trace(['A', 'B'], None, **kwargs)
|
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:
|
def test_pather_jog_rejects_length_with_position_bound() -> None:
|
||||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, 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'):
|
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||||
p.jog('A', 2, length=5, x=-999)
|
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:
|
def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
|
||||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, 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'):
|
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
|
||||||
p.uturn('A', 4, **kwargs)
|
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()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
p = Pather(lib, tools=tool)
|
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 p.pattern.ports['A'].rotation is not None
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||||
|
|
||||||
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None:
|
def test_pather_uturn_explicit_zero_length_preserves_old_shape() -> None:
|
||||||
class OutPtypeSensitiveTool(Tool):
|
lib = Library()
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
|
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
|
radius = 1 if out_ptype is None else 2
|
||||||
if ccw is None:
|
if ccw is None:
|
||||||
rotation = pi
|
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'
|
ptype = out_ptype or in_ptype or 'wire'
|
||||||
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
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')
|
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')
|
p.jog('A', 5, length=10, out_ptype='wide')
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 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:
|
def test_pather_two_l_planl_only_uturn_is_not_supported() -> None:
|
||||||
class OutPtypeSensitiveTool(Tool):
|
class PlanLOnlyTool(PlanningOnlyTool):
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
|
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
|
radius = 1 if out_ptype is None else 2
|
||||||
if ccw is None:
|
if ccw is None:
|
||||||
rotation = pi
|
rotation = pi
|
||||||
|
|
@ -164,50 +580,22 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() ->
|
||||||
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
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')
|
p.uturn('A', 5, length=10, out_ptype='wide')
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 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:
|
def test_pather_uturn_failed_two_bend_route_is_atomic() -> 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:
|
|
||||||
lib = Library()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||||
p = Pather(lib, tools=tool)
|
p = Pather(lib, tools=tool)
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
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)
|
p.uturn('A', 1.5, length=0)
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
assert p.pattern.ports['A'].rotation == 0
|
||||||
assert len(p.paths['A']) == 0
|
assert len(p._paths['A']) == 0
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.testing import assert_allclose, assert_equal
|
from numpy.testing import assert_allclose, assert_equal
|
||||||
|
|
||||||
from masque import Pather, Library, Pattern, Port
|
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
|
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")
|
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||||
return p, tool, lib
|
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:
|
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
p, tool, lib = pather_setup
|
p, tool, lib = pather_setup
|
||||||
p.straight("start", 10)
|
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 numpy.allclose(p.pattern.ports['A'].offset, (1000, 0))
|
||||||
assert p.pattern.ports['A'].ptype == 'other'
|
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:
|
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from masque import Pather, Library, Pattern, Port
|
from masque import Pather, Library, Pattern, Port
|
||||||
from masque.builder.tools import PathTool, Tool
|
from masque.builder.tools import PathTool
|
||||||
from masque.error import BuildError, PortError, PatternError
|
from masque.error import PortError, PatternError
|
||||||
|
|
||||||
|
|
||||||
def test_pather_place_treeview_resolves_once() -> None:
|
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.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
p.at('A').trace(None, 5000)
|
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(
|
other = Pattern(
|
||||||
annotations={'k': [2]},
|
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'):
|
with pytest.raises(PatternError, match='Annotation keys overlap'):
|
||||||
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
|
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'}
|
assert set(p.pattern.ports) == {'A'}
|
||||||
|
|
||||||
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
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.place(other, port_map={'X': 'A'}, append=True)
|
||||||
p.at('A').straight(2000)
|
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()
|
p.render()
|
||||||
assert p.pattern.has_shapes()
|
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.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
|
||||||
p.at('A').straight(2000)
|
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()
|
p.render()
|
||||||
assert p.pattern.has_shapes()
|
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.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
p.at('A').straight(5000)
|
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'):
|
with pytest.raises(PortError, match='Connection destination ports were not found'):
|
||||||
p.plugged({'A': 'missing'})
|
p.plugged({'A': 'missing'})
|
||||||
|
|
||||||
assert [step.opcode for step in p.paths['A']] == ['L']
|
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||||
assert set(p.paths) == {'A'}
|
assert set(p._paths) == {'A'}
|
||||||
|
|
|
||||||
514
masque/test/test_pather_primitive_offers.py
Normal file
514
masque/test/test_pather_primitive_offers.py
Normal file
|
|
@ -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']
|
||||||
|
|
@ -6,7 +6,7 @@ from numpy import pi
|
||||||
from numpy.testing import assert_allclose
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
from ..builder import Pather
|
from ..builder import Pather
|
||||||
from ..builder.tools import PathTool, Tool
|
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
|
||||||
from ..error import BuildError
|
from ..error import BuildError
|
||||||
from ..library import Library
|
from ..library import Library
|
||||||
from ..pattern import Pattern
|
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)
|
rp.at("start").straight(10).straight(10)
|
||||||
|
|
||||||
assert not rp.pattern.has_shapes()
|
assert not rp.pattern.has_shapes()
|
||||||
assert len(rp.paths["start"]) == 2
|
assert len(rp._paths["start"]) == 2
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
assert rp.pattern.has_shapes()
|
assert rp.pattern.has_shapes()
|
||||||
|
|
@ -46,21 +46,19 @@ def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Lib
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
# Clockwise bend adds the bend endpoint after the straight segment vertex.
|
# The bend route is explicit straight run plus a fixed-size bend.
|
||||||
assert len(path_shape.vertices) == 4
|
assert len(path_shape.vertices) == 5
|
||||||
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_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, tool, lib = deferred_render_setup
|
||||||
rp.at("start").jog(4, length=10)
|
rp.at("start").jog(4, length=10)
|
||||||
|
|
||||||
assert len(rp.paths["start"]) == 1
|
assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"]
|
||||||
assert rp.paths["start"][0].opcode == "S"
|
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
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, -1], [1, -1], [3, -1], [4, -1], [4, -2], [4, -10]], atol=1e-10)
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10)
|
|
||||||
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
|
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
|
||||||
|
|
||||||
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
|
@ -71,7 +69,7 @@ def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_
|
||||||
rp.render()
|
rp.render()
|
||||||
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, 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:
|
def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
rp, tool1, lib = deferred_render_setup
|
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_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
|
||||||
|
|
||||||
assert len(rp.paths["in"]) == 0
|
assert len(rp._paths["in"]) == 0
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
assert not rp.pattern.has_shapes()
|
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.rename_ports({"start": "new_start"})
|
||||||
rp.at("new_start").straight(10)
|
rp.at("new_start").straight(10)
|
||||||
|
|
||||||
assert "start" not in rp.paths
|
assert "start" not in rp._paths
|
||||||
assert len(rp.paths["new_start"]) == 2
|
assert len(rp._paths["new_start"]) == 2
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
assert rp.pattern.has_shapes()
|
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()
|
rp.at("start").straight(10).drop()
|
||||||
|
|
||||||
assert "start" not in rp.ports
|
assert "start" not in rp.ports
|
||||||
assert len(rp.paths["start"]) == 1
|
assert len(rp._paths["start"]) == 1
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
assert rp.pattern.has_shapes()
|
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])
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
|
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")
|
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()
|
pat = tree.top_pattern()
|
||||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
||||||
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10)
|
assert offer.length_domain == (1, 1)
|
||||||
assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10)
|
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")
|
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()
|
pat = tree.top_pattern()
|
||||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
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(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
|
||||||
assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10)
|
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
|
||||||
assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10)
|
assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10)
|
||||||
|
|
||||||
def test_deferred_render_uturn_fallback() -> None:
|
def test_deferred_render_uturn_fallback() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
@ -174,9 +179,8 @@ def test_deferred_render_uturn_fallback() -> None:
|
||||||
|
|
||||||
rp.at('A').uturn(offset=10000, length=5000)
|
rp.at('A').uturn(offset=10000, length=5000)
|
||||||
|
|
||||||
assert len(rp.paths['A']) == 2
|
assert len(rp._paths['A']) == 4
|
||||||
assert rp.paths['A'][0].opcode == 'L'
|
assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L']
|
||||||
assert rp.paths['A'][1].opcode == 'L'
|
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
assert rp.pattern.ports['A'].rotation is not None
|
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:
|
def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
||||||
class FullTreeTool(Tool):
|
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'
|
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()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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)]})
|
child = Pattern(annotations={'batch': [len(batch)]})
|
||||||
top.ref('_seg')
|
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 all(name.startswith('_seg') for name in lib)
|
||||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
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):
|
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()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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]})
|
child = Pattern(annotations={'length': [length]})
|
||||||
top.ref('_seg')
|
top.ref('_seg')
|
||||||
|
|
@ -229,6 +252,11 @@ def test_tool_render_fallback_preserves_segment_subtrees() -> None:
|
||||||
tree['_seg'] = child
|
tree['_seg'] = child
|
||||||
return tree
|
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()
|
lib = Library()
|
||||||
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
|
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
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:
|
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
|
||||||
class MissingSingleUseTool(Tool):
|
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'
|
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()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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:
|
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
||||||
class SharedRefTool(Tool):
|
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'
|
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()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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')
|
top.ref('shared')
|
||||||
tree['_top'] = top
|
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 'shared' in p.pattern.refs
|
||||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
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:
|
def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1000)
|
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})
|
rp.rename_ports({'A': None})
|
||||||
|
|
||||||
assert 'A' not in rp.pattern.ports
|
assert 'A' not in rp.pattern.ports
|
||||||
assert len(rp.paths['A']) == 1
|
assert len(rp._paths['A']) == 1
|
||||||
|
|
||||||
rp.render()
|
rp.render()
|
||||||
assert rp.pattern.has_shapes()
|
assert rp.pattern.has_shapes()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
|
||||||
import numpy
|
import numpy
|
||||||
|
import pytest
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.testing import assert_equal
|
from numpy.testing import assert_equal
|
||||||
|
|
||||||
from masque import Pather, Library, Pattern, Port
|
from masque import Library, PathTool, Port, Pather
|
||||||
from masque.builder.tools import PathTool, Tool
|
from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
|
||||||
from masque.error import BuildError, PortError, PatternError
|
from masque.error import BuildError, PortError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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)
|
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
|
||||||
return p, tool, lib
|
return p, tool, lib
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
p, _tool, _lib = trace_into_setup
|
p, _tool, _lib = trace_into_setup
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
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 "dst" not in p.ports
|
||||||
assert len(p.pattern.refs) == 1
|
assert len(p.pattern.refs) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
p, _tool, _lib = trace_into_setup
|
p, _tool, _lib = trace_into_setup
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
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 "src" not in p.ports
|
||||||
assert "dst" 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
|
assert len(p.pattern.refs) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
p, _tool, _lib = trace_into_setup
|
p, _tool, _lib = trace_into_setup
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
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 "src" not in p.ports
|
||||||
assert "dst" not in p.ports
|
assert "dst" not in p.ports
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
p, _tool, _lib = trace_into_setup
|
p, _tool, _lib = trace_into_setup
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
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_equal(p.ports["src"].offset, [10, 10])
|
||||||
assert "other" not in p.ports
|
assert "other" not in p.ports
|
||||||
|
|
||||||
def test_pather_trace_into() -> None:
|
|
||||||
|
def test_pather_trace_into_shapes() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1000)
|
tool = PathTool(layer='M1', width=1000)
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
|
@ -107,6 +110,79 @@ def test_pather_trace_into() -> None:
|
||||||
assert p.pattern.ports['I'].rotation is not None
|
assert p.pattern.ports['I'].rotation is not None
|
||||||
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
|
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:
|
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
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 numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
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_shapes()
|
||||||
assert not p.pattern.has_refs()
|
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()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
p = Pather(lib, tools=tool)
|
p = Pather(lib, tools=tool)
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, 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')
|
p.trace_into('A', 'B', plug_destination=False, out_ptype='other')
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
|
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
|
||||||
assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2)
|
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()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
p = Pather(lib, tools=tool)
|
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'):
|
with pytest.raises(PortError, match='overwritten'):
|
||||||
p.trace_into('A', 'B', plug_destination=False, thru='other')
|
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(
|
@pytest.mark.parametrize(
|
||||||
('dst', 'kwargs', 'match'),
|
('dst', 'kwargs', 'match'),
|
||||||
(
|
[
|
||||||
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'),
|
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'),
|
||||||
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'),
|
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'),
|
||||||
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'),
|
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'),
|
||||||
),
|
],
|
||||||
)
|
)
|
||||||
def test_pather_trace_into_rejects_reserved_route_kwargs(
|
def test_pather_trace_into_rejects_reserved_route_kwargs(
|
||||||
dst: Port,
|
dst: Port,
|
||||||
|
|
@ -186,4 +259,4 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
|
||||||
assert dst.rotation is not None
|
assert dst.rotation is not None
|
||||||
assert p.pattern.ports['B'].rotation is not None
|
assert p.pattern.ports['B'].rotation is not None
|
||||||
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
|
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
|
||||||
assert len(p.paths['A']) == 0
|
assert len(p._paths['A']) == 0
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,36 @@ def test_port_list_plugged() -> None:
|
||||||
assert not pl.ports # Both should be removed
|
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:
|
def test_port_list_plugged_empty_raises() -> None:
|
||||||
class MyPorts(PortList):
|
class MyPorts(PortList):
|
||||||
def __init__(self) -> None:
|
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"):
|
with pytest.raises(PortError, match="at least one port connection"):
|
||||||
Pattern.find_port_transform({}, {}, {})
|
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)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ from .array import is_scalar as is_scalar
|
||||||
from .autoslots import AutoSlots as AutoSlots
|
from .autoslots import AutoSlots as AutoSlots
|
||||||
from .deferreddict import DeferredDict as DeferredDict
|
from .deferreddict import DeferredDict as DeferredDict
|
||||||
from .decorators import oneshot as oneshot
|
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 (
|
from .bitwise import (
|
||||||
get_bit as get_bit,
|
get_bit as get_bit,
|
||||||
|
|
|
||||||
22
masque/utils/ptypes.py
Normal file
22
masque/utils/ptypes.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue