[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`)
|
Unified Pattern assembly and routing (`Pather`)
|
||||||
"""
|
"""
|
||||||
from typing import Self, Literal, Any, overload
|
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 copy
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -93,6 +93,9 @@ class Pather(PortList):
|
||||||
PROBE_LENGTH: float = 1e6
|
PROBE_LENGTH: float = 1e6
|
||||||
""" Large length used when probing tools for their lateral displacement """
|
""" 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
|
@property
|
||||||
def ports(self) -> dict[str, Port]:
|
def ports(self) -> dict[str, Port]:
|
||||||
return self.pattern.ports
|
return self.pattern.ports
|
||||||
|
|
@ -354,6 +357,295 @@ class Pather(PortList):
|
||||||
if self._auto_render:
|
if self._auto_render:
|
||||||
self.render(append=self._auto_render_append)
|
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:
|
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. """
|
""" Probe a tool to find the lateral displacement (radius) of its bend. """
|
||||||
kwargs_no_out = kwargs | {'out_ptype': None}
|
kwargs_no_out = kwargs | {'out_ptype': None}
|
||||||
|
|
@ -424,35 +716,26 @@ class Pather(PortList):
|
||||||
try:
|
try:
|
||||||
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
|
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
|
||||||
except (BuildError, NotImplementedError):
|
except (BuildError, NotImplementedError):
|
||||||
# Try S-bend fallback (two L-bends)
|
|
||||||
ccw0 = jog > 0
|
|
||||||
try:
|
try:
|
||||||
R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs)
|
first, second = self._plan_s_fallback(tool, portspec, in_ptype, length, jog, **kwargs)
|
||||||
R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs)
|
|
||||||
L1, L2 = length - R2, abs(jog) - R1
|
|
||||||
except (BuildError, NotImplementedError):
|
except (BuildError, NotImplementedError):
|
||||||
if not self._dead:
|
if not self._dead:
|
||||||
raise
|
raise
|
||||||
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi)
|
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
if L1 < 0 or L2 < 0:
|
self._apply_validated_double_l(
|
||||||
if not self._dead:
|
portspec,
|
||||||
raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback") from None
|
tool,
|
||||||
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi)
|
first,
|
||||||
return self
|
second,
|
||||||
|
length = length,
|
||||||
try:
|
jog = jog,
|
||||||
out_port0, data0 = tool.planL(ccw0, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None}))
|
out_rotation = pi,
|
||||||
out_port1, data1 = tool.planL(not ccw0, L2, in_ptype=out_port0.ptype, **kwargs)
|
requested_out_ptype = kwargs.get('out_ptype'),
|
||||||
except (BuildError, NotImplementedError):
|
route_name = 'S-bend',
|
||||||
if not self._dead:
|
plug_into = plug_into,
|
||||||
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)
|
|
||||||
return self
|
return self
|
||||||
if out_port is not None:
|
if out_port is not None:
|
||||||
self._apply_step('S', portspec, out_port, data, tool, plug_into)
|
self._apply_step('S', portspec, out_port, data, tool, plug_into)
|
||||||
|
|
@ -467,21 +750,26 @@ class Pather(PortList):
|
||||||
try:
|
try:
|
||||||
out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs)
|
out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs)
|
||||||
except (BuildError, NotImplementedError):
|
except (BuildError, NotImplementedError):
|
||||||
# Try U-turn fallback (two L-bends)
|
|
||||||
ccw = jog > 0
|
|
||||||
try:
|
try:
|
||||||
R = self._get_tool_R(tool, ccw, in_ptype, **kwargs)
|
first, second = self._plan_u_fallback(tool, in_ptype, length, jog, **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)
|
|
||||||
except (BuildError, NotImplementedError):
|
except (BuildError, NotImplementedError):
|
||||||
if not self._dead:
|
if not self._dead:
|
||||||
raise
|
raise
|
||||||
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=0)
|
self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=0)
|
||||||
return self
|
return self
|
||||||
else:
|
|
||||||
self._apply_step('L', portspec, out_port0, data0, tool)
|
self._apply_validated_double_l(
|
||||||
self._apply_step('L', portspec, out_port1, data1, tool, plug_into)
|
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
|
return self
|
||||||
if out_port is not None:
|
if out_port is not None:
|
||||||
self._apply_step('U', portspec, out_port, data, tool, plug_into)
|
self._apply_step('U', portspec, out_port, data, tool, plug_into)
|
||||||
|
|
@ -531,29 +819,29 @@ class Pather(PortList):
|
||||||
spacing: float | ArrayLike | None = None,
|
spacing: float | ArrayLike | None = None,
|
||||||
**bounds: Any,
|
**bounds: Any,
|
||||||
) -> Self:
|
) -> 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):
|
with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds):
|
||||||
if isinstance(portspec, str):
|
if isinstance(portspec, str):
|
||||||
portspec = [portspec]
|
portspec = [portspec]
|
||||||
pos_keys = {'p', 'x', 'y', 'pos', 'position'}
|
if len(portspec) == 1:
|
||||||
pb = {k: bounds[k] for k in bounds if k in pos_keys and bounds[k] is not None}
|
resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=False)
|
||||||
if pb:
|
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:
|
if len(portspec) > 1:
|
||||||
raise BuildError('Position bounds only allowed with a single port')
|
raise BuildError('Position bounds only allowed with a single port')
|
||||||
k, v = next(iter(pb.items()))
|
_key, _value, length = resolved
|
||||||
port = self.pattern[portspec[0]]
|
other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS and bk != 'length'}
|
||||||
assert port.rotation is not None
|
return self._traceL(portspec[0], ccw, length, **other_bounds)
|
||||||
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)
|
|
||||||
return self.trace(portspec, ccw, spacing=spacing, **bounds)
|
return self.trace(portspec, ccw, spacing=spacing, **bounds)
|
||||||
|
|
||||||
def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
|
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)
|
return self.bend(portspec, False, length, **bounds)
|
||||||
|
|
||||||
def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self:
|
def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **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):
|
with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds):
|
||||||
if isinstance(portspec, str):
|
if isinstance(portspec, str):
|
||||||
portspec = [portspec]
|
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:
|
for p in portspec:
|
||||||
self._traceS(p, length, offset, **bounds)
|
self._traceS(p, length, offset, **other_bounds)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self:
|
def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **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):
|
with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds):
|
||||||
if isinstance(portspec, str):
|
if isinstance(portspec, str):
|
||||||
portspec = [portspec]
|
portspec = [portspec]
|
||||||
|
|
@ -594,6 +903,13 @@ class Pather(PortList):
|
||||||
thru: str | None = None,
|
thru: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Self:
|
) -> 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(
|
with self._logger.log_operation(
|
||||||
self,
|
self,
|
||||||
'trace_into',
|
'trace_into',
|
||||||
|
|
@ -605,43 +921,15 @@ class Pather(PortList):
|
||||||
):
|
):
|
||||||
if self._dead:
|
if self._dead:
|
||||||
return self
|
return self
|
||||||
port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst]
|
ops = self._plan_trace_into(
|
||||||
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,
|
portspec_src,
|
||||||
None,
|
portspec_dst,
|
||||||
x=xd if src_horiz else None,
|
out_ptype = out_ptype,
|
||||||
y=yd if not src_horiz else None,
|
plug_destination = plug_destination,
|
||||||
**dst_args,
|
thru = thru,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
else:
|
self._run_route_transaction(lambda: self._execute_route_ops(ops))
|
||||||
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})
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""
|
"""
|
||||||
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
|
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 typing import Literal, Any, Self, cast
|
||||||
from collections.abc import Sequence, Callable, Iterator
|
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
|
Create a wire or waveguide that travels exactly `jog` distance along the axis
|
||||||
perpendicular to its input port (i.e. a U-bend).
|
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.
|
The output port must have an orientation identical to the input port.
|
||||||
|
|
||||||
|
|
@ -348,12 +351,12 @@ class Tool:
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> tuple[Port, Any]:
|
) -> 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
|
Plan a wire or waveguide that travels exactly `jog` distance along the axis
|
||||||
perpendicular to its input port (i.e. a U-bend).
|
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.
|
The output port must have an orientation identical to the input port.
|
||||||
|
|
||||||
|
|
@ -366,7 +369,8 @@ class Tool:
|
||||||
followed by a clockwise bend)
|
followed by a clockwise bend)
|
||||||
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
|
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.
|
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:
|
Returns:
|
||||||
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
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 "src" not in p.ports
|
||||||
assert "dst" 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)
|
# `trace_into()` now batches its internal legs before auto-rendering so the operation
|
||||||
assert len(p.pattern.refs) == 2
|
# 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:
|
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
@ -304,6 +306,184 @@ def test_pather_jog_failed_fallback_is_atomic() -> None:
|
||||||
assert len(p.paths['A']) == 0
|
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:
|
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
|
||||||
class DummyTool(Tool):
|
class DummyTool(Tool):
|
||||||
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
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