[builder] major Pather/Planner/Tool rework
This commit is contained in:
parent
0f39ce8816
commit
22e645e527
28 changed files with 6036 additions and 2467 deletions
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
514
masque/test/test_pather_primitive_offers.py
Normal file
514
masque/test/test_pather_primitive_offers.py
Normal 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']
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue