[Pather / Tool] more unification work and fixes
This commit is contained in:
parent
8712398990
commit
f9611933ac
19 changed files with 1022 additions and 137 deletions
75
MIGRATION.md
75
MIGRATION.md
|
|
@ -288,7 +288,25 @@ you are exposing:
|
|||
common callback behavior. It is not the normal class users should instantiate.
|
||||
The concrete offer classes carry the semantic fields (`length_domain`,
|
||||
`jog_domain`, `ccw`) so tools do not need to encode primitive identity in
|
||||
strings.
|
||||
strings. Each concrete offer now also exposes a class-level canonical `kind`.
|
||||
Tools must return the matching offer kind for the discovery query; for example,
|
||||
`primitive_offers('s', ...)` must return only `SOffer` instances. Mismatches are
|
||||
reported as fatal `ToolContractError`s rather than silently ignored.
|
||||
|
||||
`RenderStep` now stores that same canonical kind. Code that constructs render
|
||||
steps directly must use the kind rather than a legacy opcode:
|
||||
|
||||
```python
|
||||
# old
|
||||
RenderStep('L', tool, start, end, data)
|
||||
|
||||
# new
|
||||
RenderStep('straight', tool, start, end, data)
|
||||
```
|
||||
|
||||
Use `'bend'`, `'s'`, `'u'`, or `'plug'` for the other step types. The read-only
|
||||
`RenderStep.opcode` and `PrimitiveOffer.opcode` properties remain available for
|
||||
code that only consumes steps, but opcodes are now derived rather than stored.
|
||||
|
||||
Minimal straight-only example:
|
||||
|
||||
|
|
@ -341,9 +359,11 @@ The mapping is unpacked only for `Tool.primitive_offers()`. Route arguments do
|
|||
not leak into that namespace, and tool options are not forwarded to
|
||||
`Tool.render()`. An offer that needs route-specific render behavior must capture
|
||||
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
|
||||
does this automatically for generated straight and S-bend primitives: the flat
|
||||
mapping is snapshotted per selected primitive and passed to its generator as
|
||||
keyword arguments during rendering. AutoTool options must not change generated
|
||||
does this automatically for generated straight and S-bend primitives: the
|
||||
mapping is deep-copied per offer discovery and passed to its generator as
|
||||
keyword arguments during rendering. Consequently every AutoTool option value
|
||||
must be deep-copyable, and later mutation of nested caller-owned values does
|
||||
not affect a pending route. AutoTool options must not change generated
|
||||
ports or endpoint geometry. `PathTool` defines no tool options and rejects
|
||||
nonempty mappings.
|
||||
|
||||
|
|
@ -360,9 +380,51 @@ Primitive offers are local planning objects:
|
|||
- `parameterized_bbox` may carry opaque future-router footprint metadata
|
||||
- `commit(parameter)` returns opaque render data consumed later by `render()`
|
||||
- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton
|
||||
- domains are validated when an offer is constructed, not when it is first evaluated
|
||||
- selected parameter values must be finite; domains may use infinite open bounds but not `NaN`
|
||||
- straight/bend domains require a finite nonnegative minimum; fixed singleton domains must be finite
|
||||
- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer
|
||||
|
||||
`AutoTool.add_straight(length_range=...)` and
|
||||
`AutoTool.add_sbend(jog_range=...)` now validate their ranges before metadata
|
||||
inference. `jog_range` is an absolute-magnitude range and must have a finite,
|
||||
nonnegative lower bound; AutoTool creates the corresponding positive and
|
||||
negative S offers itself. Invalid ranges now raise immediately instead of
|
||||
registering no offers.
|
||||
|
||||
`ToolContractError` is exported from `masque` and `masque.builder`. It marks a
|
||||
broken Tool contract—such as an endpoint ptype that disagrees with its offer,
|
||||
an invalid evaluated cost, or rendered output that disagrees with the planned
|
||||
endpoint—and is fatal to route fallback. Ordinary `BuildError` raised by a
|
||||
planning callback remains a recoverable candidate rejection. Rendered output
|
||||
rotations are compared modulo one full turn; a port facing exactly backward is
|
||||
no longer accepted as equivalent. A `None` rotation remains an explicit
|
||||
wildcard.
|
||||
|
||||
Custom Tool authors can exercise discovery, offer callbacks, committed data,
|
||||
and one-step rendering without depending on pytest:
|
||||
|
||||
```python
|
||||
from masque.builder import ToolContractCase, validate_tool_contract
|
||||
|
||||
validate_tool_contract(my_tool, (
|
||||
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
|
||||
ToolContractCase('bend', in_ptype='wire', ccw=False),
|
||||
ToolContractCase('bend', in_ptype='wire', ccw=True),
|
||||
ToolContractCase('s', in_ptype='wire', require_offers=False),
|
||||
))
|
||||
```
|
||||
|
||||
Cases derive representative parameters from each returned offer domain and
|
||||
may add explicit probes. Empty discovery is an error unless
|
||||
`require_offers=False`; bbox support is required only with `check_bbox=True`.
|
||||
Validation returns normally on success and otherwise raises an
|
||||
`ExceptionGroup` of contextual `ToolContractError`s.
|
||||
|
||||
Positional routing bounds (`p`, `pos`, `position`, `x`, and `y`) now require a
|
||||
nearly Manhattan input-port direction. Arbitrarily angled ports remain valid
|
||||
for non-positional/extension routing.
|
||||
|
||||
Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype
|
||||
adapters. Requested `out_ptype` constrains only the final route endpoint; any
|
||||
intermediate ptypes are chosen by the route solver.
|
||||
|
|
@ -717,8 +779,9 @@ These are additive, but available now from `masque` and `masque.builder`:
|
|||
- from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`,
|
||||
`PortsLibraryView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`,
|
||||
`BuildReport`, `CellProvenance`, and `cell`
|
||||
- from `masque.builder`: `CostCallable`, the concrete primitive-offer classes,
|
||||
and structured route error/status types
|
||||
- from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete
|
||||
primitive-offer classes, structured route error/status types,
|
||||
`ToolContractCase`, and `validate_tool_contract`
|
||||
|
||||
## Minimal migration checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import numpy
|
|||
from numpy import pi
|
||||
from masque import Pather, Library, Pattern, Port, layer_t
|
||||
from masque.abstract import Abstract
|
||||
from masque.builder import BendOffer, RenderStep, StraightOffer, Tool
|
||||
from masque.builder import (
|
||||
BendOffer, RenderStep, StraightOffer, Tool, ToolContractCase, validate_tool_contract,
|
||||
)
|
||||
from masque.error import BuildError
|
||||
from masque.file.gdsii import writefile
|
||||
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||
|
|
@ -419,6 +421,18 @@ def prepare_tools() -> tuple[Library, Tool, Tool]:
|
|||
bend = library.abstract('m2_bend'),
|
||||
transitions = via_transitions,
|
||||
)
|
||||
|
||||
# Custom tools can be checked independently of Pather or pytest. Automatic
|
||||
# probes cover each offer domain; explicit probes can target useful process
|
||||
# dimensions. Unsupported primitive families opt out with require_offers=False.
|
||||
for tool in (M1_tool, M2_tool):
|
||||
validate_tool_contract(tool, (
|
||||
ToolContractCase('straight', in_ptype=tool.ptype, probe_parameters=(10_000,)),
|
||||
ToolContractCase('bend', in_ptype=tool.ptype, ccw=False),
|
||||
ToolContractCase('bend', in_ptype=tool.ptype, ccw=True),
|
||||
ToolContractCase('s', in_ptype=tool.ptype, require_offers=False),
|
||||
ToolContractCase('u', in_ptype=tool.ptype, require_offers=False),
|
||||
))
|
||||
return library, M1_tool, M2_tool
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ from .ports import (
|
|||
from .abstract import Abstract as Abstract
|
||||
from .builder import (
|
||||
Tool as Tool,
|
||||
ToolContractError as ToolContractError,
|
||||
Pather as Pather,
|
||||
RouteError as RouteError,
|
||||
RouteFailureDetails as RouteFailureDetails,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from .pather import (
|
|||
PortPather as PortPather,
|
||||
)
|
||||
from .error import (
|
||||
ToolContractError as ToolContractError,
|
||||
RouteError as RouteError,
|
||||
RouteFailureDetails as RouteFailureDetails,
|
||||
RouteOperation as RouteOperation,
|
||||
|
|
@ -61,11 +62,16 @@ from .error import (
|
|||
MinimumStatus as MinimumStatus,
|
||||
)
|
||||
from .utils import ell as ell
|
||||
from .tool_testing import (
|
||||
ToolContractCase as ToolContractCase,
|
||||
validate_tool_contract as validate_tool_contract,
|
||||
)
|
||||
from .tools import (
|
||||
Tool as Tool,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
RenderStep as RenderStep,
|
||||
RenderStepKind as RenderStepKind,
|
||||
PrimitiveKind as PrimitiveKind,
|
||||
CostCallable as CostCallable,
|
||||
GeneratedEndpointFn as GeneratedEndpointFn,
|
||||
|
|
|
|||
48
masque/builder/_tolerances.py
Normal file
48
masque/builder/_tolerances.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Shared numeric tolerances for builder geometry and parameter comparisons."""
|
||||
from math import isclose, remainder, tau
|
||||
from typing import Any
|
||||
|
||||
import numpy
|
||||
|
||||
|
||||
GEOMETRY_RTOL = 1e-5
|
||||
GEOMETRY_ATOL = 1e-8
|
||||
DOMAIN_RTOL = 1e-9
|
||||
DOMAIN_ATOL = 1e-12
|
||||
MANHATTAN_ANGLE_RTOL = 1e-9
|
||||
MANHATTAN_ANGLE_ATOL = 1e-9
|
||||
|
||||
|
||||
def scalar_close(a: float, b: float) -> bool:
|
||||
"""Match the solver's existing scalar-comparison behavior."""
|
||||
return isclose(float(a), float(b), rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL)
|
||||
|
||||
|
||||
def array_close(a: Any, b: Any) -> bool:
|
||||
"""Match NumPy's historical builder geometry-comparison behavior."""
|
||||
return bool(numpy.allclose(a, b, rtol=GEOMETRY_RTOL, atol=GEOMETRY_ATOL))
|
||||
|
||||
|
||||
def angles_equal(a: float, b: float) -> bool:
|
||||
"""Return true when two rotations are equal modulo one full turn."""
|
||||
delta = remainder(float(a) - float(b), tau)
|
||||
return isclose(delta, 0.0, rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL)
|
||||
|
||||
|
||||
def manhattan_axis(rotation: float) -> int | None:
|
||||
"""Return 0 for horizontal, 1 for vertical, or None for a non-cardinal angle."""
|
||||
angle = float(rotation) % (numpy.pi / 2)
|
||||
if isclose(
|
||||
angle,
|
||||
0.0,
|
||||
rel_tol=MANHATTAN_ANGLE_RTOL,
|
||||
abs_tol=MANHATTAN_ANGLE_ATOL,
|
||||
) or isclose(
|
||||
angle,
|
||||
numpy.pi / 2,
|
||||
rel_tol=MANHATTAN_ANGLE_RTOL,
|
||||
abs_tol=MANHATTAN_ANGLE_ATOL,
|
||||
):
|
||||
quarter_turn = round(float(rotation) / (numpy.pi / 2))
|
||||
return quarter_turn % 2
|
||||
return None
|
||||
|
|
@ -12,6 +12,10 @@ from ..error import BuildError
|
|||
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn']
|
||||
|
||||
|
||||
class ToolContractError(BuildError):
|
||||
"""A Tool returned data inconsistent with its routing contract."""
|
||||
|
||||
|
||||
class RouteFailurePolicy(Enum):
|
||||
"""Whether route failure may be recovered through alternate/dead planning.
|
||||
|
||||
|
|
|
|||
|
|
@ -76,13 +76,14 @@ from .planner.interface import (
|
|||
RoutePortContext,
|
||||
route_failure_policy,
|
||||
)
|
||||
from .error import RouteFailurePolicy
|
||||
from .error import RouteFailurePolicy, ToolContractError
|
||||
from .planner import (
|
||||
RouteTieBreakStrategy,
|
||||
RoutingPlanner,
|
||||
)
|
||||
from .planner.bounds import resolved_position_bound
|
||||
from .logging import PatherLogger
|
||||
from ._tolerances import angles_equal, array_close
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -343,7 +344,7 @@ class Pather(PortList):
|
|||
if not steps:
|
||||
continue
|
||||
port = self.ports.get(name, steps[-1].end_port)
|
||||
prepared.append((name, RenderStep('P', None, port.copy(), port.copy(), None)))
|
||||
prepared.append((name, RenderStep('plug', None, port.copy(), port.copy(), None)))
|
||||
return prepared
|
||||
|
||||
def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None:
|
||||
|
|
@ -1130,7 +1131,7 @@ class Pather(PortList):
|
|||
return
|
||||
|
||||
tool_name = type(batch[0].tool).__name__
|
||||
raise BuildError(
|
||||
raise ToolContractError(
|
||||
f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}'
|
||||
)
|
||||
|
||||
|
|
@ -1139,22 +1140,22 @@ class Pather(PortList):
|
|||
actual = pat.ports.get(portspec)
|
||||
tool_name = type(batch[0].tool).__name__
|
||||
if actual is None:
|
||||
raise BuildError(
|
||||
raise ToolContractError(
|
||||
f'Tool {tool_name}.render() did not produce output port {portspec!r}; '
|
||||
f'expected {expected.describe()}'
|
||||
)
|
||||
|
||||
offsets_match = bool(numpy.allclose(actual.offset, expected.offset))
|
||||
offsets_match = array_close(actual.offset, expected.offset)
|
||||
rotations_match = (
|
||||
actual.rotation is None
|
||||
or expected.rotation is None
|
||||
or bool(numpy.isclose(numpy.sin(actual.rotation - expected.rotation), 0))
|
||||
or angles_equal(actual.rotation, expected.rotation)
|
||||
)
|
||||
ptypes_match = ptypes_compatible(actual.ptype, expected.ptype)
|
||||
if offsets_match and rotations_match and ptypes_match:
|
||||
return
|
||||
|
||||
raise BuildError(
|
||||
raise ToolContractError(
|
||||
f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: '
|
||||
f'expected {expected.describe()}, got {actual.describe()}'
|
||||
)
|
||||
|
|
@ -1186,14 +1187,14 @@ class Pather(PortList):
|
|||
continue
|
||||
batch: list[RenderStep] = []
|
||||
for step in steps:
|
||||
appendable = step.opcode in ('L', 'S', 'U')
|
||||
appendable = step.kind != 'plug'
|
||||
same_tool = batch and step.tool is batch[0].tool
|
||||
if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)):
|
||||
render_batch(portspec, batch, append)
|
||||
batch = []
|
||||
if appendable:
|
||||
batch.append(step)
|
||||
elif step.opcode == 'P' and portspec in pat.ports:
|
||||
elif step.kind == 'plug' and portspec in pat.ports:
|
||||
del pat.ports[portspec]
|
||||
if batch:
|
||||
render_batch(portspec, batch, append)
|
||||
|
|
@ -1285,6 +1286,14 @@ class PortPather:
|
|||
self.pather = pather
|
||||
self.default_spacing = default_spacing
|
||||
|
||||
def _single_port(self, action: str) -> str:
|
||||
"""Return the selected port for an exact-one operation."""
|
||||
if len(self.ports) != 1:
|
||||
raise BuildError(
|
||||
f'Unable to use implicit {action}() with {len(self.ports)} ports; expected exactly one.'
|
||||
)
|
||||
return self.ports[0]
|
||||
|
||||
def retool(self, tool: Tool) -> Self:
|
||||
self.pather.retool(tool, self.ports)
|
||||
return self
|
||||
|
|
@ -1545,28 +1554,24 @@ class PortPather:
|
|||
strategy: RouteTieBreakStrategy | str | None = None,
|
||||
tool_options: Mapping[str, Any] | None = None,
|
||||
) -> Self:
|
||||
if len(self.ports) != 1:
|
||||
raise BuildError(f'Unable to use implicit trace_into() with {len(self.ports)} ports; expected exactly one.')
|
||||
port = self._single_port('trace_into')
|
||||
self.pather.trace_into(
|
||||
self.ports[0], target_port, out_ptype=out_ptype, plug_destination=plug_destination,
|
||||
port, target_port, out_ptype=out_ptype, plug_destination=plug_destination,
|
||||
thru=thru, strategy=strategy, tool_options=tool_options,
|
||||
)
|
||||
return self
|
||||
|
||||
def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.'
|
||||
'Use the pather or pattern directly to plug multiple ports.')
|
||||
self.pather.plug(other, {self.ports[0]: other_port}, **kwargs)
|
||||
port = self._single_port('plug')
|
||||
self.pather.plug(other, {port: other_port}, **kwargs)
|
||||
return self
|
||||
|
||||
def plugged(self, other_port: str | Mapping[str, str]) -> Self:
|
||||
if isinstance(other_port, Mapping):
|
||||
self.pather.plugged(dict(other_port))
|
||||
elif len(self.ports) > 1:
|
||||
raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.')
|
||||
else:
|
||||
self.pather.plugged({self.ports[0]: other_port})
|
||||
port = self._single_port('plugged')
|
||||
self.pather.plugged({port: other_port})
|
||||
return self
|
||||
|
||||
#
|
||||
|
|
@ -1604,9 +1609,7 @@ class PortPather:
|
|||
""" Rename active ports. """
|
||||
name_map: dict[str, str | None]
|
||||
if isinstance(name, str):
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError('Use a mapping to rename >1 port')
|
||||
name_map = {self.ports[0]: name}
|
||||
name_map = {self._single_port('rename'): name}
|
||||
else:
|
||||
name_map = dict(name)
|
||||
self.pather.rename_ports(name_map)
|
||||
|
|
@ -1637,9 +1640,7 @@ class PortPather:
|
|||
|
||||
def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]:
|
||||
if isinstance(name, str):
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError(f'Use a mapping to {action} >1 port')
|
||||
name_map = {self.ports[0]: name}
|
||||
name_map = {self._single_port(action): name}
|
||||
else:
|
||||
name_map = dict(name)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from numpy.typing import ArrayLike, NDArray
|
|||
from ...error import BuildError, PortError
|
||||
from ...ports import Port
|
||||
from ...utils import rotation_matrix_2d
|
||||
from .._tolerances import manhattan_axis
|
||||
from .interface import RoutePortContext
|
||||
|
||||
|
||||
|
|
@ -70,8 +71,13 @@ def resolved_position_bound(
|
|||
value = finite_scalar(raw_value, f'{key} positional bound')
|
||||
if port.rotation is None:
|
||||
raise BuildError('Ports must have rotation')
|
||||
is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9))
|
||||
if is_horiz:
|
||||
axis = manhattan_axis(port.rotation)
|
||||
if axis is None:
|
||||
raise BuildError(
|
||||
'Positional bounds require a nearly Manhattan port direction; '
|
||||
f'got rotation {port.rotation:g}'
|
||||
)
|
||||
if axis == 0:
|
||||
if key == 'y':
|
||||
raise BuildError('Port is horizontal')
|
||||
target = Port((value, port.offset[1]), rotation=None)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from dataclasses import dataclass
|
|||
from ...error import BuildError
|
||||
from ...ports import Port
|
||||
from ..tools import RenderStep, Tool
|
||||
from ..error import RouteError, RouteFailurePolicy
|
||||
from ..error import RouteError, RouteFailurePolicy, ToolContractError
|
||||
|
||||
|
||||
class RoutePlanningError(BuildError):
|
||||
|
|
@ -34,6 +34,8 @@ class RoutePlanningError(BuildError):
|
|||
|
||||
def route_failure_policy(err: Exception) -> RouteFailurePolicy:
|
||||
"""Return typed route recovery policy, defaulting generic errors to recoverable."""
|
||||
if isinstance(err, ToolContractError):
|
||||
return RouteFailurePolicy.FATAL
|
||||
if isinstance(err, RoutePlanningError):
|
||||
return err.policy
|
||||
if isinstance(err, RouteError):
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ from __future__ import annotations
|
|||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from itertools import combinations
|
||||
from math import cos, isclose as math_isclose, sin
|
||||
from math import cos, sin
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy
|
||||
|
|
@ -50,6 +50,7 @@ from ..tools import (
|
|||
SOffer,
|
||||
StraightOffer,
|
||||
Tool,
|
||||
UOffer,
|
||||
)
|
||||
from ..error import (
|
||||
MinimumStatus,
|
||||
|
|
@ -57,7 +58,9 @@ from ..error import (
|
|||
RouteFailureDetails,
|
||||
RouteFailurePolicy,
|
||||
RouteOperation,
|
||||
ToolContractError,
|
||||
)
|
||||
from .._tolerances import scalar_close
|
||||
from ..utils import ell
|
||||
from . import bounds as planner_bounds
|
||||
from .interface import (
|
||||
|
|
@ -84,7 +87,7 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr
|
|||
|
||||
def is_close(a: float, b: float) -> bool:
|
||||
"""Compare route-solver scalars with the planner tolerance."""
|
||||
return math_isclose(float(a), float(b), rel_tol=1e-5, abs_tol=1e-8)
|
||||
return scalar_close(a, b)
|
||||
|
||||
|
||||
def clean_parameter(value: float) -> float:
|
||||
|
|
@ -225,8 +228,6 @@ class SelectedPrimitive:
|
|||
"""Finite additive planning cost reported by the offer."""
|
||||
role: Literal['main', 'adapter'] = 'main'
|
||||
"""Whether this step satisfies route geometry or adapts ptype."""
|
||||
route_kind: PrimitiveKind | None = None
|
||||
"""Primitive kind used when querying the Tool for this step."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -318,7 +319,7 @@ class Solver:
|
|||
|
||||
def __init__(self, request: SolverRequest) -> None:
|
||||
self.request = request
|
||||
self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str, PrimitiveKind | None], SelectedPrimitive] = {}
|
||||
self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str], SelectedPrimitive] = {}
|
||||
self.offer_cache: dict[
|
||||
tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]],
|
||||
tuple[PrimitiveOffer, ...],
|
||||
|
|
@ -333,9 +334,9 @@ class Solver:
|
|||
for step in steps:
|
||||
if step.role == 'adapter':
|
||||
continue
|
||||
if step.route_kind == 'bend':
|
||||
if step.offer.kind == 'bend':
|
||||
count += 1
|
||||
elif step.route_kind in ('s', 'u'):
|
||||
elif step.offer.kind in ('s', 'u'):
|
||||
count += 2
|
||||
return count
|
||||
|
||||
|
|
@ -371,7 +372,6 @@ class Solver:
|
|||
tuple((
|
||||
offer_key(step.offer),
|
||||
step.role,
|
||||
step.route_kind,
|
||||
round(float(step.parameter), 9),
|
||||
endpoint_key(step.out_port),
|
||||
) for step in candidate.steps),
|
||||
|
|
@ -448,16 +448,54 @@ class Solver:
|
|||
kwargs = dict(self.request.tool_options)
|
||||
if extra:
|
||||
kwargs.update(extra)
|
||||
|
||||
def query_tool() -> tuple[PrimitiveOffer, ...]:
|
||||
expected_offer_type = {
|
||||
'straight': StraightOffer,
|
||||
'bend': BendOffer,
|
||||
's': SOffer,
|
||||
'u': UOffer,
|
||||
}[kind]
|
||||
offers = self.request.tool.primitive_offers(
|
||||
kind,
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype,
|
||||
**kwargs,
|
||||
)
|
||||
if not isinstance(offers, tuple):
|
||||
raise ToolContractError(
|
||||
f'Tool.primitive_offers({kind!r}) must return a tuple, '
|
||||
f'got {type(offers).__name__}'
|
||||
)
|
||||
for index, offer in enumerate(offers):
|
||||
if not isinstance(offer, PrimitiveOffer):
|
||||
raise ToolContractError(
|
||||
f'Tool.primitive_offers({kind!r}) item {index} must be a PrimitiveOffer, '
|
||||
f'got {type(offer).__name__}'
|
||||
)
|
||||
if offer.kind != kind:
|
||||
raise ToolContractError(
|
||||
f'Tool.primitive_offers({kind!r}) item {index} returned '
|
||||
f'{offer.kind!r} offer'
|
||||
)
|
||||
if not isinstance(offer, expected_offer_type):
|
||||
raise ToolContractError(
|
||||
f'Tool.primitive_offers({kind!r}) item {index} must be '
|
||||
f'{expected_offer_type.__name__}, got {type(offer).__name__}'
|
||||
)
|
||||
return offers
|
||||
|
||||
extra_items = tuple(sorted((extra or {}).items()))
|
||||
try:
|
||||
cache_key = (kind, in_ptype, out_ptype, tuple(sorted(kwargs.items())))
|
||||
cache_key = (kind, in_ptype, out_ptype, extra_items)
|
||||
hash(cache_key)
|
||||
except TypeError:
|
||||
return self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
|
||||
return query_tool()
|
||||
|
||||
cached = self.offer_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
offers = self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
|
||||
offers = query_tool()
|
||||
self.offer_cache[cache_key] = offers
|
||||
return offers
|
||||
|
||||
|
|
@ -469,7 +507,6 @@ class Solver:
|
|||
*,
|
||||
out_ptype: str | None,
|
||||
role: Literal['main', 'adapter'],
|
||||
route_kind: PrimitiveKind | None,
|
||||
route_name: str | None = None,
|
||||
) -> SelectedPrimitive:
|
||||
"""
|
||||
|
|
@ -481,7 +518,7 @@ class Solver:
|
|||
"""
|
||||
route_name = self.request.route_name if route_name is None else route_name
|
||||
selected = offer.canonicalize_parameter(clean_parameter(parameter))
|
||||
key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name, route_kind)
|
||||
key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name)
|
||||
cached = self.eval_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
|
@ -491,28 +528,37 @@ class Solver:
|
|||
if isinstance(offer, SOffer) and is_close(selected, 0):
|
||||
raise BuildError('zero-jog S primitive candidates are not allowed')
|
||||
out_port = offer.endpoint_at(selected)
|
||||
if not isinstance(out_port, Port):
|
||||
raise ToolContractError(
|
||||
f'{route_name} primitive endpoint_at() must return a Port, '
|
||||
f'got {type(out_port).__name__}'
|
||||
)
|
||||
if not ptypes_compatible(out_port.ptype, offer.out_ptype):
|
||||
raise RoutePlanningError(
|
||||
raise ToolContractError(
|
||||
f'{route_name} primitive endpoint ptype does not match declared offer out_ptype',
|
||||
policy=RouteFailurePolicy.FATAL,
|
||||
)
|
||||
if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype):
|
||||
raise RoutePlanningError(
|
||||
'Requested out_ptype does not match primitive endpoint ptype',
|
||||
policy=RouteFailurePolicy.FATAL,
|
||||
)
|
||||
try:
|
||||
if type(offer).cost_at is PrimitiveOffer.cost_at:
|
||||
cost = float(PrimitiveOffer._cost_for_endpoint(offer, selected, out_port))
|
||||
else:
|
||||
cost = float(offer.cost_at(selected))
|
||||
except (TypeError, ValueError, OverflowError) as err:
|
||||
raise ToolContractError(f'{route_name} primitive returned a non-numeric cost') from err
|
||||
if not numpy.isfinite(cost):
|
||||
raise BuildError(f'{route_name} primitive returned non-finite cost')
|
||||
raise ToolContractError(f'{route_name} primitive returned non-finite cost')
|
||||
if cost < 0:
|
||||
raise BuildError(f'{route_name} primitive returned negative cost')
|
||||
raise ToolContractError(f'{route_name} primitive returned negative cost')
|
||||
primitive = SelectedPrimitive(
|
||||
offer,
|
||||
selected,
|
||||
out_port,
|
||||
cost,
|
||||
role=role,
|
||||
route_kind=route_kind,
|
||||
)
|
||||
self.eval_cache[key] = primitive
|
||||
return primitive
|
||||
|
|
@ -532,7 +578,7 @@ class Solver:
|
|||
for step in steps:
|
||||
out_port = step.out_port
|
||||
if out_port.rotation is None:
|
||||
raise BuildError('Primitive endpoints must have rotation')
|
||||
raise ToolContractError('Primitive endpoints must have rotation')
|
||||
angle_cos = cos(angle)
|
||||
angle_sin = sin(angle)
|
||||
x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y)
|
||||
|
|
@ -573,7 +619,6 @@ class Solver:
|
|||
current_ptype,
|
||||
out_ptype=None,
|
||||
role='adapter',
|
||||
route_kind=kind,
|
||||
route_name=f'{kind} adapter',
|
||||
)
|
||||
except BuildError as err:
|
||||
|
|
@ -606,7 +651,6 @@ class Solver:
|
|||
current_ptype,
|
||||
out_ptype=None,
|
||||
role='main',
|
||||
route_kind='straight',
|
||||
route_name='trace',
|
||||
)
|
||||
except BuildError as err:
|
||||
|
|
@ -642,7 +686,6 @@ class Solver:
|
|||
current_ptype,
|
||||
out_ptype=None,
|
||||
role='main',
|
||||
route_kind='bend',
|
||||
route_name='trace',
|
||||
)
|
||||
except BuildError as err:
|
||||
|
|
@ -678,7 +721,6 @@ class Solver:
|
|||
current_ptype,
|
||||
out_ptype=None,
|
||||
role='main',
|
||||
route_kind=kind,
|
||||
route_name=route_name,
|
||||
)
|
||||
except BuildError as err:
|
||||
|
|
@ -814,7 +856,6 @@ class Solver:
|
|||
current_ptype,
|
||||
out_ptype=None,
|
||||
role=step.role,
|
||||
route_kind=step.route_kind,
|
||||
)
|
||||
selected.append(selected_step)
|
||||
current_ptype = selected_step.out_port.ptype
|
||||
|
|
@ -1183,7 +1224,7 @@ class RoutingPlanner:
|
|||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(current.offset)
|
||||
render_steps.append(RenderStep(
|
||||
selected.offer.opcode,
|
||||
selected.offer.kind,
|
||||
leg.tool,
|
||||
current.copy(),
|
||||
out_port.copy(),
|
||||
|
|
|
|||
368
masque/builder/tool_testing.py
Normal file
368
masque/builder/tool_testing.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"""Pytest-independent contract checks for custom routing Tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from math import isfinite
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from ..library import ILibrary, SINGLE_USE_PREFIX
|
||||
from ..ports import Port
|
||||
from ..utils import ptypes_compatible
|
||||
from ._tolerances import angles_equal, array_close, scalar_close
|
||||
from .error import ToolContractError
|
||||
from .tools import (
|
||||
BendOffer, PrimitiveKind, PrimitiveOffer, RenderStep, SOffer, StraightOffer, Tool, UOffer,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
|
||||
_RESERVED_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolContractCase:
|
||||
"""One primitive-discovery query to exercise against a custom Tool."""
|
||||
|
||||
kind: PrimitiveKind
|
||||
in_ptype: str | None = None
|
||||
out_ptype: str | None = None
|
||||
ccw: bool | None = None
|
||||
tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
|
||||
probe_parameters: tuple[float, ...] = ()
|
||||
require_offers: bool = True
|
||||
check_bbox: bool = False
|
||||
label: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind not in ('straight', 'bend', 's', 'u'):
|
||||
raise ValueError(f'Unrecognized primitive kind {self.kind!r}')
|
||||
if self.kind == 'bend':
|
||||
if self.ccw is None:
|
||||
raise ValueError('Bend ToolContractCase requires ccw')
|
||||
elif self.ccw is not None:
|
||||
raise ValueError('ccw is only valid for bend ToolContractCase')
|
||||
|
||||
try:
|
||||
options = deepcopy(dict(self.tool_options))
|
||||
except Exception as err:
|
||||
raise ValueError('ToolContractCase.tool_options must be a deep-copyable mapping') from err
|
||||
nonstring = [key for key in options if not isinstance(key, str)]
|
||||
if nonstring:
|
||||
raise ValueError(f'ToolContractCase.tool_options keys must be strings; got {nonstring!r}')
|
||||
collisions = sorted(_RESERVED_OPTION_KEYS & options.keys())
|
||||
if collisions:
|
||||
raise ValueError(f'ToolContractCase.tool_options contains reserved keys: {", ".join(collisions)}')
|
||||
|
||||
try:
|
||||
probes = tuple(float(value) for value in self.probe_parameters)
|
||||
except (TypeError, ValueError, OverflowError) as err:
|
||||
raise ValueError('ToolContractCase.probe_parameters must contain numeric scalars') from err
|
||||
if not all(isfinite(value) for value in probes):
|
||||
raise ValueError('ToolContractCase.probe_parameters must be finite')
|
||||
|
||||
object.__setattr__(self, 'ccw', None if self.ccw is None else bool(self.ccw))
|
||||
object.__setattr__(self, 'tool_options', MappingProxyType(options))
|
||||
object.__setattr__(self, 'probe_parameters', probes)
|
||||
|
||||
|
||||
def _automatic_probes(offer: PrimitiveOffer) -> tuple[float, ...]:
|
||||
"""Choose deterministic representative parameters inside one offer domain."""
|
||||
lower, upper = (float(value) for value in offer.parameter_domain)
|
||||
if lower == upper:
|
||||
return (lower,)
|
||||
if numpy.isfinite(lower) and numpy.isfinite(upper):
|
||||
midpoint = lower / 2 + upper / 2
|
||||
if midpoint == upper:
|
||||
midpoint = float(numpy.nextafter(upper, lower))
|
||||
return (lower, midpoint)
|
||||
if numpy.isfinite(lower):
|
||||
step = max(1.0, abs(lower) * 0.1)
|
||||
return (lower, lower + step)
|
||||
if numpy.isfinite(upper):
|
||||
step = max(1.0, abs(upper) * 0.1)
|
||||
return (upper - step, upper - 2 * step)
|
||||
return (-1.0, 1.0)
|
||||
|
||||
|
||||
def _offer_probes(offer: PrimitiveOffer, extras: Sequence[float]) -> tuple[float, ...]:
|
||||
"""Combine automatic and applicable explicit probes without duplicates."""
|
||||
probes: list[float] = []
|
||||
for parameter in (*_automatic_probes(offer), *extras):
|
||||
try:
|
||||
selected = offer.canonicalize_parameter(parameter)
|
||||
except Exception:
|
||||
continue
|
||||
if not any(scalar_close(selected, previous) for previous in probes):
|
||||
probes.append(selected)
|
||||
return tuple(probes)
|
||||
|
||||
|
||||
def _offer_metadata(offer: PrimitiveOffer) -> tuple[Any, ...]:
|
||||
"""Return discovery metadata that must remain stable across repeated queries."""
|
||||
cost_policy: tuple[str, Any]
|
||||
if callable(offer.cost):
|
||||
cost_policy = ('callable', type(offer.cost).__qualname__)
|
||||
else:
|
||||
cost_policy = ('factor', float(offer.cost))
|
||||
return (
|
||||
type(offer),
|
||||
offer.kind,
|
||||
offer.in_ptype,
|
||||
offer.out_ptype,
|
||||
tuple(float(value) for value in offer.parameter_domain),
|
||||
getattr(offer, 'ccw', None),
|
||||
cost_policy,
|
||||
)
|
||||
|
||||
|
||||
def _evaluated_cost(offer: PrimitiveOffer, parameter: float, endpoint: Port) -> float:
|
||||
"""Mirror the solver's one-endpoint base-cost path while honoring overrides."""
|
||||
if type(offer).cost_at is PrimitiveOffer.cost_at:
|
||||
return PrimitiveOffer._cost_for_endpoint(offer, parameter, endpoint)
|
||||
return float(offer.cost_at(parameter))
|
||||
|
||||
|
||||
def validate_tool_contract(tool: Tool, cases: Sequence[ToolContractCase]) -> None:
|
||||
"""Validate Tool discovery, offer callbacks, and one-step rendering.
|
||||
|
||||
All independent violations are collected and raised as one
|
||||
`ExceptionGroup` containing contextual `ToolContractError` instances.
|
||||
"""
|
||||
cases = tuple(cases)
|
||||
if not cases:
|
||||
raise ValueError('validate_tool_contract() requires at least one case')
|
||||
|
||||
errors: list[ToolContractError] = []
|
||||
|
||||
def violation(context: str, message: str, cause: Exception | None = None) -> None:
|
||||
err = ToolContractError(f'{context}: {message}')
|
||||
if cause is not None:
|
||||
err.__cause__ = cause
|
||||
errors.append(err)
|
||||
|
||||
def discover(case: ToolContractCase, context: str, repetition: str) -> tuple[PrimitiveOffer, ...] | None:
|
||||
expected_offer_type = {
|
||||
'straight': StraightOffer,
|
||||
'bend': BendOffer,
|
||||
's': SOffer,
|
||||
'u': UOffer,
|
||||
}[case.kind]
|
||||
try:
|
||||
kwargs = deepcopy(dict(case.tool_options))
|
||||
if case.kind == 'bend':
|
||||
kwargs['ccw'] = case.ccw
|
||||
offers = tool.primitive_offers(
|
||||
case.kind,
|
||||
in_ptype=case.in_ptype,
|
||||
out_ptype=case.out_ptype,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as err:
|
||||
violation(context, f'{repetition} discovery raised {type(err).__name__}: {err}', err)
|
||||
return None
|
||||
|
||||
if not isinstance(offers, tuple):
|
||||
violation(context, f'{repetition} discovery returned {type(offers).__name__}, expected tuple')
|
||||
return None
|
||||
valid = True
|
||||
for offer_index, offer in enumerate(offers):
|
||||
if not isinstance(offer, PrimitiveOffer):
|
||||
violation(
|
||||
context,
|
||||
f'{repetition} discovery item {offer_index} is {type(offer).__name__}, expected PrimitiveOffer',
|
||||
)
|
||||
valid = False
|
||||
elif offer.kind != case.kind:
|
||||
violation(
|
||||
context,
|
||||
f'{repetition} discovery item {offer_index} has kind {offer.kind!r}, expected {case.kind!r}',
|
||||
)
|
||||
valid = False
|
||||
elif not isinstance(offer, expected_offer_type):
|
||||
violation(
|
||||
context,
|
||||
f'{repetition} discovery item {offer_index} is {type(offer).__name__}, '
|
||||
f'expected {expected_offer_type.__name__}',
|
||||
)
|
||||
valid = False
|
||||
return offers if valid else None
|
||||
|
||||
for case_index, case in enumerate(cases):
|
||||
context = case.label or f'case {case_index} ({case.kind})'
|
||||
first = discover(case, context, 'first')
|
||||
second = discover(case, context, 'repeated')
|
||||
if first is None or second is None:
|
||||
continue
|
||||
if case.require_offers and not first:
|
||||
violation(context, 'discovery returned no offers')
|
||||
if len(first) != len(second):
|
||||
violation(context, f'discovery count changed from {len(first)} to {len(second)}')
|
||||
|
||||
matched_explicit = [False] * len(case.probe_parameters)
|
||||
for offer_index, offer in enumerate(first):
|
||||
offer_context = f'{context}, offer {offer_index}'
|
||||
repeated = second[offer_index] if offer_index < len(second) else None
|
||||
if repeated is not None and _offer_metadata(offer) != _offer_metadata(repeated):
|
||||
violation(offer_context, 'discovery metadata changed between repeated queries')
|
||||
|
||||
probes = _offer_probes(offer, case.probe_parameters)
|
||||
for explicit_index, parameter in enumerate(case.probe_parameters):
|
||||
try:
|
||||
offer.canonicalize_parameter(parameter)
|
||||
except Exception:
|
||||
continue
|
||||
matched_explicit[explicit_index] = True
|
||||
|
||||
stable_ptype: str | None = None
|
||||
stable_rotation: float | None = None
|
||||
has_stable_endpoint = False
|
||||
for parameter in probes:
|
||||
probe_context = f'{offer_context}, parameter {parameter:g}'
|
||||
try:
|
||||
endpoint = offer.endpoint_at(parameter)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'endpoint_at() raised {type(err).__name__}: {err}', err)
|
||||
continue
|
||||
if not isinstance(endpoint, Port):
|
||||
violation(probe_context, f'endpoint_at() returned {type(endpoint).__name__}, expected Port')
|
||||
continue
|
||||
if not numpy.all(numpy.isfinite(endpoint.offset)):
|
||||
violation(probe_context, 'endpoint offset must be finite')
|
||||
if endpoint.rotation is None or not numpy.isfinite(endpoint.rotation):
|
||||
violation(probe_context, 'endpoint rotation must be finite and specified')
|
||||
if not ptypes_compatible(endpoint.ptype, offer.out_ptype):
|
||||
violation(probe_context, 'endpoint ptype does not match declared out_ptype')
|
||||
|
||||
if offer.kind in ('straight', 'bend') and not scalar_close(endpoint.x, parameter):
|
||||
violation(probe_context, 'straight/bend endpoint x must equal its parameter')
|
||||
if offer.kind in ('s', 'u') and not scalar_close(endpoint.y, parameter):
|
||||
violation(probe_context, 'S/U endpoint y must equal its parameter')
|
||||
expected_rotation = {
|
||||
'straight': pi,
|
||||
'bend': -pi / 2 if isinstance(offer, BendOffer) and offer.ccw else pi / 2,
|
||||
's': pi,
|
||||
'u': 0.0,
|
||||
}[offer.kind]
|
||||
if endpoint.rotation is not None and not angles_equal(endpoint.rotation, expected_rotation):
|
||||
violation(
|
||||
probe_context,
|
||||
f'endpoint rotation does not match {offer.kind!r} geometry',
|
||||
)
|
||||
|
||||
if has_stable_endpoint:
|
||||
if endpoint.ptype != stable_ptype:
|
||||
violation(probe_context, 'endpoint ptype changes across the offer domain')
|
||||
if (
|
||||
endpoint.rotation is None
|
||||
or stable_rotation is None
|
||||
or not angles_equal(endpoint.rotation, stable_rotation)
|
||||
):
|
||||
violation(probe_context, 'endpoint rotation changes across the offer domain')
|
||||
else:
|
||||
stable_ptype = endpoint.ptype
|
||||
stable_rotation = endpoint.rotation
|
||||
has_stable_endpoint = True
|
||||
|
||||
try:
|
||||
cost = float(_evaluated_cost(offer, parameter, endpoint))
|
||||
if not numpy.isfinite(cost) or cost < 0:
|
||||
violation(probe_context, f'cost must be finite and nonnegative, got {cost!r}')
|
||||
except Exception as err:
|
||||
violation(probe_context, f'cost_at() raised {type(err).__name__}: {err}', err)
|
||||
cost = None
|
||||
|
||||
if repeated is not None:
|
||||
try:
|
||||
repeated_endpoint = repeated.endpoint_at(parameter)
|
||||
repeated_cost = float(_evaluated_cost(repeated, parameter, repeated_endpoint))
|
||||
if (
|
||||
not isinstance(repeated_endpoint, Port)
|
||||
or not array_close(repeated_endpoint.offset, endpoint.offset)
|
||||
or repeated_endpoint.ptype != endpoint.ptype
|
||||
or repeated_endpoint.rotation is None
|
||||
or endpoint.rotation is None
|
||||
or not angles_equal(repeated_endpoint.rotation, endpoint.rotation)
|
||||
):
|
||||
violation(probe_context, 'endpoint result changed after repeated discovery')
|
||||
if cost is not None and not scalar_close(repeated_cost, cost):
|
||||
violation(probe_context, 'cost result changed after repeated discovery')
|
||||
except Exception as err:
|
||||
violation(
|
||||
probe_context,
|
||||
f'repeated offer evaluation raised {type(err).__name__}: {err}',
|
||||
err,
|
||||
)
|
||||
|
||||
if case.check_bbox:
|
||||
try:
|
||||
offer.bbox_at(parameter)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'bbox_at() raised {type(err).__name__}: {err}', err)
|
||||
|
||||
try:
|
||||
data = offer.commit(parameter)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'commit() raised {type(err).__name__}: {err}', err)
|
||||
continue
|
||||
|
||||
try:
|
||||
start = Port((0, 0), rotation=pi, ptype=offer.in_ptype or 'unk')
|
||||
tree = tool.render((RenderStep(offer.kind, tool, start, endpoint.copy(), data),))
|
||||
except Exception as err:
|
||||
violation(probe_context, f'render() raised {type(err).__name__}: {err}', err)
|
||||
continue
|
||||
if not isinstance(tree, ILibrary):
|
||||
violation(probe_context, f'render() returned {type(tree).__name__}, expected ILibrary')
|
||||
continue
|
||||
try:
|
||||
top_name = tree.top()
|
||||
pattern = tree.top_pattern()
|
||||
except Exception as err:
|
||||
violation(probe_context, f'rendered tree has no valid top cell: {err}', err)
|
||||
continue
|
||||
missing = sorted(
|
||||
name
|
||||
for name in tree.dangling_refs(top_name)
|
||||
if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX)
|
||||
)
|
||||
if missing:
|
||||
violation(probe_context, f'rendered tree has missing single-use refs: {missing}')
|
||||
missing_ports = [name for name in ('A', 'B') if name not in pattern.ports]
|
||||
if missing_ports:
|
||||
violation(probe_context, f'rendered top cell is missing ports: {missing_ports}')
|
||||
continue
|
||||
input_port, output_port = pattern.ports['A'], pattern.ports['B']
|
||||
if not ptypes_compatible(input_port.ptype, offer.in_ptype):
|
||||
violation(probe_context, 'rendered input ptype does not match offer in_ptype')
|
||||
try:
|
||||
rendered_offset, rendered_rotation = input_port.measure_travel(output_port)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'unable to measure rendered endpoint: {err}', err)
|
||||
continue
|
||||
if not array_close(rendered_offset, endpoint.offset):
|
||||
violation(probe_context, 'rendered output offset does not match planned endpoint')
|
||||
if (
|
||||
rendered_rotation is None
|
||||
or endpoint.rotation is None
|
||||
or not angles_equal(rendered_rotation, endpoint.rotation)
|
||||
):
|
||||
violation(probe_context, 'rendered output rotation does not match planned endpoint')
|
||||
if not ptypes_compatible(output_port.ptype, endpoint.ptype):
|
||||
violation(probe_context, 'rendered output ptype does not match planned endpoint')
|
||||
|
||||
for parameter, matched in zip(case.probe_parameters, matched_explicit, strict=True):
|
||||
if not matched:
|
||||
violation(context, f'explicit probe {parameter:g} is outside every discovered offer domain')
|
||||
|
||||
if errors:
|
||||
raise ExceptionGroup(
|
||||
f'{type(tool).__name__} failed Tool contract validation with {len(errors)} violation(s)',
|
||||
errors,
|
||||
)
|
||||
|
|
@ -45,10 +45,11 @@ search across Tools or infer `Pather.retool()` boundaries; transitions,
|
|||
cross-ptype routes, and adapter shapes must be exposed by the active Tool as
|
||||
primitive offers.
|
||||
"""
|
||||
from typing import Literal, Any, Self
|
||||
from typing import Literal, Any, ClassVar, Self
|
||||
from collections import ChainMap
|
||||
from collections.abc import Sequence, Callable, Mapping
|
||||
from abc import ABC, abstractmethod
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field, replace
|
||||
from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt
|
||||
from types import MappingProxyType
|
||||
|
|
@ -68,14 +69,16 @@ from ..abstract import Abstract
|
|||
from ..library import ILibrary, Library, SINGLE_USE_PREFIX
|
||||
from ..error import BuildError
|
||||
from ..shapes import Path
|
||||
from ._tolerances import DOMAIN_ATOL, DOMAIN_RTOL, array_close, angles_equal
|
||||
from .error import ToolContractError
|
||||
|
||||
|
||||
def _canonicalize_domain_value(
|
||||
value: float,
|
||||
domain: tuple[float, float],
|
||||
*,
|
||||
rtol: float = 1e-9,
|
||||
atol: float = 1e-12,
|
||||
rtol: float = DOMAIN_RTOL,
|
||||
atol: float = DOMAIN_ATOL,
|
||||
) -> float:
|
||||
"""
|
||||
Canonicalize a solved primitive parameter against a route domain.
|
||||
|
|
@ -107,6 +110,29 @@ def _canonicalize_domain_value(
|
|||
return vv
|
||||
|
||||
|
||||
def _validated_parameter_domain(
|
||||
domain: tuple[float, float],
|
||||
*,
|
||||
name: str = 'Parameter domain',
|
||||
nonnegative_minimum: bool = False,
|
||||
) -> tuple[float, float]:
|
||||
"""Validate and normalize a primitive parameter domain at construction time."""
|
||||
try:
|
||||
lower_raw, upper_raw = domain
|
||||
lower, upper = float(lower_raw), float(upper_raw)
|
||||
except (TypeError, ValueError, OverflowError) as err:
|
||||
raise BuildError(f'{name} must be a pair of numeric bounds') from err
|
||||
if scalar_isnan(lower) or scalar_isnan(upper):
|
||||
raise BuildError(f'{name} must not contain NaN values: {domain}')
|
||||
if lower > upper:
|
||||
raise BuildError(f'{name} lower bound must not exceed upper bound: {domain}')
|
||||
if lower == upper and not scalar_isfinite(lower):
|
||||
raise BuildError(f'{name} singleton must be finite: {domain}')
|
||||
if nonnegative_minimum and (not scalar_isfinite(lower) or lower < 0):
|
||||
raise BuildError(f'{name} must have a finite, nonnegative minimum: {domain}')
|
||||
return lower, upper
|
||||
|
||||
|
||||
EndpointCallable = Callable[[float], Port]
|
||||
CommitCallable = Callable[[float], Any]
|
||||
BBoxCallable = Callable[[float], NDArray[numpy.float64]]
|
||||
|
|
@ -114,6 +140,21 @@ DataCallable = Callable[[float], Any]
|
|||
BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]]
|
||||
CostCallable = Callable[[float, Port], float]
|
||||
PrimitiveKind = Literal['straight', 'bend', 's', 'u']
|
||||
RenderStepKind = PrimitiveKind | Literal['plug']
|
||||
RenderOpcode = Literal['L', 'S', 'U', 'P']
|
||||
|
||||
|
||||
def _opcode_for_kind(kind: RenderStepKind) -> RenderOpcode:
|
||||
"""Return the legacy render opcode derived from one canonical kind."""
|
||||
if kind in ('straight', 'bend'):
|
||||
return 'L'
|
||||
if kind == 's':
|
||||
return 'S'
|
||||
if kind == 'u':
|
||||
return 'U'
|
||||
if kind == 'plug':
|
||||
return 'P'
|
||||
raise BuildError(f'Unrecognized render-step kind {kind!r}')
|
||||
|
||||
|
||||
def _validated_cost(cost: CostCallable | float) -> CostCallable | float:
|
||||
|
|
@ -130,13 +171,13 @@ def _validated_cost(cost: CostCallable | float) -> CostCallable | float:
|
|||
|
||||
|
||||
def _validated_cost_value(value: Any) -> float:
|
||||
"""Return a canonical evaluated cost or raise `BuildError`."""
|
||||
"""Return a canonical evaluated cost or raise `ToolContractError`."""
|
||||
try:
|
||||
cost = float(value)
|
||||
except (TypeError, ValueError, OverflowError) as err:
|
||||
raise BuildError(f'Primitive cost must be a number, got {value!r}') from err
|
||||
raise ToolContractError(f'Primitive cost must be a number, got {value!r}') from err
|
||||
if not scalar_isfinite(cost) or cost < 0:
|
||||
raise BuildError(f'Primitive cost must be nonnegative and finite, got {cost:g}')
|
||||
raise ToolContractError(f'Primitive cost must be nonnegative and finite, got {cost:g}')
|
||||
return cost
|
||||
|
||||
|
||||
|
|
@ -218,6 +259,7 @@ class PrimitiveOffer(ABC):
|
|||
"""Reserved footprint metadata; the current solver does not inspect it."""
|
||||
endpoint_planner: EndpointCallable | None = None
|
||||
commit_planner: CommitCallable | None = None
|
||||
kind: ClassVar[PrimitiveKind]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
has_endpoint = self.endpoint_planner is not None
|
||||
|
|
@ -225,12 +267,18 @@ class PrimitiveOffer(ABC):
|
|||
|
||||
if has_endpoint != has_commit:
|
||||
raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner')
|
||||
_validated_parameter_domain(
|
||||
self.parameter_domain,
|
||||
nonnegative_minimum=self.kind in ('straight', 'bend'),
|
||||
)
|
||||
object.__setattr__(self, 'cost', _validated_cost(self.cost))
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def opcode(self) -> Literal['L', 'S', 'U']:
|
||||
raise NotImplementedError
|
||||
"""Legacy render opcode derived from this offer's canonical kind."""
|
||||
opcode = _opcode_for_kind(self.kind)
|
||||
assert opcode != 'P'
|
||||
return opcode
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
|
|
@ -266,8 +314,12 @@ class PrimitiveOffer(ABC):
|
|||
out_port = self.endpoint_planner(selected)
|
||||
else:
|
||||
out_port = self.endpoint_at(selected)
|
||||
return self._cost_for_endpoint(selected, out_port)
|
||||
|
||||
def _cost_for_endpoint(self, parameter: float, out_port: Port) -> float:
|
||||
"""Evaluate the base cost policy using an endpoint already produced by planning."""
|
||||
if callable(self.cost):
|
||||
value = self.cost(selected, out_port)
|
||||
value = self.cost(parameter, out_port)
|
||||
else:
|
||||
default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y))
|
||||
value = self.cost * default_cost
|
||||
|
|
@ -315,6 +367,7 @@ class PrimitiveOffer(ABC):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class StraightOffer(PrimitiveOffer):
|
||||
"""Straight or straight-like primitive parameterized by public route length."""
|
||||
kind: ClassVar[Literal['straight']] = 'straight'
|
||||
length_domain: tuple[float, float] = (0.0, numpy.inf)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -377,10 +430,6 @@ class StraightOffer(PrimitiveOffer):
|
|||
length_domain = (float(endpoint.x), float(endpoint.x)),
|
||||
)
|
||||
|
||||
@property
|
||||
def opcode(self) -> Literal['L']:
|
||||
return 'L'
|
||||
|
||||
@property
|
||||
def parameter_domain(self) -> tuple[float, float]:
|
||||
return self.length_domain
|
||||
|
|
@ -389,6 +438,7 @@ class StraightOffer(PrimitiveOffer):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class BendOffer(PrimitiveOffer):
|
||||
"""Single-turn L-route primitive parameterized by public route length."""
|
||||
kind: ClassVar[Literal['bend']] = 'bend'
|
||||
ccw: bool = True
|
||||
length_domain: tuple[float, float] = (0.0, numpy.inf)
|
||||
|
||||
|
|
@ -454,10 +504,6 @@ class BendOffer(PrimitiveOffer):
|
|||
length_domain = (float(endpoint.x), float(endpoint.x)),
|
||||
)
|
||||
|
||||
@property
|
||||
def opcode(self) -> Literal['L']:
|
||||
return 'L'
|
||||
|
||||
@property
|
||||
def parameter_domain(self) -> tuple[float, float]:
|
||||
return self.length_domain
|
||||
|
|
@ -466,6 +512,7 @@ class BendOffer(PrimitiveOffer):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class SOffer(PrimitiveOffer):
|
||||
"""Non-turning S-route primitive parameterized by jog for a fixed route length."""
|
||||
kind: ClassVar[Literal['s']] = 's'
|
||||
jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -526,10 +573,6 @@ class SOffer(PrimitiveOffer):
|
|||
jog_domain = (float(endpoint.y), float(endpoint.y)),
|
||||
)
|
||||
|
||||
@property
|
||||
def opcode(self) -> Literal['S']:
|
||||
return 'S'
|
||||
|
||||
@property
|
||||
def parameter_domain(self) -> tuple[float, float]:
|
||||
return self.jog_domain
|
||||
|
|
@ -538,6 +581,7 @@ class SOffer(PrimitiveOffer):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class UOffer(PrimitiveOffer):
|
||||
"""U-turn-like primitive parameterized by jog for a fixed route length."""
|
||||
kind: ClassVar[Literal['u']] = 'u'
|
||||
jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -598,10 +642,6 @@ class UOffer(PrimitiveOffer):
|
|||
jog_domain = (float(endpoint.y), float(endpoint.y)),
|
||||
)
|
||||
|
||||
@property
|
||||
def opcode(self) -> Literal['U']:
|
||||
return 'U'
|
||||
|
||||
@property
|
||||
def parameter_domain(self) -> tuple[float, float]:
|
||||
return self.jog_domain
|
||||
|
|
@ -616,16 +656,11 @@ class RenderStep:
|
|||
passes batches of compatible steps to `Tool.render()` when `Pather.render()`
|
||||
is called.
|
||||
"""
|
||||
opcode: Literal['L', 'S', 'U', 'P']
|
||||
""" What operation is being performed.
|
||||
L: straight or single-bend primitive
|
||||
S: S-like primitive
|
||||
U: U-like primitive
|
||||
P: plug
|
||||
"""
|
||||
kind: RenderStepKind
|
||||
"""Canonical primitive kind, or `plug` for an assembly boundary."""
|
||||
|
||||
tool: 'Tool | None'
|
||||
""" Tool that produced this step, or `None` for `opcode='P'`. """
|
||||
"""Tool that produced this step, or `None` for `kind='plug'`."""
|
||||
|
||||
start_port: Port
|
||||
""" Input-side port before this step is rendered. """
|
||||
|
|
@ -637,18 +672,28 @@ class RenderStep:
|
|||
""" Arbitrary tool-specific data"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.opcode != 'P' and self.tool is None:
|
||||
raise BuildError('Got tool=None but the opcode is not "P"')
|
||||
if self.kind not in ('straight', 'bend', 's', 'u', 'plug'):
|
||||
raise BuildError(f'Unrecognized RenderStep kind {self.kind!r}')
|
||||
if self.kind == 'plug':
|
||||
if self.tool is not None:
|
||||
raise BuildError('RenderStep kind="plug" requires tool=None')
|
||||
elif self.tool is None:
|
||||
raise BuildError('RenderStep requires a Tool unless kind="plug"')
|
||||
|
||||
@property
|
||||
def opcode(self) -> RenderOpcode:
|
||||
"""Legacy opcode derived from the canonical render-step kind."""
|
||||
return _opcode_for_kind(self.kind)
|
||||
|
||||
def is_continuous_with(self, other: 'RenderStep') -> bool:
|
||||
"""
|
||||
Check if another RenderStep can be appended to this one.
|
||||
"""
|
||||
# Check continuity with tolerance
|
||||
offsets_match = bool(numpy.allclose(other.start_port.offset, self.end_port.offset))
|
||||
offsets_match = array_close(other.start_port.offset, self.end_port.offset)
|
||||
rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or (
|
||||
other.start_port.rotation is not None and self.end_port.rotation is not None and
|
||||
bool(numpy.isclose(other.start_port.rotation, self.end_port.rotation))
|
||||
angles_equal(other.start_port.rotation, self.end_port.rotation)
|
||||
)
|
||||
return offsets_match and rotations_match
|
||||
|
||||
|
|
@ -664,7 +709,7 @@ class RenderStep:
|
|||
pp.translate(translation)
|
||||
|
||||
return RenderStep(
|
||||
opcode = self.opcode,
|
||||
kind = self.kind,
|
||||
tool = self.tool,
|
||||
start_port = new_start,
|
||||
end_port = new_end,
|
||||
|
|
@ -682,7 +727,7 @@ class RenderStep:
|
|||
new_end.flip_across(axis=axis)
|
||||
|
||||
return RenderStep(
|
||||
opcode = self.opcode,
|
||||
kind = self.kind,
|
||||
tool = self.tool,
|
||||
start_port = new_start,
|
||||
end_port = new_end,
|
||||
|
|
@ -723,6 +768,10 @@ class Tool(ABC):
|
|||
- `'s'`: a non-turning `SOffer`
|
||||
- `'u'`: an `UOffer`
|
||||
|
||||
Every returned offer must have the requested canonical `kind` and use
|
||||
its corresponding concrete offer class. Contract mismatches are fatal
|
||||
rather than recoverable candidate rejection.
|
||||
|
||||
Other `kwargs` come only from the Pather call's explicit
|
||||
`tool_options` mapping. Tools are responsible for validating the keys
|
||||
they support.
|
||||
|
|
@ -1013,6 +1062,11 @@ class AutoTool(Tool):
|
|||
two-port straight. Metadata inference does not receive route options.
|
||||
"""
|
||||
cost = _validated_cost(cost)
|
||||
length_range = _validated_parameter_domain(
|
||||
length_range,
|
||||
name='Straight length_range',
|
||||
nonnegative_minimum=True,
|
||||
)
|
||||
ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name)
|
||||
|
||||
def data_at(length: float) -> AutoTool.GeneratedData:
|
||||
|
|
@ -1055,25 +1109,37 @@ class AutoTool(Tool):
|
|||
|
||||
in_port = abstract.ports[in_port_name]
|
||||
out_port = abstract.ports[out_port_name]
|
||||
out_ptype = out_port.ptype
|
||||
source_clockwise = self._bend_clockwise(in_port, out_port)
|
||||
if clockwise is not None and bool(clockwise) != source_clockwise:
|
||||
raise BuildError('Bend clockwise argument does not match port orientations')
|
||||
|
||||
for ccw in (False, True):
|
||||
target_clockwise = not bool(ccw)
|
||||
bend_dxy, bend_angle = self._bend2dxy(in_port, out_port, source_clockwise, target_clockwise)
|
||||
source_matches_target = source_clockwise == target_clockwise
|
||||
if source_matches_target or mirror:
|
||||
entry_port_name = in_port_name
|
||||
entry_port, exit_port = in_port, out_port
|
||||
geometry_clockwise = source_clockwise
|
||||
else:
|
||||
entry_port_name = out_port_name
|
||||
entry_port, exit_port = out_port, in_port
|
||||
geometry_clockwise = self._bend_clockwise(entry_port, exit_port)
|
||||
|
||||
bend_dxy, bend_angle = self._bend2dxy(
|
||||
entry_port,
|
||||
exit_port,
|
||||
geometry_clockwise,
|
||||
target_clockwise,
|
||||
)
|
||||
bend_dx = float(bend_dxy[0])
|
||||
bend_dy = float(bend_dxy[1])
|
||||
source_matches_target = source_clockwise == target_clockwise
|
||||
mirrored = mirror and not source_matches_target
|
||||
port_name = in_port_name if (mirror or source_matches_target) else out_port_name
|
||||
reusable_data = self.ReusableData(abstract, port_name, mirrored)
|
||||
endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=out_ptype)
|
||||
reusable_data = self.ReusableData(abstract, entry_port_name, mirrored)
|
||||
endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=exit_port.ptype)
|
||||
|
||||
self._bend_offers[int(ccw)].append(BendOffer.prebuilt(
|
||||
in_ptype = in_port.ptype,
|
||||
out_ptype = out_ptype,
|
||||
in_ptype = entry_port.ptype,
|
||||
out_ptype = exit_port.ptype,
|
||||
endpoint = endpoint,
|
||||
data = reusable_data,
|
||||
ccw = ccw,
|
||||
|
|
@ -1105,6 +1171,11 @@ class AutoTool(Tool):
|
|||
Metadata inference and endpoint planning do not receive route options.
|
||||
"""
|
||||
cost = _validated_cost(cost)
|
||||
jog_range = _validated_parameter_domain(
|
||||
jog_range,
|
||||
name='S-bend jog_range',
|
||||
nonnegative_minimum=True,
|
||||
)
|
||||
ptype, in_port_name, out_port_name = self._infer_sbend_metadata(
|
||||
fn,
|
||||
jog_range,
|
||||
|
|
@ -1345,9 +1416,11 @@ class AutoTool(Tool):
|
|||
|
||||
@staticmethod
|
||||
def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]:
|
||||
lower, upper = (float(magnitude_range[0]), float(magnitude_range[1]))
|
||||
if lower < 0 or lower > upper:
|
||||
return ()
|
||||
lower, upper = _validated_parameter_domain(
|
||||
magnitude_range,
|
||||
name='S-bend jog_range',
|
||||
nonnegative_minimum=True,
|
||||
)
|
||||
|
||||
if lower == upper:
|
||||
if lower == 0:
|
||||
|
|
@ -1374,7 +1447,10 @@ class AutoTool(Tool):
|
|||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = out_ptype
|
||||
ccw = kwargs.pop('ccw', None)
|
||||
tool_options = MappingProxyType(dict(kwargs))
|
||||
try:
|
||||
tool_options = MappingProxyType(deepcopy(dict(kwargs)))
|
||||
except Exception as err:
|
||||
raise BuildError('AutoTool tool_options must be deep-copyable') from err
|
||||
|
||||
def configured(offer: PrimitiveOffer) -> PrimitiveOffer:
|
||||
if not tool_options:
|
||||
|
|
@ -1652,12 +1728,12 @@ class PathTool(Tool):
|
|||
transform = rotation_matrix_2d(port_rot + pi)
|
||||
delta = step.end_port.offset - step.start_port.offset
|
||||
local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta
|
||||
if step.opcode == 'L':
|
||||
if step.kind in ('straight', 'bend'):
|
||||
local_vertices = self._plan_l_vertices(float(local_end[0]), float(local_end[1]))
|
||||
elif step.opcode == 'S':
|
||||
elif step.kind == 's':
|
||||
local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1]))
|
||||
else:
|
||||
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
|
||||
raise BuildError(f'Unsupported PathTool render kind {step.kind!r}')
|
||||
|
||||
for vertex in local_vertices[1:]:
|
||||
path_vertices.append(step.start_port.offset + transform @ vertex)
|
||||
|
|
@ -1666,6 +1742,6 @@ class PathTool(Tool):
|
|||
pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
|
||||
pat.ports = {
|
||||
port_names[0]: local_batch[0].start_port.copy().rotate(pi),
|
||||
port_names[1]: local_batch[-1].end_port.copy().rotate(pi),
|
||||
port_names[1]: local_batch[-1].end_port.copy(),
|
||||
}
|
||||
return tree
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from numpy.typing import ArrayLike, NDArray
|
|||
|
||||
from ..utils import rotation_matrix_2d, SupportsBool
|
||||
from ..error import BuildError
|
||||
from ._tolerances import manhattan_axis
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ports import Port
|
||||
|
|
@ -135,7 +136,13 @@ def ell(
|
|||
raise BuildError('set_rotation must be finite')
|
||||
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
||||
|
||||
is_horizontal = numpy.isclose(rotations[0] % pi, 0)
|
||||
axis = manhattan_axis(float(rotations[0]))
|
||||
if bound_type in _POSITION_BOUND_TYPES and axis is None:
|
||||
raise BuildError(
|
||||
'Positional bounds require a nearly Manhattan port direction; '
|
||||
f'got rotation {rotations[0]:g}'
|
||||
)
|
||||
is_horizontal = axis == 0
|
||||
if bound_type in ('ymin', 'ymax') and is_horizontal:
|
||||
raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!')
|
||||
if bound_type in ('xmin', 'xmax') and not is_horizontal:
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> N
|
|||
"""
|
||||
Assert a simple render-step bend budget for route signatures.
|
||||
"""
|
||||
bend_count = sum(1 for step in pather._paths[portspec] if step.opcode == 'L' and step.start_port.rotation != step.end_port.rotation)
|
||||
bend_count = sum(1 for step in pather._paths[portspec] if step.kind == 'bend')
|
||||
assert bend_count <= max_bends
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from contextlib import suppress
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ def rendered_offer_tree(
|
|||
) -> Library:
|
||||
start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk")
|
||||
end, data = commit_offer(offer, parameter)
|
||||
return tool.render((RenderStep(offer.opcode, tool, start, end, data),))
|
||||
return tool.render((RenderStep(offer.kind, tool, start, end, data),))
|
||||
|
||||
|
||||
def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
||||
|
|
@ -375,7 +376,7 @@ def wildcard_transition_tool() -> tuple[AutoTool, Library]:
|
|||
tool = (
|
||||
AutoTool(bbox_library=lib)
|
||||
.add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8))
|
||||
.add_sbend(make_core_sbend, "core", "A", "B", jog_range=(-1e8, 1e8))
|
||||
.add_sbend(make_core_sbend, "core", "A", "B", jog_range=(0, 1e8))
|
||||
.add_transition(lib.abstract("wild_core"), "WILD", "CORE")
|
||||
)
|
||||
return tool, lib
|
||||
|
|
@ -684,6 +685,24 @@ def test_autotool_add_bend_inferred_names_allow_rotational_reuse_without_mirror(
|
|||
assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "wire")
|
||||
|
||||
|
||||
def test_autotool_add_bend_reverse_reuse_swaps_cross_ptypes() -> None:
|
||||
lib = Library()
|
||||
bend = make_bend(2, ptype="core", clockwise=True)
|
||||
bend.ports["B"].ptype = "external"
|
||||
lib["bend"] = bend
|
||||
|
||||
tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend"), mirror=False)
|
||||
|
||||
cw_offer = tool.primitive_offers("bend", in_ptype="core", ccw=False)[0]
|
||||
ccw_offer = tool.primitive_offers("bend", in_ptype="external", ccw=True)[0]
|
||||
assert (cw_offer.in_ptype, cw_offer.out_ptype) == ("core", "external")
|
||||
assert (ccw_offer.in_ptype, ccw_offer.out_ptype) == ("external", "core")
|
||||
assert cw_offer.commit(2).port_name == "A"
|
||||
assert ccw_offer.commit(2).port_name == "B"
|
||||
assert_rendered_offer_endpoint_matches_plan(tool, cw_offer, 2, "core")
|
||||
assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "external")
|
||||
|
||||
|
||||
def test_autotool_add_bend_rejects_clockwise_mismatch() -> None:
|
||||
lib = Library()
|
||||
lib["bend"] = make_bend(2, ptype="wire", clockwise=True)
|
||||
|
|
@ -966,6 +985,28 @@ def test_autotool_validates_cost_before_registering_any_offers() -> None:
|
|||
AutoTool().add_sbend(unused_sbend, jog_range=(-1, 1), cost=-1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('length_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)])
|
||||
def test_autotool_rejects_invalid_straight_range_before_sampling(
|
||||
length_range: tuple[float, float],
|
||||
) -> None:
|
||||
def unused_straight(_length: float) -> Pattern:
|
||||
raise AssertionError('invalid range should be rejected before metadata inference')
|
||||
|
||||
with pytest.raises(BuildError, match='Straight length_range'):
|
||||
AutoTool().add_straight(unused_straight, length_range=length_range)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('jog_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)])
|
||||
def test_autotool_rejects_invalid_sbend_range_before_sampling(
|
||||
jog_range: tuple[float, float],
|
||||
) -> None:
|
||||
def unused_sbend(_jog: float) -> Pattern:
|
||||
raise AssertionError('invalid range should be rejected before metadata inference')
|
||||
|
||||
with pytest.raises(BuildError, match='S-bend jog_range'):
|
||||
AutoTool().add_sbend(unused_sbend, jog_range=jog_range)
|
||||
|
||||
|
||||
def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
|
||||
tool = make_sbend_tool((4, 4))
|
||||
offers = tool.primitive_offers('s', in_ptype="core")
|
||||
|
|
@ -982,9 +1023,8 @@ def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
|
|||
|
||||
|
||||
def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None:
|
||||
tool = make_sbend_tool((-4, 4))
|
||||
|
||||
assert tool.primitive_offers('s', in_ptype="core") == ()
|
||||
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
|
||||
make_sbend_tool((-4, 4))
|
||||
|
||||
|
||||
def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None:
|
||||
|
|
@ -1107,7 +1147,12 @@ def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, L
|
|||
def test_autotool_generated_primitives_snapshot_route_options() -> None:
|
||||
markers: list[str | None] = []
|
||||
|
||||
def make_marked_straight(length: float, marker: str | None = None) -> Pattern:
|
||||
def make_marked_straight(
|
||||
length: float,
|
||||
marker: str | None = None,
|
||||
nested: dict[str, list[int]] | None = None,
|
||||
) -> Pattern:
|
||||
_ = nested
|
||||
markers.append(marker)
|
||||
return make_straight(length, ptype="wire")
|
||||
|
||||
|
|
@ -1115,15 +1160,16 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None:
|
|||
p = Pather(Library(), tools=tool, render='deferred')
|
||||
p.ports["A"] = Port((0, 0), 0, ptype="wire")
|
||||
|
||||
first_options = {'marker': 'first'}
|
||||
first_options = {'marker': 'first', 'nested': {'values': [1]}}
|
||||
p.straight("A", 5, tool_options=first_options)
|
||||
first_options['marker'] = 'mutated'
|
||||
first_options['nested']['values'].append(2)
|
||||
p.straight("A", 6, tool_options={'marker': 'second'})
|
||||
|
||||
first_data, second_data = (step.data for step in p._paths['A'])
|
||||
assert isinstance(first_data, AutoTool.GeneratedData)
|
||||
assert isinstance(second_data, AutoTool.GeneratedData)
|
||||
assert dict(first_data.tool_options) == {'marker': 'first'}
|
||||
assert dict(first_data.tool_options) == {'marker': 'first', 'nested': {'values': [1]}}
|
||||
assert dict(second_data.tool_options) == {'marker': 'second'}
|
||||
|
||||
p.render()
|
||||
|
|
@ -1131,6 +1177,17 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None:
|
|||
assert markers == ['first', 'second']
|
||||
|
||||
|
||||
def test_autotool_route_options_must_be_deepcopyable() -> None:
|
||||
class NotCopyable:
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> None:
|
||||
_ = memo
|
||||
raise TypeError('no copy')
|
||||
|
||||
tool = AutoTool().add_straight(make_straight, 'wire', 'A')
|
||||
with pytest.raises(BuildError, match='must be deep-copyable'):
|
||||
tool.primitive_offers('straight', marker=NotCopyable())
|
||||
|
||||
|
||||
def test_autotool_route_options_do_not_attach_to_reusable_bends(
|
||||
multi_bend_tool: tuple[AutoTool, Library],
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import pytest
|
|||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy
|
||||
from masque import (
|
||||
MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy,
|
||||
)
|
||||
from masque.builder.planner import RoutePortContext, RoutingPlanner
|
||||
from masque.builder.planner.planner import NoLegalRouteError
|
||||
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
|
||||
|
|
@ -510,6 +512,45 @@ def test_pather_positional_bound_requires_port_rotation() -> None:
|
|||
p.trace_to('A', None, x=-5)
|
||||
|
||||
|
||||
def test_pather_positional_bound_rejects_non_manhattan_rotation() -> None:
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(Library(), tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='nearly Manhattan'):
|
||||
p.trace_to('A', None, p=-5)
|
||||
|
||||
|
||||
def test_pather_positional_bound_accepts_nearly_manhattan_rotation() -> None:
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(Library(), tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=1e-10, ptype='wire')
|
||||
|
||||
p.trace_to('A', None, x=-5)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -5e-10), atol=1e-8)
|
||||
|
||||
|
||||
def test_pather_bundle_position_bound_rejects_non_manhattan_rotation() -> None:
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(Library(), tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='nearly Manhattan'):
|
||||
p.trace(['A', 'B'], None, pmin=-5)
|
||||
|
||||
|
||||
def test_pather_bundle_extension_bound_allows_non_manhattan_rotation() -> None:
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(Library(), tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire')
|
||||
|
||||
p.trace(['A', 'B'], None, emin=5)
|
||||
assert len(p._paths['A']) == 1
|
||||
assert len(p._paths['B']) == 1
|
||||
|
||||
|
||||
def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
|
|
|
|||
|
|
@ -327,6 +327,22 @@ def test_selection_management() -> None:
|
|||
assert 'B' not in p.pattern.ports
|
||||
assert pp.ports == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['plug', 'plugged', 'rename', 'mark', 'fork'])
|
||||
def test_empty_selection_exact_one_operations_raise_build_error(action: str) -> None:
|
||||
p = Pather(Library())
|
||||
pp = p.at([])
|
||||
operations = {
|
||||
'plug': lambda: pp.plug('unused', 'A'),
|
||||
'plugged': lambda: pp.plugged('A'),
|
||||
'rename': lambda: pp.rename('new'),
|
||||
'mark': lambda: pp.mark('new'),
|
||||
'fork': lambda: pp.fork('new'),
|
||||
}
|
||||
|
||||
with pytest.raises(BuildError, match='expected exactly one'):
|
||||
operations[action]()
|
||||
|
||||
def test_mark_fork() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import numpy
|
|||
import pytest
|
||||
from numpy import pi
|
||||
|
||||
from masque import Library, Path, Port, Pather
|
||||
from masque import Library, Path, Port, Pather, ToolContractError
|
||||
from masque.builder.planner import RoutingPlanner
|
||||
from masque.builder.planner.planner import Solver, SolverRequest
|
||||
from masque.builder.tools import (
|
||||
BendOffer,
|
||||
PathTool,
|
||||
|
|
@ -46,6 +47,81 @@ class PlanningOnlyTool(Tool):
|
|||
return tree
|
||||
|
||||
|
||||
def test_tool_contract_error_is_fatal_even_when_an_alternate_offer_exists() -> None:
|
||||
class BrokenTool(PlanningOnlyTool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
return (
|
||||
StraightOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wrong'),
|
||||
commit_planner=lambda length: length,
|
||||
),
|
||||
StraightOffer.generated('wire', lambda length: length),
|
||||
)
|
||||
|
||||
pather = Pather(
|
||||
Library(),
|
||||
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
|
||||
tools=BrokenTool(),
|
||||
render='deferred',
|
||||
)
|
||||
with pytest.raises(ToolContractError, match='declared offer out_ptype'):
|
||||
pather.straight('A', 5)
|
||||
|
||||
|
||||
def test_callback_build_error_remains_recoverable_candidate_rejection() -> None:
|
||||
class RecoverableTool(PlanningOnlyTool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def rejected(length: float) -> Port:
|
||||
raise BuildError(f'unsupported length {length}')
|
||||
|
||||
return (
|
||||
StraightOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
endpoint_planner=rejected,
|
||||
commit_planner=lambda length: length,
|
||||
),
|
||||
StraightOffer.generated('wire', lambda length: length),
|
||||
)
|
||||
|
||||
pather = Pather(
|
||||
Library(),
|
||||
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
|
||||
tools=RecoverableTool(),
|
||||
render='deferred',
|
||||
)
|
||||
pather.straight('A', 5)
|
||||
assert numpy.allclose(pather.ports['A'].offset, (-5, 0))
|
||||
|
||||
|
||||
def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None:
|
||||
class CountingTool(PlanningOnlyTool):
|
||||
calls = 0
|
||||
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
self.calls += 1
|
||||
assert kwargs == {'nested': []}
|
||||
return ()
|
||||
|
||||
tool = CountingTool()
|
||||
solver = Solver(SolverRequest(
|
||||
family='straight',
|
||||
tool=tool,
|
||||
in_ptype='wire',
|
||||
tool_options={'nested': []},
|
||||
))
|
||||
solver.primitive_offers('straight', 'wire')
|
||||
solver.primitive_offers('straight', 'wire')
|
||||
assert tool.calls == 1
|
||||
|
||||
|
||||
def test_tool_requires_primitive_offers_override() -> None:
|
||||
class RenderOnlyTool(Tool):
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
|
|
@ -100,7 +176,19 @@ def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float)
|
|||
|
||||
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
|
||||
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
|
||||
canonicalize_offer_parameter(3, (10, 0))
|
||||
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=(10, 0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('domain', [(numpy.nan, 1), (1, numpy.nan), (numpy.inf, numpy.inf)])
|
||||
def test_offer_rejects_invalid_domain_at_construction(domain: tuple[float, float]) -> None:
|
||||
with pytest.raises(BuildError, match='domain'):
|
||||
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('domain', [(-1, 2), (-numpy.inf, 2)])
|
||||
def test_length_offer_requires_finite_nonnegative_minimum(domain: tuple[float, float]) -> None:
|
||||
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
|
||||
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
|
||||
|
||||
|
||||
def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None:
|
||||
|
|
@ -653,7 +741,7 @@ def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving(
|
|||
p.straight('A', 7)
|
||||
|
||||
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7}
|
||||
assert invalid_parameters == [0.0, 0.0]
|
||||
assert invalid_parameters == [0.0]
|
||||
|
||||
|
||||
def test_pather_commits_only_selected_offer() -> None:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import numpy
|
|||
from numpy import pi
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..builder import Pather, RouteError
|
||||
from ..builder import Pather, RouteError, ToolContractError
|
||||
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
|
||||
from ..error import BuildError
|
||||
from ..library import Library
|
||||
|
|
@ -307,7 +307,7 @@ def test_pathtool_bend_offer_render_geometry_matches_ports() -> None:
|
|||
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
|
||||
start = Port((0, 0), rotation=pi, ptype="wire")
|
||||
end = offer.endpoint_at(1)
|
||||
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),))
|
||||
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(1)),))
|
||||
pat = tree.top_pattern()
|
||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
||||
|
||||
|
|
@ -321,13 +321,13 @@ def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
|
|||
offer = tool.primitive_offers("s", in_ptype="wire")[0]
|
||||
start = Port((0, 0), rotation=pi, ptype="wire")
|
||||
end = offer.endpoint_at(4)
|
||||
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),))
|
||||
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(4)),))
|
||||
pat = tree.top_pattern()
|
||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
||||
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10)
|
||||
|
||||
def test_deferred_render_uturn_fallback() -> None:
|
||||
lib = Library()
|
||||
|
|
@ -362,7 +362,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
|||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
||||
})
|
||||
child = Pattern(annotations={'batch': [len(batch)]})
|
||||
top.ref('_seg')
|
||||
|
|
@ -402,7 +402,7 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
|
|||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
||||
})
|
||||
child = Pattern(annotations={'length': [length]})
|
||||
top.ref('_seg')
|
||||
|
|
@ -479,7 +479,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
|||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
||||
})
|
||||
top.ref('shared')
|
||||
tree['_top'] = top
|
||||
|
|
@ -530,6 +530,52 @@ def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append:
|
|||
assert not p.pattern.refs
|
||||
assert not lib
|
||||
|
||||
|
||||
def test_pather_render_rejects_opposite_output_rotation() -> None:
|
||||
class WrongRotationTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
return (StraightOffer.generated('wire', lambda length: length),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data
|
||||
tree = Library()
|
||||
tree['_top'] = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
})
|
||||
return tree
|
||||
|
||||
p = Pather(Library(), tools=WrongRotationTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.straight('A', 10)
|
||||
|
||||
with pytest.raises(ToolContractError, match='does not match planned endpoint'):
|
||||
p.render()
|
||||
|
||||
|
||||
def test_pather_render_allows_unspecified_output_rotation() -> None:
|
||||
class UnspecifiedRotationTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
return (StraightOffer.generated('wire', lambda length: length),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data
|
||||
tree = Library()
|
||||
tree['_top'] = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), None, ptype='wire'),
|
||||
})
|
||||
return tree
|
||||
|
||||
p = Pather(Library(), tools=UnspecifiedRotationTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.straight('A', 10)
|
||||
p.render()
|
||||
|
||||
@pytest.mark.parametrize('append', [True, False])
|
||||
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
|
||||
class WrongPtypeTool(Tool):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue