Skip to content

feat (FXC-5013): Lumped element support for arbitrary RLC circuit#3336

Open
George-Guryev-flxcmp wants to merge 1 commit intodevelopfrom
george/generalized_single_port_LE
Open

feat (FXC-5013): Lumped element support for arbitrary RLC circuit#3336
George-Guryev-flxcmp wants to merge 1 commit intodevelopfrom
george/generalized_single_port_LE

Conversation

@George-Guryev-flxcmp
Copy link
Contributor

@George-Guryev-flxcmp George-Guryev-flxcmp commented Feb 24, 2026

Summary
Introduces CircuitImpedanceModel as the main way to describe general RLC circuits for LinearLumpedElement. It subclasses AdmittanceNetwork (same (a, b) admittance rational) and adds factory methods to build from SPICE netlists or from explicit R/L/C component lists. RLCNetwork is deprecated in favor of these factories; AdmittanceNetwork gets a future-rename notice (to AdmittanceModel). Supporting types Component and NodeMapper are added for parsed netlists and node indexing.
Added

  • CircuitImpedanceModel (tidy3d/components/lumped_element.py)
  • Subclass of AdmittanceNetwork with the same (a, b) storage and medium conversion; overrides the base rename validator so no warning is emitted for this class.
  • from_spice_file()
    Builds a model from a SPICE netlist: parses R/C/L lines and optional single voltage source for port; returns a CircuitImpedanceModel via the Y→ε→pole-residue fit path.
  • from_component_list()
    Builds a model from a list of Component instances; computes driving-point admittance at frequencies, fits pole-residue ε, then extracts (a, b).
  • from_touchstone_file(...)
    Stub only; raises NotImplementedError with a message to use from_spice_file() or from_component_list().
  • Internal flow: _parse_spice_value, _parse_spice_file, _build_incidence_matrix_and_branch_admittance_factory, _create_branch_admittance_matrix, _get_effective_admittance, _admittance_to_eps_data, _fit_admittance_to_pole_residue, _eps_medium_to_admittance_ab (Y→ε→fit→(a,b)).
  • Component
    Single R, L, or C branch: element_type ('R'/'L'/'C'), node_plus, node_minus, value (Ohms/Henrys/Farads), optional name. Used by SPICE parsing and by from_component_list.
  • NodeMapper
    Maps node names to indices (ground always index 0; supports "0" and "GND"). Used when building incidence matrices and branch admittance from a component list.
  • LinearLumpedElement
    network now accepts CircuitImpedanceModel in addition to RLCNetwork and AdmittanceNetwork via NetworkType = RLCNetwork | AdmittanceNetwork | CircuitImpedanceModel.
  • Exports
    CircuitImpedanceModel is exported from tidy3d and tidy3d.rf.
    Deprecations / warnings
    RLCNetwork: A DeprecationWarning is emitted in a model validator, directing users to CircuitImpedanceModel.from_component_list or CircuitImpedanceModel.from_spice_file for general RLC circuits.
    AdmittanceNetwork: A FutureWarning is emitted (rename to AdmittanceModel); CircuitImpedanceModel overrides the validator so it does not trigger for that subclass.
    Technical notes
    Y→ε→pole-residue path: For both SPICE and component-list inputs, the effective one-port admittance Y(ω) is computed, converted to equivalent permittivity data, fitted with the dispersion fitter to a PoleResidue ε model, then converted back to admittance (a, b) coefficients (with non-negative clipping for CircuitImpedanceModel). This yields stable, causal models for LinearLumpedElement.
    SPICE parsing: Supports standard scale suffixes (K, M/MEG, m, u, n, p, f), comment lines (*, $), and continuation lines (+). Port is taken from the single voltage source (V) if present, otherwise from the first R/C/L element’s two nodes.
    Testing
    tests/test_components/test_lumped_element.py: All tests updated to use CircuitImpedanceModel for moved helpers (e.g. _parse_spice_file, _get_effective_admittance). New/updated tests:
    test_effective_admittance_parallel_rc: Component list, SPICE (with and without voltage source), and analytical Y = 1/R + jωC agree.
    test_effective_admittance_series_rc: Component list, SPICE, and analytical series RC admittance agree.
    test_effective_admittance_series_rcl: Component list, SPICE, and analytical L ‖ (R–C) admittance agree.

Note

Medium Risk
Introduces a new circuit parsing + numerical fitting path (including linear algebra and SciPy invres) and changes warning behavior, which could impact stability/serialization for lumped-element users despite being largely additive.

Overview
Adds CircuitImpedanceModel as a new network type for LinearLumpedElement, allowing arbitrary one-port R/L/C circuits to be defined from either a SPICE netlist (from_spice_file) or an explicit component list (from_component_list) and then fit into the existing (a, b) rational form.

Deprecates RLCNetwork by emitting a DeprecationWarning on use, and adds a FutureWarning on AdmittanceNetwork about a potential rename; exports the new model via tidy3d.__init__, tidy3d.rf, updates JSON schemas to accept CircuitImpedanceModel, and adds extensive unit tests covering SPICE parsing, node mapping, effective-admittance computation, and the fit path.

Written by Cursor Bugbot for commit 0650096. This will update automatically on new commits. Configure here.

@George-Guryev-flxcmp George-Guryev-flxcmp force-pushed the george/generalized_single_port_LE branch from cb3df02 to 5a81249 Compare February 24, 2026 18:34
@github-actions
Copy link
Contributor

github-actions bot commented Feb 24, 2026

Diff Coverage

Diff: origin/develop...HEAD, staged and unstaged changes

  • tidy3d/components/lumped_element.py (72.2%): Missing lines 86,114,953,1097,1107,1167,1258,1262,1336,1338-1341,1398-1399,1401-1406,1408-1409,1414,1416,1418,1430-1431,1433,1460,1462-1465,1469-1480,1482-1490,1538-1543,1600-1601,1607,1616-1617,1649

Summary

  • Total: 241 lines
  • Missing: 67 lines
  • Coverage: 72%

tidy3d/components/lumped_element.py

Lines 82-90

  82 
  83     def lookup_index(self, name: str) -> int:
  84         """Return index for a node that must already be in the mapper. Use for port nodes."""
  85         if name not in self._name_to_idx:
! 86             raise ValueError(
  87                 f"Node {name!r} is not in the circuit (not an endpoint of any R, L, or C component)."
  88             )
  89         return self._name_to_idx[name]

Lines 110-118

  110     _node_minus_idx: int = PrivateAttr(default=-1)
  111 
  112     def model_post_init(self, __context: Any) -> None:
  113         if not self.name:
! 114             object.__setattr__(self, "name", f"{self.element_type}{id(self)}")
  115 
  116 
  117 class LumpedElement(MicrowaveBaseModel, ABC):
  118     """Base class describing the interface all lumped elements obey."""

Lines 949-957

  949     @model_validator(mode="after")
  950     def _warn_future_rename(self) -> Self:
  951         """Warn about upcoming rename to AdmittanceModel."""
  952         if type(self).__name__ == "CircuitImpedanceModel":
! 953             return self
  954         warnings.warn(
  955             "AdmittanceNetwork may be renamed to AdmittanceModel in a future release. "
  956             "For building from SPICE or component lists, use CircuitImpedanceModel.",
  957             category=FutureWarning,

Lines 1093-1101

  1093 
  1094         for line in lines:
  1095             toks = line.split()
  1096             if not toks:
! 1097                 continue
  1098             kind = toks[0][0].upper()
  1099             comp_name = toks[0]
  1100 
  1101             if kind == "V":

Lines 1103-1111

  1103                     raise ValueError(
  1104                         "SPICE file must contain at most one voltage source for port detection."
  1105                     )
  1106                 if len(toks) < 3:
! 1107                     raise ValueError(f"Voltage source line needs at least two nodes: {line!r}")
  1108                 port_plus_node = toks[1]
  1109                 port_minus_node = toks[2]
  1110                 continue

Lines 1163-1171

  1163                 branch_admittance_list.append(1.0 / (1j * omega * comp.value))
  1164             elif comp.element_type == "C":
  1165                 branch_admittance_list.append(1j * omega * comp.value)
  1166             else:
! 1167                 raise ValueError(f"Unknown component type: {comp.element_type}")
  1168         return np.diag(branch_admittance_list)
  1169 
  1170     @staticmethod
  1171     def _build_incidence_matrix_and_branch_admittance_factory(

Lines 1254-1266

  1254         idx_minus = node_mapper.lookup_index(port_minus_node)
  1255         n_nodes = node_mapper.total_nodes()
  1256 
  1257         if idx_minus != 0:
! 1258             raise ValueError(
  1259                 "effective_admittance_one_port requires port_minus_node to be ground ('0' or 'GND')."
  1260             )
  1261         if idx_plus == 0:
! 1262             raise ValueError(
  1263                 "effective_admittance_one_port requires port_plus_node to be a non-ground node; "
  1264                 "port_plus_node and port_minus_node cannot both be ground."
  1265             )
  1266         # Reduced matrix has rows/cols for nodes 1, 2, ... (full indices 1, 2, ...).

Lines 1332-1345

  1332         -------
  1333         np.ndarray
  1334             Complex permittivity array (same length as *frequencies*).
  1335         """
! 1336         from tidy3d.constants import EPSILON_0
  1337 
! 1338         frequencies = np.asarray(frequencies, dtype=float)
! 1339         Y_complex = np.asarray(Y_complex, dtype=complex)
! 1340         omega = 2 * np.pi * frequencies
! 1341         return 1.0 + 1j * admittance_scale * np.conj(Y_complex) / (omega * EPSILON_0)
  1342 
  1343     @staticmethod
  1344     def _fit_admittance_to_pole_residue(
  1345         frequencies: np.ndarray,

Lines 1394-1422

  1394         ------
  1395         ValueError
  1396             If ``frequencies`` and ``Y_complex`` lengths differ or any frequency is non-positive.
  1397         """
! 1398         from tidy3d.components.dispersion_fitter import AdvancedFastFitterParam, fit
! 1399         from tidy3d.components.medium import PoleResidue
  1400 
! 1401         frequencies = np.asarray(frequencies, dtype=float)
! 1402         Y_complex = np.asarray(Y_complex, dtype=complex)
! 1403         if frequencies.size != Y_complex.size:
! 1404             raise ValueError("frequencies and Y_complex must have the same length.")
! 1405         if np.any(frequencies <= 0):
! 1406             raise ValueError("All frequencies must be positive.")
  1407 
! 1408         omega = 2 * np.pi * frequencies
! 1409         eps_data = CircuitImpedanceModel._admittance_to_eps_data(
  1410             frequencies, Y_complex, admittance_scale
  1411         )
  1412 
  1413         # Scale factor for numerical conditioning: normalize max(omega) to ~1
! 1414         scale_factor = 1.0 / (np.max(omega) + 1e-30)
  1415 
! 1416         advanced_param = AdvancedFastFitterParam(show_progress=show_progress)
  1417 
! 1418         (eps_inf, poles, residues), rms = fit(
  1419             omega_data=omega,
  1420             resp_data=eps_data,
  1421             min_num_poles=min_num_poles,
  1422             max_num_poles=max_num_poles,

Lines 1426-1437

  1426             advanced_param=advanced_param,
  1427         )
  1428 
  1429         # Build PoleResidue from fitter output
! 1430         pole_pairs = tuple((complex(a), complex(c)) for a, c in zip(poles, residues))
! 1431         medium = PoleResidue(eps_inf=float(eps_inf), poles=pole_pairs)
  1432 
! 1433         return medium, float(rms)
  1434 
  1435     @staticmethod
  1436     def _eps_medium_to_admittance_ab(
  1437         medium: PoleResidue,

Lines 1456-1494

  1456         ------
  1457         ImportError
  1458             If the microwave plugin is not available (required for pole-residue to polynomial conversion).
  1459         """
! 1460         from tidy3d.constants import EPSILON_0
  1461 
! 1462         try:
! 1463             from tidy3d.plugins.microwave.generalized_RLC_network import _pole_residue_to_ab
! 1464         except ImportError:
! 1465             raise ImportError(
  1466                 "CircuitImpedanceModel._eps_medium_to_admittance_ab requires the microwave plugin."
  1467             ) from None
  1468 
! 1469         poles_arr = np.array([complex(p) for p, _ in medium.poles])
! 1470         resid_arr = np.array([complex(c) for _, c in medium.poles])
! 1471         P, Q = _pole_residue_to_ab(float(medium.eps_inf) - 1.0, poles_arr, resid_arr)
! 1472         P = np.asarray(P)
! 1473         Q = np.asarray(Q)
! 1474         b = np.array([((-1) ** k) * Q[k] for k in range(len(Q))], dtype=float)
! 1475         a = np.zeros(len(P) + 1, dtype=float)
! 1476         for k in range(1, len(a)):
! 1477             a[k] = ((-1) ** (k + 1)) * EPSILON_0 * P[k - 1]
! 1478         b0 = b[0] if abs(b[0]) > 1e-30 else 1.0
! 1479         a = a / b0
! 1480         b = b / b0
  1481         # Clip to non-negative for CircuitImpedanceModel
! 1482         a = np.maximum(np.real(a), 0.0)
! 1483         b = np.maximum(np.real(b), 0.0)
! 1484         a = np.trim_zeros(a, "b")
! 1485         b = np.trim_zeros(b, "b")
! 1486         if len(a) == 0:
! 1487             a = np.array([0.0])
! 1488         if len(b) == 0:
! 1489             b = np.array([1.0])
! 1490         return (tuple(float(x) for x in a), tuple(float(x) for x in b))
  1491 
  1492     @classmethod
  1493     def from_spice_file(
  1494         cls,

Lines 1534-1547

  1534         -------
  1535         CircuitImpedanceModel
  1536             Model with (a, b) coefficients for use in :class:`LinearLumpedElement`.
  1537         """
! 1538         component_list, port_p, port_m = cls._parse_spice_file(spice_file)
! 1539         if port_plus_node is not None:
! 1540             port_p = port_plus_node
! 1541         if port_minus_node is not None:
! 1542             port_m = port_minus_node
! 1543         return cls.from_component_list(
  1544             component_list,
  1545             frequencies,
  1546             port_plus_node=port_p,
  1547             port_minus_node=port_m,

Lines 1596-1605

  1596         -------
  1597         CircuitImpedanceModel
  1598             Model with (a, b) coefficients for use in :class:`LinearLumpedElement`.
  1599         """
! 1600         frequencies = np.asarray(frequencies, dtype=float)
! 1601         Y_complex = cls._get_effective_admittance(
  1602             component_list,
  1603             frequencies,
  1604             port_plus_node=port_plus_node,
  1605             port_minus_node=port_minus_node,

Lines 1603-1611

  1603             frequencies,
  1604             port_plus_node=port_plus_node,
  1605             port_minus_node=port_minus_node,
  1606         )
! 1607         medium, _ = cls._fit_admittance_to_pole_residue(
  1608             frequencies=frequencies,
  1609             Y_complex=Y_complex,
  1610             min_num_poles=min_num_poles,
  1611             max_num_poles=max_num_poles,

Lines 1612-1621

  1612             tolerance_rms=tolerance_rms,
  1613             admittance_scale=admittance_scale,
  1614             show_progress=show_progress,
  1615         )
! 1616         a, b = cls._eps_medium_to_admittance_ab(medium)
! 1617         return cls(a=a, b=b)
  1618 
  1619     @classmethod
  1620     def from_touchstone_file(
  1621         cls,

Lines 1645-1653

  1645         ------
  1646         NotImplementedError
  1647             Touchstone file support is not yet implemented.
  1648         """
! 1649         raise NotImplementedError(
  1650             "CircuitImpedanceModel.from_touchstone_file is not yet implemented. "
  1651             "Use CircuitImpedanceModel.from_spice_file or from_component_list."
  1652         )

@George-Guryev-flxcmp George-Guryev-flxcmp changed the title FXC-5013 feat: Lumped element support for arbitrary RLC circuit feat(FXC-5013): Lumped element support for arbitrary RLC circuit Feb 24, 2026
@George-Guryev-flxcmp George-Guryev-flxcmp changed the title feat(FXC-5013): Lumped element support for arbitrary RLC circuit feat (FXC-5013): Lumped element support for arbitrary RLC circuit Feb 24, 2026
@George-Guryev-flxcmp George-Guryev-flxcmp force-pushed the george/generalized_single_port_LE branch from 5a81249 to 55bab10 Compare February 24, 2026 19:06
@George-Guryev-flxcmp George-Guryev-flxcmp force-pushed the george/generalized_single_port_LE branch 4 times, most recently from a38aa6d to b15736e Compare February 24, 2026 19:48
@George-Guryev-flxcmp George-Guryev-flxcmp force-pushed the george/generalized_single_port_LE branch 2 times, most recently from 055ce6c to 859f10d Compare February 25, 2026 19:14
@George-Guryev-flxcmp George-Guryev-flxcmp force-pushed the george/generalized_single_port_LE branch from 859f10d to 32108a7 Compare February 25, 2026 23:52
@George-Guryev-flxcmp George-Guryev-flxcmp force-pushed the george/generalized_single_port_LE branch from 32108a7 to 0650096 Compare February 26, 2026 04:56
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable autofix in the Cursor dashboard.

# lumped elements
from .components.lumped_element import (
AdmittanceNetwork,
CircuitImpedanceModel,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Component class not exported from public namespace

Medium Severity

CircuitImpedanceModel.from_component_list() is a public factory method that takes list[Component] as its primary input, but Component is not exported from tidy3d or tidy3d.rf. Users must discover and use the internal import path from tidy3d.components.lumped_element import Component to construct the required input. Since CircuitImpedanceModel is exported and from_component_list is prominently documented, Component is effectively part of the public API and needs to be exported alongside CircuitImpedanceModel.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant