[Pather/Planner] Improve error reporting
This commit is contained in:
parent
09b131026f
commit
3a3cd854a5
9 changed files with 777 additions and 58 deletions
|
|
@ -5,9 +5,9 @@ import pytest
|
|||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import Pather, Library, Port
|
||||
from masque import Pather, Library, Port, RouteError
|
||||
from masque.builder.planner import RoutePortContext, RoutingPlanner
|
||||
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool
|
||||
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer
|
||||
from masque.error import BuildError
|
||||
from masque.library import ILibrary
|
||||
|
||||
|
|
@ -114,15 +114,306 @@ class CountingPathTool(PathTool):
|
|||
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',
|
||||
priority_bias=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', 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',
|
||||
'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_attempted
|
||||
assert not details.minimum_exhausted
|
||||
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_attempted
|
||||
assert details.minimum_length is None
|
||||
assert details.minimum_exhausted
|
||||
assert details.minimum_cause is not None
|
||||
assert 'no legal route exists at any length' 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 not details.minimum_attempted
|
||||
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 not details.minimum_attempted
|
||||
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 not details.minimum_attempted
|
||||
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 not details.minimum_attempted
|
||||
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(BuildError, match='S-bend'):
|
||||
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
|
||||
|
|
@ -593,9 +884,12 @@ def test_pather_uturn_failed_two_bend_route_is_atomic() -> None:
|
|||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='U-turn'):
|
||||
with pytest.raises(RouteError, match='U-turn') as exc_info:
|
||||
p.uturn('A', 1.5, length=0)
|
||||
|
||||
assert exc_info.value.details.minimum_attempted
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue