[set_dead / skip_geometry] Improve dead pathers so more "broken" layouts can be successfully executed

This commit is contained in:
jan 2026-02-16 13:43:54 -08:00
commit f42e720c68
7 changed files with 273 additions and 73 deletions

View file

@ -284,8 +284,7 @@ class Builder(PortList):
do not line up) do not line up)
""" """
if self._dead: if self._dead:
logger.error('Skipping plug() since device is dead') logger.warning('Skipping geometry for plug() since device is dead')
return self
if not isinstance(other, str | Abstract | Pattern): if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it # We got a Tree; add it into self.library and grab an Abstract for it
@ -305,6 +304,7 @@ class Builder(PortList):
set_rotation = set_rotation, set_rotation = set_rotation,
append = append, append = append,
ok_connections = ok_connections, ok_connections = ok_connections,
skip_geometry = self._dead,
) )
return self return self
@ -357,8 +357,7 @@ class Builder(PortList):
are applied. are applied.
""" """
if self._dead: if self._dead:
logger.error('Skipping place() since device is dead') logger.warning('Skipping geometry for place() since device is dead')
return self
if not isinstance(other, str | Abstract | Pattern): if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it # We got a Tree; add it into self.library and grab an Abstract for it
@ -378,6 +377,7 @@ class Builder(PortList):
port_map = port_map, port_map = port_map,
skip_port_check = skip_port_check, skip_port_check = skip_port_check,
append = append, append = append,
skip_geometry = self._dead,
) )
return self return self

View file

@ -7,6 +7,8 @@ import copy
import logging import logging
from pprint import pformat from pprint import pformat
from numpy import pi
from ..pattern import Pattern from ..pattern import Pattern
from ..library import ILibrary from ..library import ILibrary
from ..error import BuildError from ..error import BuildError
@ -288,14 +290,36 @@ class Pather(Builder, PatherMixin):
LibraryError if no valid name could be picked for the pattern. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping path() since device is dead') logger.warning('Skipping geometry for path() since device is dead')
return self
tool_port_names = ('A', 'B') tool_port_names = ('A', 'B')
tool = self.tools.get(portspec, self.tools[None]) tool = self.tools.get(portspec, self.tools[None])
in_ptype = self.pattern[portspec].ptype in_ptype = self.pattern[portspec].ptype
try:
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) 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 tname = self.library << tree
if plug_into is not None: if plug_into is not None:
output = {plug_into: tool_port_names[1]} output = {plug_into: tool_port_names[1]}
@ -340,8 +364,7 @@ class Pather(Builder, PatherMixin):
LibraryError if no valid name could be picked for the pattern. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping pathS() since device is dead') logger.warning('Skipping geometry for pathS() since device is dead')
return self
tool_port_names = ('A', 'B') tool_port_names = ('A', 'B')
@ -353,6 +376,7 @@ class Pather(Builder, PatherMixin):
# Fall back to drawing two L-bends # Fall back to drawing two L-bends
ccw0 = jog > 0 ccw0 = jog > 0
kwargs_no_out = kwargs | {'out_ptype': None} 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_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out)
t_pat0 = t_tree0.top_pattern() t_pat0 = t_tree0.top_pattern()
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
@ -364,6 +388,28 @@ class Pather(Builder, PatherMixin):
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self 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 tname = self.library << tree
if plug_into is not None: if plug_into is not None:

View file

@ -253,8 +253,7 @@ class RenderPather(PatherMixin):
do not line up) do not line up)
""" """
if self._dead: if self._dead:
logger.error('Skipping plug() since device is dead') logger.warning('Skipping geometry for plug() since device is dead')
return self
other_tgt: Pattern | Abstract other_tgt: Pattern | Abstract
if isinstance(other, str): if isinstance(other, str):
@ -262,6 +261,7 @@ class RenderPather(PatherMixin):
if append and isinstance(other, Abstract): if append and isinstance(other, Abstract):
other_tgt = self.library[other.name] other_tgt = self.library[other.name]
if not self._dead:
# get rid of plugged ports # get rid of plugged ports
for kk in map_in: for kk in map_in:
if kk in self.paths: if kk in self.paths:
@ -284,6 +284,7 @@ class RenderPather(PatherMixin):
set_rotation = set_rotation, set_rotation = set_rotation,
append = append, append = append,
ok_connections = ok_connections, ok_connections = ok_connections,
skip_geometry = self._dead,
) )
return self return self
@ -334,8 +335,7 @@ class RenderPather(PatherMixin):
are applied. are applied.
""" """
if self._dead: if self._dead:
logger.error('Skipping place() since device is dead') logger.warning('Skipping geometry for place() since device is dead')
return self
other_tgt: Pattern | Abstract other_tgt: Pattern | Abstract
if isinstance(other, str): if isinstance(other, str):
@ -343,6 +343,7 @@ class RenderPather(PatherMixin):
if append and isinstance(other, Abstract): if append and isinstance(other, Abstract):
other_tgt = self.library[other.name] other_tgt = self.library[other.name]
if not self._dead:
for name, port in other_tgt.ports.items(): for name, port in other_tgt.ports.items():
new_name = port_map.get(name, name) if port_map is not None else name 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: if new_name is not None and new_name in self.paths:
@ -357,6 +358,7 @@ class RenderPather(PatherMixin):
port_map = port_map, port_map = port_map,
skip_port_check = skip_port_check, skip_port_check = skip_port_check,
append = append, append = append,
skip_geometry = self._dead,
) )
return self return self
@ -365,6 +367,7 @@ class RenderPather(PatherMixin):
self, self,
connections: dict[str, str], connections: dict[str, str],
) -> Self: ) -> Self:
if not self._dead:
for aa, bb in connections.items(): for aa, bb in connections.items():
porta = self.ports[aa] porta = self.ports[aa]
portb = self.ports[bb] portb = self.ports[bb]
@ -410,8 +413,7 @@ class RenderPather(PatherMixin):
LibraryError if no valid name could be picked for the pattern. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping path() since device is dead') logger.warning('Skipping geometry for path() since device is dead')
return self
port = self.pattern[portspec] port = self.pattern[portspec]
in_ptype = port.ptype in_ptype = port.ptype
@ -420,12 +422,26 @@ class RenderPather(PatherMixin):
tool = self.tools.get(portspec, self.tools[None]) 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 # 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) 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 # Update port
out_port.rotate_around((0, 0), pi + port_rot) out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset) out_port.translate(port.offset)
if not self._dead:
step = RenderStep('L', tool, port.copy(), out_port.copy(), data) step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step) self.paths[portspec].append(step)
@ -475,8 +491,7 @@ class RenderPather(PatherMixin):
LibraryError if no valid name could be picked for the pattern. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping pathS() since device is dead') logger.warning('Skipping geometry for pathS() since device is dead')
return self
port = self.pattern[portspec] port = self.pattern[portspec]
in_ptype = port.ptype in_ptype = port.ptype
@ -492,6 +507,7 @@ class RenderPather(PatherMixin):
# Fall back to drawing two L-bends # Fall back to drawing two L-bends
ccw0 = jog > 0 ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None}) 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 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] 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) t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
@ -501,9 +517,24 @@ class RenderPather(PatherMixin):
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self 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.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset) out_port.translate(port.offset)
if not self._dead:
step = RenderStep('S', tool, port.copy(), out_port.copy(), data) step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step) self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy() self.pattern.ports[portspec] = out_port.copy()

View file

@ -1101,6 +1101,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map: dict[str, str | None] | None = None, port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False, skip_port_check: bool = False,
append: bool = False, append: bool = False,
skip_geometry: bool = False,
) -> Self: ) -> Self:
""" """
Instantiate or append the pattern `other` into the current pattern, adding its Instantiate or append the pattern `other` into the current pattern, adding its
@ -1132,6 +1133,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced. append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`). be refs (now inside `self`).
skip_geometry: If `True`, only ports are added; geometry is skipped.
Returns: Returns:
self self
@ -1165,6 +1167,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.ports[name] = pp self.ports[name] = pp
self._log_port_update(name) self._log_port_update(name)
if skip_geometry:
return self
if append: if append:
if isinstance(other, Abstract): if isinstance(other, Abstract):
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
@ -1222,6 +1227,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation: bool | None = None, set_rotation: bool | None = None,
append: bool = False, append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (), ok_connections: Iterable[tuple[str, str]] = (),
skip_geometry: bool = False,
) -> Self: ) -> Self:
""" """
Instantiate or append a pattern into the current pattern, connecting Instantiate or append a pattern into the current pattern, connecting
@ -1276,6 +1282,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
any other ptypte. Non-allowed ptype connections will emit a any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`. `(b, a)`.
skip_geometry: If `True`, only ports are updated; geometry is skipped.
Returns: Returns:
self self
@ -1308,6 +1315,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out = {out_port_name: next(iter(map_in.keys()))} map_out = {out_port_name: next(iter(map_in.keys()))}
self.check_ports(other.ports.keys(), map_in, map_out) self.check_ports(other.ports.keys(), map_in, map_out)
try:
translation, rotation, pivot = self.find_transform( translation, rotation, pivot = self.find_transform(
other, other,
map_in, map_in,
@ -1315,6 +1323,24 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation = set_rotation, set_rotation = set_rotation,
ok_connections = ok_connections, 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 # get rid of plugged ports
for ki, vi in map_in.items(): for ki, vi in map_in.items():
@ -1323,7 +1349,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out[vi] = None map_out[vi] = None
if isinstance(other, Pattern): 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( self.place(
other, other,
@ -1334,6 +1360,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map = map_out, port_map = map_out,
skip_port_check = True, skip_port_check = True,
append = append, append = append,
skip_geometry = skip_geometry,
) )
return self return self

View file

@ -73,3 +73,57 @@ def test_builder_set_dead() -> None:
b.place("sub") b.place("sub")
assert not b.pattern.has_refs() 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)? # pi/2 (North) + CCW (90 deg) -> 0 (East)?
# Actual behavior results in pi (West). # Actual behavior results in pi (West).
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) 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 # Different tools should cause different batches/shapes
assert len(rp.pattern.shapes[(1, 0)]) == 1 assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 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()