[Pather] rework pather internals -- split route planning vs strategy selection

This commit is contained in:
Jan Petykiewicz 2026-04-02 11:34:49 -07:00
commit 2c5243237e
4 changed files with 570 additions and 97 deletions

View file

@ -2,7 +2,7 @@
Unified Pattern assembly and routing (`Pather`)
"""
from typing import Self, Literal, Any, overload
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence, Callable
import copy
import logging
from collections import defaultdict
@ -93,6 +93,9 @@ class Pather(PortList):
PROBE_LENGTH: float = 1e6
""" Large length used when probing tools for their lateral displacement """
_POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position')
""" Single-port position bounds accepted by `trace_to()` and `jog()` """
@property
def ports(self) -> dict[str, Port]:
return self.pattern.ports
@ -354,6 +357,295 @@ class Pather(PortList):
if self._auto_render:
self.render(append=self._auto_render_append)
def _transform_relative_port(self, start_port: Port, out_port: Port) -> Port:
""" Transform a tool-planned output port into layout coordinates without mutating state. """
port_rot = start_port.rotation
assert port_rot is not None
transformed = out_port.copy()
transformed.rotate_around((0, 0), pi + port_rot)
transformed.translate(start_port.offset)
return transformed
def _resolved_position_bound(
self,
portspec: str,
bounds: Mapping[str, Any],
*,
allow_length: bool,
) -> tuple[str, Any, float] | None:
"""
Resolve a single positional bound for a single port into a travel length.
"""
present = [(key, bounds[key]) for key in self._POSITION_KEYS if bounds.get(key) is not None]
if not present:
return None
if len(present) > 1:
keys = ', '.join(key for key, _value in present)
raise BuildError(f'Provide exactly one positional bound; got {keys}')
if not allow_length and bounds.get('length') is not None:
raise BuildError('length cannot be combined with a positional bound')
key, value = present[0]
port = self.pattern[portspec]
assert port.rotation is not None
is_horiz = numpy.isclose(port.rotation % pi, 0)
if is_horiz:
if key == 'y':
raise BuildError('Port is horizontal')
target = Port((value, port.offset[1]), rotation=None)
else:
if key == 'x':
raise BuildError('Port is vertical')
target = Port((port.offset[0], value), rotation=None)
(travel, _jog), _ = port.measure_travel(target)
return key, value, -float(travel)
def _validate_fallback_endpoint(
self,
portspec: str,
actual_end: Port,
*,
length: float,
jog: float,
out_rotation: float,
requested_out_ptype: str | None,
route_name: str,
) -> None:
"""
Ensure a synthesized fallback route still satisfies the public routing contract.
"""
start_port = self.pattern[portspec]
expected_local = Port((length, jog), rotation=out_rotation, ptype=actual_end.ptype)
expected_end = self._transform_relative_port(start_port, expected_local)
offsets_match = bool(numpy.allclose(actual_end.offset, expected_end.offset))
rotations_match = (
actual_end.rotation is not None
and expected_end.rotation is not None
and bool(numpy.isclose(actual_end.rotation, expected_end.rotation))
)
ptype_matches = requested_out_ptype is None or actual_end.ptype == requested_out_ptype
if offsets_match and rotations_match and ptype_matches:
return
raise BuildError(
f'{route_name} fallback via two planL() steps is unsupported for this tool/kwargs combination. '
f'Expected offset={tuple(expected_end.offset)}, rotation={expected_end.rotation}, '
f'ptype={requested_out_ptype or actual_end.ptype}; got offset={tuple(actual_end.offset)}, '
f'rotation={actual_end.rotation}, ptype={actual_end.ptype}'
)
def _apply_validated_double_l(
self,
portspec: str,
tool: Tool,
first: tuple[Port, Any],
second: tuple[Port, Any],
*,
length: float,
jog: float,
out_rotation: float,
requested_out_ptype: str | None,
route_name: str,
plug_into: str | None,
) -> None:
out_port0, data0 = first
out_port1, data1 = second
staged_port0 = self._transform_relative_port(self.pattern[portspec], out_port0)
staged_port1 = self._transform_relative_port(staged_port0, out_port1)
self._validate_fallback_endpoint(
portspec,
staged_port1,
length = length,
jog = jog,
out_rotation = out_rotation,
requested_out_ptype = requested_out_ptype,
route_name = route_name,
)
self._apply_step('L', portspec, out_port0, data0, tool)
self._apply_step('L', portspec, out_port1, data1, tool, plug_into)
def _plan_s_fallback(
self,
tool: Tool,
portspec: str,
in_ptype: str,
length: float,
jog: float,
**kwargs: Any,
) -> tuple[tuple[Port, Any], tuple[Port, Any]]:
ccw0 = jog > 0
R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs)
R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs)
L1, L2 = length - R2, abs(jog) - R1
if L1 < 0 or L2 < 0:
raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback")
first = tool.planL(ccw0, L1, in_ptype = in_ptype, **(kwargs | {'out_ptype': None}))
second = tool.planL(not ccw0, L2, in_ptype = first[0].ptype, **kwargs)
return first, second
def _plan_u_fallback(
self,
tool: Tool,
in_ptype: str,
length: float,
jog: float,
**kwargs: Any,
) -> tuple[tuple[Port, Any], tuple[Port, Any]]:
ccw = jog > 0
R = self._get_tool_R(tool, ccw, in_ptype, **kwargs)
L1, L2 = length + R, abs(jog) - R
first = tool.planL(ccw, L1, in_ptype = in_ptype, **(kwargs | {'out_ptype': None}))
second = tool.planL(ccw, L2, in_ptype = first[0].ptype, **kwargs)
return first, second
def _run_route_transaction(self, callback: Callable[[], None]) -> None:
""" Run a routing mutation atomically, rendering once at the end if auto-render is enabled. """
saved_ports = copy.deepcopy(self.pattern.ports)
saved_paths = defaultdict(list, copy.deepcopy(dict(self.paths)))
saved_auto_render = self._auto_render
self._auto_render = False
try:
callback()
except Exception:
self.pattern.ports = saved_ports
self.paths = saved_paths
raise
finally:
self._auto_render = saved_auto_render
if saved_auto_render and any(self.paths.values()):
self.render(append = self._auto_render_append)
def _execute_route_op(self, op_name: str, kwargs: dict[str, Any]) -> None:
if op_name == 'trace_to':
self.trace_to(**kwargs)
elif op_name == 'jog':
self.jog(**kwargs)
elif op_name == 'uturn':
self.uturn(**kwargs)
elif op_name == 'rename_ports':
self.rename_ports(**kwargs)
else:
raise BuildError(f'Unrecognized routing op {op_name}')
def _execute_route_ops(self, ops: Sequence[tuple[str, dict[str, Any]]]) -> None:
for op_name, op_kwargs in ops:
self._execute_route_op(op_name, op_kwargs)
def _merge_trace_into_op_kwargs(
self,
op_name: str,
user_kwargs: Mapping[str, Any],
**reserved: Any,
) -> dict[str, Any]:
""" Merge tool kwargs with internally computed op kwargs, rejecting collisions. """
collisions = sorted(set(user_kwargs) & set(reserved))
if collisions:
args = ', '.join(collisions)
raise BuildError(f'trace_into() kwargs cannot override {op_name}() arguments: {args}')
return {**user_kwargs, **reserved}
def _plan_trace_into(
self,
portspec_src: str,
portspec_dst: str,
*,
out_ptype: str | None,
plug_destination: bool,
thru: str | None,
**kwargs: Any,
) -> list[tuple[str, dict[str, Any]]]:
port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None or port_dst.rotation is None:
raise PortError('Ports must have rotation')
src_horiz = numpy.isclose(port_src.rotation % pi, 0)
dst_horiz = numpy.isclose(port_dst.rotation % pi, 0)
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
dst_args = {'out_ptype': out_ptype}
if plug_destination:
dst_args['plug_into'] = portspec_dst
ops: list[tuple[str, dict[str, Any]]] = []
if src_horiz and not dst_horiz:
ops.append(('trace_to', self._merge_trace_into_op_kwargs(
'trace_to',
kwargs,
portspec = portspec_src,
ccw = angle > pi,
x = xd,
)))
ops.append(('trace_to', self._merge_trace_into_op_kwargs(
'trace_to',
kwargs,
portspec = portspec_src,
ccw = None,
y = yd,
**dst_args,
)))
elif dst_horiz and not src_horiz:
ops.append(('trace_to', self._merge_trace_into_op_kwargs(
'trace_to',
kwargs,
portspec = portspec_src,
ccw = angle > pi,
y = yd,
)))
ops.append(('trace_to', self._merge_trace_into_op_kwargs(
'trace_to',
kwargs,
portspec = portspec_src,
ccw = None,
x = xd,
**dst_args,
)))
elif numpy.isclose(angle, pi):
(travel, jog), _ = port_src.measure_travel(port_dst)
if numpy.isclose(jog, 0):
ops.append((
'trace_to',
self._merge_trace_into_op_kwargs(
'trace_to',
kwargs,
portspec = portspec_src,
ccw = None,
x = xd if src_horiz else None,
y = yd if not src_horiz else None,
**dst_args,
),
))
else:
ops.append(('jog', self._merge_trace_into_op_kwargs(
'jog',
kwargs,
portspec = portspec_src,
offset = -jog,
length = -travel,
**dst_args,
)))
elif numpy.isclose(angle, 0):
(travel, jog), _ = port_src.measure_travel(port_dst)
ops.append(('uturn', self._merge_trace_into_op_kwargs(
'uturn',
kwargs,
portspec = portspec_src,
offset = -jog,
length = -travel,
**dst_args,
)))
else:
raise BuildError(f"Cannot route relative angle {angle}")
if thru:
ops.append(('rename_ports', {'mapping': {thru: portspec_src}}))
return ops
def _get_tool_R(self, tool: Tool, ccw: SupportsBool, in_ptype: str | None, **kwargs) -> float:
""" Probe a tool to find the lateral displacement (radius) of its bend. """
kwargs_no_out = kwargs | {'out_ptype': None}
@ -424,35 +716,26 @@ class Pather(PortList):
try:
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
except (BuildError, NotImplementedError):
# Try S-bend fallback (two L-bends)
ccw0 = jog > 0
try:
R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs)
R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs)
L1, L2 = length - R2, abs(jog) - R1
first, second = self._plan_s_fallback(tool, portspec, in_ptype, length, jog, **kwargs)
except (BuildError, NotImplementedError):
if not self._dead:
raise
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi)
return self
if L1 < 0 or L2 < 0:
if not self._dead:
raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback") from None
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi)
return self
try:
out_port0, data0 = tool.planL(ccw0, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None}))
out_port1, data1 = tool.planL(not ccw0, L2, in_ptype=out_port0.ptype, **kwargs)
except (BuildError, NotImplementedError):
if not self._dead:
raise
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi)
return self
self._apply_step('L', portspec, out_port0, data0, tool)
self._apply_step('L', portspec, out_port1, data1, tool, plug_into)
self._apply_validated_double_l(
portspec,
tool,
first,
second,
length = length,
jog = jog,
out_rotation = pi,
requested_out_ptype = kwargs.get('out_ptype'),
route_name = 'S-bend',
plug_into = plug_into,
)
return self
if out_port is not None:
self._apply_step('S', portspec, out_port, data, tool, plug_into)
@ -467,22 +750,27 @@ class Pather(PortList):
try:
out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs)
except (BuildError, NotImplementedError):
# Try U-turn fallback (two L-bends)
ccw = jog > 0
try:
R = self._get_tool_R(tool, ccw, in_ptype, **kwargs)
L1, L2 = length + R, abs(jog) - R
out_port0, data0 = tool.planL(ccw, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None}))
out_port1, data1 = tool.planL(ccw, L2, in_ptype=out_port0.ptype, **kwargs)
first, second = self._plan_u_fallback(tool, in_ptype, length, jog, **kwargs)
except (BuildError, NotImplementedError):
if not self._dead:
raise
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=0)
return self
else:
self._apply_step('L', portspec, out_port0, data0, tool)
self._apply_step('L', portspec, out_port1, data1, tool, plug_into)
return self
self._apply_validated_double_l(
portspec,
tool,
first,
second,
length = length,
jog = jog,
out_rotation = 0,
requested_out_ptype = kwargs.get('out_ptype'),
route_name = 'U-turn',
plug_into = plug_into,
)
return self
if out_port is not None:
self._apply_step('U', portspec, out_port, data, tool, plug_into)
return self
@ -531,29 +819,29 @@ class Pather(PortList):
spacing: float | ArrayLike | None = None,
**bounds: Any,
) -> Self:
"""
Route until a single positional bound is reached, or delegate to `trace()` for length/bundle bounds.
Exactly one of `p`, `pos`, `position`, `x`, or `y` may be used as a positional
bound. Positional bounds are only valid for a single port and may not be combined
with `length`.
"""
with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds):
if isinstance(portspec, str):
portspec = [portspec]
pos_keys = {'p', 'x', 'y', 'pos', 'position'}
pb = {k: bounds[k] for k in bounds if k in pos_keys and bounds[k] is not None}
if pb:
if len(portspec) == 1:
resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=False)
else:
resolved = None
pos_count = sum(bounds.get(key) is not None for key in self._POSITION_KEYS)
if pos_count:
raise BuildError('Position bounds only allowed with a single port')
if resolved is not None:
if len(portspec) > 1:
raise BuildError('Position bounds only allowed with a single port')
k, v = next(iter(pb.items()))
port = self.pattern[portspec[0]]
assert port.rotation is not None
is_horiz = numpy.isclose(port.rotation % pi, 0)
if is_horiz:
if k == 'y':
raise BuildError('Port is horizontal')
target = Port((v, port.offset[1]), rotation=None)
else:
if k == 'x':
raise BuildError('Port is vertical')
target = Port((port.offset[0], v), rotation=None)
(travel, jog), _ = port.measure_travel(target)
other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in pos_keys and bk != 'length'}
return self._traceL(portspec[0], ccw, -travel, **other_bounds)
_key, _value, length = resolved
other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS and bk != 'length'}
return self._traceL(portspec[0], ccw, length, **other_bounds)
return self.trace(portspec, ccw, spacing=spacing, **bounds)
def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
@ -569,14 +857,35 @@ class Pather(PortList):
return self.bend(portspec, False, length, **bounds)
def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self:
"""
Route an S-bend.
`length` is the along-travel displacement. If omitted, exactly one positional
bound (`p`, `pos`, `position`, `x`, or `y`) must be provided for a single port,
and the required travel distance is derived from that bound.
"""
with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds):
if isinstance(portspec, str):
portspec = [portspec]
other_bounds = dict(bounds)
if length is None:
if len(portspec) != 1:
raise BuildError('Positional length solving for jog() is only allowed with a single port')
resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=True)
if resolved is None:
raise BuildError('jog() requires either length=... or exactly one positional bound')
_key, _value, length = resolved
other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS}
for p in portspec:
self._traceS(p, length, offset, **bounds)
self._traceS(p, length, offset, **other_bounds)
return self
def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self:
"""
Route a U-turn.
`length` is the along-travel displacement to the final port. If omitted, it defaults to 0.
"""
with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds):
if isinstance(portspec, str):
portspec = [portspec]
@ -594,6 +903,13 @@ class Pather(PortList):
thru: str | None = None,
**kwargs: Any,
) -> Self:
"""
Route one port into another using the shortest supported combination of trace primitives.
If `plug_destination` is `True`, the destination port is consumed by the final step.
If `thru` is provided, that port is renamed to the source name after the route is complete.
The operation is transactional for live port state and deferred routing steps.
"""
with self._logger.log_operation(
self,
'trace_into',
@ -605,43 +921,15 @@ class Pather(PortList):
):
if self._dead:
return self
port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None or port_dst.rotation is None:
raise PortError('Ports must have rotation')
src_horiz = numpy.isclose(port_src.rotation % pi, 0)
dst_horiz = numpy.isclose(port_dst.rotation % pi, 0)
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
dst_args = {**kwargs, 'out_ptype': out_ptype}
if plug_destination:
dst_args['plug_into'] = portspec_dst
if src_horiz and not dst_horiz:
self.trace_to(portspec_src, angle > pi, x=xd, **kwargs)
self.trace_to(portspec_src, None, y=yd, **dst_args)
elif dst_horiz and not src_horiz:
self.trace_to(portspec_src, angle > pi, y=yd, **kwargs)
self.trace_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
(travel, jog), _ = port_src.measure_travel(port_dst)
if numpy.isclose(jog, 0):
self.trace_to(
portspec_src,
None,
x=xd if src_horiz else None,
y=yd if not src_horiz else None,
**dst_args,
)
else:
self.jog(portspec_src, -jog, -travel, **dst_args)
elif numpy.isclose(angle, 0):
(travel, jog), _ = port_src.measure_travel(port_dst)
self.uturn(portspec_src, -jog, length=-travel, **dst_args)
else:
raise BuildError(f"Cannot route relative angle {angle}")
if thru:
self.rename_ports({thru: portspec_src})
ops = self._plan_trace_into(
portspec_src,
portspec_dst,
out_ptype = out_ptype,
plug_destination = plug_destination,
thru = thru,
**kwargs,
)
self._run_route_transaction(lambda: self._execute_route_ops(ops))
return self
#

View file

@ -1,7 +1,9 @@
"""
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
# TODO document all tools
Concrete tools may implement native planning/rendering for `L`, `S`, or `U` routes.
Any unimplemented planning method falls back to the corresponding `trace*()` method,
and `Pather` may further synthesize some routes from simpler primitives when needed.
"""
from typing import Literal, Any, Self, cast
from collections.abc import Sequence, Callable, Iterator
@ -313,7 +315,8 @@ class Tool:
Create a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
Used by `Pather` and `RenderPather`.
Used by `Pather` and `RenderPather`. Tools may leave this unimplemented if they
do not support a native U-bend primitive.
The output port must have an orientation identical to the input port.
@ -348,12 +351,12 @@ class Tool:
**kwargs,
) -> tuple[Port, Any]:
"""
# NOTE: TODO: U-bend is WIP; this interface may change in the future.
Plan a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
Used by `RenderPather`.
Used by `RenderPather`. This is an optional native-planning hook: tools may
implement it when they can represent a U-turn directly, otherwise they may rely
on `traceU()` or let `Pather` synthesize the route from simpler primitives.
The output port must have an orientation identical to the input port.
@ -366,7 +369,8 @@ class Tool:
followed by a clockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
kwargs: Custom tool-specific parameters.
kwargs: Custom tool-specific parameters. `length` may be supplied here to
request a U-turn whose final port is displaced along both axes.
Returns:
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.

View file

@ -47,8 +47,9 @@ def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> No
assert "src" not in p.ports
assert "dst" not in p.ports
# Single bend should result in 2 segments (one for x move, one for y move)
assert len(p.pattern.refs) == 2
# `trace_into()` now batches its internal legs before auto-rendering so the operation
# can roll back cleanly on later failures.
assert len(p.pattern.refs) == 1
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:

View file

@ -1,3 +1,5 @@
from typing import Any
import pytest
import numpy
from numpy import pi
@ -304,6 +306,184 @@ def test_pather_jog_failed_fallback_is_atomic() -> None:
assert len(p.paths['A']) == 0
def test_pather_jog_length_solved_from_single_position_bound() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 2, x=-6)
assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
q = Pather(Library(), tools=tool)
q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
q.jog('A', 2, p=-6)
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
def test_pather_jog_requires_length_or_one_position_bound() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='requires either length'):
p.jog('A', 2)
with pytest.raises(BuildError, match='exactly one positional bound'):
p.jog('A', 2, x=-6, p=-6)
def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}):
p = Pather(Library(), tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='exactly one positional bound'):
p.trace_to('A', None, **kwargs)
p = Pather(Library(), tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='length cannot be combined'):
p.trace_to('A', None, x=-5, length=3)
def test_pather_uturn_none_length_defaults_to_zero() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.uturn('A', 4)
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_trace_into_failure_rolls_back_ports_and_paths() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire')
with pytest.raises(BuildError, match='does not match path ptype'):
p.trace_into('A', 'B', plug_destination=False, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2)
assert len(p.paths['A']) == 0
def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire')
p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire')
with pytest.raises(PortError, match='overwritten'):
p.trace_into('A', 'B', plug_destination=False, thru='other')
assert set(p.pattern.ports) == {'A', 'B', 'other'}
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0))
assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4))
assert len(p.paths['A']) == 0
@pytest.mark.parametrize(
('dst', 'kwargs', 'match'),
(
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'),
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'),
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'),
),
)
def test_pather_trace_into_rejects_reserved_route_kwargs(
dst: Port,
kwargs: dict[str, Any],
match: str,
) -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = dst
with pytest.raises(BuildError, match=match):
p.trace_into('A', 'B', plug_destination=False, **kwargs)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset)
assert dst.rotation is not None
assert p.pattern.ports['B'].rotation is not None
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
assert len(p.paths['A']) == 0
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None:
class OutPtypeSensitiveTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
radius = 1 if out_ptype is None else 2
if ccw is None:
rotation = pi
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = radius
else:
rotation = pi / 2
jog = -radius
ptype = out_ptype or in_ptype or 'wire'
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=OutPtypeSensitiveTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='fallback via two planL'):
p.jog('A', 5, length=10, out_ptype='wide')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p.paths['A']) == 0
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None:
class OutPtypeSensitiveTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
radius = 1 if out_ptype is None else 2
if ccw is None:
rotation = pi
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = radius
else:
rotation = pi / 2
jog = -radius
ptype = out_ptype or in_ptype or 'wire'
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=OutPtypeSensitiveTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='fallback via two planL'):
p.uturn('A', 5, length=10, out_ptype='wide')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p.paths['A']) == 0
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
class DummyTool(Tool):
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: