masque/masque/test/test_builder.py

250 lines
8 KiB
Python

import numpy
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import MinimumStatus, Pather, RouteFailureDetails
from ..builder.utils import ell
from ..error import BuildError
from ..library import Library
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 import RouteError as TopRouteError
from masque import RouteFailureDetails as TopRouteFailureDetails
from masque import RouteFailurePolicy as TopRouteFailurePolicy
from masque import MinimumStatus as TopMinimumStatus
from masque.builder import PortPather as BuilderPortPather
from masque.builder import RenderStep as BuilderRenderStep
from masque.builder import RouteError as BuilderRouteError
from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails
from masque.builder import RouteFailurePolicy as BuilderRouteFailurePolicy
from masque.builder import MinimumStatus as BuilderMinimumStatus
assert TopPortPather is BuilderPortPather
assert TopRenderStep is BuilderRenderStep
assert TopRouteError is BuilderRouteError
assert TopRouteFailureDetails is BuilderRouteFailureDetails
assert TopRouteFailurePolicy is BuilderRouteFailurePolicy
assert TopMinimumStatus is BuilderMinimumStatus
def test_route_failure_details_enforces_minimum_status_invariants() -> None:
common = {
'operation': 'trace',
'portspec': 'A',
'in_ptype': 'wire',
'out_ptype': 'wide',
'request': {},
'resolved_length': 1,
'resolved_jog': None,
'cause': 'no route',
}
with pytest.raises(BuildError, match='FOUND requires minimum_length'):
RouteFailureDetails(
**common,
minimum_length=None,
minimum_status=MinimumStatus.FOUND,
)
with pytest.raises(BuildError, match='requires minimum_length=None'):
RouteFailureDetails(
**common,
minimum_length=2,
minimum_status=MinimumStatus.NO_ROUTE,
)
def test_builder_init() -> None:
lib = Library()
b = Pather(lib, name="mypat")
assert b.pattern is lib["mypat"]
assert b.library is lib
def test_builder_place() -> None:
lib = Library()
child = Pattern()
child.ports["A"] = Port((0, 0), 0)
lib["child"] = child
b = Pather(lib)
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
assert "child_A" in b.ports
assert_equal(b.ports["child_A"].offset, [10, 20])
assert "child" in b.pattern.refs
def test_builder_plug() -> None:
lib = Library()
wire = Pattern()
wire.ports["in"] = Port((0, 0), 0)
wire.ports["out"] = Port((10, 0), pi)
lib["wire"] = wire
b = Pather(lib)
b.ports["start"] = Port((100, 100), 0)
# Plug wire's "in" port into builder's "start" port
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
# builder start: (100, 100) rotation 0
# wire in: (0, 0) rotation 0
# wire out: (10, 0) rotation pi
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
b.plug("wire", map_in={"start": "in"})
assert "start" in b.ports
assert_equal(b.ports["start"].offset, [90, 100])
assert b.ports["start"].rotation is not None
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
def test_builder_interface() -> None:
lib = Library()
source = Pattern()
source.ports["P1"] = Port((0, 0), 0)
lib["source"] = source
b = Pather.interface("source", library=lib, name="iface")
assert "in_P1" in b.ports
assert "P1" in b.ports
assert b.pattern is lib["iface"]
def test_builder_set_dead() -> None:
lib = Library()
lib["sub"] = Pattern()
b = Pather(lib)
b.set_dead()
b.place("sub")
assert not b.pattern.has_refs()
def test_builder_dead_ports() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Pather(lib, pattern=pat)
b.set_dead()
# Attempt to plug a device where ports don't line up
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
# This should NOT raise PortError because b is dead
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
# Port A should be removed, and Port B (renamed from D) should be added
assert 'A' not in b.ports
assert 'B' in b.ports
# Verify geometry was not added
assert not b.pattern.has_refs()
assert not b.pattern.has_shapes()
def test_dead_plug_best_effort() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Pather(lib, pattern=pat)
b.set_dead()
# Device with multiple ports, none of which line up correctly
other = Pattern(ports={
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
})
# Try to plug. find_transform will fail.
# It should fall back to aligning the first pair ('A' and 'P1').
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
assert 'A' not in b.ports
assert 'B' in b.ports
# Dummy transform aligns A (0,0) with P1 (10,10)
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
# P2 (20,20) rotation pi:
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
# 2. Rotate (10,10) by -pi: (-10,-10)
# 3. Translate by s_port.offset (0,0): (-10,-10)
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
# P2 rot pi + transform rot -pi = 0
assert b.ports['B'].rotation is not None
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)
def test_ell_validates_spacing_length() -> None:
ports = {
'A': Port((0, 0), 0),
'B': Port((0, 1), 0),
'C': Port((0, 2), 0),
}
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3])
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
ell(ports, True, 'min_extension', 5, spacing=[])
def test_ell_handles_array_spacing_when_ccw_none() -> None:
ports = {
'A': Port((0, 0), 0),
'B': Port((0, 1), 0),
}
scalar = ell(ports, None, 'min_extension', 5, spacing=0)
array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0]))
assert scalar == array_zero
with pytest.raises(BuildError, match='Spacing must be 0 or None'):
ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0]))
@pytest.mark.parametrize('bound_type', ['emin', 'emax', 'min_past_furthest'])
@pytest.mark.parametrize(
('rotation', 'expected'),
[
(0, 5),
(pi / 2, 7),
(pi / 6, 5),
(pi / 3, 7),
(pi / 4, 5),
],
)
def test_ell_extension_vector_selects_dominant_route_axis(
bound_type: str,
rotation: float,
expected: float,
) -> None:
result = ell({'A': Port((0, 0), rotation)}, None, bound_type, (5, 7), spacing=0)
assert_allclose(result['A'], expected)
@pytest.mark.parametrize('bound', [(-1, 2), (1, -2), (numpy.nan, 2), (1, numpy.inf), (1, 2, 3)])
def test_ell_rejects_invalid_extension_vector(bound: tuple[float, ...]) -> None:
with pytest.raises(BuildError, match='bound|negative'):
ell({'A': Port((0, 0), 0)}, None, 'emin', bound, spacing=0)
def test_ell_position_vector_still_projects_onto_route_direction() -> None:
result = ell({'A': Port((0, 0), pi)}, None, 'pmax', (5, 7), spacing=0)
assert_allclose(result['A'], 5)
def test_ell_rejects_invalid_bound_type() -> None:
with pytest.raises(BuildError, match='Invalid bound type'):
ell({'A': Port((0, 0), 0)}, None, 'nearest', 5, spacing=0)