[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

@ -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)