[Pather] rework pather internals -- split route planning vs strategy selection
This commit is contained in:
parent
cf0a245143
commit
2c5243237e
4 changed files with 570 additions and 97 deletions
|
|
@ -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
|
||||
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue