Skip to content

Commit 205b027

Browse files
flaportclaude
andcommitted
Fix portless sub-netlists and internal-port handling (#95, #96)
- Filter out sub-netlists with no ports in circuit() to prevent errors during construction of hierarchical netlists. - Change default behavior for ports on internal connection nodes: drop them instead of auto-converting to probes. Add on_internal_port flag to circuit() with modes "warn" (default), "ignore", and "as_probes". - Update and add tests for both fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7e89bb1 commit 205b027

4 files changed

Lines changed: 339 additions & 42 deletions

File tree

src/sax/circuits.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@
2828
__all__ = ["circuit", "draw_dag", "get_required_circuit_models"]
2929

3030

31+
def _filter_portless_subnets(
32+
netlist: sax.RecursiveNetlist,
33+
) -> sax.RecursiveNetlist:
34+
"""Remove sub-netlists that have no ports (except the top-level entry)."""
35+
top_level_name = next(iter(netlist))
36+
return {
37+
name: flatnet
38+
for name, flatnet in netlist.items()
39+
if name == top_level_name or flatnet.get("ports", {})
40+
}
41+
42+
3143
@overload
3244
def circuit(
3345
netlist: dict[str, Any],
@@ -37,6 +49,7 @@ def circuit(
3749
top_level_name: str = "top_level",
3850
ignore_impossible_connections: bool = False,
3951
probes: dict[str, str] | None = None,
52+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
4053
) -> tuple[sax.SDictModel, sax.CircuitInfo]: ...
4154

4255

@@ -50,6 +63,7 @@ def circuit(
5063
top_level_name: str = "top_level",
5164
ignore_impossible_connections: bool = False,
5265
probes: dict[str, str] | None = None,
66+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
5367
) -> tuple[sax.SDictModel, sax.CircuitInfo]: ...
5468

5569

@@ -63,6 +77,7 @@ def circuit(
6377
top_level_name: str = "top_level",
6478
ignore_impossible_connections: bool = False,
6579
probes: dict[str, str] | None = None,
80+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
6681
) -> tuple[sax.SDenseModel, sax.CircuitInfo]: ...
6782

6883

@@ -76,6 +91,7 @@ def circuit(
7691
top_level_name: str = "top_level",
7792
ignore_impossible_connections: bool = False,
7893
probes: dict[str, str] | None = None,
94+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
7995
) -> tuple[sax.SCooModel, sax.CircuitInfo]: ...
8096

8197

@@ -88,6 +104,7 @@ def circuit(
88104
top_level_name: str = "top_level",
89105
ignore_impossible_connections: bool = False,
90106
probes: dict[str, str] | None = None,
107+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
91108
) -> tuple[sax.Model, sax.CircuitInfo]:
92109
"""Create a circuit function for a given netlist.
93110
@@ -113,6 +130,10 @@ def circuit(
113130
connection and exposes forward and backward traveling wave ports.
114131
For a probe named "X" at instance port "inst,port", two new circuit
115132
ports are created: "X_fwd" and "X_bwd". Defaults to None.
133+
on_internal_port: How to handle top-level ports that map to internal
134+
connection nodes. ``"warn"`` (default) drops them with a warning,
135+
``"ignore"`` drops them silently, ``"as_probes"`` converts them
136+
to measurement probes (legacy behaviour).
116137
117138
Returns:
118139
Tuple containing:
@@ -158,13 +179,14 @@ def waveguide(length=10.0, neff=2.4, wl=1.55):
158179
)
159180
patch_netlist_array_instances(recnet)
160181
recnet = sax.into[sax.RecursiveNetlist](recnet)
161-
recnet, auto_probes = extract_port_probes(recnet)
182+
recnet, auto_probes = extract_port_probes(recnet, on_internal_port)
162183
if auto_probes:
163184
probes = {**(probes or {}), **auto_probes}
164185
if probes:
165186
recnet = expand_probes(recnet, probes)
166187
models = {"_ideal_probe": ideal_probe, **(models or {})}
167188
recnet = resolve_array_instances(recnet)
189+
recnet = _filter_portless_subnets(recnet)
168190
recnet = remove_unused_instances(recnet)
169191
_validate_netlist_ports(recnet)
170192
dependency_dag = _create_dag(recnet, models, validate=True)

src/sax/netlists.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import warnings
66
from copy import deepcopy
7-
from typing import cast, overload
7+
from typing import Literal, cast, overload
88

99
import networkx as nx
1010
from natsort import natsorted
@@ -656,37 +656,46 @@ def _expand_probes_recursive( # noqa: C901
656656
@overload
657657
def extract_port_probes(
658658
netlist: sax.Netlist,
659+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
659660
) -> tuple[sax.Netlist, dict[str, str]]: ...
660661

661662

662663
@overload
663664
def extract_port_probes(
664665
netlist: sax.RecursiveNetlist,
666+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
665667
) -> tuple[sax.RecursiveNetlist, dict[str, str]]: ...
666668

667669

668670
def extract_port_probes(
669671
netlist: sax.AnyNetlist,
672+
on_internal_port: Literal["warn", "ignore", "as_probes"] = "warn",
670673
) -> tuple[sax.AnyNetlist, dict[str, str]]:
671-
"""Extract ports on internal nodes and convert them to probes.
674+
"""Handle ports that map to internal connection nodes.
672675
673676
When a netlist port maps to an instance port that is already part of a
674-
connection (in ``connections`` or ``nets``), it is removed from ports and
675-
returned as a probe instead. A warning is issued for each such port.
677+
connection (in ``connections`` or ``nets``), the behaviour depends on
678+
*on_internal_port*:
679+
680+
* ``"warn"`` (default) — drop the port and emit a warning.
681+
* ``"ignore"`` — drop the port silently.
682+
* ``"as_probes"`` — convert the port to a probe (creating ``_fwd`` /
683+
``_bwd`` ports), matching the legacy behaviour.
676684
677685
Args:
678686
netlist: The netlist to inspect (flat or recursive).
687+
on_internal_port: How to handle ports on internal nodes.
679688
680689
Returns:
681-
A tuple of (modified_netlist, probes) where *probes* maps the original
682-
port name to the instance port string, ready to pass to
683-
:func:`expand_probes`.
690+
A tuple of (modified_netlist, probes) where *probes* maps original
691+
port names to instance port strings. The dict is empty unless
692+
*on_internal_port* is ``"as_probes"``.
684693
"""
685694
# Handle recursive netlist: only inspect the top-level
686695
if (recnet := sax.try_into[sax.RecursiveNetlist](netlist)) is not None:
687696
top_level_name = next(iter(recnet))
688697
top_level = recnet[top_level_name]
689-
modified_top, probes = extract_port_probes(top_level)
698+
modified_top, probes = extract_port_probes(top_level, on_internal_port)
690699
result: sax.RecursiveNetlist = {
691700
top_level_name: modified_top,
692701
**{k: v for k, v in recnet.items() if k != top_level_name},
@@ -711,14 +720,26 @@ def extract_port_probes(
711720
probes: dict[str, str] = {}
712721
for port_name, instance_port in list(ports.items()):
713722
if instance_port in internal_ports:
714-
warnings.warn(
715-
f"Port '{port_name}' maps to internal node '{instance_port}' "
716-
f"which is already part of a connection. "
717-
f"It will be interpreted as a probe (creating '{port_name}_fwd' "
718-
f"and '{port_name}_bwd' ports).",
719-
stacklevel=2,
720-
)
721-
probes[port_name] = instance_port
723+
if on_internal_port == "as_probes":
724+
warnings.warn(
725+
f"Port '{port_name}' maps to internal node "
726+
f"'{instance_port}' which is already part of a "
727+
f"connection. It will be interpreted as a probe "
728+
f"(creating '{port_name}_fwd' and "
729+
f"'{port_name}_bwd' ports).",
730+
stacklevel=2,
731+
)
732+
probes[port_name] = instance_port
733+
elif on_internal_port == "warn":
734+
warnings.warn(
735+
f"Port '{port_name}' maps to internal node "
736+
f"'{instance_port}' which is already part of a "
737+
f"connection. It will be dropped. Use the probes= "
738+
f"argument of circuit() to explicitly create "
739+
f"measurement probes.",
740+
stacklevel=2,
741+
)
742+
# "ignore" falls through — just delete silently
722743
del ports[port_name]
723744

724745
net["ports"] = ports

src/tests/test_circuit.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,65 @@ def test_1port_circuit() -> None:
6060
assert ("in", "in") in result
6161

6262

63+
def test_circuit_with_portless_subnetlist() -> None:
64+
"""Test that a sub-netlist without a 'ports' key is filtered out (#95)."""
65+
netlist = {
66+
"top_level": {
67+
"instances": {
68+
"wg": {"component": "waveguide"},
69+
},
70+
"connections": {},
71+
"ports": {
72+
"in": "wg,in0",
73+
"out": "wg,out0",
74+
},
75+
},
76+
# Sub-netlist with no "ports" key at all
77+
"unused_sub": {
78+
"instances": {
79+
"x": {"component": "waveguide"},
80+
},
81+
"connections": {},
82+
},
83+
}
84+
85+
models = {"waveguide": sax.models.straight}
86+
circuit, _ = sax.circuit(netlist, models)
87+
result = circuit()
88+
assert set(sax.get_ports(result)) == {"in", "out"}
89+
90+
91+
def test_circuit_with_empty_ports_subnetlist() -> None:
92+
"""Test that a sub-netlist with 'ports': {} is filtered out (#95)."""
93+
netlist = {
94+
"top_level": {
95+
"instances": {
96+
"wg": {"component": "waveguide"},
97+
},
98+
"connections": {},
99+
"ports": {
100+
"in": "wg,in0",
101+
"out": "wg,out0",
102+
},
103+
},
104+
# Sub-netlist with empty ports dict
105+
"unused_sub": {
106+
"instances": {
107+
"x": {"component": "waveguide"},
108+
},
109+
"connections": {},
110+
"ports": {},
111+
},
112+
}
113+
114+
models = {"waveguide": sax.models.straight}
115+
circuit, _ = sax.circuit(netlist, models)
116+
result = circuit()
117+
assert set(sax.get_ports(result)) == {"in", "out"}
118+
119+
63120
if __name__ == "__main__":
64121
print(test_circuit())
65122
print(test_1port_circuit())
123+
print(test_circuit_with_portless_subnetlist())
124+
print(test_circuit_with_empty_ports_subnetlist())

0 commit comments

Comments
 (0)