Skip to content

Commit 55bab10

Browse files
feat(FXC-5013): Lumped element support for arbitrary RLC circuit
1 parent 73f47b9 commit 55bab10

4 files changed

Lines changed: 1060 additions & 3 deletions

File tree

tests/test_components/test_lumped_element.py

Lines changed: 298 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
import tidy3d as td
1010
from tidy3d.components.geometry.utils import SnapBehavior, SnapLocation
11-
from tidy3d.components.lumped_element import network_complex_permittivity
11+
from tidy3d.components.lumped_element import (
12+
Component,
13+
network_complex_permittivity,
14+
)
1215

1316

1417
def test_lumped_resistor():
@@ -480,3 +483,297 @@ def test_very_small_lateral_width_snapping():
480483

481484
# Should not raise division by zero error
482485
structure = element.to_structure(grid)
486+
487+
488+
# --- SPICE parser tests ---
489+
490+
491+
def test_parse_spice_value_no_suffix():
492+
"""Parse numeric values without scale suffix."""
493+
parse = td.CircuitImpedanceModel._parse_spice_value
494+
assert parse("50") == 50.0
495+
assert parse("1.5") == 1.5
496+
assert parse("1e-12") == 1e-12
497+
assert parse("+2.5") == 2.5
498+
assert parse("-3") == -3.0
499+
500+
501+
@pytest.mark.parametrize(
502+
"s,expected",
503+
[
504+
("1K", 1e3),
505+
("1k", 1e3),
506+
("2.5K", 2.5e3),
507+
("1M", 1e6),
508+
("1meg", 1e6),
509+
("1MEG", 1e6),
510+
("10m", 0.01),
511+
("1u", 1e-6),
512+
("1U", 1e-6),
513+
("1n", 1e-9),
514+
("1p", 1e-12),
515+
("1f", 1e-15),
516+
],
517+
)
518+
def test_parse_spice_value_scale_suffixes(s, expected):
519+
"""Parse values with ``K`` (kilo), ``M``/``MEG`` (mega), ``m`` (milli), ``u``, ``n``, ``p``, ``f``."""
520+
assert td.CircuitImpedanceModel._parse_spice_value(s) == pytest.approx(expected)
521+
522+
523+
def test_parse_spice_value_invalid():
524+
"""Invalid value or suffix raises ``ValueError``."""
525+
parse = td.CircuitImpedanceModel._parse_spice_value
526+
with pytest.raises(ValueError, match="Empty value"):
527+
parse("")
528+
with pytest.raises(ValueError, match="Cannot parse"):
529+
parse("abc")
530+
with pytest.raises(ValueError, match="Unknown scale suffix"):
531+
parse("1x")
532+
533+
534+
def test_parse_spice_file_no_source_port_from_first_element(tmp_path):
535+
"""With no voltage source, port is taken from the first element's nodes (``_parse_spice_file``)."""
536+
netlist = """
537+
* simple RC
538+
R1 1 0 50
539+
C1 1 0 1p
540+
"""
541+
path = tmp_path / "circuit.cir"
542+
path.write_text(netlist)
543+
comp_list, port_plus, port_minus = td.CircuitImpedanceModel._parse_spice_file(path)
544+
assert len(comp_list) == 2
545+
assert comp_list[0].element_type == "R"
546+
assert comp_list[0].node_plus == "1"
547+
assert comp_list[0].node_minus == "0"
548+
assert comp_list[0].value == 50.0
549+
assert comp_list[0].name == "R1"
550+
assert comp_list[1].element_type == "C"
551+
assert comp_list[1].value == 1e-12
552+
assert port_plus == "1"
553+
assert port_minus == "0"
554+
555+
556+
def test_parse_spice_file_single_voltage_source_defines_port(tmp_path):
557+
"""Single voltage source defines port nodes (``_parse_spice_file``)."""
558+
netlist = """
559+
V1 1 0 DC 0 AC 1
560+
R1 1 0 50
561+
C1 1 0 1p
562+
"""
563+
path = tmp_path / "circuit.cir"
564+
path.write_text(netlist)
565+
comp_list, port_plus, port_minus = td.CircuitImpedanceModel._parse_spice_file(path)
566+
assert len(comp_list) == 2
567+
assert port_plus == "1"
568+
assert port_minus == "0"
569+
570+
571+
def test_parse_spice_file_comments_and_continuation(tmp_path):
572+
"""Comment lines and + continuation are handled."""
573+
netlist = """
574+
$ comment
575+
* another comment
576+
R1 1 0 50
577+
C1 1 0
578+
+ 1p
579+
"""
580+
path = tmp_path / "circuit.cir"
581+
path.write_text(netlist)
582+
comp_list, _, _ = td.CircuitImpedanceModel._parse_spice_file(path)
583+
assert len(comp_list) == 2
584+
assert comp_list[1].value == 1e-12
585+
586+
587+
def test_parse_spice_file_scale_suffixes_in_netlist(tmp_path):
588+
"""Scale suffixes ``K``, ``M``, ``m``, ``p`` in netlist are parsed."""
589+
netlist = """
590+
R1 1 0 2K
591+
C1 1 0 0.5p
592+
L1 2 1 10n
593+
"""
594+
path = tmp_path / "circuit.cir"
595+
path.write_text(netlist)
596+
comp_list, _, _ = td.CircuitImpedanceModel._parse_spice_file(path)
597+
assert comp_list[0].value == 2000.0
598+
assert comp_list[1].value == 0.5e-12
599+
assert comp_list[2].value == 10e-9
600+
601+
602+
def test_parse_spice_file_two_voltage_sources_raises(tmp_path):
603+
"""At most one voltage source is allowed."""
604+
netlist = """
605+
V1 1 0 DC 0
606+
V2 2 0 DC 0
607+
R1 1 0 50
608+
"""
609+
path = tmp_path / "circuit.cir"
610+
path.write_text(netlist)
611+
with pytest.raises(ValueError, match="at most one voltage source"):
612+
td.CircuitImpedanceModel._parse_spice_file(path)
613+
614+
615+
def test_parse_spice_file_no_rlc_raises(tmp_path):
616+
"""Netlist must contain at least one ``R``, ``C``, or ``L`` (``_parse_spice_file``)."""
617+
netlist = """
618+
V1 1 0 DC 0
619+
"""
620+
path = tmp_path / "circuit.cir"
621+
path.write_text(netlist)
622+
with pytest.raises(ValueError, match="no R, C, or L"):
623+
td.CircuitImpedanceModel._parse_spice_file(path)
624+
625+
626+
def test_parse_spice_file_component_line_too_short_raises(tmp_path):
627+
"""Malformed component line raises."""
628+
netlist = """
629+
R1 1 0
630+
"""
631+
path = tmp_path / "circuit.cir"
632+
path.write_text(netlist)
633+
with pytest.raises(ValueError, match="Component line must have name"):
634+
td.CircuitImpedanceModel._parse_spice_file(path)
635+
636+
637+
def _effective_admittance_from_component_list(
638+
component_list: list[Component],
639+
frequencies: np.ndarray,
640+
port_plus_node: str = "1",
641+
port_minus_node: str = "0",
642+
) -> np.ndarray:
643+
"""Compute one-port admittance from component list (same logic as ``from_component_list``)."""
644+
return td.CircuitImpedanceModel._get_effective_admittance(
645+
component_list, frequencies, port_plus_node=port_plus_node, port_minus_node=port_minus_node
646+
)
647+
648+
649+
def test_effective_admittance_parallel_rc(tmp_path):
650+
"""Component list, SPICE (with and without voltage source), and analytical agree for parallel RC.
651+
652+
Circuit: ``R`` and ``C`` both between node 1 and 0. Port 1-0.
653+
Analytical: ``Y = 1/R + j*omega*C``.
654+
"""
655+
R, C = 50.0, 1e-12
656+
component_list = [
657+
Component(element_type="R", node_plus="1", node_minus="0", value=R, name="R1"),
658+
Component(element_type="C", node_plus="1", node_minus="0", value=C, name="C1"),
659+
]
660+
freqs = np.linspace(0.2e9, 8e9, 30)
661+
port_plus, port_minus = "1", "0"
662+
omega = 2 * np.pi * freqs
663+
Y_analytical = 1.0 / R + 1j * omega * C
664+
665+
Y_from_list = _effective_admittance_from_component_list(
666+
component_list, freqs, port_plus_node=port_plus, port_minus_node=port_minus
667+
)
668+
assert np.allclose(Y_from_list, Y_analytical, rtol=1e-14, atol=1e-20)
669+
670+
# SPICE without voltage source: port from first element
671+
netlist = f"""
672+
R1 1 0 {R}
673+
C1 1 0 {C}
674+
"""
675+
path = tmp_path / "parallel_rc.cir"
676+
path.write_text(netlist)
677+
comp_list_spice, port_plus_s, port_minus_s = td.CircuitImpedanceModel._parse_spice_file(path)
678+
assert port_plus_s == port_plus and port_minus_s == port_minus
679+
Y_from_spice = _effective_admittance_from_component_list(
680+
comp_list_spice, freqs, port_plus_node=port_plus_s, port_minus_node=port_minus_s
681+
)
682+
assert np.allclose(Y_from_list, Y_from_spice, rtol=1e-14, atol=1e-20)
683+
assert np.allclose(Y_from_spice, Y_analytical, rtol=1e-14, atol=1e-20)
684+
685+
# SPICE with voltage source: port from V
686+
netlist_v = f"""
687+
V1 1 0 DC 0 AC 1
688+
R1 1 0 {R}
689+
C1 1 0 {C}
690+
"""
691+
path_v = tmp_path / "with_v.cir"
692+
path_v.write_text(netlist_v)
693+
comp_list_spice_v, port_plus_v, port_minus_v = td.CircuitImpedanceModel._parse_spice_file(
694+
path_v
695+
)
696+
assert port_plus_v == "1" and port_minus_v == "0"
697+
Y_from_spice_v = _effective_admittance_from_component_list(
698+
comp_list_spice_v, freqs, port_plus_node=port_plus_v, port_minus_node=port_minus_v
699+
)
700+
assert np.allclose(Y_from_list, Y_from_spice_v, rtol=1e-14, atol=1e-20)
701+
assert np.allclose(Y_from_spice_v, Y_analytical, rtol=1e-14, atol=1e-20)
702+
703+
704+
def test_effective_admittance_series_rc(tmp_path):
705+
"""Component list, SPICE, and analytical agree for series RC.
706+
707+
Circuit: node 1 -- ``R`` -- node 2 -- ``C`` -- node 0. Port 1-0.
708+
Analytical: ``Y = 1/(R + 1/(j*omega*C)) = j*omega*C / (1 + j*omega*R*C)``.
709+
"""
710+
R, C = 50.0, 1e-12
711+
component_list = [
712+
Component(element_type="R", node_plus="1", node_minus="2", value=R, name="R1"),
713+
Component(element_type="C", node_plus="2", node_minus="0", value=C, name="C1"),
714+
]
715+
freqs = np.linspace(0.2e9, 8e9, 30)
716+
port_plus, port_minus = "1", "0"
717+
omega = 2 * np.pi * freqs
718+
Y_analytical = (1j * omega * C) / (1.0 + 1j * omega * R * C)
719+
720+
Y_from_list = _effective_admittance_from_component_list(
721+
component_list, freqs, port_plus_node=port_plus, port_minus_node=port_minus
722+
)
723+
assert np.allclose(Y_from_list, Y_analytical, rtol=1e-14, atol=1e-20)
724+
725+
# SPICE: use voltage source to define port 1-0 (no single element has both 1 and 0 in series RC)
726+
netlist = f"""
727+
V1 1 0 DC 0 AC 1
728+
R1 1 2 {R}
729+
C1 2 0 {C}
730+
"""
731+
path = tmp_path / "series_rc.cir"
732+
path.write_text(netlist)
733+
comp_list_spice, port_plus_s, port_minus_s = td.CircuitImpedanceModel._parse_spice_file(path)
734+
assert port_plus_s == "1" and port_minus_s == "0"
735+
Y_from_spice = _effective_admittance_from_component_list(
736+
comp_list_spice, freqs, port_plus_node=port_plus_s, port_minus_node=port_minus_s
737+
)
738+
assert np.allclose(Y_from_list, Y_from_spice, rtol=1e-14, atol=1e-20)
739+
assert np.allclose(Y_from_spice, Y_analytical, rtol=1e-14, atol=1e-20)
740+
741+
742+
def test_effective_admittance_series_rcl(tmp_path):
743+
"""Component list, SPICE, and analytical agree for ``L`` in parallel with (``R`` in series ``C``).
744+
745+
Circuit: ``L`` between 1-0, ``R`` between 1-2, ``C`` between 2-0. Port 1-0.
746+
Analytical: ``Y = 1/(j*omega*L) + 1/(R + 1/(j*omega*C))``.
747+
"""
748+
R, C, L = 50.0, 1e-12, 2e-9
749+
component_list = [
750+
Component(element_type="R", node_plus="1", node_minus="2", value=R, name="R1"),
751+
Component(element_type="C", node_plus="2", node_minus="0", value=C, name="C1"),
752+
Component(element_type="L", node_plus="1", node_minus="0", value=L, name="L1"),
753+
]
754+
freqs = np.linspace(0.2e9, 8e9, 30)
755+
port_plus, port_minus = "1", "0"
756+
omega = 2 * np.pi * freqs
757+
Y_L = 1.0 / (1j * omega * L)
758+
Z_RC = R + 1.0 / (1j * omega * C)
759+
Y_analytical = Y_L + 1.0 / Z_RC
760+
761+
Y_from_list = _effective_admittance_from_component_list(
762+
component_list, freqs, port_plus_node=port_plus, port_minus_node=port_minus
763+
)
764+
assert np.allclose(Y_from_list, Y_analytical, rtol=1e-14, atol=1e-20)
765+
766+
netlist = f"""
767+
L1 1 0 {L}
768+
R1 1 2 {R}
769+
C1 2 0 {C}
770+
"""
771+
path = tmp_path / "series_rcl.cir"
772+
path.write_text(netlist)
773+
comp_list_spice, port_plus_s, port_minus_s = td.CircuitImpedanceModel._parse_spice_file(path)
774+
assert port_plus_s == "1" and port_minus_s == "0", "port from first element (L1 1 0)"
775+
Y_from_spice = _effective_admittance_from_component_list(
776+
comp_list_spice, freqs, port_plus_node=port_plus_s, port_minus_node=port_minus_s
777+
)
778+
assert np.allclose(Y_from_list, Y_from_spice, rtol=1e-14, atol=1e-20)
779+
assert np.allclose(Y_from_spice, Y_analytical, rtol=1e-14, atol=1e-20)

tidy3d/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
# lumped elements
308308
from .components.lumped_element import (
309309
AdmittanceNetwork,
310+
CircuitImpedanceModel,
310311
CoaxialLumpedResistor,
311312
LinearLumpedElement,
312313
LumpedElement,
@@ -567,6 +568,7 @@ def set_logging_level(level: str) -> None:
567568
"ChargeToleranceSpec",
568569
"ChebSampling",
569570
"ClipOperation",
571+
"CircuitImpedanceModel",
570572
"CoaxialLumpedResistor",
571573
"CompositeCurrentIntegral",
572574
"CompositeCurrentIntegralSpec",

0 commit comments

Comments
 (0)