250 lines
8 KiB
Python
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)
|