diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 7e33b18..0f2eebc 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -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 diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index a612400..a187ec6 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -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')