[Pather] improve bounds handling for bundles

This commit is contained in:
Jan Petykiewicz 2026-04-02 12:18:03 -07:00
commit 8100d8095a
2 changed files with 141 additions and 10 deletions

View file

@ -96,6 +96,11 @@ class Pather(PortList):
_POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position')
""" Single-port position bounds accepted by `trace_to()` and `jog()` """
_BUNDLE_BOUND_KEYS: tuple[str, ...] = (
'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest',
)
""" Bounds accepted by `trace()` / `trace_to()` when solving bundle extensions """
@property
def ports(self) -> dict[str, Port]:
return self.pattern.ports
@ -401,6 +406,86 @@ class Pather(PortList):
(travel, _jog), _ = port.measure_travel(target)
return key, value, -float(travel)
@staticmethod
def _format_route_key_list(keys: Sequence[str]) -> str:
return ', '.join(keys)
@staticmethod
def _present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]:
return [key for key in keys if bounds.get(key) is not None]
def _present_bundle_bounds(self, bounds: Mapping[str, Any]) -> list[str]:
return self._present_keys(bounds, self._BUNDLE_BOUND_KEYS)
def _validate_trace_args(
self,
portspec: Sequence[str],
*,
length: float | None,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
bundle_bounds = self._present_bundle_bounds(bounds)
if len(bundle_bounds) > 1:
args = self._format_route_key_list(bundle_bounds)
raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}')
invalid_with_length = self._present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds
invalid_with_each = self._present_keys(bounds, ('set_rotation',)) + bundle_bounds
if length is not None:
if len(portspec) > 1:
raise BuildError('length only allowed with a single port')
if spacing is not None:
invalid_with_length.append('spacing')
if invalid_with_length:
args = self._format_route_key_list(invalid_with_length)
raise BuildError(f'length cannot be combined with other routing bounds: {args}')
return
if bounds.get('each') is not None:
if spacing is not None:
invalid_with_each.append('spacing')
if invalid_with_each:
args = self._format_route_key_list(invalid_with_each)
raise BuildError(f'each cannot be combined with other routing bounds: {args}')
return
if not bundle_bounds:
raise BuildError('No bound type specified for trace()')
def _validate_trace_to_positional_args(
self,
*,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
invalid = self._present_keys(bounds, ('each', 'set_rotation')) + self._present_bundle_bounds(bounds)
if spacing is not None:
invalid.append('spacing')
if invalid:
args = self._format_route_key_list(invalid)
raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}')
def _validate_jog_args(self, *, length: float | None, bounds: Mapping[str, Any]) -> None:
invalid = self._present_keys(bounds, ('each', 'set_rotation')) + self._present_bundle_bounds(bounds)
if length is not None:
invalid = self._present_keys(bounds, self._POSITION_KEYS) + invalid
if invalid:
args = self._format_route_key_list(invalid)
raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}')
return
if invalid:
args = self._format_route_key_list(invalid)
raise BuildError(f'Unsupported routing bounds for jog(): {args}')
def _validate_uturn_args(self, bounds: Mapping[str, Any]) -> None:
invalid = self._present_keys(bounds, self._POSITION_KEYS + ('each', 'set_rotation')) + self._present_bundle_bounds(bounds)
if invalid:
args = self._format_route_key_list(invalid)
raise BuildError(f'Unsupported routing bounds for uturn(): {args}')
def _validate_fallback_endpoint(
self,
portspec: str,
@ -787,23 +872,29 @@ class Pather(PortList):
spacing: float | ArrayLike | None = None,
**bounds: Any,
) -> Self:
"""
Route one or more ports using straight segments or single 90-degree bends.
Provide exactly one routing mode:
- `length` for a single port,
- `each` to extend each selected port independently by the same amount, or
- one bundle bound such as `xmin`, `emax`, or `min_past_furthest`.
`spacing` and `set_rotation` are only valid when using a bundle bound.
"""
with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, **bounds):
if isinstance(portspec, str):
portspec = [portspec]
self._validate_trace_args(portspec, length=length, spacing=spacing, bounds=bounds)
if length is not None:
if len(portspec) > 1:
raise BuildError('length only allowed with a single port')
return self._traceL(portspec[0], ccw, length, **bounds)
if 'each' in bounds:
if bounds.get('each') is not None:
each = bounds.pop('each')
for p in portspec:
self._traceL(p, ccw, each, **bounds)
return self
# Bundle routing
bt_keys = {'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'}
bt = next((k for k in bounds if k in bt_keys), None)
if not bt:
raise BuildError('No bound type specified for trace()')
bt = self._present_bundle_bounds(bounds)[0]
bval = bounds.pop(bt)
set_rot = bounds.pop('set_rotation', None)
exts = ell(self.pattern[tuple(portspec)], ccw, spacing=spacing, bound=bval, bound_type=bt, set_rotation=set_rot)
@ -824,7 +915,7 @@ class Pather(PortList):
Exactly one of `p`, `pos`, `position`, `x`, or `y` may be used as a positional
bound. Positional bounds are only valid for a single port and may not be combined
with `length`.
with `length`, `spacing`, `each`, or bundle-bound keywords such as `xmin`/`emax`.
"""
with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds):
if isinstance(portspec, str):
@ -839,6 +930,7 @@ class Pather(PortList):
if resolved is not None:
if len(portspec) > 1:
raise BuildError('Position bounds only allowed with a single port')
self._validate_trace_to_positional_args(spacing=spacing, bounds=bounds)
_key, _value, length = resolved
other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS and bk != 'length'}
return self._traceL(portspec[0], ccw, length, **other_bounds)
@ -862,11 +954,13 @@ class Pather(PortList):
`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.
and the required travel distance is derived from that bound. When `length` is
provided, no other routing-bound keywords are accepted.
"""
with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds):
if isinstance(portspec, str):
portspec = [portspec]
self._validate_jog_args(length=length, bounds=bounds)
other_bounds = dict(bounds)
if length is None:
if len(portspec) != 1:
@ -884,11 +978,13 @@ class Pather(PortList):
"""
Route a U-turn.
`length` is the along-travel displacement to the final port. If omitted, it defaults to 0.
`length` is the along-travel displacement to the final port. If omitted, it defaults
to 0. Positional and bundle-bound keywords are not supported for this operation.
"""
with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds):
if isinstance(portspec, str):
portspec = [portspec]
self._validate_uturn_args(bounds)
for p in portspec:
self._traceU(p, offset, length=length if length else 0, **bounds)
return self

View file

@ -351,6 +351,41 @@ def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
p.trace_to('A', None, x=-5, length=3)
def test_pather_trace_rejects_length_with_bundle_bound() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='length cannot be combined'):
p.trace('A', None, length=5, xmin=-100)
@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}))
def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='exactly one bundle bound'):
p.trace(['A', 'B'], None, **kwargs)
def test_pather_jog_rejects_length_with_position_bound() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='length cannot be combined'):
p.jog('A', 2, length=5, x=-999)
@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10}))
def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
p.uturn('A', 4, **kwargs)
def test_pather_uturn_none_length_defaults_to_zero() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')