Compare commits
2 commits
7b589e4f44
...
42d291b1b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 42d291b1b6 | |||
| e7f7cae8b9 |
7 changed files with 275 additions and 10 deletions
|
|
@ -57,6 +57,7 @@ for custom Tools.
|
|||
from .pather import (
|
||||
Pather as Pather,
|
||||
PortPather as PortPather,
|
||||
RouteCompletionCallback as RouteCompletionCallback,
|
||||
)
|
||||
from .error import (
|
||||
ToolContractError as ToolContractError,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Rendering is not transactional across Pattern and Library mutations. A render
|
|||
exception is terminal for that Pather: callers may catch it for reporting or
|
||||
cleanup, but must not retry rendering or continue routing with the same object.
|
||||
"""
|
||||
from typing import Self, Any, Literal, overload
|
||||
from typing import Self, Any, Literal, Protocol, overload
|
||||
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
|
||||
import copy
|
||||
import logging
|
||||
|
|
@ -56,6 +56,7 @@ from pprint import pformat
|
|||
from contextlib import contextmanager
|
||||
from itertools import chain
|
||||
from types import TracebackType
|
||||
from types import MappingProxyType
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
|
@ -92,6 +93,19 @@ RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'w
|
|||
RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
|
||||
|
||||
|
||||
class RouteCompletionCallback(Protocol):
|
||||
"""Callback invoked after one routing operation updates live Pather state."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
pather: 'Pather',
|
||||
endpoints: Mapping[str, Port],
|
||||
/,
|
||||
) -> None:
|
||||
"""Handle copied final endpoints keyed by their original routed names."""
|
||||
...
|
||||
|
||||
|
||||
def _present_route_args(**kwargs: Any) -> dict[str, Any]:
|
||||
"""Return explicitly supplied route arguments for planner validation."""
|
||||
return {key: value for key, value in kwargs.items() if value is not None}
|
||||
|
|
@ -144,10 +158,25 @@ class Pather(PortList):
|
|||
- `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend
|
||||
starting at 'my_port'. If the `Pather` is used as a context manager,
|
||||
geometry is generated on clean context exit.
|
||||
|
||||
Examples: Route completion
|
||||
==========================
|
||||
Route completion callbacks can add labels or other endpoint annotations
|
||||
without wrapping the individual routing methods::
|
||||
|
||||
def label_route_endpoints(pather, endpoints):
|
||||
for name, port in endpoints.items():
|
||||
pather.label('PORT_LABELS', string=name, offset=port.offset)
|
||||
|
||||
pather = Pather(
|
||||
library,
|
||||
tools=my_tool,
|
||||
on_route_complete=label_route_endpoints,
|
||||
)
|
||||
"""
|
||||
__slots__ = (
|
||||
'pattern', 'library', 'tools', 'planner', '_paths',
|
||||
'_dead', '_logger', '_render_policy', '_render_append', '_context_depth'
|
||||
'on_route_complete', '_dead', '_logger', '_render_policy', '_render_append', '_context_depth'
|
||||
)
|
||||
|
||||
pattern: Pattern
|
||||
|
|
@ -173,6 +202,9 @@ class Pather(PortList):
|
|||
than on the planner instance.
|
||||
"""
|
||||
|
||||
on_route_complete: RouteCompletionCallback | None
|
||||
"""Optional callback run once after each routing operation updates live state."""
|
||||
|
||||
_dead: bool
|
||||
""" If True, geometry generation is skipped (for debugging) """
|
||||
|
||||
|
|
@ -238,6 +270,7 @@ class Pather(PortList):
|
|||
render: RenderPolicy = 'auto',
|
||||
render_append: bool = True,
|
||||
planner: RoutingPlanner | None = None,
|
||||
on_route_complete: RouteCompletionCallback | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
|
|
@ -259,6 +292,11 @@ class Pather(PortList):
|
|||
whether to append geometry or add a reference.
|
||||
planner: Optional stateless route-selection planner. If omitted,
|
||||
a new `RoutingPlanner` is used.
|
||||
on_route_complete: Optional callback invoked once per routing
|
||||
operation after port updates, plugs, and renames, but before
|
||||
automatic rendering. It receives this Pather and a read-only,
|
||||
request-ordered mapping from original routed names to copied
|
||||
final Ports. Callback exceptions propagate without rollback.
|
||||
"""
|
||||
if render not in RENDER_POLICIES:
|
||||
raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}')
|
||||
|
|
@ -271,6 +309,7 @@ class Pather(PortList):
|
|||
self.library = library
|
||||
self.pattern = pattern if pattern is not None else Pattern()
|
||||
self.planner = RoutingPlanner() if planner is None else planner
|
||||
self.on_route_complete = on_route_complete
|
||||
self._paths = defaultdict(list)
|
||||
|
||||
if ports is not None:
|
||||
|
|
@ -500,6 +539,14 @@ class Pather(PortList):
|
|||
#
|
||||
# Routing Logic (Deferred / Incremental)
|
||||
#
|
||||
def _notify_route_complete(self, endpoints: Iterable[tuple[str, Port]]) -> None:
|
||||
"""Invoke the configured route callback with isolated endpoint snapshots."""
|
||||
callback = self.on_route_complete
|
||||
if callback is None:
|
||||
return
|
||||
snapshots = MappingProxyType({name: port.copy() for name, port in endpoints})
|
||||
callback(self, snapshots)
|
||||
|
||||
def _apply_route_result(self, result: PreparedRouteResult) -> None:
|
||||
"""
|
||||
Apply every action and deferred rename in a prepared route result.
|
||||
|
|
@ -521,6 +568,9 @@ class Pather(PortList):
|
|||
self.plugged({action.portspec: action.plug_into})
|
||||
for old_name, new_name in result.renames:
|
||||
self.rename_ports({old_name: new_name})
|
||||
self._notify_route_complete(
|
||||
(action.portspec, action.final_port) for action in result.actions
|
||||
)
|
||||
render_immediately = (
|
||||
self._render_policy == 'immediate'
|
||||
or (self._render_policy == 'auto' and self._context_depth == 0)
|
||||
|
|
@ -539,7 +589,7 @@ class Pather(PortList):
|
|||
*,
|
||||
out_rot: float | None = None,
|
||||
out_ptype: str | None = None,
|
||||
) -> None:
|
||||
) -> Port:
|
||||
"""
|
||||
Move a dead Pather port without generating geometry.
|
||||
|
||||
|
|
@ -564,6 +614,7 @@ class Pather(PortList):
|
|||
self.pattern.ports[portspec] = out_port
|
||||
if plug_into is not None:
|
||||
self.plugged({portspec: plug_into})
|
||||
return out_port.copy()
|
||||
|
||||
#
|
||||
# High-level Routing Methods
|
||||
|
|
@ -639,7 +690,7 @@ class Pather(PortList):
|
|||
raise
|
||||
if length is not None and len(contexts) == 1:
|
||||
context = contexts[0]
|
||||
self._apply_dead_fallback(
|
||||
endpoint = self._apply_dead_fallback(
|
||||
context.portspec,
|
||||
length,
|
||||
0,
|
||||
|
|
@ -647,11 +698,13 @@ class Pather(PortList):
|
|||
context.port.ptype,
|
||||
out_ptype = out_ptype,
|
||||
)
|
||||
self._notify_route_complete(((context.portspec, endpoint),))
|
||||
return self
|
||||
if bounds.get('each') is not None:
|
||||
each = bounds['each']
|
||||
endpoints: list[tuple[str, Port]] = []
|
||||
for context in contexts:
|
||||
self._apply_dead_fallback(
|
||||
endpoint = self._apply_dead_fallback(
|
||||
context.portspec,
|
||||
each,
|
||||
0,
|
||||
|
|
@ -659,6 +712,8 @@ class Pather(PortList):
|
|||
context.port.ptype,
|
||||
out_ptype = out_ptype,
|
||||
)
|
||||
endpoints.append((context.portspec, endpoint))
|
||||
self._notify_route_complete(endpoints)
|
||||
return self
|
||||
raise
|
||||
self._apply_route_result(result)
|
||||
|
|
@ -755,7 +810,7 @@ class Pather(PortList):
|
|||
raise
|
||||
_key, _value, length = resolved
|
||||
context = contexts[0]
|
||||
self._apply_dead_fallback(
|
||||
endpoint = self._apply_dead_fallback(
|
||||
context.portspec,
|
||||
length,
|
||||
0,
|
||||
|
|
@ -763,6 +818,7 @@ class Pather(PortList):
|
|||
context.port.ptype,
|
||||
out_ptype = out_ptype,
|
||||
)
|
||||
self._notify_route_complete(((context.portspec, endpoint),))
|
||||
return self
|
||||
self._apply_route_result(result)
|
||||
return self
|
||||
|
|
@ -962,7 +1018,7 @@ class Pather(PortList):
|
|||
if length is None:
|
||||
raise
|
||||
context = contexts[0]
|
||||
self._apply_dead_fallback(
|
||||
endpoint = self._apply_dead_fallback(
|
||||
context.portspec,
|
||||
length,
|
||||
0,
|
||||
|
|
@ -970,10 +1026,11 @@ class Pather(PortList):
|
|||
context.port.ptype,
|
||||
out_ptype = out_ptype,
|
||||
)
|
||||
self._notify_route_complete(((context.portspec, endpoint),))
|
||||
return self
|
||||
fallback_length = length if length is not None else 0
|
||||
context = contexts[0]
|
||||
self._apply_dead_fallback(
|
||||
endpoint = self._apply_dead_fallback(
|
||||
context.portspec,
|
||||
fallback_length,
|
||||
offset,
|
||||
|
|
@ -982,6 +1039,7 @@ class Pather(PortList):
|
|||
out_rot = pi,
|
||||
out_ptype = out_ptype,
|
||||
)
|
||||
self._notify_route_complete(((context.portspec, endpoint),))
|
||||
return self
|
||||
self._apply_route_result(result)
|
||||
return self
|
||||
|
|
@ -1035,7 +1093,7 @@ class Pather(PortList):
|
|||
):
|
||||
raise
|
||||
context = contexts[0]
|
||||
self._apply_dead_fallback(
|
||||
endpoint = self._apply_dead_fallback(
|
||||
context.portspec,
|
||||
length,
|
||||
offset,
|
||||
|
|
@ -1044,6 +1102,7 @@ class Pather(PortList):
|
|||
out_rot = 0.0,
|
||||
out_ptype = out_ptype,
|
||||
)
|
||||
self._notify_route_complete(((context.portspec, endpoint),))
|
||||
return self
|
||||
self._apply_route_result(result)
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -47,5 +47,9 @@ class IBorrowing(ABC):
|
|||
|
||||
The source may use a different name, which is returned alongside it.
|
||||
Port metadata may differ because ports are not layout-file content.
|
||||
The result is not recursively resolved: consumers must follow further
|
||||
borrowing views themselves and enforce format-specific constraints such
|
||||
as whether the visible and source names must match. `None` means the
|
||||
source cannot be reused safely or no source provenance is available.
|
||||
"""
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
|
|||
name: set(child_graph.get(name, set()))
|
||||
for name in self._order
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
if key not in self._names:
|
||||
raise KeyError(key)
|
||||
|
|
@ -136,6 +137,7 @@ class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
|
|||
refs = self._source.find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
|
||||
return {parent: transforms for parent, transforms in refs.items() if parent in self._names}
|
||||
|
||||
|
||||
class Library(ILibrary):
|
||||
"""
|
||||
Default implementation for a writeable library.
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class PortsLibraryView(ILibraryView, IMaterializable, IBorrowing):
|
|||
self._replace = replace
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
self._lookups_in_progress: list[str] = []
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.materialize(key, persist=True)
|
||||
|
||||
|
|
@ -160,6 +161,7 @@ class PortsLibraryView(ILibraryView, IMaterializable, IBorrowing):
|
|||
return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling))
|
||||
return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
|
||||
|
||||
|
||||
class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
|
||||
"""
|
||||
Mutable overlay over one or more source libraries.
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ from numpy.testing import assert_allclose
|
|||
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import lazy as gdsii_lazy
|
||||
from ..file.gdsii import lazy_write as gdsii_lazy_write
|
||||
from ..error import LibraryError
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
from ..library import IBorrowing, IMaterializable, LazyLibrary, Library, OverlayLibrary, PortsLibraryView
|
||||
from ..library import IBorrowing, IMaterializable, LazyLibrary, Library, LibraryView, OverlayLibrary, PortsLibraryView
|
||||
|
||||
|
||||
def _make_lazy_port_library() -> Library:
|
||||
|
|
@ -76,6 +77,19 @@ def test_gdsii_lazy_write_materializes_transiently() -> None:
|
|||
assert info['name'] == 'transient'
|
||||
|
||||
|
||||
def test_gdsii_raw_copy_provenance_cycle_falls_back() -> None:
|
||||
class SelfBorrowingView(LibraryView, IBorrowing):
|
||||
def borrowed_sources(self) -> tuple['SelfBorrowingView', ...]:
|
||||
return (self,)
|
||||
|
||||
def source_cell(self, name: str) -> tuple['SelfBorrowingView', str] | None:
|
||||
return self, name
|
||||
|
||||
view = SelfBorrowingView({'top': Pattern()})
|
||||
|
||||
assert gdsii_lazy_write._resolve_raw_struct(view, 'top') is None
|
||||
|
||||
|
||||
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
|
|
@ -147,6 +161,23 @@ def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path
|
|||
assert set(roundtrip) == {'leaf', 'child', 'top'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_borrowed_sources_require_matching_units(tmp_path: Path) -> None:
|
||||
gds_a = tmp_path / 'units_a.gds'
|
||||
gds_b = tmp_path / 'units_b.gds'
|
||||
source = Library({'top': Pattern()})
|
||||
gdsii.writefile(source, gds_a, meters_per_unit=1e-9, library_name='units-a')
|
||||
gdsii.writefile(source, gds_b, meters_per_unit=2e-9, library_name='units-b')
|
||||
|
||||
lazy_a, _ = gdsii_lazy.readfile(gds_a)
|
||||
lazy_b, _ = gdsii_lazy.readfile(gds_b)
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lazy_a)
|
||||
overlay.add_source(lazy_b, rename_theirs=lambda lib, name: lib.get_name(name))
|
||||
|
||||
with pytest.raises(LibraryError, match='identical units'):
|
||||
gdsii_lazy.write(overlay, io.BytesIO())
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_subtree_preserves_source_laziness(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_overlay_subtree_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
|
|
|
|||
166
masque/test/test_pather_route_completion.py
Normal file
166
masque/test/test_pather_route_completion.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
|
||||
from masque import Library, Pather, Port
|
||||
from masque.builder import PathTool, RenderStep, RouteCompletionCallback, Tool
|
||||
from masque.error import BuildError
|
||||
|
||||
|
||||
def test_route_completion_can_label_before_automatic_render() -> None:
|
||||
calls = 0
|
||||
|
||||
def label_endpoints(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
assert not pather.pattern.has_shapes()
|
||||
assert numpy.allclose(pather.ports['A'].offset, endpoints['A'].offset)
|
||||
for name, port in endpoints.items():
|
||||
pather.label('LABELS', string=name, offset=port.offset)
|
||||
|
||||
callback: RouteCompletionCallback = label_endpoints
|
||||
p = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||
on_route_complete=callback,
|
||||
)
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.at('A').straight(5)
|
||||
|
||||
assert calls == 1
|
||||
assert p.pattern.has_shapes()
|
||||
assert len(p.pattern.labels['LABELS']) == 1
|
||||
assert p.pattern.labels['LABELS'][0].string == 'A'
|
||||
assert numpy.allclose(p.pattern.labels['LABELS'][0].offset, p.ports['A'].offset)
|
||||
|
||||
|
||||
def test_bundle_route_completion_is_ordered_read_only_and_isolated() -> None:
|
||||
calls: list[Mapping[str, Port]] = []
|
||||
|
||||
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||
_ = pather
|
||||
calls.append(endpoints)
|
||||
with pytest.raises(TypeError):
|
||||
endpoints['extra'] = Port((0, 0)) # type: ignore[index]
|
||||
endpoints['B'].translate((100, 100))
|
||||
|
||||
p = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||
render='deferred',
|
||||
on_route_complete=record,
|
||||
)
|
||||
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.trace(['A', 'B'], None, each=5)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert list(calls[0]) == ['A', 'B']
|
||||
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
|
||||
assert numpy.allclose(p.ports['B'].offset, (-5, 2))
|
||||
|
||||
|
||||
def test_trace_into_completion_retains_consumed_source_endpoint() -> None:
|
||||
calls: list[Mapping[str, Port]] = []
|
||||
|
||||
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||
assert set(pather.ports) == {'src'}
|
||||
assert numpy.allclose(pather.ports['src'].offset, (20, 0))
|
||||
calls.append(endpoints)
|
||||
|
||||
p = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||
render='deferred',
|
||||
on_route_complete=record,
|
||||
)
|
||||
p.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.ports['dst'] = Port((-10, 0), rotation=numpy.pi, ptype='wire')
|
||||
p.ports['thru'] = Port((20, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.trace_into('src', 'dst', thru='thru')
|
||||
|
||||
assert len(calls) == 1
|
||||
assert list(calls[0]) == ['src']
|
||||
assert numpy.allclose(calls[0]['src'].offset, (-10, 0))
|
||||
|
||||
|
||||
def test_route_completion_exception_propagates_before_render() -> None:
|
||||
def fail(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||
_ = pather, endpoints
|
||||
raise RuntimeError('completion failed')
|
||||
|
||||
p = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||
on_route_complete=fail,
|
||||
)
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(RuntimeError, match='completion failed'):
|
||||
p.straight('A', 5)
|
||||
|
||||
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
|
||||
assert p._paths['A']
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
class NoRouteTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
**kwargs: Any,
|
||||
) -> tuple[()]:
|
||||
_ = kind, kwargs
|
||||
return ()
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
**kwargs: Any,
|
||||
) -> Library:
|
||||
_ = batch, port_names, kwargs
|
||||
return Library()
|
||||
|
||||
|
||||
def test_dead_bundle_fallback_invokes_route_completion_once() -> None:
|
||||
calls: list[Mapping[str, Port]] = []
|
||||
p = Pather(
|
||||
Library(),
|
||||
tools=NoRouteTool(),
|
||||
on_route_complete=lambda _pather, endpoints: calls.append(endpoints),
|
||||
)
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
|
||||
p.set_dead()
|
||||
|
||||
p.trace(['B', 'A'], None, each=5)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert list(calls[0]) == ['B', 'A']
|
||||
assert numpy.allclose(calls[0]['B'].offset, (-5, 2))
|
||||
assert numpy.allclose(calls[0]['A'].offset, (-5, 0))
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_failed_route_does_not_invoke_route_completion() -> None:
|
||||
calls = 0
|
||||
|
||||
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||
nonlocal calls
|
||||
_ = pather, endpoints
|
||||
calls += 1
|
||||
|
||||
p = Pather(Library(), tools=NoRouteTool(), on_route_complete=record)
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError):
|
||||
p.straight('A', 5)
|
||||
|
||||
assert calls == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue