Compare commits

..

3 commits

8 changed files with 355 additions and 75 deletions

View file

@ -275,6 +275,10 @@ class Builder(PortList):
Returns:
self
Note:
If the builder is 'dead' (see `set_dead()`), geometry generation is
skipped but ports are still updated.
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other_names`.
@ -284,8 +288,7 @@ class Builder(PortList):
do not line up)
"""
if self._dead:
logger.error('Skipping plug() since device is dead')
return self
logger.warning('Skipping geometry for plug() since device is dead')
if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it
@ -305,6 +308,7 @@ class Builder(PortList):
set_rotation = set_rotation,
append = append,
ok_connections = ok_connections,
skip_geometry = self._dead,
)
return self
@ -350,6 +354,10 @@ class Builder(PortList):
Returns:
self
Note:
If the builder is 'dead' (see `set_dead()`), geometry generation is
skipped but ports are still updated.
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other.ports`.
@ -357,8 +365,7 @@ class Builder(PortList):
are applied.
"""
if self._dead:
logger.error('Skipping place() since device is dead')
return self
logger.warning('Skipping geometry for place() since device is dead')
if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it
@ -378,6 +385,7 @@ class Builder(PortList):
port_map = port_map,
skip_port_check = skip_port_check,
append = append,
skip_geometry = self._dead,
)
return self
@ -425,13 +433,18 @@ class Builder(PortList):
def set_dead(self) -> Self:
"""
Disallows further changes through `plug()` or `place()`.
Suppresses geometry generation for subsequent `plug()` and `place()`
operations. Unlike a complete skip, the port state is still tracked
and updated, using 'best-effort' fallbacks for impossible transforms.
This allows a layout script to execute through problematic sections
while maintaining valid port references for downstream code.
This is meant for debugging:
```
dev.plug(a, ...)
dev.set_dead() # added for debug purposes
dev.plug(b, ...) # usually raises an error, but now skipped
dev.plug(c, ...) # also skipped
dev.plug(b, ...) # usually raises an error, but now uses fallback port update
dev.plug(c, ...) # also updated via fallback
dev.pattern.visualize() # shows the device as of the set_dead() call
```

View file

@ -7,6 +7,8 @@ import copy
import logging
from pprint import pformat
from numpy import pi
from ..pattern import Pattern
from ..library import ILibrary
from ..error import BuildError
@ -283,19 +285,47 @@ class Pather(Builder, PatherMixin):
Returns:
self
Note:
If the builder is 'dead', this operation will still attempt to update
the target port's location. If the pathing tool fails (e.g. due to an
impossible length), a dummy linear extension is used to maintain port
consistency for downstream operations.
Raises:
BuildError if `distance` is too small to fit the bend (if a bend is present).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping path() since device is dead')
return self
logger.warning('Skipping geometry for path() since device is dead')
tool_port_names = ('A', 'B')
tool = self.tools.get(portspec, self.tools[None])
in_ptype = self.pattern[portspec].ptype
try:
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
except (BuildError, NotImplementedError):
if not self._dead:
raise
logger.warning("Tool path failed for dead pather. Using dummy extension.")
# Fallback for dead pather: manually update the port instead of plugging
port = self.pattern[portspec]
port_rot = port.rotation
if ccw is None:
out_rot = pi
elif bool(ccw):
out_rot = -pi / 2
else:
out_rot = pi / 2
out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype)
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
self.pattern.ports[portspec] = out_port
self._log_port_update(portspec)
if plug_into is not None:
self.plugged({portspec: plug_into})
return self
tname = self.library << tree
if plug_into is not None:
output = {plug_into: tool_port_names[1]}
@ -335,13 +365,18 @@ class Pather(Builder, PatherMixin):
Returns:
self
Note:
If the builder is 'dead', this operation will still attempt to update
the target port's location. If the pathing tool fails (e.g. due to an
impossible length), a dummy linear extension is used to maintain port
consistency for downstream operations.
Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping pathS() since device is dead')
return self
logger.warning('Skipping geometry for pathS() since device is dead')
tool_port_names = ('A', 'B')
@ -353,6 +388,7 @@ class Pather(Builder, PatherMixin):
# Fall back to drawing two L-bends
ccw0 = jog > 0
kwargs_no_out = kwargs | {'out_ptype': None}
try:
t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out)
t_pat0 = t_tree0.top_pattern()
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
@ -364,6 +400,28 @@ class Pather(Builder, PatherMixin):
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self
except (BuildError, NotImplementedError):
if not self._dead:
raise
# Fall through to dummy extension below
except BuildError:
if not self._dead:
raise
# Fall through to dummy extension below
if self._dead:
logger.warning("Tool pathS failed for dead pather. Using dummy extension.")
# Fallback for dead pather: manually update the port instead of plugging
port = self.pattern[portspec]
port_rot = port.rotation
out_port = Port((length, jog), rotation=pi, ptype=in_ptype)
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
self.pattern.ports[portspec] = out_port
self._log_port_update(portspec)
if plug_into is not None:
self.plugged({portspec: plug_into})
return self
tname = self.library << tree
if plug_into is not None:

View file

@ -253,8 +253,7 @@ class RenderPather(PatherMixin):
do not line up)
"""
if self._dead:
logger.error('Skipping plug() since device is dead')
return self
logger.warning('Skipping geometry for plug() since device is dead')
other_tgt: Pattern | Abstract
if isinstance(other, str):
@ -262,6 +261,7 @@ class RenderPather(PatherMixin):
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
if not self._dead:
# get rid of plugged ports
for kk in map_in:
if kk in self.paths:
@ -284,6 +284,7 @@ class RenderPather(PatherMixin):
set_rotation = set_rotation,
append = append,
ok_connections = ok_connections,
skip_geometry = self._dead,
)
return self
@ -334,8 +335,7 @@ class RenderPather(PatherMixin):
are applied.
"""
if self._dead:
logger.error('Skipping place() since device is dead')
return self
logger.warning('Skipping geometry for place() since device is dead')
other_tgt: Pattern | Abstract
if isinstance(other, str):
@ -343,6 +343,7 @@ class RenderPather(PatherMixin):
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
if not self._dead:
for name, port in other_tgt.ports.items():
new_name = port_map.get(name, name) if port_map is not None else name
if new_name is not None and new_name in self.paths:
@ -357,6 +358,7 @@ class RenderPather(PatherMixin):
port_map = port_map,
skip_port_check = skip_port_check,
append = append,
skip_geometry = self._dead,
)
return self
@ -365,6 +367,7 @@ class RenderPather(PatherMixin):
self,
connections: dict[str, str],
) -> Self:
if not self._dead:
for aa, bb in connections.items():
porta = self.ports[aa]
portb = self.ports[bb]
@ -405,13 +408,18 @@ class RenderPather(PatherMixin):
Returns:
self
Note:
If the builder is 'dead', this operation will still attempt to update
the target port's location. If the pathing tool fails (e.g. due to an
impossible length), a dummy linear extension is used to maintain port
consistency for downstream operations.
Raises:
BuildError if `distance` is too small to fit the bend (if a bend is present).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping path() since device is dead')
return self
logger.warning('Skipping geometry for path() since device is dead')
port = self.pattern[portspec]
in_ptype = port.ptype
@ -420,16 +428,31 @@ class RenderPather(PatherMixin):
tool = self.tools.get(portspec, self.tools[None])
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
try:
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
except (BuildError, NotImplementedError):
if not self._dead:
raise
logger.warning("Tool planning failed for dead pather. Using dummy extension.")
if ccw is None:
out_rot = pi
elif bool(ccw):
out_rot = -pi / 2
else:
out_rot = pi / 2
out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype)
data = None
# Update port
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
if not self._dead:
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy()
self._log_port_update(portspec)
if plug_into is not None:
self.plugged({portspec: plug_into})
@ -469,13 +492,18 @@ class RenderPather(PatherMixin):
Returns:
self
Note:
If the builder is 'dead', this operation will still attempt to update
the target port's location. If the pathing tool fails (e.g. due to an
impossible length), a dummy linear extension is used to maintain port
consistency for downstream operations.
Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping pathS() since device is dead')
return self
logger.warning('Skipping geometry for pathS() since device is dead')
port = self.pattern[portspec]
in_ptype = port.ptype
@ -491,6 +519,7 @@ class RenderPather(PatherMixin):
# Fall back to drawing two L-bends
ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None})
try:
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
@ -500,12 +529,28 @@ class RenderPather(PatherMixin):
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self
except (BuildError, NotImplementedError):
if not self._dead:
raise
# Fall through to dummy extension below
except BuildError:
if not self._dead:
raise
# Fall through to dummy extension below
if self._dead:
logger.warning("Tool planning failed for dead pather. Using dummy extension.")
out_port = Port((length, jog), rotation=pi, ptype=in_ptype)
data = None
if out_port is not None:
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
if not self._dead:
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy()
self._log_port_update(portspec)
if plug_into is not None:
self.plugged({portspec: plug_into})

View file

@ -638,6 +638,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
"""
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast('Positionable', entry).translate(offset)
self._log_bulk_update(f"translate({offset})")
return self
def scale_elements(self, c: float) -> Self:
@ -705,6 +706,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.rotate_elements(rotation)
self.rotate_element_centers(rotation)
self.translate_elements(+pivot)
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
return self
def rotate_element_centers(self, rotation: float) -> Self:
@ -761,6 +763,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
"""
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
cast('Flippable', entry).flip_across(axis=axis)
self._log_bulk_update(f"mirror({axis})")
return self
def copy(self) -> Self:
@ -1098,6 +1101,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
skip_geometry: bool = False,
) -> Self:
"""
Instantiate or append the pattern `other` into the current pattern, adding its
@ -1129,6 +1133,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
skip_geometry: If `True`, the operation only updates the port list and
skips adding any geometry (shapes, labels, or references). This
allows the pattern assembly to proceed for port-tracking purposes
even when layout generation is suppressed.
Returns:
self
@ -1160,6 +1168,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
pp.rotate_around(pivot, rotation)
pp.translate(offset)
self.ports[name] = pp
self._log_port_update(name)
if skip_geometry:
return self
if append:
if isinstance(other, Abstract):
@ -1218,6 +1230,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation: bool | None = None,
append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
skip_geometry: bool = False,
) -> Self:
"""
Instantiate or append a pattern into the current pattern, connecting
@ -1272,6 +1285,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
skip_geometry: If `True`, only ports are updated and geometry is
skipped. If a valid transform cannot be found (e.g. due to
misaligned ports), a 'best-effort' dummy transform is used
to ensure new ports are still added at approximate locations,
allowing downstream routing to continue.
Returns:
self
@ -1304,6 +1322,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out = {out_port_name: next(iter(map_in.keys()))}
self.check_ports(other.ports.keys(), map_in, map_out)
try:
translation, rotation, pivot = self.find_transform(
other,
map_in,
@ -1311,14 +1330,33 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation = set_rotation,
ok_connections = ok_connections,
)
except PortError:
if not skip_geometry:
raise
logger.warning("Port transform failed for dead device. Using dummy transform.")
if map_in:
ki, vi = next(iter(map_in.items()))
s_port = self.ports[ki]
o_port = other.ports[vi].deepcopy()
if mirrored:
o_port.mirror()
o_port.offset[1] *= -1
translation = s_port.offset - o_port.offset
rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0
pivot = o_port.offset
else:
translation = numpy.zeros(2)
rotation = 0.0
pivot = numpy.zeros(2)
# get rid of plugged ports
for ki, vi in map_in.items():
del self.ports[ki]
self._log_port_removal(ki)
map_out[vi] = None
if isinstance(other, Pattern):
assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)'
self.place(
other,
@ -1329,6 +1367,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map = map_out,
skip_port_check = True,
append = append,
skip_geometry = skip_geometry,
)
return self

View file

@ -17,6 +17,7 @@ from .error import PortError, format_stacktrace
logger = logging.getLogger(__name__)
port_logger = logging.getLogger('masque.ports')
@functools.total_ordering
@ -207,6 +208,19 @@ class PortList(metaclass=ABCMeta):
def ports(self, value: dict[str, Port]) -> None:
pass
def _log_port_update(self, name: str) -> None:
""" Log the current state of the named port """
port_logger.info("Port %s: %s", name, self.ports[name])
def _log_port_removal(self, name: str) -> None:
""" Log that the named port has been removed """
port_logger.info("Port %s: removed", name)
def _log_bulk_update(self, label: str) -> None:
""" Log all current ports at DEBUG level """
for name, port in self.ports.items():
port_logger.debug("%s: Port %s: %s", label, name, port)
@overload
def __getitem__(self, key: str) -> Port:
pass
@ -260,6 +274,7 @@ class PortList(metaclass=ABCMeta):
raise PortError(f'Port {name} already exists.')
assert name not in self.ports
self.ports[name] = value
self._log_port_update(name)
return self
def rename_ports(
@ -286,11 +301,22 @@ class PortList(metaclass=ABCMeta):
if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
for kk, vv in mapping.items():
if vv is None:
self._log_port_removal(kk)
elif vv != kk:
self._log_port_removal(kk)
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
if None in renamed:
del renamed[None]
self.ports.update(renamed) # type: ignore
for vv in mapping.values():
if vv is not None:
self._log_port_update(vv)
return self
def add_port_pair(
@ -319,6 +345,8 @@ class PortList(metaclass=ABCMeta):
}
self.check_ports(names)
self.ports.update(new_ports)
self._log_port_update(names[0])
self._log_port_update(names[1])
return self
def plugged(
@ -388,6 +416,7 @@ class PortList(metaclass=ABCMeta):
for pp in chain(a_names, b_names):
del self.ports[pp]
self._log_port_removal(pp)
return self
def check_ports(

View file

@ -73,3 +73,57 @@ def test_builder_set_dead() -> None:
b.place("sub")
assert not b.pattern.has_refs()
def test_builder_dead_ports() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Builder(lib, pattern=pat)
b.set_dead()
# Attempt to plug a device where ports don't line up
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
# This should NOT raise PortError because b is dead
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
# Port A should be removed, and Port B (renamed from D) should be added
assert 'A' not in b.ports
assert 'B' in b.ports
# Verify geometry was not added
assert not b.pattern.has_refs()
assert not b.pattern.has_shapes()
def test_dead_plug_best_effort() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Builder(lib, pattern=pat)
b.set_dead()
# Device with multiple ports, none of which line up correctly
other = Pattern(ports={
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
})
# Try to plug. find_transform will fail.
# It should fall back to aligning the first pair ('A' and 'P1').
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
assert 'A' not in b.ports
assert 'B' in b.ports
# Dummy transform aligns A (0,0) with P1 (10,10)
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
# P2 (20,20) rotation pi:
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
# 2. Rotate (10,10) by -pi: (-10,-10)
# 3. Translate by s_port.offset (0,0): (-10,-10)
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
# P2 rot pi + transform rot -pi = 0
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)

View file

@ -81,3 +81,25 @@ def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> N
# pi/2 (North) + CCW (90 deg) -> 0 (East)?
# Actual behavior results in pi (West).
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
def test_pather_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
p.set_dead()
# Path with negative length (impossible for PathTool, would normally raise BuildError)
p.path("in", None, -10)
# Port 'in' should be updated by dummy extension despite tool failure
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
# Downstream path should work correctly using the dummy port location
p.path("in", None, 20)
# 10 + (-20) = -10
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
# Verify no geometry
assert not p.pattern.has_shapes()

View file

@ -73,3 +73,23 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar
# Different tools should cause different batches/shapes
assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 0)]) == 1
def test_renderpather_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
rp = RenderPather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
rp.set_dead()
# Impossible path
rp.path("in", None, -10)
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
# Verify no render steps were added
assert len(rp.paths["in"]) == 0
# Verify no geometry
rp.render()
assert not rp.pattern.has_shapes()