[builder] major Pather/Planner/Tool rework

This commit is contained in:
Jan Petykiewicz 2026-07-08 23:58:24 -07:00
commit 22e645e527
28 changed files with 6036 additions and 2467 deletions

View file

@ -81,7 +81,6 @@ from .builder import (
Tool as Tool,
Pather as Pather,
RenderStep as RenderStep,
SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool,
PortPather as PortPather,

View file

@ -1,3 +1,42 @@
"""
Builder helpers for port-based assembly and primitive-offer routing.
A routing `Tool` describes the primitive route families it can provide by
returning `PrimitiveOffer` objects. Each offer is a parameterized planning
candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost,
optional footprint metadata, and a commit hook for producing tool-specific
render data after a concrete parameter has been selected.
`Pather` owns user-facing route operations such as `trace()`, `jog()`,
`uturn()`, and `trace_into()`. For each operation, it asks the active `Tool` for
offers and passes those offers plus route constraints to the internal router.
The router selects a sequence of internal selected primitives, each pairing an
offer with a concrete parameter, endpoint, and cost.
Route commit is separate from route selection. Once a route is selected,
`Pather` calls `offer.commit(parameter)` only for the selected primitives and
stores the returned opaque tool payload in `RenderStep.data`. Later,
`Pather.render()` batches compatible `RenderStep`s and calls `Tool.render()` to
turn those committed payloads into geometry.
`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract.
`RenderStep` is `Pather`'s deferred-render record, and
`masque.builder.planner` is an internal planner implementation rather than a
stable public API.
The practical layering is:
- user code drives `Pather` and chooses Tools per port or by default,
- Tools describe local legal motion primitives without touching Pather state,
- the internal router composes those primitives into high-level route shapes,
- Pather applies the prepared result to ports, deferred render queues, and the
target pattern/library.
Code outside the builder package should prefer the exports here over importing
from `masque.builder.planner`. The planner package is intentionally available
for tests and internal maintenance, but it is not the compatibility boundary
for custom Tools.
"""
from .pather import (
Pather as Pather,
PortPather as PortPather,
@ -5,9 +44,13 @@ from .pather import (
from .utils import ell as ell
from .tools import (
Tool as Tool,
RenderStep as RenderStep,
SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool,
RenderStep as RenderStep,
PrimitiveKind as PrimitiveKind,
PrimitiveOffer as PrimitiveOffer,
StraightOffer as StraightOffer,
BendOffer as BendOffer,
SOffer as SOffer,
UOffer as UOffer,
)
from .logging import logged_op as logged_op

View file

@ -1,19 +1,13 @@
"""
Logging and operation decorators for Pather
"""
"""Logging helpers for Pather."""
from typing import TYPE_CHECKING, Any
from collections.abc import Iterator, Sequence, Callable
from collections.abc import Iterator, Sequence
import logging
from functools import wraps
import inspect
import numpy
from contextlib import contextmanager
if TYPE_CHECKING:
from .pather import Pather
logger = logging.getLogger(__name__)
def _format_log_args(**kwargs) -> str:
arg_strs = []
@ -84,37 +78,3 @@ class PatherLogger:
self.indent -= 1
self.depth -= 1
def logged_op(
portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Decorator to wrap Pather methods with logging.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
sig = inspect.signature(func)
@wraps(func)
def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any:
logger_obj = getattr(self, '_logger', None)
if logger_obj is None or not logger_obj.debug:
return func(self, *args, **kwargs)
bound = sig.bind(self, *args, **kwargs)
bound.apply_defaults()
all_args = bound.arguments
# remove 'self' from logged args
logged_args = {k: v for k, v in all_args.items() if k != 'self'}
ps = portspec_getter(all_args) if portspec_getter else None
# Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation
logged_args.pop('portspec', None)
with logger_obj.log_operation(self, func.__name__, ps, **logged_args):
if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'):
logger.warning(f"Skipping geometry for {func.__name__}() since device is dead")
return func(self, *args, **kwargs)
return wrapper
return decorator

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
"""
Simplified primitive-offer route planner used by `Pather`.
This package is the Pather-facing route-selection implementation. It keeps
the public Tool contract narrow: offers are evaluated during planning, and
offer commits are deferred until after a complete route is selected.
"""
from .interface import (
PreparedRouteAction as PreparedRouteAction,
PreparedRouteResult as PreparedRouteResult,
RoutePlanningError as RoutePlanningError,
RoutePortContext as RoutePortContext,
route_error_is_fatal as route_error_is_fatal,
)
from .planner import RoutingPlanner as RoutingPlanner

View file

@ -0,0 +1,257 @@
"""
Argument validation and bound resolution for Pather routing calls.
This module keeps user-facing mode validation outside the solver. It converts
single-port positional bounds into local travel lengths and derives multi-port
S/U bundle specs before primitive offers are considered.
The solver expects one coherent route intent at a time. This module enforces
that public routing modes are not mixed: explicit length, per-port `each`,
positional bounds, and bundle bounds are mutually constrained before any Tool
offers are queried. Multi-port S/U bundles are also normalized here into exact
per-port public lengths and offsets.
"""
from __future__ import annotations
# ruff: noqa: TC001,TC002,TC003
from typing import Any
from collections.abc import Mapping, Sequence
from pprint import pformat
import numpy
from numpy import pi
from numpy.typing import ArrayLike
from ...error import BuildError, PortError
from ...ports import Port
from ...utils import rotation_matrix_2d
from .interface import RoutePortContext
POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position')
BUNDLE_BOUND_KEYS: tuple[str, ...] = (
'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest',
)
def resolved_position_bound(
port: Port,
bounds: Mapping[str, Any],
*,
allow_length: bool,
) -> tuple[str, Any, float] | None:
"""Resolve a single positional bound for a single port into a travel length."""
present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None]
if not present:
return None
if len(present) > 1:
keys = ', '.join(key for key, _value in present)
raise BuildError(f'Provide exactly one positional bound; got {keys}')
if not allow_length and bounds.get('length') is not None:
raise BuildError('length cannot be combined with a positional bound')
key, value = present[0]
if port.rotation is None:
raise BuildError('Ports must have rotation')
is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9))
if is_horiz:
if key == 'y':
raise BuildError('Port is horizontal')
target = Port((value, port.offset[1]), rotation=None)
else:
if key == 'x':
raise BuildError('Port is vertical')
target = Port((port.offset[0], value), rotation=None)
(travel, _jog), _ = port.measure_travel(target)
return key, value, -float(travel)
def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]:
"""Return keys whose bound value is explicitly present and non-None."""
return [key for key in keys if bounds.get(key) is not None]
def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]:
"""Return active multi-port trace bound keys."""
return present_keys(bounds, BUNDLE_BOUND_KEYS)
def validate_trace_args(
portspec: Sequence[str],
*,
length: float | None,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""
Validate mutually-exclusive `trace()` routing modes.
A trace request is either an explicit single-port length, an `each` length
for all ports, a single-port omitted-length solve, or a bundle solve with
exactly one bundle bound.
"""
bundle_bounds = present_bundle_bounds(bounds)
if len(bundle_bounds) > 1:
args = ', '.join(bundle_bounds)
raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}')
invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds
invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds
if length is not None:
if len(portspec) > 1:
raise BuildError('length only allowed with a single port')
if spacing is not None:
invalid_with_length.append('spacing')
if invalid_with_length:
args = ', '.join(invalid_with_length)
raise BuildError(f'length cannot be combined with other routing bounds: {args}')
return
if bounds.get('each') is not None:
if spacing is not None:
invalid_with_each.append('spacing')
if invalid_with_each:
args = ', '.join(invalid_with_each)
raise BuildError(f'each cannot be combined with other routing bounds: {args}')
return
if not bundle_bounds and len(portspec) == 1:
if spacing is not None:
raise BuildError('spacing cannot be combined with omitted-length single-port trace()')
invalid = present_keys(bounds, ('set_rotation',))
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}')
return
if not bundle_bounds:
raise BuildError('No bound type specified for trace()')
def validate_trace_to_positional_args(
*,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""Reject bound combinations that cannot be mixed with a single positional `trace_to()` target."""
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
if spacing is not None:
invalid.append('spacing')
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}')
def validate_jog_args(
portspec: Sequence[str],
*,
length: float | None,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""
Validate `jog()` mode constraints before S-route planning.
Single-port jogs may derive length from a positional bound. Multi-port jogs
require spacing and cannot combine omitted length with positional bounds.
"""
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
if len(portspec) == 1 and spacing is not None:
invalid.append('spacing')
if len(portspec) > 1 and length is None:
invalid += present_keys(bounds, POSITION_KEYS)
if length is not None:
invalid = present_keys(bounds, POSITION_KEYS) + invalid
if invalid:
args = ', '.join(invalid)
raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}')
return
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Unsupported routing bounds for jog(): {args}')
def validate_uturn_args(
portspec: Sequence[str],
*,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""Validate `uturn()` arguments, which do not support positional or bundle-bound keywords."""
invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds)
if len(portspec) == 1 and spacing is not None:
invalid.append('spacing')
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Unsupported routing bounds for uturn(): {args}')
def su_bundle_specs(
contexts: Sequence[RoutePortContext],
offset: float,
length: float,
spacing: float | ArrayLike | None,
*,
route_name: str,
) -> tuple[tuple[str, float, float], ...]:
"""
Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs.
Ports are ordered from the inside of the first bend outward. The first spec
receives the requested base route; later specs add cumulative spacing to
both route length and lateral offset so the bundle keeps the requested
separation.
"""
if spacing is None:
raise BuildError(f'Must provide spacing for multi-port {route_name}()')
ports = {context.portspec: context.port for context in contexts}
has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool)
if not has_rotation.all():
raise PortError(f'Ports must have rotation for multi-port {route_name}()')
rotations = numpy.array([port.rotation for port in ports.values()], dtype=float)
if not numpy.allclose(rotations[0], rotations):
port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()}
raise BuildError(
f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n'
+ pformat(port_rotations)
)
direction = rotations[0] + pi
rot_matrix = rotation_matrix_2d(-direction)
orig_offsets = numpy.array([port.offset for port in ports.values()])
rot_offsets = (rot_matrix @ orig_offsets.T).T
first_ccw = bool(offset > 0)
y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
steps = numpy.zeros(len(ports), dtype=float)
if spacing_arr.size == 1:
steps[1:] = spacing_arr[0]
elif spacing_arr.size == len(ports) - 1:
steps[1:] = spacing_arr
else:
raise BuildError(
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
f'got length {spacing_arr.size}'
)
if not numpy.all(numpy.isfinite(steps)):
raise BuildError('spacing must contain only finite values')
names = tuple(ports.keys())
ordered_spacings = numpy.cumsum(steps)
anchor_y = float(rot_offsets[y_order[0], 1])
specs: list[tuple[str, float, float]] = []
for order_index, port_index in enumerate(y_order):
spacing_offset = float(ordered_spacings[order_index])
start_y = float(rot_offsets[port_index, 1])
specs.append((
names[port_index],
float(length) + spacing_offset,
float(offset) - start_y + anchor_y + spacing_offset,
))
return tuple(specs)

View file

@ -0,0 +1,83 @@
"""
Planner/Pather exchange types.
`Pather` snapshots live routing state into these records before calling the
planner. The planner returns prepared actions that `Pather` can apply without
needing to know solver internals.
"""
from __future__ import annotations
# ruff: noqa: TC001
from dataclasses import dataclass
from ...error import BuildError
from ...ports import Port
from ..tools import RenderStep, Tool
class RoutePlanningError(BuildError):
"""Route-planning error with fallback policy metadata."""
fatal: bool
def __init__(self, *args: object, fatal: bool = False) -> None:
super().__init__(*args)
self.fatal = fatal
def route_error_is_fatal(err: Exception) -> bool:
"""Return true when a planning error should bypass dead-Pather fallback."""
return bool(getattr(err, 'fatal', False))
@dataclass(frozen=True, slots=True)
class RoutePortContext:
"""
Immutable planning view of one live Pather port.
`port` is a copy of the live port so failed route selection leaves Pather
state unchanged. `tool` is the already-resolved routing Tool for this
portspec.
"""
portspec: str
"""Live Pather port name being planned."""
port: Port
"""Copied live port used as immutable route input."""
tool: Tool
"""Resolved Tool for this port."""
@dataclass(frozen=True, slots=True)
class PreparedRouteAction:
"""
Prepared mutation for one routed Pather port.
The planner has already committed selected primitive offers into
`render_steps` and computed the final live port. `plug_into`, when set,
names the destination port to consume after the route endpoint is applied.
"""
portspec: str
"""Live Pather port name to update."""
render_steps: tuple[RenderStep, ...]
"""Committed route steps to append to Pather's pending render queue."""
final_port: Port
"""Final live port value after all route steps."""
plug_into: str | None = None
"""Optional destination port to consume after the final port is applied."""
@dataclass(frozen=True, slots=True)
class PreparedRouteResult:
"""
Complete prepared result for one Pather routing operation.
`actions` are applied first. `renames` are deferred until after all route
actions so trace-into/thru behavior can be represented without exposing the
solver's selected primitive sequence to Pather.
"""
actions: tuple[PreparedRouteAction, ...]
"""Prepared per-port route mutations."""
renames: tuple[tuple[str, str], ...] = ()
"""Deferred `(old_name, new_name)` port renames applied after actions."""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1398,10 +1398,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
else:
if isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
elif isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
ports = {}
for name, port in other.ports.items():
@ -1527,11 +1526,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will emit a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
skip_geometry: If `True`, only ports are updated and geometry is
skipped. If a valid transform cannot be found (e.g. due to
misaligned ports), a 'best-effort' dummy transform is used

View file

@ -12,7 +12,7 @@ from numpy import pi
from numpy.typing import ArrayLike, NDArray
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
from .utils import rotate_offsets_around, rotation_matrix_2d
from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d
from .error import PortError, format_stacktrace
@ -542,7 +542,7 @@ class PortList(metaclass=ABCMeta):
a_types = [pp.ptype for pp in a_ports]
b_types = [pp.ptype for pp in b_ports]
type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
type_conflicts = numpy.array([not ptypes_compatible(at, bt)
for at, bt in zip(a_types, b_types, strict=True)])
if type_conflicts.any():
@ -641,11 +641,10 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `other` should be rotated. Otherwise,
`set_rotation` must remain `None`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will log a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
Returns:
- The (x, y) translation (performed last)
@ -694,11 +693,10 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `o_ports` should be rotated. Otherwise,
`set_rotation` must remain `None`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will log a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
Returns:
- The (x, y) translation (performed last)
@ -725,8 +723,10 @@ class PortList(metaclass=ABCMeta):
o_rotations *= -1
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs)
for st, ot in zip(s_types, o_types, strict=True)])
type_conflicts = numpy.array([
not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs
for st, ot in zip(s_types, o_types, strict=True)
])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (kk, vv) in enumerate(map_in.items()):

View file

@ -1,9 +1,14 @@
from typing import Any
from collections.abc import Callable
from copy import deepcopy
import numpy
from numpy.typing import ArrayLike, NDArray
from numpy.testing import assert_allclose
from masque import Pather, Port
from masque.builder.tools import RenderStep
def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]:
"""
@ -25,3 +30,117 @@ def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: flo
Assert that an object's single-shape bounds match `expected`.
"""
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
def normalized_route_data(data: Any) -> Any:
"""
Return a deterministic, comparison-friendly representation of route data.
"""
if isinstance(data, dict):
return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0])))
if isinstance(data, list | tuple):
return tuple(normalized_route_data(value) for value in data)
if isinstance(data, numpy.ndarray):
return tuple(normalized_route_data(value) for value in data.tolist())
if isinstance(data, numpy.generic):
return data.item()
try:
hash(data)
except TypeError:
return repr(data)
return data
def route_step_signature(step: RenderStep) -> tuple[Any, ...]:
"""
Return the stable planning-relevant portion of one rendered route step.
"""
return (
step.opcode,
tuple(round(float(value), 9) for value in step.start_port.offset),
None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9),
step.start_port.ptype,
tuple(round(float(value), 9) for value in step.end_port.offset),
None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9),
step.end_port.ptype,
normalized_route_data(step.data),
)
def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]:
"""
Return a deterministic signature for a pather route.
"""
return tuple(route_step_signature(step) for step in pather._paths[portspec])
def route_endpoint(pather: Pather, portspec: str) -> Port:
"""
Return the endpoint of a routed port, falling back to the live port for empty routes.
"""
steps = pather._paths[portspec]
if not steps:
return pather.pattern[portspec]
return steps[-1].end_port
def assert_route_endpoint(
pather: Pather,
portspec: str,
expected: Port,
*,
atol: float = 1e-8,
) -> None:
"""
Assert that a route endpoint matches an expected port pose and ptype.
"""
actual = route_endpoint(pather, portspec)
assert_allclose(actual.offset, expected.offset, atol=atol)
if expected.rotation is None:
assert actual.rotation is None
else:
assert actual.rotation is not None
assert numpy.isclose(actual.rotation, expected.rotation, atol=atol)
assert actual.ptype == expected.ptype
def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None:
"""
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)
assert bend_count <= max_bends
def assert_route_deterministic(
make_pather: Callable[[], Pather],
route: Callable[[Pather], None],
portspec: str,
) -> None:
"""
Assert that the same route operation produces the same route signature twice.
"""
first = make_pather()
route(first)
first_signature = route_signature(first, portspec)
second = make_pather()
route(second)
assert route_signature(second, portspec) == first_signature
def assert_route_failure_does_not_mutate(
pather: Pather,
route: Callable[[], None],
expected_exception: type[BaseException],
) -> BaseException:
"""
Assert that a failing route operation leaves pending route steps untouched.
"""
before = deepcopy(dict(pather._paths))
try:
route()
except expected_exception as err:
assert dict(pather._paths) == before
return err
raise AssertionError(f'Expected {expected_exception.__name__}')

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,16 @@ from ..pattern import Pattern
from ..ports import Port
def test_builder_public_imports() -> None:
from masque import PortPather as TopPortPather
from masque import RenderStep as TopRenderStep
from masque.builder import PortPather as BuilderPortPather
from masque.builder import RenderStep as BuilderRenderStep
assert TopPortPather is BuilderPortPather
assert TopRenderStep is BuilderRenderStep
def test_builder_init() -> None:
lib = Library()
b = Pather(lib, name="mypat")

View file

@ -7,30 +7,30 @@ from masque import Pather, Library, Pattern, Port
from masque.builder.tools import AutoTool
def make_straight(length, width=2, ptype="wire"):
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
pat = Pattern()
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
return pat
def make_bend(R, width=2, ptype="wire", clockwise=True):
def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern:
pat = Pattern()
# Rectangular approximation of a 90 degree bend.
if clockwise:
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0)
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype)
pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype)
else:
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R)
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype)
pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype)
return pat
@pytest.fixture
def multi_bend_tool():
def multi_bend_tool() -> tuple[AutoTool, Library]:
lib = Library()
lib["b1"] = make_bend(2, ptype="wire")
@ -38,19 +38,13 @@ def multi_bend_tool():
lib["b2"] = make_bend(5, ptype="wire")
b2_abs = lib.abstract("b2")
tool = AutoTool(
straights=[
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8))
],
bends=[
AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True),
AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True)
],
sbends=[],
transitions={},
default_out_ptype="wire"
)
tool = (
AutoTool()
.add_straight("wire", make_straight, "A", length_range=(0, 10))
.add_straight("wire", lambda length: make_straight(length, width=4), "A", length_range=(10, 1e8))
.add_bend(b1_abs, "A", "B", clockwise=True, mirror=True)
.add_bend(b2_abs, "A", "B", clockwise=True, mirror=True)
)
return tool, lib
def test_autotool_uturn() -> None:
@ -70,13 +64,11 @@ def test_autotool_uturn() -> None:
bend_pat.ports['out'] = Port((500, -500), pi/2)
lib['bend'] = bend_pat
tool = AutoTool(
straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')],
bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)],
sbends=[],
transitions={},
default_out_ptype='wire'
)
tool = (
AutoTool()
.add_straight('wire', make_straight, 'in')
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), 0)
@ -88,7 +80,7 @@ def test_autotool_uturn() -> None:
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
def test_deferred_render_autotool_double_L(multi_bend_tool) -> None:
def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None:
tool, lib = multi_bend_tool
rp = Pather(lib, tools=tool, auto_render=False)
rp.ports["A"] = Port((0,0), 0, ptype="wire")
@ -101,22 +93,10 @@ def test_deferred_render_autotool_double_L(multi_bend_tool) -> None:
rp.render()
assert len(rp.pattern.refs) > 0
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
tool, lib = multi_bend_tool
class BasicTool(AutoTool):
def planU(self, *args, **kwargs):
raise NotImplementedError()
tool_basic = BasicTool(
straights=tool.straights,
bends=tool.bends,
sbends=tool.sbends,
transitions=tool.transitions,
default_out_ptype=tool.default_out_ptype
)
p = Pather(lib, tools=tool_basic)
p = Pather(lib, tools=tool)
p.ports["A"] = Port((0,0), 0, ptype="wire")
p.uturn("A", 10, length=5)

View file

@ -1,26 +1,131 @@
from typing import Any
from collections.abc import Sequence
from typing import Any, Literal, Never
import pytest
import numpy
from numpy import pi
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool
from masque.error import BuildError, PortError, PatternError
from masque import Pather, Library, Port
from masque.builder.planner import RoutePortContext, RoutingPlanner
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool
from masque.error import BuildError
from masque.library import ILibrary
def test_pather_jog_failed_fallback_is_atomic() -> None:
class PlanningOnlyTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[Any, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('planning_only_tool')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
return tree
class FirstPortOnlyTraceTool(PlanningOnlyTool):
def __init__(self) -> None:
self.render_calls = 0
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[StraightOffer | BendOffer, ...]:
_ = out_ptype
if in_ptype != 'wire':
return ()
if kind == 'straight':
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
def commit(length: float) -> dict[str, float | str]:
return {'kind': 'straight', 'length': length}
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=endpoint,
commit_planner=commit,
),)
if kind == 'bend':
ccw = bool(kwargs['ccw'])
def endpoint(length: float) -> Port:
return Port(
(length, 1 if ccw else -1),
rotation=-pi / 2 if ccw else pi / 2,
ptype='wire',
)
def commit(length: float) -> dict[str, float | str]:
return {'kind': 'bend', 'length': length}
return (BendOffer(
in_ptype='wire',
out_ptype='wire',
ccw=ccw,
length_domain=(1, numpy.inf),
endpoint_planner=endpoint,
commit_planner=commit,
),)
return ()
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any,
) -> Library:
_ = batch, port_names, kwargs
self.render_calls += 1
tree, pat = Library.mktree('trace')
pat.add_port_pair(names=port_names, ptype='wire')
return tree
class CountingPathTool(PathTool):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.render_calls = 0
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any,
) -> ILibrary:
self.render_calls += 1
return super().render(batch, port_names=port_names, **kwargs)
def test_pather_jog_failed_two_bend_route_is_atomic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='shorter than required bend'):
with pytest.raises(BuildError, match='S-bend'):
p.jog('A', 1.5, length=1.5)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
lib = Library()
@ -32,7 +137,19 @@ def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_pather_auto_render_batches_multi_step_selected_route_once() -> None:
lib = Library()
tool = CountingPathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=True)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 4, length=10)
assert tool.render_calls == 1
assert len(p._paths['A']) == 0
assert p.pattern.has_shapes()
def test_pather_jog_length_solved_from_single_position_bound() -> None:
lib = Library()
@ -50,18 +167,59 @@ def test_pather_jog_length_solved_from_single_position_bound() -> None:
q.jog('A', 2, p=-6)
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
def test_pather_jog_requires_length_or_one_position_bound() -> None:
def test_pather_positional_bound_requires_port_rotation() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=None, ptype='wire')
with pytest.raises(BuildError, match='Ports must have rotation'):
p.trace_to('A', None, x=-5)
def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='requires either length'):
p.jog('A', 2)
p.jog('A', 2)
assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -2))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert [step.opcode for step in p._paths['A']] == ['L', 'L', 'L']
with pytest.raises(BuildError, match='exactly one positional bound'):
p.jog('A', 2, x=-6, p=-6)
def test_pather_trace_omitted_length_uses_minimum_offer() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace('A', None)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
p.trace('A', True)
assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -1))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2)
def test_pather_trace_to_without_bound_uses_single_port_trace_minimum() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace_to('A', False)
assert numpy.allclose(p.pattern.ports['A'].offset, (-1, 1))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 3 * pi / 2)
def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
@ -83,7 +241,7 @@ def test_pather_trace_rejects_length_with_bundle_bound() -> None:
with pytest.raises(BuildError, match='length cannot be combined'):
p.trace('A', None, length=5, xmin=-100)
@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}))
@pytest.mark.parametrize('kwargs', [{'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}]) # noqa: PT007
def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
@ -92,6 +250,116 @@ def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) ->
with pytest.raises(BuildError, match='exactly one bundle bound'):
p.trace(['A', 'B'], None, **kwargs)
def test_planner_constrained_bend_requires_jog() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
context = RoutePortContext('A', Port((0, 0), rotation=0, ptype='wire'), tool)
with pytest.raises(BuildError, match='trace route requires a jog constraint'):
RoutingPlanner().plan_leg('bend', context, length=5, constrain_jog=True)
def test_pather_trace_each_plans_all_ports_before_mutation() -> None:
tool = FirstPortOnlyTraceTool()
p = Pather(Library(), tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-2, 5), rotation=0, ptype='blocked')
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
p.trace(['A', 'B'], None, each=5)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (-2, 5))
assert p.pattern.ports['A'].ptype == 'wire'
assert p.pattern.ports['B'].ptype == 'blocked'
assert len(p._paths['A']) == 0
assert len(p._paths['B']) == 0
def test_pather_bundle_trace_plans_all_ports_before_mutation_or_render() -> None:
tool = FirstPortOnlyTraceTool()
p = Pather(Library(), tools=tool, auto_render=True)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='blocked')
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
p.trace(['A', 'B'], True, xmin=-10, spacing=2)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4))
assert p.pattern.ports['A'].rotation == 0
assert p.pattern.ports['B'].rotation == 0
assert len(p._paths['A']) == 0
assert len(p._paths['B']) == 0
assert tool.render_calls == 0
assert not p.pattern.has_shapes()
def test_pather_route_commit_failure_is_atomic_for_multi_port_trace() -> None:
class CommitFailureTool(PlanningOnlyTool):
def __init__(self) -> None:
self.committed: list[str | None] = []
self.render_calls = 0
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[StraightOffer, ...]:
_ = out_ptype, kwargs
if kind != 'straight':
return ()
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=in_ptype)
def commit(length: float) -> dict[str, float | str | None]:
_ = length
self.committed.append(in_ptype)
if in_ptype == 'bad':
raise BuildError('selected commit failed')
return {'ptype': in_ptype, 'length': length}
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=in_ptype,
endpoint_planner=endpoint,
commit_planner=commit,
),)
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any,
) -> Library:
_ = batch, port_names, kwargs
self.render_calls += 1
tree, pat = Library.mktree('commit_failure_tool')
pat.add_port_pair(names=port_names)
return tree
tool = CommitFailureTool()
p = Pather(Library(), tools=tool, auto_render=True)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='bad')
with pytest.raises(BuildError, match='selected commit failed'):
p.trace(['A', 'B'], None, each=5)
assert tool.committed == ['wire', 'bad']
assert tool.render_calls == 0
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4))
assert p.pattern.ports['A'].ptype == 'wire'
assert p.pattern.ports['B'].ptype == 'bad'
assert len(p._paths['A']) == 0
assert len(p._paths['B']) == 0
assert not p.pattern.has_shapes()
def test_pather_jog_rejects_length_with_position_bound() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
@ -99,7 +367,7 @@ def test_pather_jog_rejects_length_with_position_bound() -> None:
with pytest.raises(BuildError, match='length cannot be combined'):
p.jog('A', 2, length=5, x=-999)
@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10}))
@pytest.mark.parametrize('kwargs', [{'x': -999}, {'xmin': -10}]) # noqa: PT007
def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
@ -107,7 +375,7 @@ def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
p.uturn('A', 4, **kwargs)
def test_pather_uturn_none_length_defaults_to_zero() -> None:
def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
@ -119,9 +387,82 @@ def test_pather_uturn_none_length_defaults_to_zero() -> None:
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None:
class OutPtypeSensitiveTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
def test_pather_uturn_explicit_zero_length_preserves_old_shape() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.uturn('A', 4, length=0)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
def test_pather_uturn_does_not_use_direct_planl_fallback() -> None:
class PlanLOnlyTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> Never:
del kind, in_ptype, out_ptype, kwargs
raise NotImplementedError
def legacy_planL(
self,
ccw: object,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**_kwargs: Any,
) -> tuple[Port, dict[str, object]]:
del out_ptype
if ccw is None:
rotation = pi
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = 1
else:
rotation = pi / 2
jog = -1
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='No legal primitive offer for omitted-length U-turn'):
p.uturn('A', 5)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p._paths['A']) == 0
with pytest.raises((BuildError, NotImplementedError)):
p.uturn('A', 5, length=10)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p._paths['A']) == 0
def test_pather_su_topology_rejects_out_ptype_sensitive_planl_jog() -> None:
class OutPtypeSensitiveTool(PlanningOnlyTool):
def legacy_planL(
self,
ccw: object,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**_kwargs: Any,
) -> tuple[Port, dict[str, object]]:
radius = 1 if out_ptype is None else 2
if ccw is None:
rotation = pi
@ -135,19 +476,94 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> N
ptype = out_ptype or in_ptype or 'wire'
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=OutPtypeSensitiveTool())
p = Pather(Library(), tools=OutPtypeSensitiveTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='fallback via two planL'):
with pytest.raises((BuildError, NotImplementedError)):
p.jog('A', 5, length=10, out_ptype='wide')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None:
class OutPtypeSensitiveTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
def test_pather_two_l_planl_only_uturn_is_not_supported() -> None:
class PlanLOnlyTool(PlanningOnlyTool):
def legacy_planL(
self,
ccw: object,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**_kwargs: Any,
) -> tuple[Port, dict[str, object]]:
del out_ptype
if ccw is None:
rotation = pi
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = 1
else:
rotation = pi / 2
jog = -1
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises((BuildError, NotImplementedError)):
p.uturn('A', 5, length=10)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p._paths['A']) == 0
def test_pather_two_l_planl_only_jog_is_not_supported() -> None:
class PlanLOnlyTool(PlanningOnlyTool):
def legacy_planL(
self,
ccw: object,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**_kwargs: Any,
) -> tuple[Port, dict[str, object]]:
del out_ptype
if ccw is None:
rotation = pi
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = 1
else:
rotation = pi / 2
jog = -1
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
p = Pather(Library(), tools=PlanLOnlyTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises((BuildError, NotImplementedError)):
p.jog('A', 5, length=10, out_ptype='unk')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
def test_pather_su_topology_rejects_out_ptype_sensitive_planl_uturn() -> None:
class OutPtypeSensitiveTool(PlanningOnlyTool):
def legacy_planL(
self,
ccw: object,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**_kwargs: Any,
) -> tuple[Port, dict[str, object]]:
radius = 1 if out_ptype is None else 2
if ccw is None:
rotation = pi
@ -164,50 +580,22 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() ->
p = Pather(Library(), tools=OutPtypeSensitiveTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='fallback via two planL'):
with pytest.raises((BuildError, NotImplementedError)):
p.uturn('A', 5, length=10, out_ptype='wide')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
class DummyTool(Tool):
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
lib = Library()
pat = Pattern()
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire')
lib['top'] = pat
return lib
out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y'))
assert numpy.allclose(out_port.offset, (5, 0))
assert numpy.isclose(out_port.rotation, pi)
def test_tool_planS_fallback_accepts_custom_port_names() -> None:
class DummyTool(Tool):
def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
lib = Library()
pat = Pattern()
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire')
lib['top'] = pat
return lib
out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y'))
assert numpy.allclose(out_port.offset, (5, 2))
assert numpy.isclose(out_port.rotation, pi)
def test_pather_uturn_failed_fallback_is_atomic() -> None:
def test_pather_uturn_failed_two_bend_route_is_atomic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='shorter than required bend'):
with pytest.raises(BuildError, match='U-turn'):
p.uturn('A', 1.5, length=0)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0

View file

@ -1,10 +1,13 @@
from typing import Any
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose, assert_equal
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool
from masque.builder import PathTool, PrimitiveOffer, StraightOffer
from masque.builder.planner import RoutingPlanner
from masque.error import BuildError, PortError
@ -17,6 +20,79 @@ def pather_setup() -> tuple[Pather, PathTool, Library]:
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return p, tool, lib
def test_builder_tool_symbol_exports() -> None:
import masque
import masque.builder
import masque.builder.tools as builder_tools
package_root_exports = (
'RenderStep',
'PortPather',
)
builder_tool_names = (
'PrimitiveOffer',
'StraightOffer',
'BendOffer',
'SOffer',
'UOffer',
)
internal_names = (
'PTypeMatch',
'canonicalize_domain_value',
'ptype_match',
'ptypes_compatible',
)
for name in (*package_root_exports, *builder_tool_names):
assert hasattr(masque.builder, name)
for name in package_root_exports:
assert hasattr(masque, name)
for name in (*builder_tool_names, *internal_names):
assert not hasattr(masque, name)
for name in internal_names:
assert not hasattr(masque.builder, name)
for name in builder_tool_names:
assert hasattr(masque.builder, name)
assert hasattr(builder_tools, name)
def test_pather_pending_render_steps_are_private() -> None:
p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1), auto_render=False)
assert not hasattr(p, 'paths')
assert hasattr(p, '_paths')
def test_pather_accepts_and_reuses_planner_instance() -> None:
class CountingPlanner(RoutingPlanner):
def __init__(self) -> None:
self.trace_to_calls = 0
def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any:
self.trace_to_calls += 1
return super().plan_trace_to_route(*args, **kwargs)
planner = CountingPlanner()
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
auto_render=False,
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 1)
p.straight('A', 2)
assert p.planner is planner
assert planner.trace_to_calls == 2
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.straight("start", 10)
@ -322,7 +398,90 @@ def test_pather_dead_fallback_preserves_out_ptype() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0))
assert p.pattern.ports['A'].ptype == 'other'
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None:
class MismatchedEndpointTool(PathTool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001,ARG002
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
return (StraightOffer(
in_ptype=in_ptype,
out_ptype='other',
endpoint_planner=endpoint,
commit_planner=lambda length: {'length': length},
),)
p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
p.straight('A', 1000)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None:
class PartlyMismatchedEndpointTool(PathTool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001,ARG002
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def mismatched_endpoint(length: float) -> Port:
ptype = 'wire' if numpy.isclose(length, 0) else 'other'
return Port((length, 0), rotation=pi, ptype=ptype)
def valid_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
return (
StraightOffer(
in_ptype=in_ptype,
out_ptype='wire',
endpoint_planner=mismatched_endpoint,
commit_planner=lambda length: {'kind': 'mismatched', 'length': length},
),
StraightOffer(
in_ptype=in_ptype,
out_ptype='wire',
endpoint_planner=valid_endpoint,
commit_planner=lambda length: {'kind': 'valid', 'length': length},
),
)
p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
p.straight('A', 1000)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
lib = Library()

View file

@ -1,12 +1,10 @@
from typing import Any
import pytest
import numpy
from numpy import pi
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool
from masque.error import BuildError, PortError, PatternError
from masque.builder.tools import PathTool
from masque.error import PortError, PatternError
def test_pather_place_treeview_resolves_once() -> None:
@ -46,7 +44,7 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None:
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').trace(None, 5000)
assert [step.opcode for step in p.paths['A']] == ['L']
assert [step.opcode for step in p._paths['A']] == ['L']
other = Pattern(
annotations={'k': [2]},
@ -56,7 +54,7 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None:
with pytest.raises(PatternError, match='Annotation keys overlap'):
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
assert [step.opcode for step in p.paths['A']] == ['L']
assert [step.opcode for step in p._paths['A']] == ['L']
assert set(p.pattern.ports) == {'A'}
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
@ -72,7 +70,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
p.place(other, port_map={'X': 'A'}, append=True)
p.at('A').straight(2000)
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
p.render()
assert p.pattern.has_shapes()
@ -98,7 +96,7 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
p.at('A').straight(2000)
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
p.render()
assert p.pattern.has_shapes()
@ -113,10 +111,10 @@ def test_pather_failed_plugged_does_not_add_break_marker() -> None:
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
assert [step.opcode for step in p.paths['A']] == ['L']
assert [step.opcode for step in p._paths['A']] == ['L']
with pytest.raises(PortError, match='Connection destination ports were not found'):
p.plugged({'A': 'missing'})
assert [step.opcode for step in p.paths['A']] == ['L']
assert set(p.paths) == {'A'}
assert [step.opcode for step in p._paths['A']] == ['L']
assert set(p._paths) == {'A'}

View file

@ -0,0 +1,514 @@
from collections.abc import Callable
from typing import Any, Literal
import numpy
import pytest
from numpy import pi
from masque import Library, Path, Port, Pather
from masque.builder.tools import (
BendOffer,
PathTool,
PrimitiveOffer,
SOffer,
StraightOffer,
Tool,
UOffer,
)
from masque.error import BuildError
from masque.utils import PTypeMatch, ptype_match
def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]:
return {
'endpoint_planner': lambda parameter: planner(parameter)[0],
'commit_planner': lambda parameter: planner(parameter)[1],
}
class PlanningOnlyTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('planning_only_tool')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
return tree
def test_tool_requires_primitive_offers_override() -> None:
class RenderOnlyTool(Tool):
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(TypeError):
RenderOnlyTool()
def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None:
class BaseCallingTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(NotImplementedError):
BaseCallingTool().primitive_offers('straight')
def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float:
offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
return offer.canonicalize_parameter(value)
def test_offer_canonicalize_parameter_half_open_and_singleton() -> None:
assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0
assert canonicalize_offer_parameter(3, (0, 10)) == 3
with pytest.raises(BuildError, match='outside half-open domain'):
canonicalize_offer_parameter(10, (0, 10))
assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5
with pytest.raises(BuildError, match='outside singleton domain'):
canonicalize_offer_parameter(5.1, (5, 5))
@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf])
def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None:
with pytest.raises(BuildError, match='must be finite'):
canonicalize_offer_parameter(value, (0, 10))
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
canonicalize_offer_parameter(3, (10, 0))
def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None:
assert ptype_match('wire', 'wire') is PTypeMatch.EXACT
assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD
assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD
assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH
def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None:
endpoint_calls: list[float] = []
commit_calls: list[float] = []
def endpoint(length: float) -> Port:
endpoint_calls.append(length)
return Port((length, 2), rotation=pi, ptype='wire')
def commit(length: float) -> dict[str, float]:
commit_calls.append(length)
return {'length': length}
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
length_domain=(5, 5),
endpoint_planner=endpoint,
commit_planner=commit,
)
assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2))
assert offer.cost_at(5 + 1e-13) == 5 + pi
assert commit_calls == []
assert offer.commit(5 + 1e-13) == {'length': 5}
assert endpoint_calls == [5, 5]
assert commit_calls == [5]
def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None:
def data_at(length: float) -> dict[str, float]:
return {'length': length}
offer = StraightOffer.generated(
'wire',
data_at,
length_domain=(2, 8),
priority_bias=3,
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]),
)
endpoint = offer.endpoint_at(4)
assert offer.length_domain == (2, 8)
assert offer.priority_bias == 3
assert numpy.allclose(endpoint.offset, [4, 0])
assert endpoint.rotation == pi
assert endpoint.ptype == 'wire'
assert offer.commit(4) == {'length': 4}
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]])
def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None:
def endpoint_at(jog: float) -> Port:
return Port((10, jog), rotation=pi, ptype='wire')
def data_at(jog: float) -> dict[str, Any]:
return {'jog': abs(jog), 'mirrored': jog < 0}
offer = SOffer.generated(
'wire',
endpoint_at,
data_at,
jog_domain=(-5, 5),
bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]),
)
endpoint = offer.endpoint_at(-3)
assert numpy.allclose(endpoint.offset, [10, -3])
assert offer.commit(-3) == {'jog': 3, 'mirrored': True}
assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]])
def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
def endpoint_at(length: float) -> Port:
return Port((length, length), rotation=-pi / 2, ptype='wire')
def data_at(length: float) -> dict[str, float]:
return {'length': length}
offer = BendOffer.generated(
'wire',
endpoint_at,
data_at,
ccw=True,
length_domain=(4, 4),
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]),
)
endpoint = offer.endpoint_at(4)
assert offer.ccw
assert offer.length_domain == (4, 4)
assert numpy.allclose(endpoint.offset, [4, 4])
assert endpoint.rotation == 3 * pi / 2
assert offer.commit(4) == {'length': 4}
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]])
def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
def endpoint_at(jog: float) -> Port:
return Port((12, jog), rotation=0, ptype='wire')
def data_at(jog: float) -> dict[str, float]:
return {'jog': jog}
offer = UOffer.generated(
'wire',
endpoint_at,
data_at,
jog_domain=(-6, 6),
bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]),
)
endpoint = offer.endpoint_at(-4)
assert offer.jog_domain == (-6, 6)
assert numpy.allclose(endpoint.offset, [12, -4])
assert endpoint.rotation == 0
assert offer.commit(-4) == {'jog': -4}
assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]])
def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None:
data = {'kind': 'prebuilt'}
bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731
endpoint = Port((5, 2), rotation=pi / 2, ptype='out')
cases = [
(StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5),
(BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5),
(SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
(UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
]
for offer, parameter in cases:
first = offer.endpoint_at(parameter)
first.offset[:] = 99
second = offer.endpoint_at(parameter)
assert numpy.allclose(second.offset, [5, 2])
assert second.rotation == pi / 2
assert second.ptype == 'out'
assert offer.commit(parameter) is data
assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]])
def test_offer_rejects_negative_priority_bias() -> None:
with pytest.raises(BuildError, match='priority_bias must be nonnegative'):
StraightOffer(in_ptype='wire', out_ptype='wire', priority_bias=-1)
def test_offer_rejects_one_sided_split_callbacks() -> None:
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='require both'):
StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint)
def test_offer_bbox_at_validates_bounds() -> None:
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]),
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
)
assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]])
bad = StraightOffer(
in_ptype='wire',
out_ptype='wire',
bbox_planner=lambda _length: numpy.array([0, 1]),
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
)
with pytest.raises(BuildError, match='shape'):
bad.bbox_at(3)
def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('straight', in_ptype='wire')[0]
bounds = offer.bbox_at(10)
expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single()
assert numpy.allclose(bounds, expected)
def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0]
bounds = offer.bbox_at(1)
expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single()
assert isinstance(offer, BendOffer)
assert offer.length_domain == (1, 1)
assert numpy.allclose(bounds, expected)
def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('s', in_ptype='wire', length=6)[0]
bounds = offer.bbox_at(3)
expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single()
assert isinstance(offer, SOffer)
assert numpy.allclose(bounds, expected)
def test_pathtool_u_offers_remain_unsupported() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
assert tool.primitive_offers('u', in_ptype='wire') == ()
@pytest.mark.parametrize(
('kind', 'kwargs'),
[
('straight', {}),
('bend', {'ccw': True}),
('s', {'length': 6}),
],
)
def test_pathtool_out_ptype_unk_is_wildcard(
kind: Literal['straight', 'bend', 's'],
kwargs: dict[str, Any],
) -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs)
assert offers
assert offers[0].out_ptype == 'wire'
def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
class NoOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
raise NotImplementedError
p = Pather(Library(), tools=NoOfferTool(), auto_render=False)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
p.straight('A', 5)
@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError])
def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None:
class BrokenTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
raise err_type('offer query failed')
p = Pather(Library(), tools=BrokenTool(), auto_render=False)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(err_type):
p.straight('A', 5)
def test_pather_selects_lowest_cost_offer() -> None:
class MultiOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def high(length: float) -> tuple[Port, dict[str, str | float]]:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'}
def low(length: float) -> tuple[Port, dict[str, str | float]]:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'}
return (
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=10, **offer_callbacks(high)),
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=0, **offer_callbacks(low)),
)
p = Pather(Library(), tools=MultiOfferTool(), auto_render=False)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'low'}
assert numpy.allclose(p.ports['A'].offset, (-7, 0))
def test_pather_commits_only_selected_offer() -> None:
committed: list[str] = []
class RecordingTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def make(label: str, priority: float) -> StraightOffer:
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
def commit(length: float) -> dict[str, float | str]:
committed.append(label)
return {'kind': label, 'length': length}
return StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
priority_bias=priority,
endpoint_planner=endpoint,
commit_planner=commit,
)
return (make('expensive', 100), make('cheap', 0))
p = Pather(Library(), tools=RecordingTool(), auto_render=False)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 5)
assert committed == ['cheap']
assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5}
def test_pather_routes_with_offer_only_s_and_u_tool() -> None:
class OfferOnlyTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
endpoint_ptype = out_ptype or in_ptype
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length},
)),
),)
if kind == 's':
return (SOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((5, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog},
)),
),)
if kind == 'u':
return (UOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((2, jog), rotation=0, ptype=endpoint_ptype),
{'kind': 'u', 'jog': jog},
)),
),)
return ()
p = Pather(Library(), tools=OfferOnlyTool(), auto_render=False)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 3)
p.uturn('A', 4)
assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u']

View file

@ -6,7 +6,7 @@ from numpy import pi
from numpy.testing import assert_allclose
from ..builder import Pather
from ..builder.tools import PathTool, Tool
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
from ..error import BuildError
from ..library import Library
from ..pattern import Pattern
@ -29,7 +29,7 @@ def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup
rp.at("start").straight(10).straight(10)
assert not rp.pattern.has_shapes()
assert len(rp.paths["start"]) == 2
assert len(rp._paths["start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
@ -46,21 +46,19 @@ def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Lib
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
# Clockwise bend adds the bend endpoint after the straight segment vertex.
assert len(path_shape.vertices) == 4
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
# The bend route is explicit straight run plus a fixed-size bend.
assert len(path_shape.vertices) == 5
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10)
def test_deferred_render_jog_uses_native_pathtool_planS(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").jog(4, length=10)
assert len(rp.paths["start"]) == 1
assert rp.paths["start"][0].opcode == "S"
assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"]
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
# Native PathTool S-bends place the jog width/2 before the route end.
assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10)
assert_allclose(path_shape.vertices, [[0, 0], [0, -1], [1, -1], [3, -1], [4, -1], [4, -2], [4, -10]], atol=1e-10)
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
@ -71,7 +69,7 @@ def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10)
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10)
def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool1, lib = deferred_render_setup
@ -110,7 +108,7 @@ def test_deferred_render_dead_ports() -> None:
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
assert len(rp.paths["in"]) == 0
assert len(rp._paths["in"]) == 0
rp.render()
assert not rp.pattern.has_shapes()
@ -121,8 +119,8 @@ def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTo
rp.rename_ports({"start": "new_start"})
rp.at("new_start").straight(10)
assert "start" not in rp.paths
assert len(rp.paths["new_start"]) == 2
assert "start" not in rp._paths
assert len(rp._paths["new_start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
@ -137,7 +135,7 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende
rp.at("start").straight(10).drop()
assert "start" not in rp.ports
assert len(rp.paths["start"]) == 1
assert len(rp._paths["start"]) == 1
rp.render()
assert rp.pattern.has_shapes()
@ -145,26 +143,33 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
def test_pathtool_traceL_bend_geometry_matches_ports() -> None:
def test_pathtool_bend_offer_render_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
tree = tool.traceL(True, 10)
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(1)
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10)
assert offer.length_domain == (1, 1)
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10)
def test_pathtool_traceS_geometry_matches_ports() -> None:
def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
tree = tool.traceS(10, 4)
offer = tool.primitive_offers("s", in_ptype="wire")[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(4)
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10)
assert_allclose(pat.ports["B"].rotation, pi, 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"].rotation, 0, atol=1e-10)
def test_deferred_render_uturn_fallback() -> None:
lib = Library()
@ -174,9 +179,8 @@ def test_deferred_render_uturn_fallback() -> None:
rp.at('A').uturn(offset=10000, length=5000)
assert len(rp.paths['A']) == 2
assert rp.paths['A'][0].opcode == 'L'
assert rp.paths['A'][1].opcode == 'L'
assert len(rp._paths['A']) == 4
assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L']
rp.render()
assert rp.pattern.ports['A'].rotation is not None
@ -184,15 +188,23 @@ def test_deferred_render_uturn_fallback() -> None:
def test_pather_render_auto_renames_single_use_tool_children() -> None:
class FullTreeTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((1, 0), pi, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
child = Pattern(annotations={'batch': [len(batch)]})
top.ref('_seg')
@ -215,13 +227,24 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
assert all(name.startswith('_seg') for name in lib)
assert p.pattern.referenced_patterns() <= set(lib.keys())
def test_tool_render_fallback_preserves_segment_subtrees() -> None:
def test_custom_tool_render_preserves_segment_subtrees() -> None:
class TraceTreeTool(Tool):
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: self._trace(length),
),)
def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
child = Pattern(annotations={'length': [length]})
top.ref('_seg')
@ -229,6 +252,11 @@ def test_tool_render_fallback_preserves_segment_subtrees() -> None:
tree['_seg'] = child
return tree
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
assert len(batch) == 1
assert isinstance(batch[0].data, Library)
return batch[0].data
lib = Library()
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
@ -242,11 +270,18 @@ def test_tool_render_fallback_preserves_segment_subtrees() -> None:
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
class MissingSingleUseTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
@ -270,15 +305,23 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
class SharedRefTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((1, 0), pi, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
top.ref('shared')
tree['_top'] = top
@ -295,6 +338,74 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
assert 'shared' in p.pattern.refs
assert p.pattern.referenced_patterns() <= set(lib.keys())
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None:
class WrongEndpointTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length + 1, 0), pi, ptype='wire'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongEndpointTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
class WrongPtypeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='metal'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongPtypeTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
@ -305,7 +416,7 @@ def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() ->
rp.rename_ports({'A': None})
assert 'A' not in rp.pattern.ports
assert len(rp.paths['A']) == 1
assert len(rp._paths['A']) == 1
rp.render()
assert rp.pattern.has_shapes()

View file

@ -1,13 +1,13 @@
from typing import Any
import pytest
import numpy
import pytest
from numpy import pi
from numpy.testing import assert_equal
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool
from masque.error import BuildError, PortError, PatternError
from masque import Library, PathTool, Port, Pather
from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
from masque.error import BuildError, PortError
@pytest.fixture
@ -17,6 +17,7 @@ def trace_into_setup() -> tuple[Pather, PathTool, Library]:
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
return p, tool, lib
def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -28,6 +29,7 @@ def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library])
assert "dst" not in p.ports
assert len(p.pattern.refs) == 1
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -37,10 +39,9 @@ def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
assert "src" not in p.ports
assert "dst" not in p.ports
# `trace_into()` batches internal legs before auto-rendering so the operation
# rolls back cleanly on later failures.
assert len(p.pattern.refs) == 1
def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -51,6 +52,7 @@ def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) ->
assert "src" not in p.ports
assert "dst" not in p.ports
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -63,7 +65,8 @@ def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
assert_equal(p.ports["src"].offset, [10, 10])
assert "other" not in p.ports
def test_pather_trace_into() -> None:
def test_pather_trace_into_shapes() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, auto_render=False)
@ -76,7 +79,7 @@ def test_pather_trace_into() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
p.pattern.ports['C'] = Port((0, 0), rotation=0)
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2)
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2)
p.at('C').trace_into('D', plug_destination=False)
assert 'D' in p.pattern.ports
assert 'C' in p.pattern.ports
@ -107,6 +110,79 @@ def test_pather_trace_into() -> None:
assert p.pattern.ports['I'].rotation is not None
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
class TransitionTool(Tool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = in_ptype
if kind == 'straight':
def native_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='m1wire')
native = StraightOffer(
in_ptype='m1wire',
out_ptype='m1wire',
endpoint_planner=native_endpoint,
commit_planner=lambda length: {'kind': 'straight', 'length': length},
)
if out_ptype in ('unk', 'm1wire'):
return (native,)
def transition_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='m2wire')
transition = StraightOffer(
in_ptype='m1wire',
out_ptype='m2wire',
length_domain=(2500, numpy.inf),
endpoint_planner=transition_endpoint,
commit_planner=lambda length: {'kind': 'transition', 'length': length},
)
return native, transition
if kind == 'bend':
ccw = bool(kwargs['ccw'])
def endpoint(length: float) -> Port:
return Port(
(length, 500 if ccw else -500),
rotation=-pi / 2 if ccw else pi / 2,
ptype='m1wire',
)
return (BendOffer(
in_ptype='m1wire',
out_ptype='m1wire',
ccw=ccw,
length_domain=(500, numpy.inf),
endpoint_planner=endpoint,
commit_planner=lambda length: {'kind': 'bend', 'length': length},
),)
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('transition_tool')
pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire')
return tree
p = Pather(Library(), tools=TransitionTool(), auto_render=False)
p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire')
p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire')
p.trace_into('src', 'dst')
assert 'src' not in p.pattern.ports
assert 'dst' not in p.pattern.ports
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
@ -121,27 +197,29 @@ def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
assert not p.pattern.has_shapes()
assert not p.pattern.has_refs()
def test_pather_trace_into_failure_rolls_back_ports_and_paths() -> None:
def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire')
with pytest.raises(BuildError, match='does not match path ptype'):
with pytest.raises(BuildError):
p.trace_into('A', 'B', plug_destination=False, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2)
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None:
def test_pather_trace_into_rename_failure_propagates() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
@ -152,19 +230,14 @@ def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None:
with pytest.raises(PortError, match='overwritten'):
p.trace_into('A', 'B', plug_destination=False, thru='other')
assert set(p.pattern.ports) == {'A', 'B', 'other'}
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0))
assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4))
assert len(p.paths['A']) == 0
@pytest.mark.parametrize(
('dst', 'kwargs', 'match'),
(
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'),
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'),
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'),
),
[
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'),
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'),
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'),
],
)
def test_pather_trace_into_rejects_reserved_route_kwargs(
dst: Port,
@ -186,4 +259,4 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
assert dst.rotation is not None
assert p.pattern.ports['B'].rotation is not None
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0

View file

@ -169,6 +169,36 @@ def test_port_list_plugged() -> None:
assert not pl.ports # Both should be removed
def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("WARNING", logger="masque.ports")
compatible_cases = [
("wire", "wire"),
("unk", "wire"),
(None, "wire"),
]
for left, right in compatible_cases:
caplog.clear()
pl = Pattern(ports={
"A": Port((10, 10), 0, ptype=left), # type: ignore[arg-type]
"B": Port((10, 10), pi, ptype=right), # type: ignore[arg-type]
})
pl.plugged({"A": "B"})
assert not any("conflicting types" in record.message for record in caplog.records)
caplog.clear()
pl = Pattern(ports={
"A": Port((10, 10), 0, ptype="wire"),
"B": Port((10, 10), pi, ptype="metal"),
})
pl.plugged({"A": "B"})
assert any("conflicting types" in record.message for record in caplog.records)
def test_port_list_plugged_empty_raises() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
@ -291,3 +321,34 @@ def test_find_transform_requires_connection_map() -> None:
with pytest.raises(PortError, match="at least one port connection"):
Pattern.find_port_transform({}, {}, {})
def test_find_transform_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("WARNING", logger="masque.ports")
compatible_cases = [
("wire", "wire"),
("wire", "unk"),
("wire", None),
]
for left, right in compatible_cases:
caplog.clear()
host = Pattern(ports={"A": Port((0, 0), 0, ptype=left)}) # type: ignore[arg-type]
other = Pattern(ports={"X": Port((0, 0), pi, ptype=right)}) # type: ignore[arg-type]
host.find_transform(other, {"A": "X"})
assert not any("conflicting types" in record.message for record in caplog.records)
caplog.clear()
host = Pattern(ports={"A": Port((0, 0), 0, ptype="wire")})
other = Pattern(ports={"X": Port((0, 0), pi, ptype="metal")})
host.find_transform(other, {"A": "X"})
assert any("conflicting types" in record.message for record in caplog.records)
caplog.clear()
host.find_transform(other, {"A": "X"}, ok_connections={("wire", "metal")})
assert not any("conflicting types" in record.message for record in caplog.records)

View file

@ -10,6 +10,11 @@ from .array import is_scalar as is_scalar
from .autoslots import AutoSlots as AutoSlots
from .deferreddict import DeferredDict as DeferredDict
from .decorators import oneshot as oneshot
from .ptypes import (
PTypeMatch as PTypeMatch,
ptype_match as ptype_match,
ptypes_compatible as ptypes_compatible,
)
from .bitwise import (
get_bit as get_bit,

22
masque/utils/ptypes.py Normal file
View file

@ -0,0 +1,22 @@
from enum import Enum
class PTypeMatch(Enum):
"""Result of comparing two port types."""
EXACT = 'exact'
WILDCARD = 'wildcard'
MISMATCH = 'mismatch'
def ptype_match(left: str | None, right: str | None) -> PTypeMatch:
"""Compare ptypes, treating `None` and `"unk"` as wildcards."""
if left in (None, 'unk') or right in (None, 'unk'):
return PTypeMatch.WILDCARD
if left == right:
return PTypeMatch.EXACT
return PTypeMatch.MISMATCH
def ptypes_compatible(left: str | None, right: str | None) -> bool:
"""Return true when two ptypes may connect under normal compatibility rules."""
return ptype_match(left, right) is not PTypeMatch.MISMATCH