[pather / planner] add bend_policy, default to error on non-minimal number of bends
This commit is contained in:
parent
1da5ac550a
commit
91a5d63b57
5 changed files with 267 additions and 25 deletions
16
MIGRATION.md
16
MIGRATION.md
|
|
@ -450,15 +450,17 @@ Stable imports for custom tool authors live in `masque.builder`. The
|
|||
`masque.builder.planner` module is an internal planner implementation; do not
|
||||
import it from user code.
|
||||
|
||||
`trace_into()` uses the same primitive-offer route selection and now searches
|
||||
bounded route topologies with up to four bend roles. This preserves the common
|
||||
straight, bend, S-like, U-like, and dogleg cases while allowing routes that
|
||||
need an additional bounded bend pair. Bend-family requests search one-bend
|
||||
`trace_into()` uses the same primitive-offer route selection and defaults to
|
||||
the minimal main-route bend count required by the endpoint relationship: zero
|
||||
for a straight, one for a quarter-turn, and two for S- and U-like connections.
|
||||
Ptype adapters do not consume this bend budget. Set `bend_policy='flexible'`
|
||||
to search bounded route topologies with up to four bend roles, including
|
||||
dogleg and loop-like fallbacks. Bend-family requests then search one-bend
|
||||
routes before three-bend routes; other families search zero-to-two-bend routes
|
||||
before four-bend routes. The first band with a legal route wins. Within that
|
||||
band, candidates are ordered by total primitive-offer cost, adapter count, step
|
||||
count, and deterministic discovery order. The route `strategy` affects only
|
||||
that final discovery-order tie-break.
|
||||
band, candidates are ordered by total primitive-offer cost, adapter count,
|
||||
step count, and deterministic discovery order. The route `strategy` affects
|
||||
only that final discovery-order tie-break.
|
||||
|
||||
Explicit-length `jog()` routes may also be satisfied by composing a straight
|
||||
primitive before or after an omitted-length native S primitive. `uturn()` routes
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ from .planner.interface import (
|
|||
from .error import RouteFailurePolicy, ToolContractError
|
||||
from .planner import (
|
||||
RouteTieBreakStrategy,
|
||||
TraceIntoBendPolicy,
|
||||
RoutingPlanner,
|
||||
)
|
||||
from .planner.bounds import resolved_position_bound
|
||||
|
|
@ -1116,20 +1117,26 @@ class Pather(PortList):
|
|||
plug_destination: bool = True,
|
||||
thru: str | None = None,
|
||||
strategy: RouteTieBreakStrategy | str | None = None,
|
||||
bend_policy: TraceIntoBendPolicy | str | None = None,
|
||||
tool_options: Mapping[str, Any] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Route one port into another using a bounded primitive-offer selection.
|
||||
|
||||
The current baseline searches bounded primitive-offer routes with up to
|
||||
four bend roles, including straight, single-bend, S-like, U-like, and
|
||||
dogleg topologies. Bend-family requests try one-bend routes before
|
||||
three-bend routes; other families try zero-to-two-bend routes before
|
||||
four-bend routes. The first band with a legal candidate wins. Within a
|
||||
band, candidates are ordered by total cost, adapter count, step count,
|
||||
the requested straight-vs-turn topology preference, and deterministic
|
||||
discovery order. `strategy` therefore affects only otherwise tied
|
||||
candidates.
|
||||
By default, searches only the exact main-route bend count required by
|
||||
the endpoint relationship: zero for a straight, one for a quarter-turn,
|
||||
and two for S- and U-like connections. This rejects extra dogleg and
|
||||
loop-like fallback routes without inspecting primitive geometry. Ptype
|
||||
adapters do not consume this bend budget.
|
||||
|
||||
Set `bend_policy='flexible'` to search bounded primitive-offer routes
|
||||
with up to four bend roles. Bend-family requests try one-bend routes
|
||||
before three-bend routes; other families try zero-to-two-bend routes
|
||||
before four-bend routes. The first band with a legal candidate wins.
|
||||
Within a band, candidates are ordered by total cost, adapter count,
|
||||
step count, the requested straight-vs-turn topology preference, and
|
||||
deterministic discovery order. `strategy` therefore affects only
|
||||
otherwise tied candidates.
|
||||
Custom planning options may be supplied through `tool_options`; they
|
||||
are forwarded only to primitive offer generation.
|
||||
|
||||
|
|
@ -1149,6 +1156,7 @@ class Pather(PortList):
|
|||
plug_destination=plug_destination,
|
||||
thru=thru,
|
||||
strategy=strategy,
|
||||
bend_policy=bend_policy,
|
||||
tool_options=options,
|
||||
):
|
||||
result = self.planner.plan_trace_into(
|
||||
|
|
@ -1159,6 +1167,7 @@ class Pather(PortList):
|
|||
plug_destination = plug_destination,
|
||||
thru = thru,
|
||||
strategy = strategy,
|
||||
bend_policy = bend_policy,
|
||||
tool_options = options,
|
||||
)
|
||||
self._apply_route_result(result)
|
||||
|
|
@ -1615,12 +1624,13 @@ class PortPather:
|
|||
plug_destination: bool = True,
|
||||
thru: str | None = None,
|
||||
strategy: RouteTieBreakStrategy | str | None = None,
|
||||
bend_policy: TraceIntoBendPolicy | str | None = None,
|
||||
tool_options: Mapping[str, Any] | None = None,
|
||||
) -> Self:
|
||||
port = self._single_port('trace_into')
|
||||
self.pather.trace_into(
|
||||
port, target_port, out_ptype=out_ptype, plug_destination=plug_destination,
|
||||
thru=thru, strategy=strategy, tool_options=tool_options,
|
||||
thru=thru, strategy=strategy, bend_policy=bend_policy, tool_options=tool_options,
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ from .interface import (
|
|||
route_failure_policy as route_failure_policy,
|
||||
)
|
||||
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
|
||||
from .planner import TraceIntoBendPolicy as TraceIntoBendPolicy
|
||||
from .planner import RoutingPlanner as RoutingPlanner
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ from .interface import (
|
|||
)
|
||||
|
||||
RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
|
||||
TraceIntoBendPolicy = Literal['flexible', 'minimal']
|
||||
COST_RTOL = 1e-10
|
||||
COST_ATOL = 1e-8
|
||||
|
||||
|
|
@ -87,6 +88,17 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr
|
|||
raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first')
|
||||
|
||||
|
||||
def validate_trace_into_bend_policy(
|
||||
bend_policy: TraceIntoBendPolicy | str,
|
||||
) -> TraceIntoBendPolicy:
|
||||
"""Return a supported trace-into bend policy or raise a routing error."""
|
||||
if bend_policy in ('flexible', 'minimal'):
|
||||
return bend_policy
|
||||
raise BuildError(
|
||||
f'Invalid trace_into bend policy {bend_policy!r}; expected flexible or minimal'
|
||||
)
|
||||
|
||||
|
||||
def is_close(a: float, b: float) -> bool:
|
||||
"""Compare route-solver scalars with the planner tolerance."""
|
||||
return scalar_close(a, b)
|
||||
|
|
@ -1057,9 +1069,16 @@ class RoutingPlanner:
|
|||
|
||||
TRACE_INTO_MAX_BENDS: int = 4
|
||||
DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first'
|
||||
DEFAULT_TRACE_INTO_BEND_POLICY: TraceIntoBendPolicy = 'minimal'
|
||||
|
||||
def __init__(self, strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY,
|
||||
*,
|
||||
bend_policy: TraceIntoBendPolicy = DEFAULT_TRACE_INTO_BEND_POLICY,
|
||||
) -> None:
|
||||
self.strategy = validate_strategy(strategy)
|
||||
self.bend_policy = validate_trace_into_bend_policy(bend_policy)
|
||||
|
||||
def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy:
|
||||
"""Return the per-route strategy or the planner default."""
|
||||
|
|
@ -1067,9 +1086,26 @@ class RoutingPlanner:
|
|||
return getattr(self, 'strategy', self.DEFAULT_STRATEGY)
|
||||
return validate_strategy(strategy)
|
||||
|
||||
def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]:
|
||||
"""Return non-overlapping trace_into bend-budget bands for staged solving."""
|
||||
def resolve_trace_into_bend_policy(
|
||||
self,
|
||||
bend_policy: TraceIntoBendPolicy | str | None,
|
||||
) -> TraceIntoBendPolicy:
|
||||
"""Return the per-route trace-into bend policy or the planner default."""
|
||||
if bend_policy is None:
|
||||
return getattr(self, 'bend_policy', self.DEFAULT_TRACE_INTO_BEND_POLICY)
|
||||
return validate_trace_into_bend_policy(bend_policy)
|
||||
|
||||
def trace_into_bend_bands(
|
||||
self,
|
||||
family: PrimitiveKind,
|
||||
*,
|
||||
bend_policy: TraceIntoBendPolicy | str | None = None,
|
||||
) -> tuple[tuple[int, int], ...]:
|
||||
"""Return trace_into bend-budget bands for the requested detour policy."""
|
||||
max_bends = self.TRACE_INTO_MAX_BENDS
|
||||
if self.resolve_trace_into_bend_policy(bend_policy) == 'minimal':
|
||||
required_bends = 0 if family == 'straight' else 1 if family == 'bend' else 2
|
||||
return ((required_bends, required_bends),) if required_bends <= max_bends else ()
|
||||
if family == 'bend':
|
||||
return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends)
|
||||
bands: list[tuple[int, int]] = []
|
||||
|
|
@ -1723,9 +1759,11 @@ class RoutingPlanner:
|
|||
plug_destination: bool,
|
||||
thru: str | None,
|
||||
strategy: RouteTieBreakStrategy | str | None = None,
|
||||
bend_policy: TraceIntoBendPolicy | str | None = None,
|
||||
tool_options: Mapping[str, Any] | None = None,
|
||||
) -> PreparedRouteResult:
|
||||
"""Plan a bounded route from one source port into a destination port."""
|
||||
resolved_bend_policy = self.resolve_trace_into_bend_policy(bend_policy)
|
||||
if out_ptype is None:
|
||||
out_ptype = port_dst.ptype
|
||||
if context_src.port.rotation is None or port_dst.rotation is None:
|
||||
|
|
@ -1734,6 +1772,8 @@ class RoutingPlanner:
|
|||
desired.rotation = port_dst.rotation - pi
|
||||
desired.ptype = out_ptype
|
||||
family, length, jog, ccw = self.trace_into_spec(context_src.port, desired)
|
||||
bend_bands = self.trace_into_bend_bands(family, bend_policy=resolved_bend_policy)
|
||||
max_bends = max((band[1] for band in bend_bands), default=0)
|
||||
request = self.solver_request(
|
||||
family,
|
||||
context_src,
|
||||
|
|
@ -1741,7 +1781,7 @@ class RoutingPlanner:
|
|||
jog=jog,
|
||||
ccw=ccw,
|
||||
constrain_jog=family == 'bend',
|
||||
max_bends=self.TRACE_INTO_MAX_BENDS,
|
||||
max_bends=max_bends,
|
||||
strategy=strategy,
|
||||
tool_options=tool_options,
|
||||
out_ptype=out_ptype,
|
||||
|
|
@ -1749,7 +1789,7 @@ class RoutingPlanner:
|
|||
solver = self.solver_for_request(request)
|
||||
candidate = None
|
||||
last_error: Exception | None = None
|
||||
for min_bends, max_bends in self.trace_into_bend_bands(family):
|
||||
for min_bends, max_bends in bend_bands:
|
||||
try:
|
||||
candidate = solver.solve(min_bends=min_bends, max_bends=max_bends)
|
||||
break
|
||||
|
|
|
|||
|
|
@ -113,6 +113,73 @@ def test_pather_trace_into_shapes() -> None:
|
|||
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'dst',
|
||||
[
|
||||
Port((-10_000, 0), rotation=pi),
|
||||
Port((-10_000, 2_000), rotation=pi),
|
||||
Port((-5_000, 5_000), rotation=pi / 2),
|
||||
Port((-10_000, 2_000), rotation=0),
|
||||
],
|
||||
)
|
||||
def test_pather_trace_into_minimal_policy_accepts_required_topologies(dst: Port) -> None:
|
||||
pather = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=1_000),
|
||||
render='deferred',
|
||||
)
|
||||
pather.ports['src'] = Port((0, 0), rotation=0)
|
||||
pather.ports['dst'] = dst
|
||||
|
||||
pather.trace_into('src', 'dst', plug_destination=False, bend_policy='minimal')
|
||||
|
||||
assert numpy.allclose(pather.ports['src'].offset, dst.offset)
|
||||
assert pather.ports['src'].rotation is not None
|
||||
assert numpy.isclose((pather.ports['src'].rotation - dst.rotation) % (2 * pi), pi)
|
||||
|
||||
|
||||
def test_pather_trace_into_bend_policy_changes_real_solver_fallback() -> None:
|
||||
def make_pather() -> Pather:
|
||||
pather = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=2, ptype='wire'),
|
||||
render='deferred',
|
||||
)
|
||||
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
pather.ports['dst'] = Port((2, 0), rotation=pi, ptype='wire')
|
||||
return pather
|
||||
|
||||
flexible = make_pather()
|
||||
flexible.at('src').trace_into(
|
||||
'dst',
|
||||
plug_destination=False,
|
||||
bend_policy='flexible',
|
||||
)
|
||||
|
||||
assert_equal(flexible.ports['src'].offset, (2, 0))
|
||||
assert flexible.ports['src'].rotation is not None
|
||||
assert numpy.isclose(flexible.ports['src'].rotation, 0)
|
||||
bend_roles = sum(
|
||||
1 if step.kind == 'bend' else 2 if step.kind in ('s', 'u') else 0
|
||||
for step in flexible._paths['src']
|
||||
)
|
||||
assert bend_roles == 4
|
||||
|
||||
minimal = make_pather()
|
||||
with pytest.raises(BuildError):
|
||||
minimal.at('src').trace_into(
|
||||
'dst',
|
||||
plug_destination=False,
|
||||
)
|
||||
|
||||
assert set(minimal.ports) == {'src', 'dst'}
|
||||
assert_equal(minimal.ports['src'].offset, (0, 0))
|
||||
assert numpy.isclose(minimal.ports['src'].rotation, 0)
|
||||
assert_equal(minimal.ports['dst'].offset, (2, 0))
|
||||
assert numpy.isclose(minimal.ports['dst'].rotation, pi)
|
||||
assert not minimal._paths
|
||||
|
||||
|
||||
def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None:
|
||||
p = Pather(
|
||||
Library(),
|
||||
|
|
@ -305,6 +372,7 @@ class TraceIntoBudgetSolver:
|
|||
|
||||
class TraceIntoBudgetPlanner(RoutingPlanner):
|
||||
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
|
||||
super().__init__()
|
||||
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
|
||||
self.solver_requests = 0
|
||||
|
||||
|
|
@ -340,7 +408,15 @@ def test_trace_into_reuses_solver_across_staged_bend_bands(
|
|||
planner = TraceIntoBudgetPlanner(successes)
|
||||
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
|
||||
|
||||
planner.plan_trace_into(context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None)
|
||||
planner.plan_trace_into(
|
||||
context,
|
||||
'dst',
|
||||
dst,
|
||||
out_ptype=None,
|
||||
plug_destination=True,
|
||||
thru=None,
|
||||
bend_policy='flexible',
|
||||
)
|
||||
|
||||
assert planner.solver.attempts == attempts
|
||||
assert planner.solver_requests == 1
|
||||
|
|
@ -351,7 +427,15 @@ def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None:
|
|||
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
|
||||
|
||||
with pytest.raises(RoutePlanningError, match='fatal'):
|
||||
planner.plan_trace_into(context, 'dst', Port((-10, 0), rotation=pi, ptype='wire'), out_ptype=None, plug_destination=True, thru=None)
|
||||
planner.plan_trace_into(
|
||||
context,
|
||||
'dst',
|
||||
Port((-10, 0), rotation=pi, ptype='wire'),
|
||||
out_ptype=None,
|
||||
plug_destination=True,
|
||||
thru=None,
|
||||
bend_policy='flexible',
|
||||
)
|
||||
|
||||
assert planner.solver.attempts == [(0, 2)]
|
||||
assert planner.solver_requests == 1
|
||||
|
|
@ -361,8 +445,113 @@ def test_trace_into_bend_bands_respect_max_bends() -> None:
|
|||
class OneBendPlanner(RoutingPlanner):
|
||||
TRACE_INTO_MAX_BENDS = 1
|
||||
|
||||
planner = OneBendPlanner()
|
||||
planner = OneBendPlanner(bend_policy='flexible')
|
||||
|
||||
assert planner.trace_into_bend_bands('straight') == ((0, 0),)
|
||||
assert planner.trace_into_bend_bands('s') == ((0, 0),)
|
||||
assert planner.trace_into_bend_bands('bend') == ((1, 1),)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('family', 'expected'),
|
||||
[
|
||||
('straight', ((0, 0),)),
|
||||
('bend', ((1, 1),)),
|
||||
('s', ((2, 2),)),
|
||||
('u', ((2, 2),)),
|
||||
],
|
||||
)
|
||||
def test_trace_into_default_minimal_bend_bands(family: str, expected: tuple[tuple[int, int], ...]) -> None:
|
||||
planner = RoutingPlanner()
|
||||
|
||||
assert planner.trace_into_bend_bands(family) == expected
|
||||
assert planner.trace_into_bend_bands(family, bend_policy='flexible') == (
|
||||
((1, 1), (3, 3)) if family == 'bend' else ((0, 2), (4, 4))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('dst', 'required_band'),
|
||||
[
|
||||
(Port((-10, 0), rotation=pi, ptype='wire'), (0, 0)),
|
||||
(Port((-10, -5), rotation=pi, ptype='wire'), (2, 2)),
|
||||
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), (1, 1)),
|
||||
(Port((-10, -5), rotation=0, ptype='wire'), (2, 2)),
|
||||
],
|
||||
)
|
||||
def test_trace_into_minimal_policy_uses_orientation_required_band(
|
||||
dst: Port,
|
||||
required_band: tuple[int, int],
|
||||
) -> None:
|
||||
planner = TraceIntoBudgetPlanner({required_band})
|
||||
context = RoutePortContext(
|
||||
'src',
|
||||
Port((0, 0), rotation=0, ptype='wire'),
|
||||
PathTool(layer='M1', width=1, ptype='wire'),
|
||||
)
|
||||
|
||||
planner.plan_trace_into(
|
||||
context,
|
||||
'dst',
|
||||
dst,
|
||||
out_ptype=None,
|
||||
plug_destination=True,
|
||||
thru=None,
|
||||
bend_policy='minimal',
|
||||
)
|
||||
|
||||
assert planner.solver.attempts == [required_band]
|
||||
|
||||
|
||||
def test_trace_into_minimal_policy_rejects_fallback_without_mutation() -> None:
|
||||
planner = TraceIntoBudgetPlanner({(4, 4)})
|
||||
pather = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||
planner=planner,
|
||||
render='deferred',
|
||||
)
|
||||
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='try next budget'):
|
||||
pather.trace_into('src', 'dst', bend_policy='minimal')
|
||||
|
||||
assert planner.solver.attempts == [(0, 0)]
|
||||
assert set(pather.ports) == {'src', 'dst'}
|
||||
assert_equal(pather.ports['src'].offset, (0, 0))
|
||||
assert_equal(pather.ports['dst'].offset, (-10, 0))
|
||||
assert not pather._paths
|
||||
|
||||
|
||||
def test_trace_into_bend_policy_planner_default_and_route_override() -> None:
|
||||
context = RoutePortContext(
|
||||
'src',
|
||||
Port((0, 0), rotation=0, ptype='wire'),
|
||||
PathTool(layer='M1', width=1, ptype='wire'),
|
||||
)
|
||||
dst = Port((-10, 0), rotation=pi, ptype='wire')
|
||||
minimal_planner = TraceIntoBudgetPlanner({(4, 4)})
|
||||
|
||||
with pytest.raises(BuildError, match='try next budget'):
|
||||
minimal_planner.plan_trace_into(
|
||||
context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None,
|
||||
)
|
||||
assert minimal_planner.solver.attempts == [(0, 0)]
|
||||
|
||||
flexible_planner = TraceIntoBudgetPlanner({(4, 4)})
|
||||
flexible_planner.plan_trace_into(
|
||||
context,
|
||||
'dst',
|
||||
dst,
|
||||
out_ptype=None,
|
||||
plug_destination=True,
|
||||
thru=None,
|
||||
bend_policy='flexible',
|
||||
)
|
||||
assert flexible_planner.solver.attempts == [(0, 2), (4, 4)]
|
||||
|
||||
|
||||
def test_trace_into_rejects_invalid_bend_policy() -> None:
|
||||
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
|
||||
RoutingPlanner(bend_policy='sideways') # type: ignore[arg-type]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue