masque/masque/test/test_pather_constraints.py

1193 lines
42 KiB
Python

from collections.abc import Sequence
from typing import Any, Literal, Never
from types import FunctionType
import inspect
import traceback
import pytest
import numpy
from numpy import pi
from masque import (
MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy,
)
from masque.builder.planner import RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import NoLegalRouteError
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
from masque.error import BuildError
from masque.library import ILibrary
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
def _external_failing_ccw(pather: Pather) -> None:
pather.ccw('A', 10, out_ptype='optical')
def _external_failing_trace_to(pather: Pather) -> None:
pather.trace_to('A', True, length=10, out_ptype='optical')
def _external_failing_portpather_ccw(pather: Pather) -> None:
pather.at('A').ccw(10, out_ptype='optical')
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)
class RequestCountingTool(PlanningOnlyTool):
def __init__(self) -> None:
self.offer_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[Any, ...]:
self.offer_calls += 1
return super().primitive_offers(
kind,
in_ptype=in_ptype,
out_ptype=out_ptype,
**kwargs,
)
class PreferredMinimumTool(PlanningOnlyTool):
def __init__(self) -> None:
self.commit_calls = 0
self.diagnostic_context_kwargs: list[Any] = []
def _commit(self, parameter: float) -> dict[str, float]:
self.commit_calls += 1
return {'parameter': parameter}
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[Any, ...]:
_ = out_ptype
self.diagnostic_context_kwargs.append(kwargs.get('diagnostic_context'))
if kind == 'bend':
ccw = bool(kwargs['ccw'])
rotation = -pi / 2 if ccw else pi / 2
jog = 1 if ccw else -1
return (
BendOffer(
in_ptype=in_ptype,
out_ptype='wide',
cost=100,
ccw=ccw,
length_domain=(2, 2),
endpoint_planner=lambda _length: Port((2, jog), rotation, ptype='wide'),
commit_planner=self._commit,
),
BendOffer(
in_ptype=in_ptype,
out_ptype='wide',
ccw=ccw,
length_domain=(5, 5),
endpoint_planner=lambda _length: Port((5, jog), rotation, ptype='wide'),
commit_planner=self._commit,
),
)
if kind == 'u':
return (UOffer(
in_ptype=in_ptype,
out_ptype='wide',
jog_domain=(4, 4),
endpoint_planner=lambda _jog: Port((5, 4), 0, ptype='wide'),
commit_planner=self._commit,
),)
return ()
def test_route_error_reports_preferred_minimum_and_request_details() -> None:
tool = PreferredMinimumTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(RouteError) as exc_info:
p.ccw('A', 1, out_ptype='wide', tool_options={'diagnostic_context': 'tool-value'})
details = exc_info.value.details
assert isinstance(exc_info.value, BuildError)
assert details.operation == 'trace_to'
assert details.portspec == 'A'
assert details.in_ptype == 'wire'
assert details.out_ptype == 'wide'
assert details.request == {
'ccw': True,
'length': 1,
'out_ptype': 'wide',
'tool_options': {'diagnostic_context': 'tool-value'},
}
assert details.resolved_length == 1
assert details.resolved_jog is None
# The length-2 route exists, but normal cost ranking prefers length 5.
assert details.minimum_length == 5
assert details.minimum_status is MinimumStatus.FOUND
assert exc_info.value.policy is RouteFailurePolicy.RECOVERABLE
assert details.minimum_cause is None
assert 'preferred_minimum_length: 5' in str(exc_info.value)
assert tool.commit_calls == 0
assert tool.diagnostic_context_kwargs
assert set(tool.diagnostic_context_kwargs) == {'tool-value'}
assert not p._paths
with pytest.raises(TypeError):
details.request['new'] = 'value' # type: ignore[index]
def test_route_error_reports_uturn_preferred_minimum() -> None:
tool = PreferredMinimumTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(RouteError) as exc_info:
p.uturn('A', 4, length=1, out_ptype='wide')
details = exc_info.value.details
assert details.operation == 'uturn'
assert details.resolved_length == 1
assert details.resolved_jog == 4
assert details.minimum_length == 5
assert tool.commit_calls == 0
def test_route_error_reports_no_route_at_any_length() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
with pytest.raises(RouteError) as exc_info:
p.ccw('A', 10, out_ptype='optical')
details = exc_info.value.details
assert details.minimum_status is MinimumStatus.NO_ROUTE
assert details.minimum_length is None
assert details.minimum_cause is not None
assert 'no legal route exists at any length' in str(exc_info.value)
def test_route_error_reports_external_pather_call_site() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_ccw.__code__.replace(co_filename='/project/user_layout.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
message = str(exc_info.value)
assert 'Stack trace:' not in message
route_error = exc_info.value
native_frames = traceback.extract_tb(route_error.__traceback__)
assert native_frames
assert [frame.name for frame in native_frames if frame.filename.endswith('/builder/pather.py')] == [
'ccw',
'bend',
]
assert not any('/builder/planner/' in frame.filename for frame in native_frames)
native = ''.join(traceback.format_exception(route_error))
assert native.count('/project/user_layout.py') == 1
assert '\nStack trace:\n' not in native
assert route_error.__cause__ is not None
assert isinstance(route_error._call_stack, tuple)
assert sum(frame.filename == '/project/user_layout.py' for frame in route_error._call_stack) == 1
assert any(frame.filename.endswith('/builder/pather.py') for frame in route_error._call_stack)
assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack)
def test_route_error_direct_trace_to_starts_native_traceback_at_caller() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_trace_to.__code__.replace(co_filename='/project/direct_route.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
frames = traceback.extract_tb(exc_info.value.__traceback__)
assert sum(frame.filename == '/project/direct_route.py' for frame in frames) == 1
assert not any(frame.filename.endswith('/builder/pather.py') for frame in frames)
assert not any('/builder/planner/' in frame.filename for frame in frames)
def test_route_error_portpather_keeps_only_forwarding_frames() -> None:
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
external_call = FunctionType(
_external_failing_portpather_ccw.__code__.replace(co_filename='/project/selected_route.py'),
globals(),
)
with pytest.raises(RouteError) as exc_info:
external_call(p)
frames = traceback.extract_tb(exc_info.value.__traceback__)
assert sum(frame.filename == '/project/selected_route.py' for frame in frames) == 1
assert [frame.name for frame in frames if frame.filename.endswith('/builder/pather.py')] == [
'ccw',
'bend',
'trace_to',
]
assert not any('/builder/planner/' in frame.filename for frame in frames)
def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None:
planner = RoutingPlanner()
context = RoutePortContext(
'A',
Port((0, 0), rotation=0, ptype='wire'),
PlanningOnlyTool(),
)
solve_count = 0
def solver_for_request(_request: Any) -> Any:
nonlocal solve_count
solve_count += 1
current_solve = solve_count
class FailingSolver:
def solve(self) -> Never:
if current_solve == 1:
raise NoLegalRouteError('requested length has no route')
raise RuntimeError('minimum diagnosis failed')
return FailingSolver()
monkeypatch.setattr(planner, 'solver_for_request', solver_for_request)
with pytest.raises(RouteError) as exc_info:
planner.plan_leg(
'bend',
context,
'trace_to',
{'ccw': True, 'length': 1},
length=1,
ccw=True,
)
details = exc_info.value.details
assert details.minimum_status is MinimumStatus.FAILED
assert details.minimum_length is None
assert details.minimum_cause == 'minimum diagnosis failed'
assert 'minimum-length calculation failed' in str(exc_info.value)
@pytest.mark.parametrize('length', [-1, numpy.nan, numpy.inf])
def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: float) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
).set_dead()
with pytest.raises(RouteError) as exc_info:
p.ccw('A', length)
details = exc_info.value.details
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert exc_info.value.policy is RouteFailurePolicy.FATAL
assert details.minimum_length is None
assert tool.offer_calls == 0
assert numpy.allclose(p.ports['A'].offset, (0, 0))
def test_negative_position_length_reports_bound_without_offer_query() -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(RouteError) as exc_info:
p.ccw('A', x=1)
details = exc_info.value.details
assert details.request == {'ccw': True, 'x': 1}
assert details.resolved_length == -1
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0
def test_negative_bundle_length_reports_failing_port_and_bound() -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={
'A': Port((0, 0), rotation=0, ptype='wire'),
'B': Port((0, 4), rotation=0, ptype='wire'),
},
tools=tool,
render='deferred',
)
with pytest.raises(RouteError) as exc_info:
p.trace(['A', 'B'], True, emax=0, spacing=2)
details = exc_info.value.details
assert details.portspec == 'A'
assert details.request == {'ccw': True, 'emax': 0, 'spacing': 2}
assert details.resolved_length == -2
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0
def test_negative_each_length_reports_failing_port_without_offer_query() -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={
'A': Port((0, 0), rotation=0, ptype='wire'),
'B': Port((0, 4), rotation=0, ptype='wire'),
},
tools=tool,
render='deferred',
)
with pytest.raises(RouteError) as exc_info:
p.trace(['A', 'B'], None, each=-2)
details = exc_info.value.details
assert details.portspec == 'A'
assert details.request == {'ccw': None, 'each': -2}
assert details.resolved_length == -2
assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0
@pytest.mark.parametrize(
'operation',
[
lambda p: p.trace([], None, length=1),
lambda p: p.trace_to([], None, length=1),
lambda p: p.jog([], 2, length=3),
lambda p: p.uturn([], 2, length=3),
],
ids=['trace', 'trace_to', 'jog', 'uturn'],
)
def test_pather_rejects_empty_route_selection_before_planning(operation: Any) -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(BuildError, match='at least one port'):
operation(p)
assert tool.offer_calls == 0
assert not p.ports
assert not p._paths
@pytest.mark.parametrize(
'operation',
[
lambda p: p.trace(['A', 'A'], None, each=1),
lambda p: p.trace_to(['A', 'A'], None, xmin=-10),
lambda p: p.jog(['A', 'A'], 2, length=3, spacing=1),
lambda p: p.uturn(['A', 'A'], 2, length=3, spacing=1),
lambda p: p.at(['A', 'A']).straight(1),
],
ids=['trace', 'trace_to', 'jog', 'uturn', 'port_pather'],
)
def test_pather_rejects_duplicate_route_selection_before_planning(operation: Any) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match=r"duplicates: \['A'\]"):
operation(p)
assert tool.offer_calls == 0
assert numpy.allclose(p.ports['A'].offset, (0, 0))
assert not p._paths
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, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(RouteError, match='S-bend') as exc_info:
p.jog('A', 1.5, length=1.5)
assert exc_info.value.details.minimum_length == 2
assert exc_info.value.details.resolved_jog == 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
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, render='immediate')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 1.5, length=5)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
assert p.pattern.ports['A'].rotation == 0
assert len(p._paths['A']) == 0
def test_pather_immediate_render_batches_multi_step_selected_route_once() -> None:
lib = Library()
tool = CountingPathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, render='immediate')
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()
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.jog('A', 2, x=-6)
assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
q = Pather(Library(), tools=tool)
q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
q.jog('A', 2, p=-6)
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
def test_pather_positional_bound_requires_port_rotation() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), render='deferred')
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_positional_bound_rejects_non_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
with pytest.raises(BuildError, match='nearly Manhattan'):
p.trace_to('A', None, p=-5)
def test_pather_positional_bound_accepts_nearly_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=1e-10, ptype='wire')
p.trace_to('A', None, x=-5)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -5e-10), atol=1e-8)
def test_pather_bundle_position_bound_rejects_non_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire')
with pytest.raises(BuildError, match='nearly Manhattan'):
p.trace(['A', 'B'], None, pmin=-5)
def test_pather_bundle_extension_bound_allows_non_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire')
p.trace(['A', 'B'], None, emin=5)
assert len(p._paths['A']) == 1
assert len(p._paths['B']) == 1
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, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
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, render='deferred')
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, render='deferred')
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')
for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}):
p = Pather(Library(), tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='exactly one positional bound'):
p.trace_to('A', None, **kwargs)
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='length cannot be combined'):
p.trace_to('A', None, x=-5, length=3)
def test_pather_trace_rejects_length_with_bundle_bound() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
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}]) # 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')
p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire')
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)
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, render='immediate')
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, render='immediate')
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')
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}]) # 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')
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.uturn('A', 4, **kwargs)
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)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.uturn('A', 4)
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_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(), render='deferred')
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
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = radius
else:
rotation = pi / 2
jog = -radius
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(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
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
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(), render='deferred')
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(), render='deferred')
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
jog = 0
elif bool(ccw):
rotation = -pi / 2
jog = radius
else:
rotation = pi / 2
jog = -radius
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.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
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
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(RouteError, match='U-turn') as exc_info:
p.uturn('A', 1.5, length=0)
assert exc_info.value.details.minimum_status is MinimumStatus.NO_ROUTE
assert exc_info.value.details.minimum_length is None
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p._paths['A']) == 0
@pytest.mark.parametrize('route_type', [Pather, PortPather])
@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into'])
def test_routing_entry_points_have_no_catch_all_kwargs(route_type: type, name: str) -> None:
parameters = inspect.signature(getattr(route_type, name)).parameters.values()
assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters)
@pytest.mark.parametrize('route_type', [Pather, PortPather])
@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into'])
def test_routing_entry_points_use_generic_plan_options(route_type: type, name: str) -> None:
parameters = inspect.signature(getattr(route_type, name)).parameters
assert 'plan_options' in parameters
assert 'strategy' not in parameters
assert 'bend_policy' not in parameters
def test_routing_typo_fails_before_tool_lookup() -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.straight('A', lenght=10) # type: ignore[call-arg]
assert tool.offer_calls == 0
@pytest.mark.parametrize('tool_options', [{1: 'bad'}, {'ccw': False}, {'out_ptype': 'bad'}])
def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, Any]) -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(BuildError, match='tool_options'):
p.trace('A', None, tool_options=tool_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize('plan_options', [7, {1: 'bad'}])
def test_plan_options_reject_invalid_mapping_shape(plan_options: Any) -> None:
tool = RequestCountingTool()
p = Pather(Library(), tools=tool, render='deferred')
with pytest.raises(BuildError, match='plan_options'):
p.trace('A', None, plan_options=plan_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize(
'plan_options',
[
{'bend_policy': 'flexible'},
{'unknown': True},
],
)
def test_default_planner_rejects_unsupported_plan_options_before_tool_lookup(
plan_options: dict[str, Any],
) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='unsupported keys'):
p.trace('A', None, length=1, plan_options=plan_options)
assert tool.offer_calls == 0
@pytest.mark.parametrize(
'operation',
[
lambda p: p.jog('A', numpy.nan, length=1),
lambda p: p.uturn('A', numpy.inf, length=1),
lambda p: p.straight('A', x=numpy.nan),
],
ids=['jog-offset', 'uturn-offset', 'position-bound'],
)
def test_nonfinite_route_geometry_fails_before_offer_query(operation: Any) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='finite'):
operation(p)
assert tool.offer_calls == 0
@pytest.mark.parametrize('spacing', [-1, numpy.nan, numpy.inf])
def test_invalid_bundle_spacing_fails_before_offer_query(spacing: float) -> None:
tool = RequestCountingTool()
p = Pather(
Library(),
ports={
'A': Port((0, 0), rotation=0, ptype='wire'),
'B': Port((0, 4), rotation=0, ptype='wire'),
},
tools=tool,
render='deferred',
)
with pytest.raises(BuildError, match='spacing'):
p.trace(['A', 'B'], True, xmin=-10, spacing=spacing)
assert tool.offer_calls == 0