1050 lines
37 KiB
Python
1050 lines
37 KiB
Python
from collections.abc import Sequence
|
|
from typing import Any, Literal, Never
|
|
import inspect
|
|
|
|
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
|
|
|
|
|
|
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_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)
|
|
|
|
|
|
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(
|
|
'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
|