[builder] major Pather/Planner/Tool rework

This commit is contained in:
Jan Petykiewicz 2026-07-08 23:58:24 -07:00
commit 22e645e527
28 changed files with 6036 additions and 2467 deletions

View file

@ -1,26 +1,131 @@
from typing import Any
from collections.abc import Sequence
from typing import Any, Literal, Never
import pytest
import numpy
from numpy import pi
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool
from masque.error import BuildError, PortError, PatternError
from masque import Pather, Library, Port
from masque.builder.planner import RoutePortContext, RoutingPlanner
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool
from masque.error import BuildError
from masque.library import ILibrary
def test_pather_jog_failed_fallback_is_atomic() -> None:
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)
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)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='shorter than required bend'):
with pytest.raises(BuildError, match='S-bend'):
p.jog('A', 1.5, length=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
assert len(p._paths['A']) == 0
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
lib = Library()
@ -32,7 +137,19 @@ def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0
def test_pather_auto_render_batches_multi_step_selected_route_once() -> None:
lib = Library()
tool = CountingPathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool, auto_render=True)
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()
@ -50,18 +167,59 @@ def test_pather_jog_length_solved_from_single_position_bound() -> None:
q.jog('A', 2, p=-6)
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
def test_pather_jog_requires_length_or_one_position_bound() -> None:
def test_pather_positional_bound_requires_port_rotation() -> None:
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), auto_render=False)
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_jog_omitted_length_uses_minimum_length_route() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='requires either length'):
p.jog('A', 2)
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, auto_render=False)
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, auto_render=False)
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')
@ -83,7 +241,7 @@ def test_pather_trace_rejects_length_with_bundle_bound() -> None:
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}))
@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')
@ -92,6 +250,116 @@ def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) ->
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, auto_render=False)
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, auto_render=True)
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, auto_render=True)
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')
@ -99,7 +367,7 @@ def test_pather_jog_rejects_length_with_position_bound() -> None:
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}))
@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')
@ -107,7 +375,7 @@ def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
p.uturn('A', 4, **kwargs)
def test_pather_uturn_none_length_defaults_to_zero() -> None:
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)
@ -119,9 +387,82 @@ def test_pather_uturn_none_length_defaults_to_zero() -> None:
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None:
class OutPtypeSensitiveTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
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(), auto_render=False)
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
@ -135,19 +476,94 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> N
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 = Pather(Library(), tools=OutPtypeSensitiveTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='fallback via two planL'):
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
assert len(p._paths['A']) == 0
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None:
class OutPtypeSensitiveTool(Tool):
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
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(), auto_render=False)
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(), auto_render=False)
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
@ -164,50 +580,22 @@ def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() ->
p = Pather(Library(), tools=OutPtypeSensitiveTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='fallback via two planL'):
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
assert len(p._paths['A']) == 0
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
class DummyTool(Tool):
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
lib = Library()
pat = Pattern()
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire')
lib['top'] = pat
return lib
out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y'))
assert numpy.allclose(out_port.offset, (5, 0))
assert numpy.isclose(out_port.rotation, pi)
def test_tool_planS_fallback_accepts_custom_port_names() -> None:
class DummyTool(Tool):
def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
lib = Library()
pat = Pattern()
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire')
lib['top'] = pat
return lib
out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y'))
assert numpy.allclose(out_port.offset, (5, 2))
assert numpy.isclose(out_port.rotation, pi)
def test_pather_uturn_failed_fallback_is_atomic() -> None:
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(BuildError, match='shorter than required bend'):
with pytest.raises(BuildError, match='U-turn'):
p.uturn('A', 1.5, length=0)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0
assert len(p._paths['A']) == 0