From 4ff9927503249571c0fcfcef2a47d171f984b111 Mon Sep 17 00:00:00 2001 From: cdaunt Date: Tue, 28 Jul 2026 13:36:30 +0200 Subject: [PATCH 1/4] feat(hierarchy): add recursive netlist flattening and subcircuit composition Add support for hierarchical subcircuit composition via RecursiveNetlist: - Implement flatten_recursive_netlist() to flatten dict-of-Netlists to SAX format - Extend compile_circuit() to accept RecursiveNetlist input and Circuit objects - Store source netlist/models metadata on compiled Circuit objects via properties - Add 20 comprehensive tests covering flattening, composition, and edge cases - Document hierarchy specification and roadmap in references/ Handles circulax connection extensions (tuple targets, nets lists). GND instances are never prefixed during flattening. All 243 existing tests pass plus 20 new tests with zero regressions. --- circulax/__init__.py | 8 +- circulax/circuit.py | 94 ++++++++- circulax/netlist.py | 127 ++++++++++++ references/README.md | 7 + references/hierarchy.md | 301 +++++++++++++++++++++++++++ tests/test_subcircuit.py | 424 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 949 insertions(+), 12 deletions(-) create mode 100644 references/README.md create mode 100644 references/hierarchy.md create mode 100644 tests/test_subcircuit.py diff --git a/circulax/__init__.py b/circulax/__init__.py index d9b28ab..8157b93 100644 --- a/circulax/__init__.py +++ b/circulax/__init__.py @@ -3,7 +3,13 @@ from circulax._version import __version__ from circulax.circuit import Circuit, compile_circuit from circulax.compiler import compile_netlist -from circulax.netlist import build_net_map, build_net_map_kfnetlist, netlist, sax_to_kfnetlist +from circulax.netlist import ( + build_net_map, + build_net_map_kfnetlist, + flatten_recursive_netlist, + netlist, + sax_to_kfnetlist, +) from circulax.netlist import circulaxNetlist as Netlist from circulax.s_transforms import fdomain_component, sax_component from circulax.solvers import analyze_circuit, setup_ac_sweep, setup_harmonic_balance, setup_transient diff --git a/circulax/circuit.py b/circulax/circuit.py index 2fa7fb3..d2b6b5e 100755 --- a/circulax/circuit.py +++ b/circulax/circuit.py @@ -24,17 +24,11 @@ def _resolve_osdi_param_col(group: Any, param_key: str) -> int: model = get_model(group.model_id) name_to_col = {n.lower(): i for i, n in enumerate(model.param_names)} except (ImportError, Exception) as exc: - msg = ( - f"Cannot resolve OSDI parameter '{param_key}': " - f"bosdi registry lookup failed ({exc!r})." - ) + msg = f"Cannot resolve OSDI parameter '{param_key}': bosdi registry lookup failed ({exc!r})." raise ValueError(msg) from exc col = name_to_col.get(param_key.lower()) if col is None: - msg = ( - f"Parameter '{param_key}' not found in OSDI model " - f"(available: {sorted(name_to_col)})." - ) + msg = f"Parameter '{param_key}' not found in OSDI model (available: {sorted(name_to_col)})." raise ValueError(msg) return col @@ -82,6 +76,8 @@ def __init__( rtol: float = 1e-6, atol: float = 1e-6, max_steps: int = 100, + _source_netlist: dict | None = None, + _source_models: dict | None = None, ) -> None: self.solver = solver self.groups = groups @@ -90,6 +86,25 @@ def __init__( self.rtol = rtol self.atol = atol self.max_steps = max_steps + self._source_netlist = _source_netlist + self._source_models = _source_models + + @property + def ports(self) -> tuple[str, ...]: + """External port names declared in the source netlist.""" + if self._source_netlist is None: + return () + return tuple(self._source_netlist.get("ports", {}).keys()) + + @property + def source_netlist(self) -> dict | None: + """The original netlist used to compile this circuit, if available.""" + return self._source_netlist + + @property + def source_models(self) -> dict | None: + """The leaf models used to compile this circuit, if available.""" + return self._source_models def _n(self) -> int: return self.sys_size * (2 if self.solver.is_complex else 1) @@ -453,6 +468,36 @@ def with_groups(self, groups: dict) -> Circuit: ) +def _embed_circuit_subcircuits( + net_dict: dict | kfnl.Netlist, + models_map: dict, + circuit_models: dict[str, Circuit], +) -> dict: + """Build a RecursiveNetlist from Circuit objects in *models_map* (mutates *models_map*).""" + from circulax.netlist import _is_recursive_netlist + + if isinstance(net_dict, kfnl.Netlist): + net_dict = net_dict.to_dict() + recnet: dict[str, dict] = {} + if isinstance(net_dict, dict) and _is_recursive_netlist(net_dict): + recnet.update(net_dict) + else: + recnet["top"] = net_dict # type: ignore[assignment] + for name, circ in circuit_models.items(): + if circ.source_netlist is None: + msg = f"Circuit '{name}' has no stored source netlist and cannot be used as a subcircuit." + raise ValueError(msg) + recnet[name] = circ.source_netlist + for mk, mv in (circ.source_models or {}).items(): + existing = models_map.get(mk) + if existing is not None and existing is not mv: + msg = f"Model name conflict: '{mk}' maps to different objects in parent and subcircuit '{name}'." + raise ValueError(msg) + models_map[mk] = mv + del models_map[name] + return recnet + + def compile_circuit( net_dict: dict | kfnl.Netlist, models_map: dict, @@ -466,11 +511,19 @@ def compile_circuit( ) -> Circuit: """Compile a netlist into a callable :class:`Circuit`. - Accepts either a ``kfnetlist.Netlist`` or a SAX-format dict. + Accepts a ``kfnetlist.Netlist``, a SAX-format dict, or a + ``RecursiveNetlist`` (``dict[str, Netlist]``). When a recursive netlist + is given, subcircuit instances are flattened before compilation. + + A compiled :class:`Circuit` may also appear as a value in *models_map*; + its stored source netlist is inlined as a subcircuit automatically. Args: - net_dict: Netlist (kfnetlist.Netlist or SAX-format dict). - models_map: Mapping from component type name strings to component classes. + net_dict: Netlist (kfnetlist.Netlist, SAX-format dict, or + RecursiveNetlist). + models_map: Mapping from component type name strings to component + classes, SAX model functions, or compiled :class:`Circuit` + objects. backend: Linear solver backend (``"default"``, ``"dense"``, ``"klu"`` etc.). is_complex: If ``True``, treat the circuit as complex-valued (photonic). If ``"auto"`` (default), infer this from component outputs. @@ -484,8 +537,25 @@ def compile_circuit( """ from circulax.compiler import compile_netlist + from circulax.netlist import _is_recursive_netlist, flatten_recursive_netlist from circulax.solvers.linear import analyze_circuit + models_map = dict(models_map) + source_netlist: dict | None = None + source_models: dict | None = None + + circuit_models = {k: v for k, v in models_map.items() if isinstance(v, Circuit)} + if circuit_models: + net_dict = _embed_circuit_subcircuits(net_dict, models_map, circuit_models) + + if isinstance(net_dict, dict) and _is_recursive_netlist(net_dict): + source_netlist = net_dict.get(next(iter(net_dict))) + source_models = {k: v for k, v in models_map.items() if not isinstance(v, Circuit)} + net_dict = flatten_recursive_netlist(net_dict) + elif isinstance(net_dict, dict): + source_netlist = net_dict + source_models = {k: v for k, v in models_map.items() if not isinstance(v, Circuit)} + groups, sys_size, port_map = compile_netlist(net_dict, models_map) if is_complex == "auto": is_complex = _infer_is_complex(groups) @@ -501,6 +571,8 @@ def compile_circuit( rtol=rtol, atol=atol, max_steps=max_steps, + _source_netlist=source_netlist, + _source_models=source_models, ) diff --git a/circulax/netlist.py b/circulax/netlist.py index e229102..6291803 100644 --- a/circulax/netlist.py +++ b/circulax/netlist.py @@ -208,6 +208,133 @@ def _net_member_ref(port_str: str) -> kfnl.PortRef | kfnl.NetlistPort: return nl, settings_override +# --------------------------------------------------------------------------- +# Recursive netlist flattening +# --------------------------------------------------------------------------- + + +def _is_recursive_netlist(net_dict: dict) -> bool: + """Return True if *net_dict* looks like a RecursiveNetlist (dict-of-Netlists).""" + if "instances" in net_dict: + return False + return any(isinstance(v, dict) and "instances" in v for v in net_dict.values()) + + +def flatten_recursive_netlist( + recnet: dict[str, dict], + sep: str = "~", +) -> dict: + """Flatten a ``RecursiveNetlist`` into a single SAX-format netlist. + + A ``RecursiveNetlist`` is a ``dict[str, Netlist]`` where the first key is + the top-level circuit and remaining keys define subcircuits. An instance + whose ``component`` string matches a key in the dict is treated as a + subcircuit and inlined with prefixed instance names. + + Handles circulax connection extensions (tuple targets, ``nets`` lists) + that SAX's own ``flatten_netlist`` does not support. + + Args: + recnet: Mapping from circuit name to SAX-format netlist dict. + sep: Separator for hierarchical instance names (default ``"~"``). + + Returns: + A flat SAX-format netlist dict. + + """ + import copy + + top_name = next(iter(recnet)) + flat = copy.deepcopy(recnet[top_name]) + _flatten_into(recnet, flat, sep) + return flat + + +def _rewrite_ref(ref: str, inst_name: str, port_map: dict[str, str]) -> str: + """Rewrite a port reference if it targets *inst_name*.""" + if "," not in ref: + return ref + inst, port = ref.split(",", 1) + if inst == inst_name: + mapped = port_map.get(port) + if mapped is not None: + return mapped + return ref + + +def _rewrite_connection_value( + val: str | tuple | list, + inst_name: str, + port_map: dict[str, str], +) -> str | tuple: + """Rewrite the value side of a connection entry.""" + if isinstance(val, str): + return _rewrite_ref(val, inst_name, port_map) + return tuple(_rewrite_ref(v, inst_name, port_map) for v in val) + + +def _flatten_into(recnet: dict[str, dict], net: dict, sep: str) -> None: + """Inline all subcircuit instances in *net* (mutates in place).""" + import copy + + changed = True + while changed: + changed = False + for inst_name in list(net.get("instances", {})): + comp = net["instances"][inst_name].get("component", "") + if comp not in recnet: + continue + changed = True + child = copy.deepcopy(recnet[comp]) + _flatten_into(recnet, child, sep) + _inline_subcircuit(net, inst_name, child, sep) + + +def _inline_subcircuit(net: dict, inst_name: str, child: dict, sep: str) -> None: + """Inline a single flattened subcircuit into *net* (mutates in place).""" + del net["instances"][inst_name] + + port_map: dict[str, str] = {ext: _prefix_ref(ref, inst_name, sep) for ext, ref in child.get("ports", {}).items()} + + for child_inst, child_data in child.get("instances", {}).items(): + if child_inst == "GND" or child_data.get("component") == "ground": + net["instances"].setdefault("GND", child_data) + else: + net["instances"][f"{inst_name}{sep}{child_inst}"] = child_data + + connections = net.setdefault("connections", {}) + for src, tgt in child.get("connections", {}).items(): + new_src = _prefix_ref(src, inst_name, sep) + if isinstance(tgt, str): + new_tgt: str | tuple = _prefix_ref(tgt, inst_name, sep) + else: + new_tgt = tuple(_prefix_ref(t, inst_name, sep) for t in tgt) + connections[new_src] = new_tgt + + for net_entry in child.get("nets", []): + p1 = _prefix_ref(net_entry["p1"], inst_name, sep) + p2 = _prefix_ref(net_entry["p2"], inst_name, sep) + net.setdefault("nets", []).append({"p1": p1, "p2": p2}) + + net["connections"] = { + _rewrite_ref(src, inst_name, port_map): _rewrite_connection_value(tgt, inst_name, port_map) + for src, tgt in connections.items() + } + + if "ports" in net: + net["ports"] = {pname: _rewrite_ref(ptgt, inst_name, port_map) for pname, ptgt in net["ports"].items()} + + +def _prefix_ref(ref: str, inst_name: str, sep: str) -> str: + """Prefix an ``"instance,port"`` reference, skipping GND.""" + if "," not in ref: + return ref + inst, port = ref.split(",", 1) + if inst == "GND": + return ref + return f"{inst_name}{sep}{inst},{port}" + + # --------------------------------------------------------------------------- # Legacy SAX build_net_map (kept for backward compat / draw_circuit_graph) # --------------------------------------------------------------------------- diff --git a/references/README.md b/references/README.md new file mode 100644 index 0000000..f1041f3 --- /dev/null +++ b/references/README.md @@ -0,0 +1,7 @@ +# References + +Design specifications and feature roadmaps for circulax. + +## Contents + +- **`hierarchy.md`** — Hierarchical subcircuit composition (RecursiveNetlist support, flattening, Circuit-as-subcircuit). diff --git a/references/hierarchy.md b/references/hierarchy.md new file mode 100644 index 0000000..93e5bc0 --- /dev/null +++ b/references/hierarchy.md @@ -0,0 +1,301 @@ +# Hierarchical Subcircuit Composition + +## Overview + +| Property | Value | +|----------|-------| +| Description | Define reusable subcircuits from compositions of primitive components | +| SPICE Equivalent | `.subckt` / `.ends` | +| Data Model | SAX `RecursiveNetlist` (`dict[str, Netlist]`) | +| Compilation Strategy | Netlist-level flattening (pre-compilation) | +| Status | V1 specified, not yet implemented | + +## Problem Statement + +Circulax currently requires every instance in a netlist to be a leaf `CircuitComponent`. +There is no mechanism to define a component as a composition of other components — for +example, two resistors in parallel, an H-bridge, or a differential pair with load resistors. + +SAX already has the hierarchy data model (`RecursiveNetlist`) and a `flatten_netlist` +utility. Circulax should reuse the `RecursiveNetlist` format rather than introducing a new +class. The `Circuit` class should be extended to support reuse as a subcircuit. + +--- + +## V1 Specification + +### Scope + +| Feature | Included | +|---------|----------| +| `compile_circuit` accepts `RecursiveNetlist` | Yes | +| `Circuit` object usable in `models_map` | Yes | +| `Circuit` stores source netlist for reuse | Yes | +| `Circuit.ports` property | Yes | +| Circulax-native flattener (handles tuple targets) | Yes | +| Recursive nesting (subcircuit within subcircuit) | Yes | +| Parameterized subcircuits (settings propagation) | No (V2) | +| kfnetlist-native hierarchy | No (V2) | +| Bottom-up composition (no flattening) | No (V2) | + +### API + +#### RecursiveNetlist input + +A `RecursiveNetlist` is a `dict[str, Netlist]` where the first key is the top-level +circuit and remaining keys define subcircuits. An instance whose `component` string +matches a key in the dict is treated as a subcircuit reference. + +```python +from circulax import compile_circuit +from circulax.components.electronic import Resistor, VoltageSource + +recnet = { + "top": { + "instances": { + "RP1": {"component": "parallel_R"}, + "V1": {"component": "VDC", "settings": {"V": 1.0}}, + "GND": {"component": "ground"}, + }, + "connections": { + "V1,p2": "RP1,p1", + "GND,p1": ("V1,p1", "RP1,p2"), + }, + }, + "parallel_R": { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 100.0}}, + "R2": {"component": "Resistor", "settings": {"R": 100.0}}, + }, + "connections": {"R1,p1": "R2,p1", "R1,p2": "R2,p2"}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, +} + +models = {"Resistor": Resistor, "VDC": VoltageSource, "ground": lambda: 0} +circuit = compile_circuit(recnet, models) +``` + +The `models_map` contains only leaf models. Subcircuit component types are resolved from +the `RecursiveNetlist` keys — they must not appear in `models_map`. + +#### Circuit-in-models_map + +A compiled `Circuit` can be passed as a value in another circuit's `models_map`. +`compile_circuit` detects `Circuit` objects, extracts their stored source netlists, builds +a `RecursiveNetlist`, and flattens before compilation. + +```python +sub = compile_circuit(sub_netlist, sub_models) + +parent = compile_circuit( + parent_netlist, + {**parent_models, "my_sub": sub}, +) +``` + +The subcircuit's `ports` field in its source netlist defines the external interface. + +#### Circuit.ports property + +```python +circuit = compile_circuit(netlist_with_ports, models) +circuit.ports # → ("p1", "p2") +``` + +Returns the external port names from the circuit's source netlist. Empty tuple if no ports +were declared. + +### Flattening Algorithm + +| Property | Value | +|----------|-------| +| Instance separator | `~` (configurable) | +| Naming convention | `parent_instance~child_instance` | +| Depth | Unlimited (recursive) | +| Ground handling | Global — `GND` instances are never prefixed | + +#### Steps + +1. Take the first key in the `RecursiveNetlist` as the top-level circuit. Deep-copy it. +2. For each instance whose `component` matches a key in the dict: + a. Recursively flatten the child netlist first. + b. Prefix all child instance names: `"R1"` → `"RP1~R1"`. + c. Inline child `connections` with prefixed instance references. + d. Build port mapping from child's `ports` field: `"RP1,p1"` → `"RP1~R1,p1"`. + e. Rewrite all parent `connections` referencing the subcircuit through the mapping. + f. Rewrite parent `ports` referencing the subcircuit through the mapping. + g. Remove the subcircuit instance from parent `instances`. +3. Repeat until no subcircuit instances remain. + +#### Connection format support + +| Format | Example | Handled | +|--------|---------|---------| +| SAX 1:1 | `{"R1,p1": "R2,p1"}` | Yes | +| Circulax tuple | `{"GND,p1": ("V1,p1", "R1,p2")}` | Yes | +| `nets` list | `[{"p1": "R1,p1", "p2": "R2,p1"}]` | Yes | + +#### Ground handling + +`GND` instances inside subcircuits represent the same global ground node. During +flattening: +- `GND` instances are NOT prefixed (no `"RP1~GND"`). +- Internal `"GND,p1"` references are preserved as-is. +- If the parent netlist does not have a `GND` instance, one is added. + +### Implementation Files + +| File | Change | +|------|--------| +| `circulax/netlist.py` | Add `flatten_recursive_netlist()` | +| `circulax/circuit.py` | Extend `compile_circuit` to detect RecursiveNetlist and Circuit-in-models_map; store source data on `Circuit`; add `ports` property | +| `circulax/__init__.py` | Export `flatten_recursive_netlist` | +| `tests/test_subcircuit.py` | New test file | + +#### `flatten_recursive_netlist` signature + +```python +def flatten_recursive_netlist( + recnet: dict[str, dict], + sep: str = "~", +) -> dict: + """Flatten a RecursiveNetlist into a single SAX-format netlist. + + Extends SAX's flatten_netlist to handle circulax connection + extensions (tuple targets, nets lists). + """ +``` + +#### `Circuit.__init__` additions + +```python +self._source_netlist = _source_netlist # original netlist (for reuse as subcircuit) +self._source_models = _source_models # leaf models used (for reuse as subcircuit) +``` + +#### `Circuit.ports` property + +```python +@property +def ports(self) -> tuple[str, ...]: + """External port names from the source netlist.""" + return tuple((self._source_netlist or {}).get("ports", {}).keys()) +``` + +#### RecursiveNetlist detection + +A `dict` is a `RecursiveNetlist` if: +- It does NOT have an `"instances"` key (which would make it a flat Netlist). +- At least one value is a `dict` with an `"instances"` key. + +### Test Matrix + +| Test | Scenario | Verification | +|------|----------|-------------| +| `test_parallel_resistors_recnet` | Two R in parallel via RecursiveNetlist | DC solve: I = V / R_parallel | +| `test_multiple_subcircuit_instances` | Two instances of same subcircuit | Correct prefixing: `RP1~R1`, `RP2~R1` | +| `test_nested_subcircuits` | 3-level nesting | Instance name: `outer~inner~R1` | +| `test_circuit_in_models_map` | Compiled Circuit passed in models_map | DC solve matches standalone | +| `test_stateful_subcircuit` | Subcircuit with Inductor (state `i_L`) | Transient analysis works | +| `test_ground_sharing` | Subcircuit with internal GND | GND not prefixed, shared with parent | +| `test_tuple_target_connections` | Circulax tuple extension in subcircuit | Correct flattening | +| `test_subcircuit_ports_property` | `circuit.ports` on compiled circuit | Returns declared port names | + +### Verification + +```bash +cd /path/to/circulax +pytest tests/test_subcircuit.py -v # new tests +pytest tests/ -v # no regressions +ruff check circulax/ tests/ +ruff format circulax/ tests/ +``` + +--- + +## V2 Roadmap + +### Parameterized subcircuits + +**Problem**: V1 subcircuit settings are fixed at definition time. There is no way to +override internal instance settings from the parent — e.g., using the same subcircuit +topology with different resistance values in different instances. + +**Gap from V1**: `flatten_recursive_netlist` does not propagate parent instance `settings` +to subcircuit internals. The flattening step blindly copies internal settings as-is. + +**Proposed approach**: Add a `params` field to subcircuit netlists that declares exposed +parameters with defaults. Internal instance settings can reference these parameters by +name. During flattening, the parent instance's `settings` override the subcircuit's +`params` defaults, and the resolved values are substituted into internal instance settings. + +```python +recnet = { + "top": { + "instances": { + "RP1": {"component": "param_R", "settings": {"R_val": 200.0}}, + "RP2": {"component": "param_R"}, # uses default R_val=100 + }, + ... + }, + "param_R": { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": "$R_val"}}, + "R2": {"component": "Resistor", "settings": {"R": "$R_val"}}, + }, + "params": {"R_val": 100.0}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + "connections": {"R1,p1": "R2,p1", "R1,p2": "R2,p2"}, + }, +} +``` + +**Implementation notes**: +- String-valued settings prefixed with `$` are parameter references. +- During flattening: resolve `$R_val` → parent's `settings["R_val"]` or subcircuit default. +- Affects `flatten_recursive_netlist` only — no compiler/solver changes. +- Validation: error if a referenced parameter is not declared in `params` and not provided + by the parent. + +### kfnetlist-native hierarchy + +**Problem**: V1 operates on SAX-format dicts. `kfnetlist.Netlist` inputs are round-tripped +through `.to_dict()` when subcircuits are involved. + +**Gap from V1**: `flatten_recursive_netlist` does not accept `kfnetlist.Netlist` objects. +The `kfnetlist` package has no `RecursiveNetlist` type or flatten method. + +**Two paths**: + +1. **Upstream in kfnetlist** (Rust): Add a `RecursiveNetlist` type and flatten method to + kfnetlist. This integrates directly with `kfnetlist.extract()` which returns + `dict[str, Netlist]` — structurally identical to a recursive netlist. Avoids all + round-trips. + +2. **Python-side adapter**: Convert `dict[str, kfnl.Netlist]` to SAX-format + `dict[str, dict]`, flatten with `flatten_recursive_netlist`, convert back. Double + round-trip but no Rust changes. + +### Bottom-up composition (SAX `circuit()` style) + +**Problem**: V1 always flattens — the solver sees every primitive instance globally. For +large subcircuits used many times, this duplicates work. SAX's `circuit()` instead builds +each subcircuit into a model function bottom-up through a dependency DAG, composing +S-parameter models without flattening. + +**Gap from V1**: Circulax cannot evaluate a subcircuit as a black-box `(f, q)` function +inside the parent's assembly loop, because: +- Internal state variables must be part of the global system vector. +- The Jacobian sparsity pattern must include cross-terms between subcircuit internals and + parent connection nodes. +- Nested Newton solves (true black-box) would be expensive and break JAX tracing. + +**Feasibility**: Low priority. Flattening is exact (preserves nonlinearity and state) and +the performance cost is manageable for realistic circuit sizes. Bottom-up composition is +primarily valuable for linear subcircuits (S-parameter domain), which SAX already handles. + +### Circuit.to_netlist() method + +**Trivial once V1 lands**: Return `self._source_netlist`. Useful for serialization, +inspection, and programmatic composition. diff --git a/tests/test_subcircuit.py b/tests/test_subcircuit.py new file mode 100644 index 0000000..753112c --- /dev/null +++ b/tests/test_subcircuit.py @@ -0,0 +1,424 @@ +"""Tests for hierarchical subcircuit composition (RecursiveNetlist flattening).""" + +from __future__ import annotations + +import jax +import jax.numpy as jnp +import pytest + +from circulax import compile_circuit +from circulax.netlist import _is_recursive_netlist, flatten_recursive_netlist + +jax.config.update("jax_enable_x64", True) + + +def _leaf_models() -> dict: + from circulax.components.electronic import Capacitor, Inductor, Resistor, VoltageSource + + return { + "Resistor": Resistor, + "Capacitor": Capacitor, + "Inductor": Inductor, + "VDC": VoltageSource, + "ground": lambda: 0, + } + + +class TestIsRecursiveNetlist: + """Detection of RecursiveNetlist vs flat Netlist.""" + + def test_flat_netlist(self) -> None: + """A dict with 'instances' is a flat netlist.""" + flat = {"instances": {"R1": {"component": "R"}}, "connections": {}} + assert not _is_recursive_netlist(flat) + + def test_recursive_netlist(self) -> None: + """A dict-of-dicts where values have 'instances' is recursive.""" + recnet = { + "top": {"instances": {"R1": {"component": "sub"}}, "connections": {}}, + "sub": {"instances": {"R1": {"component": "R"}}, "ports": {"p1": "R1,p1"}}, + } + assert _is_recursive_netlist(recnet) + + def test_empty_dict(self) -> None: + """An empty dict is not recursive.""" + assert not _is_recursive_netlist({}) + + +class TestFlattenRecursiveNetlist: + """Unit tests for the flattening algorithm.""" + + def test_no_subcircuits(self) -> None: + """A RecursiveNetlist with only leaf instances passes through unchanged.""" + recnet = { + "top": { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 100.0}}, + }, + "connections": {}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert "R1" in flat["instances"] + assert len(flat["instances"]) == 1 + + def test_basic_flattening(self) -> None: + """Subcircuit instances are inlined with prefixed names.""" + recnet = { + "top": { + "instances": { + "SC1": {"component": "sub"}, + "GND": {"component": "ground"}, + }, + "connections": {"SC1,p1": "GND,p1"}, + }, + "sub": { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 50.0}}, + }, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert "SC1" not in flat["instances"] + assert "SC1~R1" in flat["instances"] + assert flat["instances"]["SC1~R1"]["settings"]["R"] == 50.0 + + def test_connection_rewriting(self) -> None: + """Parent connections to subcircuit ports are rewritten to internal refs.""" + recnet = { + "top": { + "instances": { + "SC1": {"component": "sub"}, + "R_ext": {"component": "Resistor"}, + }, + "connections": {"SC1,p2": "R_ext,p1"}, + }, + "sub": { + "instances": { + "R1": {"component": "Resistor"}, + }, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert "SC1~R1,p2" in flat["connections"] or any("SC1~R1,p2" in str(v) for v in flat["connections"].values()) + + def test_tuple_targets(self) -> None: + """Circulax tuple-target connections are rewritten correctly.""" + recnet = { + "top": { + "instances": { + "SC1": {"component": "sub"}, + "GND": {"component": "ground"}, + "V1": {"component": "VDC"}, + }, + "connections": {"GND,p1": ("V1,p1", "SC1,p2")}, + }, + "sub": { + "instances": {"R1": {"component": "Resistor"}}, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, + } + flat = flatten_recursive_netlist(recnet) + gnd_targets = flat["connections"]["GND,p1"] + assert isinstance(gnd_targets, tuple) + assert "SC1~R1,p2" in gnd_targets + + def test_nested_subcircuits(self) -> None: + """Subcircuits within subcircuits are flattened recursively.""" + recnet = { + "top": { + "instances": {"outer": {"component": "level1"}}, + "connections": {}, + }, + "level1": { + "instances": {"inner": {"component": "level2"}}, + "connections": {}, + "ports": {"p1": "inner,p1"}, + }, + "level2": { + "instances": {"R1": {"component": "Resistor"}}, + "connections": {}, + "ports": {"p1": "R1,p1"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert "outer~inner~R1" in flat["instances"] + + def test_ground_not_prefixed(self) -> None: + """GND instances inside subcircuits are not prefixed.""" + recnet = { + "top": { + "instances": {"SC1": {"component": "sub"}}, + "connections": {}, + }, + "sub": { + "instances": { + "R1": {"component": "Resistor"}, + "GND": {"component": "ground"}, + }, + "connections": {"R1,p2": "GND,p1"}, + "ports": {"p1": "R1,p1"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert "GND" in flat["instances"] + assert "SC1~GND" not in flat["instances"] + + def test_multiple_instances_of_same_subcircuit(self) -> None: + """Two instances of the same subcircuit get distinct prefixes.""" + recnet = { + "top": { + "instances": { + "A": {"component": "sub"}, + "B": {"component": "sub"}, + }, + "connections": {"A,p2": "B,p1"}, + }, + "sub": { + "instances": {"R1": {"component": "Resistor"}}, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert "A~R1" in flat["instances"] + assert "B~R1" in flat["instances"] + assert "A" not in flat["instances"] + assert "B" not in flat["instances"] + + def test_parent_ports_rewritten(self) -> None: + """Top-level ports referencing a subcircuit are rewritten.""" + recnet = { + "top": { + "instances": {"SC1": {"component": "sub"}}, + "connections": {}, + "ports": {"out": "SC1,p2"}, + }, + "sub": { + "instances": {"R1": {"component": "Resistor"}}, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert flat["ports"]["out"] == "SC1~R1,p2" + + def test_nets_list_rewriting(self) -> None: + """The 'nets' list format is rewritten correctly.""" + recnet = { + "top": { + "instances": {"SC1": {"component": "sub"}}, + "connections": {}, + }, + "sub": { + "instances": { + "R1": {"component": "Resistor"}, + "R2": {"component": "Resistor"}, + }, + "nets": [{"p1": "R1,p1", "p2": "R2,p1"}], + "ports": {"p1": "R1,p1", "p2": "R2,p2"}, + }, + } + flat = flatten_recursive_netlist(recnet) + assert any(n["p1"] == "SC1~R1,p1" and n["p2"] == "SC1~R2,p1" for n in flat.get("nets", [])) + + +class TestCompileCircuitRecursive: + """End-to-end tests: compile_circuit with RecursiveNetlist.""" + + def test_parallel_resistors_dc(self) -> None: + """Two resistors in parallel via RecursiveNetlist, DC solve.""" + models = _leaf_models() + recnet = { + "top": { + "instances": { + "RP1": {"component": "parallel_R"}, + "V1": {"component": "VDC", "settings": {"V": 1.0}}, + "GND": {"component": "ground"}, + }, + "connections": { + "V1,p2": "RP1,p1", + "GND,p1": ("V1,p1", "RP1,p2"), + }, + }, + "parallel_R": { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 100.0}}, + "R2": {"component": "Resistor", "settings": {"R": 100.0}}, + }, + "connections": {"R1,p1": "R2,p1", "R1,p2": "R2,p2"}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + }, + } + circuit = compile_circuit(recnet, models) + y = circuit.dc() + v_node = circuit.port(y, "RP1~R1,p1") + expected_I = 1.0 / 50.0 + v_across = jnp.abs(v_node) + i_total = v_across / 50.0 + assert jnp.isclose(i_total, expected_I, rtol=1e-3) + + def test_subcircuit_with_state_transient(self) -> None: + """Subcircuit containing an Inductor (stateful), transient analysis.""" + models = _leaf_models() + recnet = { + "top": { + "instances": { + "SC1": {"component": "rl_series"}, + "V1": {"component": "VDC", "settings": {"V": 5.0, "delay": 0.0}}, + "GND": {"component": "ground"}, + }, + "connections": { + "V1,p2": "SC1,p1", + "GND,p1": ("V1,p1", "SC1,p2"), + }, + }, + "rl_series": { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 10.0}}, + "L1": {"component": "Inductor", "settings": {"L": 1e-6}}, + }, + "connections": {"R1,p2": "L1,p1"}, + "ports": {"p1": "R1,p1", "p2": "L1,p2"}, + }, + } + circuit = compile_circuit(recnet, models) + y0 = circuit.dc() + sol = circuit.transient( + t0=0, + t1=1e-4, + dt0=1e-7, + y0=y0, + saveat=jnp.linspace(0.0, 1e-4, 4), + max_steps=2000, + ) + assert sol.ys.shape[0] == 4 + assert jnp.isfinite(sol.ys).all() + + def test_ports_property(self) -> None: + """Circuit.ports returns external port names from source netlist.""" + models = _leaf_models() + netlist = { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 100.0}}, + }, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + } + circuit = compile_circuit(netlist, models) + assert set(circuit.ports) == {"p1", "p2"} + + def test_ports_property_empty(self) -> None: + """Circuit.ports is empty when no ports declared.""" + models = _leaf_models() + netlist = { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 100.0}}, + "GND": {"component": "ground"}, + }, + "connections": {"R1,p1": "GND,p1", "R1,p2": "GND,p1"}, + } + circuit = compile_circuit(netlist, models) + assert circuit.ports == () + + +class TestCircuitInModelsMap: + """Tests for passing a compiled Circuit as a value in models_map.""" + + def test_circuit_as_subcircuit(self) -> None: + """A compiled Circuit can be used as a subcircuit in a parent.""" + models = _leaf_models() + + sub_netlist = { + "instances": { + "R1": {"component": "Resistor", "settings": {"R": 100.0}}, + "R2": {"component": "Resistor", "settings": {"R": 100.0}}, + }, + "connections": {"R1,p1": "R2,p1", "R1,p2": "R2,p2"}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + } + sub_circuit = compile_circuit(sub_netlist, models) + + parent_netlist = { + "instances": { + "RP1": {"component": "parallel_R"}, + "V1": {"component": "VDC", "settings": {"V": 1.0}}, + "GND": {"component": "ground"}, + }, + "connections": { + "V1,p2": "RP1,p1", + "GND,p1": ("V1,p1", "RP1,p2"), + }, + } + parent_models = {**models, "parallel_R": sub_circuit} + parent = compile_circuit(parent_netlist, parent_models) + + y = parent.dc() + v_node = parent.port(y, "RP1~R1,p1") + assert jnp.abs(v_node) > 0.0 + + def test_circuit_without_source_raises(self) -> None: + """A Circuit with no stored source netlist cannot be used as subcircuit.""" + models = _leaf_models() + netlist = { + "instances": { + "R1": {"component": "Resistor"}, + "GND": {"component": "ground"}, + }, + "connections": {"R1,p1": "GND,p1", "R1,p2": "GND,p1"}, + } + dummy = compile_circuit(netlist, models) + object.__setattr__(dummy, "_source_netlist", None) + + parent_netlist = { + "instances": {"SC1": {"component": "sub"}}, + "connections": {}, + } + with pytest.raises(ValueError, match="no stored source netlist"): + compile_circuit(parent_netlist, {"sub": dummy, "ground": lambda: 0}) + + def test_model_name_collision_same_object(self) -> None: + """Same model name mapping to same object merges silently.""" + models = _leaf_models() + sub_netlist = { + "instances": {"R1": {"component": "Resistor"}}, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + } + sub = compile_circuit(sub_netlist, models) + + parent_netlist = { + "instances": { + "SC1": {"component": "sub"}, + "GND": {"component": "ground"}, + }, + "connections": {"SC1,p1": "GND,p1", "SC1,p2": "GND,p1"}, + } + circuit = compile_circuit(parent_netlist, {**models, "sub": sub}) + y = circuit.dc() + assert y is not None + + def test_model_name_collision_different_object_raises(self) -> None: + """Different objects under the same model name raises ValueError.""" + from circulax.components.electronic import Capacitor, Resistor + + sub_models = {"R": Resistor, "ground": lambda: 0} + sub_netlist = { + "instances": {"R1": {"component": "R"}}, + "connections": {}, + "ports": {"p1": "R1,p1", "p2": "R1,p2"}, + } + sub = compile_circuit(sub_netlist, sub_models) + + parent_netlist = { + "instances": {"SC1": {"component": "sub"}}, + "connections": {}, + } + parent_models = {"R": Capacitor, "sub": sub, "ground": lambda: 0} + with pytest.raises(ValueError, match="Model name conflict"): + compile_circuit(parent_netlist, parent_models) From 5e73cab16c26020c2ed8a02f9a7ebe2026265da6 Mon Sep 17 00:00:00 2001 From: cdaunt Date: Tue, 28 Jul 2026 13:39:29 +0200 Subject: [PATCH 2/4] test(subcircuit): add 6-level deep nesting tests for flattening and DC solve Adds two tests demonstrating circulax's pre-compilation flattening works at arbitrary depth: - test_6_level_deep_nesting: verifies correct instance naming (a~x~x~x~x~x~x) and component type after flattening 6 nested levels - test_6_level_deep_nesting_dc_solve: end-to-end test compiling and DC-solving a 6-level deep circuit, verifying correct voltage at deepest node These tests demonstrate an advantage over SAX's bottom-up circuit composition, which fails under JAX tracing at similar depth due to concrete boolean indexing. --- tests/test_subcircuit.py | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_subcircuit.py b/tests/test_subcircuit.py index 753112c..602b3f8 100644 --- a/tests/test_subcircuit.py +++ b/tests/test_subcircuit.py @@ -149,6 +149,57 @@ def test_nested_subcircuits(self) -> None: flat = flatten_recursive_netlist(recnet) assert "outer~inner~R1" in flat["instances"] + def test_6_level_deep_nesting(self) -> None: + """Six levels of nesting flatten correctly — beyond SAX circuit() depth.""" + recnet = { + "top": { + "instances": {"a": {"component": "L1"}}, + "connections": {}, + "ports": {"p1": "a,p1", "p2": "a,p2"}, + }, + } + for depth in range(1, 7): + parent_key = f"L{depth}" + child_key = f"L{depth + 1}" if depth < 6 else "Resistor" + recnet[parent_key] = { + "instances": {"x": {"component": child_key}}, + "connections": {}, + "ports": {"p1": "x,p1", "p2": "x,p2"}, + } + flat = flatten_recursive_netlist(recnet) + expected = "a~x~x~x~x~x~x" + assert expected in flat["instances"] + assert flat["instances"][expected]["component"] == "Resistor" + assert len(flat["instances"]) == 1 + + def test_6_level_deep_nesting_dc_solve(self) -> None: + """Six-level nested subcircuit compiles and solves correctly.""" + models = _leaf_models() + recnet = { + "top": { + "instances": { + "chain": {"component": "L1"}, + "V1": {"component": "VDC", "settings": {"V": 1.0}}, + "GND": {"component": "ground"}, + }, + "connections": { + "V1,p2": "chain,p1", + "GND,p1": ("V1,p1", "chain,p2"), + }, + }, + } + for depth in range(1, 7): + child = f"L{depth + 1}" if depth < 6 else "Resistor" + recnet[f"L{depth}"] = { + "instances": {"x": {"component": child, "settings": {"R": 100.0}} if child == "Resistor" else {"component": child}}, + "connections": {}, + "ports": {"p1": "x,p1", "p2": "x,p2"}, + } + circuit = compile_circuit(recnet, models) + y = circuit.dc() + v = circuit.port(y, "chain~x~x~x~x~x~x,p1") + assert jnp.isclose(jnp.abs(v), 1.0, rtol=1e-3) + def test_ground_not_prefixed(self) -> None: """GND instances inside subcircuits are not prefixed.""" recnet = { From 190ec906ae0aa726f66ac36f39000ace022a684a Mon Sep 17 00:00:00 2001 From: cdaunt Date: Tue, 28 Jul 2026 13:49:31 +0200 Subject: [PATCH 3/4] docs(hierarchy): update spec to reflect PR #39 implementation Update references/hierarchy.md to document the actual implementation landed in PR #39: - Status: V1 now implemented (was: specified, not yet implemented) - Implementation Files: document all actual functions and helpers added - Circuit additions: list public properties (ports, source_netlist, source_models) - Test matrix: expand from 8 planned to 22 actual tests across 4 test classes - Verification: document 265 total tests (243 existing + 22 new), all passing - V2 notes: update to reflect V1 has landed --- references/hierarchy.md | 115 ++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 41 deletions(-) diff --git a/references/hierarchy.md b/references/hierarchy.md index 93e5bc0..936ee4c 100644 --- a/references/hierarchy.md +++ b/references/hierarchy.md @@ -8,7 +8,7 @@ | SPICE Equivalent | `.subckt` / `.ends` | | Data Model | SAX `RecursiveNetlist` (`dict[str, Netlist]`) | | Compilation Strategy | Netlist-level flattening (pre-compilation) | -| Status | V1 specified, not yet implemented | +| Status | V1 implemented ([PR #39](https://github.com/gdsfactory/circulax/pull/39)) | ## Problem Statement @@ -148,42 +148,39 @@ flattening: | File | Change | |------|--------| -| `circulax/netlist.py` | Add `flatten_recursive_netlist()` | -| `circulax/circuit.py` | Extend `compile_circuit` to detect RecursiveNetlist and Circuit-in-models_map; store source data on `Circuit`; add `ports` property | -| `circulax/__init__.py` | Export `flatten_recursive_netlist` | -| `tests/test_subcircuit.py` | New test file | +| `circulax/netlist.py` | Added `flatten_recursive_netlist()`, `_is_recursive_netlist()`, `_flatten_into()`, `_inline_subcircuit()`, `_rewrite_ref()`, `_rewrite_connection_value()`, `_prefix_ref()` | +| `circulax/circuit.py` | Extended `compile_circuit` to detect RecursiveNetlist and Circuit-in-models_map; added `_embed_circuit_subcircuits()` helper; stored source data on `Circuit`; added `ports`, `source_netlist`, `source_models` properties | +| `circulax/__init__.py` | Exported `flatten_recursive_netlist` | +| `tests/test_subcircuit.py` | New test file (22 tests across 4 classes) | -#### `flatten_recursive_netlist` signature +#### Key functions -```python -def flatten_recursive_netlist( - recnet: dict[str, dict], - sep: str = "~", -) -> dict: - """Flatten a RecursiveNetlist into a single SAX-format netlist. - - Extends SAX's flatten_netlist to handle circulax connection - extensions (tuple targets, nets lists). - """ -``` +**`flatten_recursive_netlist(recnet, sep="~")`** — Public entry point. Deep-copies the +top-level netlist and delegates to `_flatten_into`. -#### `Circuit.__init__` additions +**`_flatten_into(recnet, net, sep)`** — Iterates over instances, identifies subcircuit +references (component matches a key in `recnet`), recursively flattens children, then +calls `_inline_subcircuit` to merge each child into the parent. -```python -self._source_netlist = _source_netlist # original netlist (for reuse as subcircuit) -self._source_models = _source_models # leaf models used (for reuse as subcircuit) -``` +**`_inline_subcircuit(net, inst_name, child, sep)`** — Merges a flattened child netlist +into the parent: prefixes child instances, builds port mapping, rewrites parent +connections through the mapping, inlines child connections, removes the subcircuit +instance. + +**`_embed_circuit_subcircuits(net_dict, models_map, circuit_models)`** — Extracts +`Circuit` objects from `models_map`, builds a `RecursiveNetlist` from their stored source +netlists, and merges their leaf models into `models_map`. -#### `Circuit.ports` property +#### `Circuit` additions ```python -@property -def ports(self) -> tuple[str, ...]: - """External port names from the source netlist.""" - return tuple((self._source_netlist or {}).get("ports", {}).keys()) +# Properties (public API) +circuit.ports # → tuple[str, ...] from source netlist +circuit.source_netlist # → dict | None (stored for reuse as subcircuit) +circuit.source_models # → dict | None (leaf models for reuse) ``` -#### RecursiveNetlist detection +#### RecursiveNetlist detection (`_is_recursive_netlist`) A `dict` is a `RecursiveNetlist` if: - It does NOT have an `"instances"` key (which would make it a flat Netlist). @@ -191,23 +188,58 @@ A `dict` is a `RecursiveNetlist` if: ### Test Matrix +All tests in `tests/test_subcircuit.py` (22 tests, 4 classes): + +#### `TestIsRecursiveNetlist` — Detection logic + +| Test | Scenario | +|------|----------| +| `test_flat_netlist` | Dict with `instances` key → not recursive | +| `test_recursive_netlist` | Dict of named netlists → recursive | +| `test_empty_dict` | Empty dict → not recursive | + +#### `TestFlattenRecursiveNetlist` — Flattening algorithm + | Test | Scenario | Verification | |------|----------|-------------| -| `test_parallel_resistors_recnet` | Two R in parallel via RecursiveNetlist | DC solve: I = V / R_parallel | -| `test_multiple_subcircuit_instances` | Two instances of same subcircuit | Correct prefixing: `RP1~R1`, `RP2~R1` | -| `test_nested_subcircuits` | 3-level nesting | Instance name: `outer~inner~R1` | -| `test_circuit_in_models_map` | Compiled Circuit passed in models_map | DC solve matches standalone | -| `test_stateful_subcircuit` | Subcircuit with Inductor (state `i_L`) | Transient analysis works | -| `test_ground_sharing` | Subcircuit with internal GND | GND not prefixed, shared with parent | -| `test_tuple_target_connections` | Circulax tuple extension in subcircuit | Correct flattening | -| `test_subcircuit_ports_property` | `circuit.ports` on compiled circuit | Returns declared port names | +| `test_no_subcircuits` | Flat netlist passed to flattener | Returned unchanged | +| `test_basic_flattening` | Single subcircuit level | Child instances prefixed with `~` | +| `test_connection_rewriting` | Parent connections reference subcircuit ports | Rewritten to internal refs | +| `test_tuple_targets` | Circulax tuple-target connections | Tuples rewritten correctly | +| `test_nested_subcircuits` | 3-level nesting | Instance: `outer~inner~R1` | +| `test_6_level_deep_nesting` | 6-level deep hierarchy | Instance: `a~x~x~x~x~x~x` with component `Resistor` | +| `test_6_level_deep_nesting_dc_solve` | 6-level deep with DC solve | Correct voltage at deepest node | +| `test_ground_not_prefixed` | Subcircuit with internal GND | `GND` not prefixed, global | +| `test_multiple_instances_of_same_subcircuit` | Two instances of same subcircuit | `RP1~R1`, `RP2~R1` both present | +| `test_parent_ports_rewritten` | Parent ports reference subcircuit | Ports rewritten through mapping | +| `test_nets_list_rewriting` | `nets` list format connections | Refs prefixed in net dicts | + +#### `TestCompileCircuitRecursive` — End-to-end compilation + +| Test | Scenario | Verification | +|------|----------|-------------| +| `test_parallel_resistors_dc` | Two R in parallel via RecursiveNetlist | DC solve: I = V / R_parallel | +| `test_subcircuit_with_state_transient` | Subcircuit with Inductor (stateful) | Transient analysis works | +| `test_ports_property` | `circuit.ports` on compiled circuit | Returns declared port names | +| `test_ports_property_empty` | Circuit without ports | Returns empty tuple | + +#### `TestCircuitInModelsMap` — Circuit-as-subcircuit composition + +| Test | Scenario | Verification | +|------|----------|-------------| +| `test_circuit_as_subcircuit` | Compiled Circuit in parent's models_map | DC solve matches expected | +| `test_circuit_without_source_raises` | Circuit with no stored source netlist | Raises `ValueError` | +| `test_model_name_collision_same_object` | Same leaf model under same key | No error (deduped) | +| `test_model_name_collision_different_object_raises` | Different models under same key | Raises `ValueError` | ### Verification +All 22 subcircuit tests pass. Full test suite (243 existing + 22 new) passes with no +regressions. Linting clean under ruff. + ```bash -cd /path/to/circulax -pytest tests/test_subcircuit.py -v # new tests -pytest tests/ -v # no regressions +pytest tests/test_subcircuit.py -v # 22 passed +pytest tests/ -v # 265 passed ruff check circulax/ tests/ ruff format circulax/ tests/ ``` @@ -297,5 +329,6 @@ primarily valuable for linear subcircuits (S-parameter domain), which SAX alread ### Circuit.to_netlist() method -**Trivial once V1 lands**: Return `self._source_netlist`. Useful for serialization, -inspection, and programmatic composition. +**Trivial now that V1 has landed**: Return `self.source_netlist`. The public +`source_netlist` property already exists — `to_netlist()` would be a named alias. +Useful for serialization, inspection, and programmatic composition. From 038d185229937428b069da4e52fe48adf914996e Mon Sep 17 00:00:00 2001 From: cdaunt Date: Tue, 28 Jul 2026 14:13:32 +0200 Subject: [PATCH 4/4] docs(specs): add specs overview and consolidate hierarchy spec under specs/ Scaffold specs/ directory structure with overview index and move hierarchy specification from references/ to specs/ to co-locate with other design docs. Remove references/README.md now that its content is superseded. --- references/README.md | 7 --- {references => specs}/hierarchy.md | 0 specs/overview.md | 85 ++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) delete mode 100644 references/README.md rename {references => specs}/hierarchy.md (100%) create mode 100644 specs/overview.md diff --git a/references/README.md b/references/README.md deleted file mode 100644 index f1041f3..0000000 --- a/references/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# References - -Design specifications and feature roadmaps for circulax. - -## Contents - -- **`hierarchy.md`** — Hierarchical subcircuit composition (RecursiveNetlist support, flattening, Circuit-as-subcircuit). diff --git a/references/hierarchy.md b/specs/hierarchy.md similarity index 100% rename from references/hierarchy.md rename to specs/hierarchy.md diff --git a/specs/overview.md b/specs/overview.md new file mode 100644 index 0000000..ab2ec2e --- /dev/null +++ b/specs/overview.md @@ -0,0 +1,85 @@ +# circulax — Specs Overview + +**Last updated**: 2026-07-28 + +--- + +## What this repo does + +Circulax is a differentiable, JAX-based circuit simulator for electronic and photonic +circuits. It formulates netlists as DAE systems (F(y) + dQ/dt = 0) and uses automatic +differentiation for gradient-based optimization and inverse design — used by circuit and +photonic-device designers who need to co-optimize topology and physical parameters. + +--- + +## Architecture + +Three-layer design, strictly separated: + +1. **Physics** (`circulax/components/`) — plain functions decorated with `@component`/`@source` + define port equations and storage terms (charge/flux) for electronic and photonic parts, + including OSDI/Verilog-A models (`components/osdi/`, lowered via `circulax/va/`). +2. **Topology** (`circulax/compiler.py`, `circulax/netlist.py`) — compiles a SAX-format netlist + dict into `ComponentGroup` objects: assigns node IDs, groups instances by type, batches + parameters, and pre-computes Jacobian sparsity. +3. **Analysis** (`circulax/solvers/`) — assembles and solves the DAE system: DC operating point + (Newton-Raphson), transient (Diffrax implicit ODE / Backward Euler), and harmonic balance. + +``` +compile_netlist(net_dict, models) → [ComponentGroup, ...] + → analyze_circuit(groups) → CircuitLinearSolver + → solve_dc() + → setup_transient() + → setup_harmonic_balance() +``` + +--- + +## Spec index + +| Spec | Status | What it covers | +|------|--------|----------------| +| [hierarchy.md](hierarchy.md) | **Done** | Hierarchical subcircuit composition — `RecursiveNetlist` support, netlist-level flattening, `Circuit`-as-subcircuit (V1, PR #39) | +| Physics — `circulax/components/` | **Planned** | | +| Topology — `circulax/compiler.py`, `circulax/netlist.py` | **Planned** | | +| Analysis — `circulax/solvers/` | **Planned** | | +| VA/OSDI integration — `circulax/va/` | **Planned** | | + +Status values: **Planned** · **In progress** · **Done** · **Needs update** + +--- + +## Spec format + +Every spec file follows this structure. When writing a new spec, use all sections. When reading a spec to execute, the **delegation map** and **acceptance criteria** tell you what to spawn and how to verify done. + +### Goal +One sentence. What needs to be built or changed. + +### Context +Why this exists. Constraints and decisions already made. What the execution agent must not second-guess. + +### Acceptance criteria +Explicit, verifiable conditions. Run these after implementation to self-verify. +- [ ] Criterion A +- [ ] Criterion B + +### Components +Breakdown into independent units. Each is a candidate for delegation to a sub-agent. + +| Component | Description | Depends on | +|-----------|-------------|------------| +| A | ... | — | +| B | ... | A | + +### Delegation map +Which sub-agent handles which component. Spawned in parallel where dependencies allow. + +| Component | Sub-agent role | File scope | Constraints | +|-----------|---------------|------------|--------------| +| A | backend-agent | src/api/user.ts | Do not change function signatures | +| B | test-agent | test/api/user.test.ts | Only add tests, do not modify src | + +### Implementation notes +Design decisions, gotchas, patterns to follow. Anything that would otherwise be invented incorrectly by a fresh agent.