[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.
|
common callback behavior. It is not the normal class users should instantiate.
|
||||||
The concrete offer classes carry the semantic fields (`length_domain`,
|
The concrete offer classes carry the semantic fields (`length_domain`,
|
||||||
`jog_domain`, `ccw`) so tools do not need to encode primitive identity in
|
`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:
|
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
|
not leak into that namespace, and tool options are not forwarded to
|
||||||
`Tool.render()`. An offer that needs route-specific render behavior must capture
|
`Tool.render()`. An offer that needs route-specific render behavior must capture
|
||||||
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
|
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
|
||||||
does this automatically for generated straight and S-bend primitives: the flat
|
does this automatically for generated straight and S-bend primitives: the
|
||||||
mapping is snapshotted per selected primitive and passed to its generator as
|
mapping is deep-copied per offer discovery and passed to its generator as
|
||||||
keyword arguments during rendering. AutoTool options must not change generated
|
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
|
ports or endpoint geometry. `PathTool` defines no tool options and rejects
|
||||||
nonempty mappings.
|
nonempty mappings.
|
||||||
|
|
||||||
|
|
@ -360,9 +380,51 @@ Primitive offers are local planning objects:
|
||||||
- `parameterized_bbox` may carry opaque future-router footprint metadata
|
- `parameterized_bbox` may carry opaque future-router footprint metadata
|
||||||
- `commit(parameter)` returns opaque render data consumed later by `render()`
|
- `commit(parameter)` returns opaque render data consumed later by `render()`
|
||||||
- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton
|
- `(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`
|
- 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
|
- `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
|
Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype
|
||||||
adapters. Requested `out_ptype` constrains only the final route endpoint; any
|
adapters. Requested `out_ptype` constrains only the final route endpoint; any
|
||||||
intermediate ptypes are chosen by the route solver.
|
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`,
|
- from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`,
|
||||||
`PortsLibraryView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`,
|
`PortsLibraryView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`,
|
||||||
`BuildReport`, `CellProvenance`, and `cell`
|
`BuildReport`, `CellProvenance`, and `cell`
|
||||||
- from `masque.builder`: `CostCallable`, the concrete primitive-offer classes,
|
- from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete
|
||||||
and structured route error/status types
|
primitive-offer classes, structured route error/status types,
|
||||||
|
`ToolContractCase`, and `validate_tool_contract`
|
||||||
|
|
||||||
## Minimal migration checklist
|
## Minimal migration checklist
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from masque import Pather, Library, Pattern, Port, layer_t
|
from masque import Pather, Library, Pattern, Port, layer_t
|
||||||
from masque.abstract import Abstract
|
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.error import BuildError
|
||||||
from masque.file.gdsii import writefile
|
from masque.file.gdsii import writefile
|
||||||
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||||
|
|
@ -419,6 +421,18 @@ def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||||
bend = library.abstract('m2_bend'),
|
bend = library.abstract('m2_bend'),
|
||||||
transitions = via_transitions,
|
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
|
return library, M1_tool, M2_tool
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ from .ports import (
|
||||||
from .abstract import Abstract as Abstract
|
from .abstract import Abstract as Abstract
|
||||||
from .builder import (
|
from .builder import (
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
|
ToolContractError as ToolContractError,
|
||||||
Pather as Pather,
|
Pather as Pather,
|
||||||
RouteError as RouteError,
|
RouteError as RouteError,
|
||||||
RouteFailureDetails as RouteFailureDetails,
|
RouteFailureDetails as RouteFailureDetails,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ from .pather import (
|
||||||
PortPather as PortPather,
|
PortPather as PortPather,
|
||||||
)
|
)
|
||||||
from .error import (
|
from .error import (
|
||||||
|
ToolContractError as ToolContractError,
|
||||||
RouteError as RouteError,
|
RouteError as RouteError,
|
||||||
RouteFailureDetails as RouteFailureDetails,
|
RouteFailureDetails as RouteFailureDetails,
|
||||||
RouteOperation as RouteOperation,
|
RouteOperation as RouteOperation,
|
||||||
|
|
@ -61,11 +62,16 @@ from .error import (
|
||||||
MinimumStatus as MinimumStatus,
|
MinimumStatus as MinimumStatus,
|
||||||
)
|
)
|
||||||
from .utils import ell as ell
|
from .utils import ell as ell
|
||||||
|
from .tool_testing import (
|
||||||
|
ToolContractCase as ToolContractCase,
|
||||||
|
validate_tool_contract as validate_tool_contract,
|
||||||
|
)
|
||||||
from .tools import (
|
from .tools import (
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
AutoTool as AutoTool,
|
AutoTool as AutoTool,
|
||||||
PathTool as PathTool,
|
PathTool as PathTool,
|
||||||
RenderStep as RenderStep,
|
RenderStep as RenderStep,
|
||||||
|
RenderStepKind as RenderStepKind,
|
||||||
PrimitiveKind as PrimitiveKind,
|
PrimitiveKind as PrimitiveKind,
|
||||||
CostCallable as CostCallable,
|
CostCallable as CostCallable,
|
||||||
GeneratedEndpointFn as GeneratedEndpointFn,
|
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']
|
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn']
|
||||||
|
|
||||||
|
|
||||||
|
class ToolContractError(BuildError):
|
||||||
|
"""A Tool returned data inconsistent with its routing contract."""
|
||||||
|
|
||||||
|
|
||||||
class RouteFailurePolicy(Enum):
|
class RouteFailurePolicy(Enum):
|
||||||
"""Whether route failure may be recovered through alternate/dead planning.
|
"""Whether route failure may be recovered through alternate/dead planning.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,14 @@ from .planner.interface import (
|
||||||
RoutePortContext,
|
RoutePortContext,
|
||||||
route_failure_policy,
|
route_failure_policy,
|
||||||
)
|
)
|
||||||
from .error import RouteFailurePolicy
|
from .error import RouteFailurePolicy, ToolContractError
|
||||||
from .planner import (
|
from .planner import (
|
||||||
RouteTieBreakStrategy,
|
RouteTieBreakStrategy,
|
||||||
RoutingPlanner,
|
RoutingPlanner,
|
||||||
)
|
)
|
||||||
from .planner.bounds import resolved_position_bound
|
from .planner.bounds import resolved_position_bound
|
||||||
from .logging import PatherLogger
|
from .logging import PatherLogger
|
||||||
|
from ._tolerances import angles_equal, array_close
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -343,7 +344,7 @@ class Pather(PortList):
|
||||||
if not steps:
|
if not steps:
|
||||||
continue
|
continue
|
||||||
port = self.ports.get(name, steps[-1].end_port)
|
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
|
return prepared
|
||||||
|
|
||||||
def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None:
|
def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None:
|
||||||
|
|
@ -1130,7 +1131,7 @@ class Pather(PortList):
|
||||||
return
|
return
|
||||||
|
|
||||||
tool_name = type(batch[0].tool).__name__
|
tool_name = type(batch[0].tool).__name__
|
||||||
raise BuildError(
|
raise ToolContractError(
|
||||||
f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}'
|
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)
|
actual = pat.ports.get(portspec)
|
||||||
tool_name = type(batch[0].tool).__name__
|
tool_name = type(batch[0].tool).__name__
|
||||||
if actual is None:
|
if actual is None:
|
||||||
raise BuildError(
|
raise ToolContractError(
|
||||||
f'Tool {tool_name}.render() did not produce output port {portspec!r}; '
|
f'Tool {tool_name}.render() did not produce output port {portspec!r}; '
|
||||||
f'expected {expected.describe()}'
|
f'expected {expected.describe()}'
|
||||||
)
|
)
|
||||||
|
|
||||||
offsets_match = bool(numpy.allclose(actual.offset, expected.offset))
|
offsets_match = array_close(actual.offset, expected.offset)
|
||||||
rotations_match = (
|
rotations_match = (
|
||||||
actual.rotation is None
|
actual.rotation is None
|
||||||
or expected.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)
|
ptypes_match = ptypes_compatible(actual.ptype, expected.ptype)
|
||||||
if offsets_match and rotations_match and ptypes_match:
|
if offsets_match and rotations_match and ptypes_match:
|
||||||
return
|
return
|
||||||
|
|
||||||
raise BuildError(
|
raise ToolContractError(
|
||||||
f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: '
|
f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: '
|
||||||
f'expected {expected.describe()}, got {actual.describe()}'
|
f'expected {expected.describe()}, got {actual.describe()}'
|
||||||
)
|
)
|
||||||
|
|
@ -1186,14 +1187,14 @@ class Pather(PortList):
|
||||||
continue
|
continue
|
||||||
batch: list[RenderStep] = []
|
batch: list[RenderStep] = []
|
||||||
for step in steps:
|
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
|
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)):
|
if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)):
|
||||||
render_batch(portspec, batch, append)
|
render_batch(portspec, batch, append)
|
||||||
batch = []
|
batch = []
|
||||||
if appendable:
|
if appendable:
|
||||||
batch.append(step)
|
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]
|
del pat.ports[portspec]
|
||||||
if batch:
|
if batch:
|
||||||
render_batch(portspec, batch, append)
|
render_batch(portspec, batch, append)
|
||||||
|
|
@ -1285,6 +1286,14 @@ class PortPather:
|
||||||
self.pather = pather
|
self.pather = pather
|
||||||
self.default_spacing = default_spacing
|
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:
|
def retool(self, tool: Tool) -> Self:
|
||||||
self.pather.retool(tool, self.ports)
|
self.pather.retool(tool, self.ports)
|
||||||
return self
|
return self
|
||||||
|
|
@ -1545,28 +1554,24 @@ class PortPather:
|
||||||
strategy: RouteTieBreakStrategy | str | None = None,
|
strategy: RouteTieBreakStrategy | str | None = None,
|
||||||
tool_options: Mapping[str, Any] | None = None,
|
tool_options: Mapping[str, Any] | None = None,
|
||||||
) -> Self:
|
) -> Self:
|
||||||
if len(self.ports) != 1:
|
port = self._single_port('trace_into')
|
||||||
raise BuildError(f'Unable to use implicit trace_into() with {len(self.ports)} ports; expected exactly one.')
|
|
||||||
self.pather.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,
|
thru=thru, strategy=strategy, tool_options=tool_options,
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self:
|
def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self:
|
||||||
if len(self.ports) > 1:
|
port = self._single_port('plug')
|
||||||
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.'
|
self.pather.plug(other, {port: other_port}, **kwargs)
|
||||||
'Use the pather or pattern directly to plug multiple ports.')
|
|
||||||
self.pather.plug(other, {self.ports[0]: other_port}, **kwargs)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def plugged(self, other_port: str | Mapping[str, str]) -> Self:
|
def plugged(self, other_port: str | Mapping[str, str]) -> Self:
|
||||||
if isinstance(other_port, Mapping):
|
if isinstance(other_port, Mapping):
|
||||||
self.pather.plugged(dict(other_port))
|
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:
|
else:
|
||||||
self.pather.plugged({self.ports[0]: other_port})
|
port = self._single_port('plugged')
|
||||||
|
self.pather.plugged({port: other_port})
|
||||||
return self
|
return self
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
@ -1604,9 +1609,7 @@ class PortPather:
|
||||||
""" Rename active ports. """
|
""" Rename active ports. """
|
||||||
name_map: dict[str, str | None]
|
name_map: dict[str, str | None]
|
||||||
if isinstance(name, str):
|
if isinstance(name, str):
|
||||||
if len(self.ports) > 1:
|
name_map = {self._single_port('rename'): name}
|
||||||
raise BuildError('Use a mapping to rename >1 port')
|
|
||||||
name_map = {self.ports[0]: name}
|
|
||||||
else:
|
else:
|
||||||
name_map = dict(name)
|
name_map = dict(name)
|
||||||
self.pather.rename_ports(name_map)
|
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]:
|
def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]:
|
||||||
if isinstance(name, str):
|
if isinstance(name, str):
|
||||||
if len(self.ports) > 1:
|
name_map = {self._single_port(action): name}
|
||||||
raise BuildError(f'Use a mapping to {action} >1 port')
|
|
||||||
name_map = {self.ports[0]: name}
|
|
||||||
else:
|
else:
|
||||||
name_map = dict(name)
|
name_map = dict(name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from numpy.typing import ArrayLike, NDArray
|
||||||
from ...error import BuildError, PortError
|
from ...error import BuildError, PortError
|
||||||
from ...ports import Port
|
from ...ports import Port
|
||||||
from ...utils import rotation_matrix_2d
|
from ...utils import rotation_matrix_2d
|
||||||
|
from .._tolerances import manhattan_axis
|
||||||
from .interface import RoutePortContext
|
from .interface import RoutePortContext
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,8 +71,13 @@ def resolved_position_bound(
|
||||||
value = finite_scalar(raw_value, f'{key} positional bound')
|
value = finite_scalar(raw_value, f'{key} positional bound')
|
||||||
if port.rotation is None:
|
if port.rotation is None:
|
||||||
raise BuildError('Ports must have rotation')
|
raise BuildError('Ports must have rotation')
|
||||||
is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9))
|
axis = manhattan_axis(port.rotation)
|
||||||
if is_horiz:
|
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':
|
if key == 'y':
|
||||||
raise BuildError('Port is horizontal')
|
raise BuildError('Port is horizontal')
|
||||||
target = Port((value, port.offset[1]), rotation=None)
|
target = Port((value, port.offset[1]), rotation=None)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||||
from ...error import BuildError
|
from ...error import BuildError
|
||||||
from ...ports import Port
|
from ...ports import Port
|
||||||
from ..tools import RenderStep, Tool
|
from ..tools import RenderStep, Tool
|
||||||
from ..error import RouteError, RouteFailurePolicy
|
from ..error import RouteError, RouteFailurePolicy, ToolContractError
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanningError(BuildError):
|
class RoutePlanningError(BuildError):
|
||||||
|
|
@ -34,6 +34,8 @@ class RoutePlanningError(BuildError):
|
||||||
|
|
||||||
def route_failure_policy(err: Exception) -> RouteFailurePolicy:
|
def route_failure_policy(err: Exception) -> RouteFailurePolicy:
|
||||||
"""Return typed route recovery policy, defaulting generic errors to recoverable."""
|
"""Return typed route recovery policy, defaulting generic errors to recoverable."""
|
||||||
|
if isinstance(err, ToolContractError):
|
||||||
|
return RouteFailurePolicy.FATAL
|
||||||
if isinstance(err, RoutePlanningError):
|
if isinstance(err, RoutePlanningError):
|
||||||
return err.policy
|
return err.policy
|
||||||
if isinstance(err, RouteError):
|
if isinstance(err, RouteError):
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ from __future__ import annotations
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from itertools import combinations
|
from itertools import combinations
|
||||||
from math import cos, isclose as math_isclose, sin
|
from math import cos, sin
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
|
|
@ -50,6 +50,7 @@ from ..tools import (
|
||||||
SOffer,
|
SOffer,
|
||||||
StraightOffer,
|
StraightOffer,
|
||||||
Tool,
|
Tool,
|
||||||
|
UOffer,
|
||||||
)
|
)
|
||||||
from ..error import (
|
from ..error import (
|
||||||
MinimumStatus,
|
MinimumStatus,
|
||||||
|
|
@ -57,7 +58,9 @@ from ..error import (
|
||||||
RouteFailureDetails,
|
RouteFailureDetails,
|
||||||
RouteFailurePolicy,
|
RouteFailurePolicy,
|
||||||
RouteOperation,
|
RouteOperation,
|
||||||
|
ToolContractError,
|
||||||
)
|
)
|
||||||
|
from .._tolerances import scalar_close
|
||||||
from ..utils import ell
|
from ..utils import ell
|
||||||
from . import bounds as planner_bounds
|
from . import bounds as planner_bounds
|
||||||
from .interface import (
|
from .interface import (
|
||||||
|
|
@ -84,7 +87,7 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr
|
||||||
|
|
||||||
def is_close(a: float, b: float) -> bool:
|
def is_close(a: float, b: float) -> bool:
|
||||||
"""Compare route-solver scalars with the planner tolerance."""
|
"""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:
|
def clean_parameter(value: float) -> float:
|
||||||
|
|
@ -225,8 +228,6 @@ class SelectedPrimitive:
|
||||||
"""Finite additive planning cost reported by the offer."""
|
"""Finite additive planning cost reported by the offer."""
|
||||||
role: Literal['main', 'adapter'] = 'main'
|
role: Literal['main', 'adapter'] = 'main'
|
||||||
"""Whether this step satisfies route geometry or adapts ptype."""
|
"""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)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -318,7 +319,7 @@ class Solver:
|
||||||
|
|
||||||
def __init__(self, request: SolverRequest) -> None:
|
def __init__(self, request: SolverRequest) -> None:
|
||||||
self.request = request
|
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[
|
self.offer_cache: dict[
|
||||||
tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]],
|
tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]],
|
||||||
tuple[PrimitiveOffer, ...],
|
tuple[PrimitiveOffer, ...],
|
||||||
|
|
@ -333,9 +334,9 @@ class Solver:
|
||||||
for step in steps:
|
for step in steps:
|
||||||
if step.role == 'adapter':
|
if step.role == 'adapter':
|
||||||
continue
|
continue
|
||||||
if step.route_kind == 'bend':
|
if step.offer.kind == 'bend':
|
||||||
count += 1
|
count += 1
|
||||||
elif step.route_kind in ('s', 'u'):
|
elif step.offer.kind in ('s', 'u'):
|
||||||
count += 2
|
count += 2
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
@ -371,7 +372,6 @@ class Solver:
|
||||||
tuple((
|
tuple((
|
||||||
offer_key(step.offer),
|
offer_key(step.offer),
|
||||||
step.role,
|
step.role,
|
||||||
step.route_kind,
|
|
||||||
round(float(step.parameter), 9),
|
round(float(step.parameter), 9),
|
||||||
endpoint_key(step.out_port),
|
endpoint_key(step.out_port),
|
||||||
) for step in candidate.steps),
|
) for step in candidate.steps),
|
||||||
|
|
@ -448,16 +448,54 @@ class Solver:
|
||||||
kwargs = dict(self.request.tool_options)
|
kwargs = dict(self.request.tool_options)
|
||||||
if extra:
|
if extra:
|
||||||
kwargs.update(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:
|
try:
|
||||||
cache_key = (kind, in_ptype, out_ptype, tuple(sorted(kwargs.items())))
|
cache_key = (kind, in_ptype, out_ptype, extra_items)
|
||||||
hash(cache_key)
|
hash(cache_key)
|
||||||
except TypeError:
|
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)
|
cached = self.offer_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
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
|
self.offer_cache[cache_key] = offers
|
||||||
return offers
|
return offers
|
||||||
|
|
||||||
|
|
@ -469,7 +507,6 @@ class Solver:
|
||||||
*,
|
*,
|
||||||
out_ptype: str | None,
|
out_ptype: str | None,
|
||||||
role: Literal['main', 'adapter'],
|
role: Literal['main', 'adapter'],
|
||||||
route_kind: PrimitiveKind | None,
|
|
||||||
route_name: str | None = None,
|
route_name: str | None = None,
|
||||||
) -> SelectedPrimitive:
|
) -> SelectedPrimitive:
|
||||||
"""
|
"""
|
||||||
|
|
@ -481,7 +518,7 @@ class Solver:
|
||||||
"""
|
"""
|
||||||
route_name = self.request.route_name if route_name is None else route_name
|
route_name = self.request.route_name if route_name is None else route_name
|
||||||
selected = offer.canonicalize_parameter(clean_parameter(parameter))
|
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)
|
cached = self.eval_cache.get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
@ -491,28 +528,37 @@ class Solver:
|
||||||
if isinstance(offer, SOffer) and is_close(selected, 0):
|
if isinstance(offer, SOffer) and is_close(selected, 0):
|
||||||
raise BuildError('zero-jog S primitive candidates are not allowed')
|
raise BuildError('zero-jog S primitive candidates are not allowed')
|
||||||
out_port = offer.endpoint_at(selected)
|
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):
|
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',
|
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):
|
if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype):
|
||||||
raise RoutePlanningError(
|
raise RoutePlanningError(
|
||||||
'Requested out_ptype does not match primitive endpoint ptype',
|
'Requested out_ptype does not match primitive endpoint ptype',
|
||||||
policy=RouteFailurePolicy.FATAL,
|
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))
|
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):
|
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:
|
if cost < 0:
|
||||||
raise BuildError(f'{route_name} primitive returned negative cost')
|
raise ToolContractError(f'{route_name} primitive returned negative cost')
|
||||||
primitive = SelectedPrimitive(
|
primitive = SelectedPrimitive(
|
||||||
offer,
|
offer,
|
||||||
selected,
|
selected,
|
||||||
out_port,
|
out_port,
|
||||||
cost,
|
cost,
|
||||||
role=role,
|
role=role,
|
||||||
route_kind=route_kind,
|
|
||||||
)
|
)
|
||||||
self.eval_cache[key] = primitive
|
self.eval_cache[key] = primitive
|
||||||
return primitive
|
return primitive
|
||||||
|
|
@ -532,7 +578,7 @@ class Solver:
|
||||||
for step in steps:
|
for step in steps:
|
||||||
out_port = step.out_port
|
out_port = step.out_port
|
||||||
if out_port.rotation is None:
|
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_cos = cos(angle)
|
||||||
angle_sin = sin(angle)
|
angle_sin = sin(angle)
|
||||||
x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y)
|
x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y)
|
||||||
|
|
@ -573,7 +619,6 @@ class Solver:
|
||||||
current_ptype,
|
current_ptype,
|
||||||
out_ptype=None,
|
out_ptype=None,
|
||||||
role='adapter',
|
role='adapter',
|
||||||
route_kind=kind,
|
|
||||||
route_name=f'{kind} adapter',
|
route_name=f'{kind} adapter',
|
||||||
)
|
)
|
||||||
except BuildError as err:
|
except BuildError as err:
|
||||||
|
|
@ -606,7 +651,6 @@ class Solver:
|
||||||
current_ptype,
|
current_ptype,
|
||||||
out_ptype=None,
|
out_ptype=None,
|
||||||
role='main',
|
role='main',
|
||||||
route_kind='straight',
|
|
||||||
route_name='trace',
|
route_name='trace',
|
||||||
)
|
)
|
||||||
except BuildError as err:
|
except BuildError as err:
|
||||||
|
|
@ -642,7 +686,6 @@ class Solver:
|
||||||
current_ptype,
|
current_ptype,
|
||||||
out_ptype=None,
|
out_ptype=None,
|
||||||
role='main',
|
role='main',
|
||||||
route_kind='bend',
|
|
||||||
route_name='trace',
|
route_name='trace',
|
||||||
)
|
)
|
||||||
except BuildError as err:
|
except BuildError as err:
|
||||||
|
|
@ -678,7 +721,6 @@ class Solver:
|
||||||
current_ptype,
|
current_ptype,
|
||||||
out_ptype=None,
|
out_ptype=None,
|
||||||
role='main',
|
role='main',
|
||||||
route_kind=kind,
|
|
||||||
route_name=route_name,
|
route_name=route_name,
|
||||||
)
|
)
|
||||||
except BuildError as err:
|
except BuildError as err:
|
||||||
|
|
@ -814,7 +856,6 @@ class Solver:
|
||||||
current_ptype,
|
current_ptype,
|
||||||
out_ptype=None,
|
out_ptype=None,
|
||||||
role=step.role,
|
role=step.role,
|
||||||
route_kind=step.route_kind,
|
|
||||||
)
|
)
|
||||||
selected.append(selected_step)
|
selected.append(selected_step)
|
||||||
current_ptype = selected_step.out_port.ptype
|
current_ptype = selected_step.out_port.ptype
|
||||||
|
|
@ -1183,7 +1224,7 @@ class RoutingPlanner:
|
||||||
out_port.rotate_around((0, 0), pi + port_rot)
|
out_port.rotate_around((0, 0), pi + port_rot)
|
||||||
out_port.translate(current.offset)
|
out_port.translate(current.offset)
|
||||||
render_steps.append(RenderStep(
|
render_steps.append(RenderStep(
|
||||||
selected.offer.opcode,
|
selected.offer.kind,
|
||||||
leg.tool,
|
leg.tool,
|
||||||
current.copy(),
|
current.copy(),
|
||||||
out_port.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
|
cross-ptype routes, and adapter shapes must be exposed by the active Tool as
|
||||||
primitive offers.
|
primitive offers.
|
||||||
"""
|
"""
|
||||||
from typing import Literal, Any, Self
|
from typing import Literal, Any, ClassVar, Self
|
||||||
from collections import ChainMap
|
from collections import ChainMap
|
||||||
from collections.abc import Sequence, Callable, Mapping
|
from collections.abc import Sequence, Callable, Mapping
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt
|
from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
|
|
@ -68,14 +69,16 @@ from ..abstract import Abstract
|
||||||
from ..library import ILibrary, Library, SINGLE_USE_PREFIX
|
from ..library import ILibrary, Library, SINGLE_USE_PREFIX
|
||||||
from ..error import BuildError
|
from ..error import BuildError
|
||||||
from ..shapes import Path
|
from ..shapes import Path
|
||||||
|
from ._tolerances import DOMAIN_ATOL, DOMAIN_RTOL, array_close, angles_equal
|
||||||
|
from .error import ToolContractError
|
||||||
|
|
||||||
|
|
||||||
def _canonicalize_domain_value(
|
def _canonicalize_domain_value(
|
||||||
value: float,
|
value: float,
|
||||||
domain: tuple[float, float],
|
domain: tuple[float, float],
|
||||||
*,
|
*,
|
||||||
rtol: float = 1e-9,
|
rtol: float = DOMAIN_RTOL,
|
||||||
atol: float = 1e-12,
|
atol: float = DOMAIN_ATOL,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Canonicalize a solved primitive parameter against a route domain.
|
Canonicalize a solved primitive parameter against a route domain.
|
||||||
|
|
@ -107,6 +110,29 @@ def _canonicalize_domain_value(
|
||||||
return vv
|
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]
|
EndpointCallable = Callable[[float], Port]
|
||||||
CommitCallable = Callable[[float], Any]
|
CommitCallable = Callable[[float], Any]
|
||||||
BBoxCallable = Callable[[float], NDArray[numpy.float64]]
|
BBoxCallable = Callable[[float], NDArray[numpy.float64]]
|
||||||
|
|
@ -114,6 +140,21 @@ DataCallable = Callable[[float], Any]
|
||||||
BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]]
|
BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]]
|
||||||
CostCallable = Callable[[float, Port], float]
|
CostCallable = Callable[[float, Port], float]
|
||||||
PrimitiveKind = Literal['straight', 'bend', 's', 'u']
|
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:
|
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:
|
def _validated_cost_value(value: Any) -> float:
|
||||||
"""Return a canonical evaluated cost or raise `BuildError`."""
|
"""Return a canonical evaluated cost or raise `ToolContractError`."""
|
||||||
try:
|
try:
|
||||||
cost = float(value)
|
cost = float(value)
|
||||||
except (TypeError, ValueError, OverflowError) as err:
|
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:
|
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
|
return cost
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -218,6 +259,7 @@ class PrimitiveOffer(ABC):
|
||||||
"""Reserved footprint metadata; the current solver does not inspect it."""
|
"""Reserved footprint metadata; the current solver does not inspect it."""
|
||||||
endpoint_planner: EndpointCallable | None = None
|
endpoint_planner: EndpointCallable | None = None
|
||||||
commit_planner: CommitCallable | None = None
|
commit_planner: CommitCallable | None = None
|
||||||
|
kind: ClassVar[PrimitiveKind]
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
has_endpoint = self.endpoint_planner is not None
|
has_endpoint = self.endpoint_planner is not None
|
||||||
|
|
@ -225,12 +267,18 @@ class PrimitiveOffer(ABC):
|
||||||
|
|
||||||
if has_endpoint != has_commit:
|
if has_endpoint != has_commit:
|
||||||
raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner')
|
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))
|
object.__setattr__(self, 'cost', _validated_cost(self.cost))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
|
||||||
def opcode(self) -> Literal['L', 'S', 'U']:
|
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
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -266,8 +314,12 @@ class PrimitiveOffer(ABC):
|
||||||
out_port = self.endpoint_planner(selected)
|
out_port = self.endpoint_planner(selected)
|
||||||
else:
|
else:
|
||||||
out_port = self.endpoint_at(selected)
|
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):
|
if callable(self.cost):
|
||||||
value = self.cost(selected, out_port)
|
value = self.cost(parameter, out_port)
|
||||||
else:
|
else:
|
||||||
default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y))
|
default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y))
|
||||||
value = self.cost * default_cost
|
value = self.cost * default_cost
|
||||||
|
|
@ -315,6 +367,7 @@ class PrimitiveOffer(ABC):
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class StraightOffer(PrimitiveOffer):
|
class StraightOffer(PrimitiveOffer):
|
||||||
"""Straight or straight-like primitive parameterized by public route length."""
|
"""Straight or straight-like primitive parameterized by public route length."""
|
||||||
|
kind: ClassVar[Literal['straight']] = 'straight'
|
||||||
length_domain: tuple[float, float] = (0.0, numpy.inf)
|
length_domain: tuple[float, float] = (0.0, numpy.inf)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -377,10 +430,6 @@ class StraightOffer(PrimitiveOffer):
|
||||||
length_domain = (float(endpoint.x), float(endpoint.x)),
|
length_domain = (float(endpoint.x), float(endpoint.x)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def opcode(self) -> Literal['L']:
|
|
||||||
return 'L'
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameter_domain(self) -> tuple[float, float]:
|
def parameter_domain(self) -> tuple[float, float]:
|
||||||
return self.length_domain
|
return self.length_domain
|
||||||
|
|
@ -389,6 +438,7 @@ class StraightOffer(PrimitiveOffer):
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class BendOffer(PrimitiveOffer):
|
class BendOffer(PrimitiveOffer):
|
||||||
"""Single-turn L-route primitive parameterized by public route length."""
|
"""Single-turn L-route primitive parameterized by public route length."""
|
||||||
|
kind: ClassVar[Literal['bend']] = 'bend'
|
||||||
ccw: bool = True
|
ccw: bool = True
|
||||||
length_domain: tuple[float, float] = (0.0, numpy.inf)
|
length_domain: tuple[float, float] = (0.0, numpy.inf)
|
||||||
|
|
||||||
|
|
@ -454,10 +504,6 @@ class BendOffer(PrimitiveOffer):
|
||||||
length_domain = (float(endpoint.x), float(endpoint.x)),
|
length_domain = (float(endpoint.x), float(endpoint.x)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def opcode(self) -> Literal['L']:
|
|
||||||
return 'L'
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameter_domain(self) -> tuple[float, float]:
|
def parameter_domain(self) -> tuple[float, float]:
|
||||||
return self.length_domain
|
return self.length_domain
|
||||||
|
|
@ -466,6 +512,7 @@ class BendOffer(PrimitiveOffer):
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SOffer(PrimitiveOffer):
|
class SOffer(PrimitiveOffer):
|
||||||
"""Non-turning S-route primitive parameterized by jog for a fixed route length."""
|
"""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)
|
jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -526,10 +573,6 @@ class SOffer(PrimitiveOffer):
|
||||||
jog_domain = (float(endpoint.y), float(endpoint.y)),
|
jog_domain = (float(endpoint.y), float(endpoint.y)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def opcode(self) -> Literal['S']:
|
|
||||||
return 'S'
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameter_domain(self) -> tuple[float, float]:
|
def parameter_domain(self) -> tuple[float, float]:
|
||||||
return self.jog_domain
|
return self.jog_domain
|
||||||
|
|
@ -538,6 +581,7 @@ class SOffer(PrimitiveOffer):
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class UOffer(PrimitiveOffer):
|
class UOffer(PrimitiveOffer):
|
||||||
"""U-turn-like primitive parameterized by jog for a fixed route length."""
|
"""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)
|
jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -598,10 +642,6 @@ class UOffer(PrimitiveOffer):
|
||||||
jog_domain = (float(endpoint.y), float(endpoint.y)),
|
jog_domain = (float(endpoint.y), float(endpoint.y)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def opcode(self) -> Literal['U']:
|
|
||||||
return 'U'
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameter_domain(self) -> tuple[float, float]:
|
def parameter_domain(self) -> tuple[float, float]:
|
||||||
return self.jog_domain
|
return self.jog_domain
|
||||||
|
|
@ -616,16 +656,11 @@ class RenderStep:
|
||||||
passes batches of compatible steps to `Tool.render()` when `Pather.render()`
|
passes batches of compatible steps to `Tool.render()` when `Pather.render()`
|
||||||
is called.
|
is called.
|
||||||
"""
|
"""
|
||||||
opcode: Literal['L', 'S', 'U', 'P']
|
kind: RenderStepKind
|
||||||
""" What operation is being performed.
|
"""Canonical primitive kind, or `plug` for an assembly boundary."""
|
||||||
L: straight or single-bend primitive
|
|
||||||
S: S-like primitive
|
|
||||||
U: U-like primitive
|
|
||||||
P: plug
|
|
||||||
"""
|
|
||||||
|
|
||||||
tool: 'Tool | None'
|
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
|
start_port: Port
|
||||||
""" Input-side port before this step is rendered. """
|
""" Input-side port before this step is rendered. """
|
||||||
|
|
@ -637,18 +672,28 @@ class RenderStep:
|
||||||
""" Arbitrary tool-specific data"""
|
""" Arbitrary tool-specific data"""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.opcode != 'P' and self.tool is None:
|
if self.kind not in ('straight', 'bend', 's', 'u', 'plug'):
|
||||||
raise BuildError('Got tool=None but the opcode is not "P"')
|
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:
|
def is_continuous_with(self, other: 'RenderStep') -> bool:
|
||||||
"""
|
"""
|
||||||
Check if another RenderStep can be appended to this one.
|
Check if another RenderStep can be appended to this one.
|
||||||
"""
|
"""
|
||||||
# Check continuity with tolerance
|
# 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 (
|
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
|
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
|
return offsets_match and rotations_match
|
||||||
|
|
||||||
|
|
@ -664,7 +709,7 @@ class RenderStep:
|
||||||
pp.translate(translation)
|
pp.translate(translation)
|
||||||
|
|
||||||
return RenderStep(
|
return RenderStep(
|
||||||
opcode = self.opcode,
|
kind = self.kind,
|
||||||
tool = self.tool,
|
tool = self.tool,
|
||||||
start_port = new_start,
|
start_port = new_start,
|
||||||
end_port = new_end,
|
end_port = new_end,
|
||||||
|
|
@ -682,7 +727,7 @@ class RenderStep:
|
||||||
new_end.flip_across(axis=axis)
|
new_end.flip_across(axis=axis)
|
||||||
|
|
||||||
return RenderStep(
|
return RenderStep(
|
||||||
opcode = self.opcode,
|
kind = self.kind,
|
||||||
tool = self.tool,
|
tool = self.tool,
|
||||||
start_port = new_start,
|
start_port = new_start,
|
||||||
end_port = new_end,
|
end_port = new_end,
|
||||||
|
|
@ -723,6 +768,10 @@ class Tool(ABC):
|
||||||
- `'s'`: a non-turning `SOffer`
|
- `'s'`: a non-turning `SOffer`
|
||||||
- `'u'`: an `UOffer`
|
- `'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
|
Other `kwargs` come only from the Pather call's explicit
|
||||||
`tool_options` mapping. Tools are responsible for validating the keys
|
`tool_options` mapping. Tools are responsible for validating the keys
|
||||||
they support.
|
they support.
|
||||||
|
|
@ -1013,6 +1062,11 @@ class AutoTool(Tool):
|
||||||
two-port straight. Metadata inference does not receive route options.
|
two-port straight. Metadata inference does not receive route options.
|
||||||
"""
|
"""
|
||||||
cost = _validated_cost(cost)
|
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)
|
ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name)
|
||||||
|
|
||||||
def data_at(length: float) -> AutoTool.GeneratedData:
|
def data_at(length: float) -> AutoTool.GeneratedData:
|
||||||
|
|
@ -1055,25 +1109,37 @@ class AutoTool(Tool):
|
||||||
|
|
||||||
in_port = abstract.ports[in_port_name]
|
in_port = abstract.ports[in_port_name]
|
||||||
out_port = abstract.ports[out_port_name]
|
out_port = abstract.ports[out_port_name]
|
||||||
out_ptype = out_port.ptype
|
|
||||||
source_clockwise = self._bend_clockwise(in_port, out_port)
|
source_clockwise = self._bend_clockwise(in_port, out_port)
|
||||||
if clockwise is not None and bool(clockwise) != source_clockwise:
|
if clockwise is not None and bool(clockwise) != source_clockwise:
|
||||||
raise BuildError('Bend clockwise argument does not match port orientations')
|
raise BuildError('Bend clockwise argument does not match port orientations')
|
||||||
|
|
||||||
for ccw in (False, True):
|
for ccw in (False, True):
|
||||||
target_clockwise = not bool(ccw)
|
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_dx = float(bend_dxy[0])
|
||||||
bend_dy = float(bend_dxy[1])
|
bend_dy = float(bend_dxy[1])
|
||||||
source_matches_target = source_clockwise == target_clockwise
|
|
||||||
mirrored = mirror and not source_matches_target
|
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, entry_port_name, mirrored)
|
||||||
reusable_data = self.ReusableData(abstract, port_name, mirrored)
|
endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=exit_port.ptype)
|
||||||
endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=out_ptype)
|
|
||||||
|
|
||||||
self._bend_offers[int(ccw)].append(BendOffer.prebuilt(
|
self._bend_offers[int(ccw)].append(BendOffer.prebuilt(
|
||||||
in_ptype = in_port.ptype,
|
in_ptype = entry_port.ptype,
|
||||||
out_ptype = out_ptype,
|
out_ptype = exit_port.ptype,
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
data = reusable_data,
|
data = reusable_data,
|
||||||
ccw = ccw,
|
ccw = ccw,
|
||||||
|
|
@ -1105,6 +1171,11 @@ class AutoTool(Tool):
|
||||||
Metadata inference and endpoint planning do not receive route options.
|
Metadata inference and endpoint planning do not receive route options.
|
||||||
"""
|
"""
|
||||||
cost = _validated_cost(cost)
|
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(
|
ptype, in_port_name, out_port_name = self._infer_sbend_metadata(
|
||||||
fn,
|
fn,
|
||||||
jog_range,
|
jog_range,
|
||||||
|
|
@ -1345,9 +1416,11 @@ class AutoTool(Tool):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]:
|
def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]:
|
||||||
lower, upper = (float(magnitude_range[0]), float(magnitude_range[1]))
|
lower, upper = _validated_parameter_domain(
|
||||||
if lower < 0 or lower > upper:
|
magnitude_range,
|
||||||
return ()
|
name='S-bend jog_range',
|
||||||
|
nonnegative_minimum=True,
|
||||||
|
)
|
||||||
|
|
||||||
if lower == upper:
|
if lower == upper:
|
||||||
if lower == 0:
|
if lower == 0:
|
||||||
|
|
@ -1374,7 +1447,10 @@ class AutoTool(Tool):
|
||||||
) -> tuple[PrimitiveOffer, ...]:
|
) -> tuple[PrimitiveOffer, ...]:
|
||||||
_ = out_ptype
|
_ = out_ptype
|
||||||
ccw = kwargs.pop('ccw', None)
|
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:
|
def configured(offer: PrimitiveOffer) -> PrimitiveOffer:
|
||||||
if not tool_options:
|
if not tool_options:
|
||||||
|
|
@ -1652,12 +1728,12 @@ class PathTool(Tool):
|
||||||
transform = rotation_matrix_2d(port_rot + pi)
|
transform = rotation_matrix_2d(port_rot + pi)
|
||||||
delta = step.end_port.offset - step.start_port.offset
|
delta = step.end_port.offset - step.start_port.offset
|
||||||
local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta
|
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]))
|
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]))
|
local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1]))
|
||||||
else:
|
else:
|
||||||
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
|
raise BuildError(f'Unsupported PathTool render kind {step.kind!r}')
|
||||||
|
|
||||||
for vertex in local_vertices[1:]:
|
for vertex in local_vertices[1:]:
|
||||||
path_vertices.append(step.start_port.offset + transform @ vertex)
|
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.path(layer=self.layer, width=self.width, vertices=path_vertices)
|
||||||
pat.ports = {
|
pat.ports = {
|
||||||
port_names[0]: local_batch[0].start_port.copy().rotate(pi),
|
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
|
return tree
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from ..utils import rotation_matrix_2d, SupportsBool
|
from ..utils import rotation_matrix_2d, SupportsBool
|
||||||
from ..error import BuildError
|
from ..error import BuildError
|
||||||
|
from ._tolerances import manhattan_axis
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..ports import Port
|
from ..ports import Port
|
||||||
|
|
@ -135,7 +136,13 @@ def ell(
|
||||||
raise BuildError('set_rotation must be finite')
|
raise BuildError('set_rotation must be finite')
|
||||||
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
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:
|
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!')
|
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:
|
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.
|
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
|
assert bend_count <= max_bends
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from contextlib import suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.testing import assert_allclose
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ def rendered_offer_tree(
|
||||||
) -> Library:
|
) -> Library:
|
||||||
start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk")
|
start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk")
|
||||||
end, data = commit_offer(offer, parameter)
|
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:
|
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 = (
|
tool = (
|
||||||
AutoTool(bbox_library=lib)
|
AutoTool(bbox_library=lib)
|
||||||
.add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8))
|
.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")
|
.add_transition(lib.abstract("wild_core"), "WILD", "CORE")
|
||||||
)
|
)
|
||||||
return tool, lib
|
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")
|
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:
|
def test_autotool_add_bend_rejects_clockwise_mismatch() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
lib["bend"] = make_bend(2, ptype="wire", clockwise=True)
|
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)
|
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:
|
def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
|
||||||
tool = make_sbend_tool((4, 4))
|
tool = make_sbend_tool((4, 4))
|
||||||
offers = tool.primitive_offers('s', in_ptype="core")
|
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:
|
def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None:
|
||||||
tool = make_sbend_tool((-4, 4))
|
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
|
||||||
|
make_sbend_tool((-4, 4))
|
||||||
assert tool.primitive_offers('s', in_ptype="core") == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None:
|
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:
|
def test_autotool_generated_primitives_snapshot_route_options() -> None:
|
||||||
markers: list[str | 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)
|
markers.append(marker)
|
||||||
return make_straight(length, ptype="wire")
|
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 = Pather(Library(), tools=tool, render='deferred')
|
||||||
p.ports["A"] = Port((0, 0), 0, ptype="wire")
|
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)
|
p.straight("A", 5, tool_options=first_options)
|
||||||
first_options['marker'] = 'mutated'
|
first_options['marker'] = 'mutated'
|
||||||
|
first_options['nested']['values'].append(2)
|
||||||
p.straight("A", 6, tool_options={'marker': 'second'})
|
p.straight("A", 6, tool_options={'marker': 'second'})
|
||||||
|
|
||||||
first_data, second_data = (step.data for step in p._paths['A'])
|
first_data, second_data = (step.data for step in p._paths['A'])
|
||||||
assert isinstance(first_data, AutoTool.GeneratedData)
|
assert isinstance(first_data, AutoTool.GeneratedData)
|
||||||
assert isinstance(second_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'}
|
assert dict(second_data.tool_options) == {'marker': 'second'}
|
||||||
|
|
||||||
p.render()
|
p.render()
|
||||||
|
|
@ -1131,6 +1177,17 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None:
|
||||||
assert markers == ['first', 'second']
|
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(
|
def test_autotool_route_options_do_not_attach_to_reusable_bends(
|
||||||
multi_bend_tool: tuple[AutoTool, Library],
|
multi_bend_tool: tuple[AutoTool, Library],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import pytest
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
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 import RoutePortContext, RoutingPlanner
|
||||||
from masque.builder.planner.planner import NoLegalRouteError
|
from masque.builder.planner.planner import NoLegalRouteError
|
||||||
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
|
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)
|
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:
|
def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
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 'B' not in p.pattern.ports
|
||||||
assert pp.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:
|
def test_mark_fork() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
p = Pather(lib)
|
p = Pather(lib)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import numpy
|
||||||
import pytest
|
import pytest
|
||||||
from numpy import pi
|
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 import RoutingPlanner
|
||||||
|
from masque.builder.planner.planner import Solver, SolverRequest
|
||||||
from masque.builder.tools import (
|
from masque.builder.tools import (
|
||||||
BendOffer,
|
BendOffer,
|
||||||
PathTool,
|
PathTool,
|
||||||
|
|
@ -46,6 +47,81 @@ class PlanningOnlyTool(Tool):
|
||||||
return tree
|
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:
|
def test_tool_requires_primitive_offers_override() -> None:
|
||||||
class RenderOnlyTool(Tool):
|
class RenderOnlyTool(Tool):
|
||||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
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:
|
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
|
||||||
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
|
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:
|
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)
|
p.straight('A', 7)
|
||||||
|
|
||||||
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 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:
|
def test_pather_commits_only_selected_offer() -> None:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.testing import assert_allclose
|
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 ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
|
||||||
from ..error import BuildError
|
from ..error import BuildError
|
||||||
from ..library import Library
|
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]
|
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
|
||||||
start = Port((0, 0), rotation=pi, ptype="wire")
|
start = Port((0, 0), rotation=pi, ptype="wire")
|
||||||
end = offer.endpoint_at(1)
|
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()
|
pat = tree.top_pattern()
|
||||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
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]
|
offer = tool.primitive_offers("s", in_ptype="wire")[0]
|
||||||
start = Port((0, 0), rotation=pi, ptype="wire")
|
start = Port((0, 0), rotation=pi, ptype="wire")
|
||||||
end = offer.endpoint_at(4)
|
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()
|
pat = tree.top_pattern()
|
||||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
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(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"].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:
|
def test_deferred_render_uturn_fallback() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
@ -362,7 +362,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
||||||
tree = Library()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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)]})
|
child = Pattern(annotations={'batch': [len(batch)]})
|
||||||
top.ref('_seg')
|
top.ref('_seg')
|
||||||
|
|
@ -402,7 +402,7 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
|
||||||
tree = Library()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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]})
|
child = Pattern(annotations={'length': [length]})
|
||||||
top.ref('_seg')
|
top.ref('_seg')
|
||||||
|
|
@ -479,7 +479,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
||||||
tree = Library()
|
tree = Library()
|
||||||
top = Pattern(ports={
|
top = Pattern(ports={
|
||||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
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')
|
top.ref('shared')
|
||||||
tree['_top'] = top
|
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 p.pattern.refs
|
||||||
assert not lib
|
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])
|
@pytest.mark.parametrize('append', [True, False])
|
||||||
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
|
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
|
||||||
class WrongPtypeTool(Tool):
|
class WrongPtypeTool(Tool):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue