"""Tests for the netlist pre-processor (LTspice -> ngspice translation).""" import pytest from pyngspice.netlist import ( preprocess_netlist, _process_inductor_rser, _collect_component_names, _is_savecurrents_option, _process_capacitor_probe, _parse_save_cap_currents, ) class TestInductorRser: """Test Rser= parameter extraction from inductor lines.""" def test_basic_rser(self): netlist = """Tesla Coil L1 p1 0 8.5u Rser=0.012 C1 p1 0 100n .tran 1u 100u .end """ result = preprocess_netlist(netlist) assert "L1 p1 _rser_L1 8.5u" in result assert "R_L1_ser _rser_L1 0 0.012" in result assert "Rser" not in result def test_rser_with_suffix(self): """Rser value with engineering suffix (12m = 12 milliohms).""" netlist = """Test L1 a b 10u Rser=12m .end """ result = preprocess_netlist(netlist) assert "L1 a _rser_L1 10u" in result assert "R_L1_ser _rser_L1 b 12m" in result def test_rser_scientific_notation(self): """Rser value in scientific notation.""" netlist = """Test L1 a b 1.5e-6 Rser=1.2e-3 .end """ result = preprocess_netlist(netlist) assert "R_L1_ser" in result assert "1.2e-3" in result def test_multiple_inductors(self): """Multiple inductors with Rser= in same netlist.""" netlist = """Multi Inductor L1 a 0 10u Rser=0.01 L2 b 0 20u Rser=0.02 L3 c 0 30u Rser=0.03 .end """ result = preprocess_netlist(netlist) assert "R_L1_ser" in result assert "R_L2_ser" in result assert "R_L3_ser" in result assert "_rser_L1" in result assert "_rser_L2" in result assert "_rser_L3" in result def test_named_inductor(self): """Inductor with alphanumeric name (Lprimary).""" netlist = """Test Lprimary drive tank 8.5u Rser=0.012 .end """ result = preprocess_netlist(netlist) assert "Lprimary drive _rser_Lprimary 8.5u" in result assert "R_Lprimary_ser _rser_Lprimary tank 0.012" in result def test_inductor_without_rser_unchanged(self): """Inductors without Rser= should pass through unchanged.""" netlist = """Test L1 a b 10u .end """ result = preprocess_netlist(netlist) assert "L1 a b 10u" in result assert "R_L1" not in result def test_rser_with_spaces(self): """Rser with spaces around equals sign.""" netlist = """Test L1 a b 10u Rser = 0.05 .end """ result = preprocess_netlist(netlist) assert "R_L1_ser" in result assert "0.05" in result def test_resistors_before_end(self): """Extra resistor lines should be inserted before .end.""" netlist = """Test L1 a 0 10u Rser=0.01 .tran 1u 100u .end """ result = preprocess_netlist(netlist) lines = result.splitlines() # Find positions rser_idx = next(i for i, l in enumerate(lines) if "R_L1_ser" in l) end_idx = next(i for i, l in enumerate(lines) if l.strip().lower() == '.end') assert rser_idx < end_idx class TestBackanno: """Test .backanno directive stripping.""" def test_backanno_removed(self): netlist = """Test V1 in 0 1 .backanno .end """ result = preprocess_netlist(netlist) assert ".backanno" not in result assert ".end" in result assert "V1 in 0 1" in result def test_backanno_case_insensitive(self): netlist = """Test V1 in 0 1 .BACKANNO .end """ result = preprocess_netlist(netlist) assert ".BACKANNO" not in result class TestPassthrough: """Test that standard SPICE constructs pass through unchanged.""" def test_simple_netlist_unchanged(self): netlist = """Simple RC V1 in 0 DC 1 R1 in out 1k C1 out 0 1u .tran 0.1m 10m .end""" result = preprocess_netlist(netlist) assert result == netlist def test_behavioral_source_unchanged(self): """Behavioral sources should pass through.""" netlist = """Test B2 N015 0 V=I(L1)*{iscale} .end """ result = preprocess_netlist(netlist) assert "B2 N015 0 V=I(L1)*{iscale}" in result def test_subcircuit_unchanged(self): """Subcircuit definitions should pass through.""" netlist = """Test .subckt mycomp in out R1 in out 1k .ends mycomp .end """ result = preprocess_netlist(netlist) assert ".subckt mycomp in out" in result assert ".ends mycomp" in result def test_continuation_lines(self): """Continuation lines (starting with +) should pass through.""" netlist = """Test V1 in 0 PULSE(0 5 0 1n 1n + 500n 1u) .end """ result = preprocess_netlist(netlist) assert "+ 500n 1u)" in result def test_empty_lines_preserved(self): """Empty lines should be preserved.""" netlist = """Test V1 in 0 1 R1 in out 1k .end""" result = preprocess_netlist(netlist) assert "\n\n" in result class TestProcessInductorRser: """Test the internal _process_inductor_rser function directly.""" def test_basic(self): line, extras = _process_inductor_rser("L1 p1 0 8.5u Rser=0.012") assert line == "L1 p1 _rser_L1 8.5u" assert len(extras) == 1 assert extras[0] == "R_L1_ser _rser_L1 0 0.012" def test_no_rser_returns_original(self): line, extras = _process_inductor_rser("L1 p1 0 8.5u") assert line == "L1 p1 0 8.5u" assert extras == [] def test_preserves_remaining_params(self): """Parameters after Rser= that are not LTspice-specific should be kept.""" line, extras = _process_inductor_rser("L1 a b 10u Rser=0.01 IC=0.5") assert "IC=0.5" in line assert len(extras) == 1 class TestSaveCurrents: """Test .options savecurrents expansion into explicit .save directives.""" def test_savecurrents_expanded(self): """`.options savecurrents` should be removed and .save directives added.""" netlist = """Tesla Coil V1 vin 0 AC 1 C1 vin out 0.03u R1 out 0 50 .options savecurrents .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) assert ".options savecurrents" not in result.lower() assert ".save all" in result assert "i(V1)" in result assert "i(C1)" in result assert "i(R1)" in result def test_savecurrents_includes_all_components(self): """All R, C, L, V components should get i(name) saves.""" netlist = """Test V1 in 0 DC 1 R1 in mid 1k L1 mid out 10u C1 out 0 1u .options savecurrents .tran 1u 100u .end """ result = preprocess_netlist(netlist) assert "i(V1)" in result assert "i(R1)" in result assert "i(L1)" in result assert "i(C1)" in result def test_savecurrents_skips_K_elements(self): """K (coupling) elements should NOT get i(K1) saves.""" netlist = """Test V1 in 0 AC 1 L1 in 0 10u L2 0 out 20u K1 L1 L2 0.5 .options savecurrents .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert "i(K1)" not in result assert "i(V1)" in result assert "i(L1)" in result assert "i(L2)" in result def test_savecurrents_includes_rser_resistors(self): """Expanded Rser resistors should be included in .save directives.""" netlist = """Test V1 in 0 AC 1 L1 in 0 10u Rser=0.01 .options savecurrents .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert "i(R_L1_ser)" in result assert "i(L1)" in result assert "i(V1)" in result def test_savecurrents_case_insensitive(self): """.OPTIONS SAVECURRENTS should be handled.""" netlist = """Test V1 in 0 1 R1 in 0 1k .OPTIONS SAVECURRENTS .op .end """ result = preprocess_netlist(netlist) assert ".OPTIONS SAVECURRENTS" not in result assert ".save all" in result assert "i(V1)" in result def test_no_savecurrents_unchanged(self): """Netlists without .options savecurrents should not get .save directives.""" netlist = """Test V1 in 0 DC 1 R1 in out 1k C1 out 0 1u .tran 0.1m 10m .end""" result = preprocess_netlist(netlist) assert ".save" not in result.lower() assert result == netlist def test_savecurrents_with_rser_full_tesla_coil(self): """Full Tesla coil netlist: Rser + savecurrents + K coupling + probes.""" netlist = """Tesla Coil AC Analysis V1 vin 0 AC 1 C_mmc vin p1 0.03u L1 p1 0 10.927u Rser=0.001 L2 0 top 15.987m Rser=0.001 C_topload top 0 13.822p K1 L1 L2 0.3204 .options savecurrents .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # .options savecurrents removed assert ".options savecurrents" not in result.lower() # Rser expanded assert "R_L1_ser" in result assert "R_L2_ser" in result # Capacitor probes inserted assert "V_probe_C_mmc" in result assert "V_probe_C_topload" in result # .save directives present assert ".save all" in result assert "i(V1)" in result assert "i(L1)" in result assert "i(L2)" in result assert "i(R_L1_ser)" in result assert "i(R_L2_ser)" in result assert "i(V_probe_C_mmc)" in result assert "i(V_probe_C_topload)" in result # K element NOT in saves assert "i(K1)" not in result # .end still present assert ".end" in result def test_save_directives_before_end(self): """.save directives should appear before .end.""" netlist = """Test V1 in 0 1 R1 in 0 1k .options savecurrents .op .end """ result = preprocess_netlist(netlist) lines = result.splitlines() save_idx = next(i for i, l in enumerate(lines) if '.save all' in l) end_idx = next(i for i, l in enumerate(lines) if l.strip().lower() == '.end') assert save_idx < end_idx class TestIsSaveCurrentsOption: """Test the _is_savecurrents_option helper.""" def test_standard(self): assert _is_savecurrents_option(".options savecurrents") def test_uppercase(self): assert _is_savecurrents_option(".OPTIONS SAVECURRENTS") def test_mixed_case(self): assert _is_savecurrents_option(".Options SaveCurrents") def test_singular_option(self): assert _is_savecurrents_option(".option savecurrents") def test_extra_spaces(self): assert _is_savecurrents_option(".options savecurrents") def test_not_savecurrents(self): assert not _is_savecurrents_option(".options reltol=1e-4") def test_not_options(self): assert not _is_savecurrents_option("V1 in 0 1") class TestCollectComponentNames: """Test the _collect_component_names helper.""" def test_basic(self): lines = ["Title", "V1 in 0 1", "R1 in out 1k", ".tran 1u 10u", ".end"] names = _collect_component_names(lines) assert names == ["V1", "R1"] def test_skips_comments(self): lines = ["* Comment", "V1 in 0 1", "* Another comment", ".end"] names = _collect_component_names(lines) assert names == ["V1"] def test_skips_K_elements(self): lines = ["V1 in 0 1", "L1 in 0 10u", "K1 L1 L2 0.5", ".end"] names = _collect_component_names(lines) assert "K1" not in names assert "V1" in names assert "L1" in names def test_skips_directives(self): lines = [".tran 1u 10u", "V1 in 0 1", ".end"] names = _collect_component_names(lines) assert names == ["V1"] class TestCapacitorProbes: """Test capacitor current probe insertion (0V voltage sources).""" def test_probe_inserted(self): """Basic capacitor should get a 0V probe voltage source.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u .options savecurrents .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert 'C1 _probe_C1 out 1u' in result assert 'V_probe_C1 in _probe_C1 0' in result def test_probe_multiple_caps(self): """Multiple capacitors should each get separate probes.""" netlist = """Test V1 in 0 AC 1 C1 in mid 1u C2 mid out 2u .options savecurrents .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert 'V_probe_C1' in result assert 'V_probe_C2' in result assert '_probe_C1' in result assert '_probe_C2' in result def test_probe_preserves_params(self): """Capacitor parameters (IC=, etc.) should be preserved.""" netlist = """Test V1 in 0 1 C1 in out 1u IC=0.5 .options savecurrents .tran 1u 100u .end """ result = preprocess_netlist(netlist) assert 'C1 _probe_C1 out 1u IC=0.5' in result def test_probe_only_with_savecurrents(self): """No probes should be inserted without .options savecurrents.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert '_probe_' not in result assert 'V_probe_' not in result def test_probe_with_rser_combined(self): """Full Tesla coil: Rser + probes + savecurrents.""" netlist = """Tesla Coil AC Analysis V1 vin 0 AC 1 C_mmc vin p1 0.03u L1 p1 0 10.927u Rser=0.001 L2 0 top 15.987m Rser=0.001 C_topload top 0 13.822p K1 L1 L2 0.3204 .options savecurrents .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # Rser expanded assert 'R_L1_ser' in result assert 'R_L2_ser' in result # Capacitor probes inserted assert 'V_probe_C_mmc' in result assert 'V_probe_C_topload' in result assert 'C_mmc _probe_C_mmc p1 0.03u' in result assert 'C_topload _probe_C_topload 0 13.822p' in result # Probe V sources in .save assert 'i(V_probe_C_mmc)' in result assert 'i(V_probe_C_topload)' in result # K element NOT in saves assert 'i(K1)' not in result def test_probe_naming_convention(self): """Probe names should follow V_probe_Cname convention.""" netlist = """Test V1 in 0 1 C_mmc in 0 1u .options savecurrents .op .end """ result = preprocess_netlist(netlist) assert '_probe_C_mmc' in result assert 'V_probe_C_mmc' in result assert 'V_probe_C_mmc in _probe_C_mmc 0' in result def test_probe_not_inside_subckt(self): """Capacitors inside .subckt should NOT get probes.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u .subckt mycomp a b C2 a b 10u .ends mycomp .options savecurrents .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert 'V_probe_C1' in result # top-level probed assert 'V_probe_C2' not in result # subcircuit NOT probed def test_probe_before_end(self): """Probe V sources should be inserted before .end.""" netlist = """Test V1 in 0 1 C1 in 0 1u .options savecurrents .op .end """ result = preprocess_netlist(netlist) lines = result.splitlines() probe_idx = next(i for i, l in enumerate(lines) if 'V_probe_C1' in l) end_idx = next(i for i, l in enumerate(lines) if l.strip().lower() == '.end') assert probe_idx < end_idx class TestProcessCapacitorProbe: """Test the _process_capacitor_probe helper.""" def test_basic(self): mod, probe = _process_capacitor_probe("C1 in out 1u") assert mod == "C1 _probe_C1 out 1u" assert probe == "V_probe_C1 in _probe_C1 0" def test_named_cap(self): mod, probe = _process_capacitor_probe("C_mmc vin p1 0.03u") assert mod == "C_mmc _probe_C_mmc p1 0.03u" assert probe == "V_probe_C_mmc vin _probe_C_mmc 0" def test_preserves_params(self): mod, probe = _process_capacitor_probe("C1 a b 10u IC=0.5") assert "IC=0.5" in mod assert "_probe_C1" in mod def test_too_few_tokens(self): """Lines with fewer than 4 tokens should be returned unchanged.""" mod, probe = _process_capacitor_probe("C1 a") assert mod == "C1 a" assert probe == "" class TestParseSaveCapCurrents: """Test the _parse_save_cap_currents helper.""" def test_single_cap(self): result = _parse_save_cap_currents(".save i(C_mmc)") assert result == ["C_mmc"] def test_multiple_caps(self): result = _parse_save_cap_currents(".save i(C_mmc) i(C_topload)") assert result == ["C_mmc", "C_topload"] def test_non_cap_ignored(self): result = _parse_save_cap_currents(".save i(V1) i(R1) i(L1)") assert result == [] def test_mixed(self): result = _parse_save_cap_currents(".save i(C_mmc) i(V1) i(C_topload)") assert result == ["C_mmc", "C_topload"] def test_voltage_saves_ignored(self): result = _parse_save_cap_currents(".save v(out) v(in)") assert result == [] def test_not_save_directive(self): result = _parse_save_cap_currents("V1 in 0 1") assert result == [] def test_case_insensitive(self): result = _parse_save_cap_currents(".SAVE I(C_mmc)") assert result == ["C_mmc"] def test_save_all(self): result = _parse_save_cap_currents(".save all") assert result == [] class TestTargetedCapacitorProbes: """Test targeted capacitor probing via .save i(C_name) directives.""" def test_single_targeted_probe(self): """Only the named capacitor should get a probe.""" netlist = """Tesla Coil V1 vin 0 AC 1 C_mmc vin p1 0.03u C_topload p1 0 13.822p .save i(C_mmc) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # C_mmc should be probed assert 'V_probe_C_mmc' in result assert 'C_mmc _probe_C_mmc p1 0.03u' in result # C_topload should NOT be probed assert 'V_probe_C_topload' not in result assert '_probe_C_topload' not in result # .save should reference probe, not original cap assert 'i(V_probe_C_mmc)' in result # .save all should be present assert '.save all' in result def test_multiple_targeted_probes_same_line(self): """Multiple caps on one .save line should all get probes.""" netlist = """Test V1 vin 0 AC 1 C_mmc vin p1 0.03u C_topload p1 0 13.822p C_other p1 0 10p .save i(C_mmc) i(C_topload) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) assert 'V_probe_C_mmc' in result assert 'V_probe_C_topload' in result assert 'V_probe_C_other' not in result assert 'i(V_probe_C_mmc)' in result assert 'i(V_probe_C_topload)' in result def test_multiple_save_lines(self): """Caps on separate .save lines should all get probes.""" netlist = """Test V1 vin 0 AC 1 C_mmc vin p1 0.03u C_topload p1 0 13.822p .save i(C_mmc) .save i(C_topload) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) assert 'V_probe_C_mmc' in result assert 'V_probe_C_topload' in result def test_non_capacitor_save_unchanged(self): """.save i(V1) and .save i(L1) should pass through without probes.""" netlist = """Test V1 vin 0 AC 1 L1 vin 0 10u C1 vin 0 1u .save i(V1) i(L1) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # No probes inserted (no capacitor current saves) assert 'V_probe_' not in result assert '_probe_' not in result # Original saves preserved assert 'i(V1)' in result assert 'i(L1)' in result def test_mixed_save_cap_and_non_cap(self): """.save with both cap and non-cap currents.""" netlist = """Test V1 vin 0 AC 1 C_mmc vin p1 0.03u L1 p1 0 10u .save i(C_mmc) i(V1) i(L1) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # Cap probed assert 'V_probe_C_mmc' in result # .save rewritten for cap, others preserved assert 'i(V_probe_C_mmc)' in result assert 'i(V1)' in result assert 'i(L1)' in result def test_savecurrents_overrides_targeted(self): """When both .options savecurrents and .save i(C_name) exist, savecurrents takes precedence (probes everything).""" netlist = """Test V1 vin 0 AC 1 C_mmc vin p1 0.03u C_topload p1 0 13.822p .save i(C_mmc) .options savecurrents .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # Both caps probed (savecurrents probes everything) assert 'V_probe_C_mmc' in result assert 'V_probe_C_topload' in result def test_no_save_no_probes(self): """Without .save or .options savecurrents, no probes should be inserted.""" netlist = """Test V1 vin 0 AC 1 C1 vin 0 1u .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) assert 'V_probe_' not in result assert '_probe_' not in result def test_targeted_probe_not_inside_subckt(self): """Capacitors inside .subckt should NOT get probes even if targeted.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u .subckt mycomp a b C1 a b 10u .ends mycomp .save i(C1) .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) # Top-level C1 probed assert 'V_probe_C1' in result # Only one probe V-source line (not the subckt one) probe_vsource_lines = [l for l in result.splitlines() if l.strip().startswith('V_probe_C1')] assert len(probe_vsource_lines) == 1 def test_save_voltage_with_cap_current(self): """.save v(out) alongside .save i(C1) should both work.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u R1 out 0 1k .save v(out) i(C1) .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) assert 'V_probe_C1' in result assert 'v(out)' in result assert 'i(V_probe_C1)' in result def test_targeted_probe_case_insensitive(self): """.save I(c_mmc) should match C_mmc component (case-insensitive).""" netlist = """Test V1 vin 0 AC 1 C_mmc vin p1 0.03u .save I(c_mmc) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # Should probe C_mmc (case-insensitive match) assert 'V_probe_C_mmc' in result def test_save_all_not_duplicated(self): """If user already has .save all, don't add another.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u .save all .save i(C1) .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) # Should have exactly one .save all save_all_count = sum(1 for line in result.splitlines() if line.strip().lower().split() == ['.save', 'all']) assert save_all_count == 1 def test_targeted_with_rser_combined(self): """Targeted probing combined with inductor Rser expansion.""" netlist = """Tesla Coil AC Analysis V1 vin 0 AC 1 C_mmc vin p1 0.03u L1 p1 0 10.927u Rser=0.001 C_topload top 0 13.822p .save i(C_mmc) .ac dec 100 1k 3meg .end """ result = preprocess_netlist(netlist) # Rser expanded assert 'R_L1_ser' in result # Only C_mmc probed, not C_topload assert 'V_probe_C_mmc' in result assert 'V_probe_C_topload' not in result # .save rewritten assert 'i(V_probe_C_mmc)' in result def test_probes_before_end(self): """Targeted probe V-sources should appear before .end.""" netlist = """Test V1 in 0 AC 1 C1 in out 1u .save i(C1) .ac dec 10 1k 1meg .end """ result = preprocess_netlist(netlist) lines = result.splitlines() end_idx = next(i for i, l in enumerate(lines) if l.strip().lower() == '.end') probe_idx = next(i for i, l in enumerate(lines) if 'V_probe_C1' in l and l.strip().startswith('V_probe')) assert probe_idx < end_idx