[Pather/Planner] Improve error reporting

This commit is contained in:
Jan Petykiewicz 2026-07-12 15:27:14 -07:00
commit 3a3cd854a5
9 changed files with 777 additions and 58 deletions

View file

@ -14,11 +14,17 @@ 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.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
assert TopPortPather is BuilderPortPather
assert TopRenderStep is BuilderRenderStep
assert TopRouteError is BuilderRouteError
assert TopRouteFailureDetails is BuilderRouteFailureDetails
def test_builder_init() -> None:
@ -171,3 +177,41 @@ def test_ell_handles_array_spacing_when_ccw_none() -> None:
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)

View file

@ -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

View file

@ -5,7 +5,7 @@ import numpy
from numpy import pi
from numpy.testing import assert_allclose, assert_equal
from masque import Pather, Library, Pattern, Port
from masque import Pather, Library, Pattern, Port, RouteError
from masque.builder import PathTool, PrimitiveOffer, StraightOffer
from masque.builder.planner import RoutingPlanner
from masque.error import BuildError, PortError
@ -136,12 +136,13 @@ def test_pather_dead_ports() -> None:
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
p.set_dead()
p.straight("in", -10)
with pytest.raises(RouteError, match='finite and nonnegative'):
p.straight("in", -10)
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
assert_allclose(p.ports["in"].offset, [0, 0], atol=1e-10)
p.straight("in", 20)
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
assert_allclose(p.ports["in"].offset, [-20, 0], atol=1e-10)
assert not p.pattern.has_shapes()
@ -394,9 +395,9 @@ def test_pather_dead_fallback_preserves_out_ptype() -> None:
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
p.straight('A', -1000, out_ptype='other')
p.straight('A', 1000, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0))
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 0))
assert p.pattern.ports['A'].ptype == 'other'
assert len(p._paths['A']) == 0

View file

@ -6,7 +6,7 @@ import numpy
from numpy import pi
from numpy.testing import assert_allclose
from ..builder import Pather
from ..builder import Pather, RouteError
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
from ..error import BuildError
from ..library import Library
@ -122,9 +122,10 @@ def test_deferred_render_dead_ports() -> None:
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred')
rp.set_dead()
rp.straight("in", -10)
with pytest.raises(RouteError, match='finite and nonnegative'):
rp.straight("in", -10)
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
assert_allclose(rp.ports["in"].offset, [0, 0], atol=1e-10)
assert len(rp._paths["in"]) == 0