- SVG waveform plots (svg_plot.py): pure-SVG timeseries, Bode, spectrum
generation with plot_waveform MCP tool — no matplotlib dependency
- Circuit tuning tool (tune_circuit): single-shot simulate → measure →
compare targets → suggest adjustments workflow for iterative design
- 5 new circuit templates: Sallen-Key lowpass, boost converter,
instrumentation amplifier, current mirror, transimpedance amplifier
(both netlist and .asc schematic generators, 15 total templates)
- Fix all 6 prompts to return list[Message] per FastMCP 2.x spec
- Add ltspice://templates and ltspice://template/{name} resources
- Add troubleshoot_simulation prompt
- Integration tests for RC lowpass and non-inverting amp (2/4 pass;
CE amp and Colpitts oscillator have pre-existing schematic bugs)
- 360 unit tests passing, ruff clean
448 lines
13 KiB
Python
448 lines
13 KiB
Python
"""Tests for the 5 new circuit templates (netlist + asc generator)."""
|
|
|
|
import pytest
|
|
|
|
from mcp_ltspice.asc_generator import (
|
|
AscSchematic,
|
|
generate_boost_converter,
|
|
generate_current_mirror,
|
|
generate_instrumentation_amp,
|
|
generate_sallen_key_lowpass,
|
|
generate_transimpedance_amp,
|
|
)
|
|
from mcp_ltspice.netlist import (
|
|
Netlist,
|
|
boost_converter,
|
|
current_mirror,
|
|
instrumentation_amplifier,
|
|
sallen_key_lowpass,
|
|
transimpedance_amplifier,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Netlist template tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSallenKeyLowpassNetlist:
|
|
def test_returns_netlist(self):
|
|
n = sallen_key_lowpass()
|
|
assert isinstance(n, Netlist)
|
|
|
|
def test_component_count(self):
|
|
n = sallen_key_lowpass()
|
|
# V1, Vpos, Vneg, R1, R2, C1, C2, X1 = 8 components
|
|
assert len(n.components) == 8
|
|
|
|
def test_render_contains_key_components(self):
|
|
text = sallen_key_lowpass().render()
|
|
assert "R1" in text
|
|
assert "R2" in text
|
|
assert "C1" in text
|
|
assert "C2" in text
|
|
assert "X1" in text
|
|
assert "LT1001" in text
|
|
assert ".ac" in text
|
|
|
|
def test_custom_params(self):
|
|
n = sallen_key_lowpass(r1="4.7k", r2="4.7k", c1="22n", c2="22n")
|
|
text = n.render()
|
|
assert "4.7k" in text
|
|
assert "22n" in text
|
|
|
|
def test_has_backanno_and_end(self):
|
|
text = sallen_key_lowpass().render()
|
|
assert ".backanno" in text
|
|
assert ".end" in text
|
|
|
|
|
|
class TestBoostConverterNetlist:
|
|
def test_returns_netlist(self):
|
|
n = boost_converter()
|
|
assert isinstance(n, Netlist)
|
|
|
|
def test_component_count(self):
|
|
n = boost_converter()
|
|
# Vin, Vgate, L1, M1, D1, Cout, Rload = 7 components
|
|
assert len(n.components) == 7
|
|
|
|
def test_render_contains_key_components(self):
|
|
text = boost_converter().render()
|
|
assert "L1" in text
|
|
assert "M1" in text
|
|
assert "D1" in text
|
|
assert "Cout" in text
|
|
assert "Rload" in text
|
|
assert "PULSE(" in text
|
|
assert ".tran" in text
|
|
|
|
def test_custom_params(self):
|
|
n = boost_converter(ind="22u", r_load="100", v_in="3.3", duty_cycle=0.6)
|
|
text = n.render()
|
|
assert "22u" in text
|
|
assert "100" in text
|
|
assert "3.3" in text
|
|
|
|
def test_has_backanno_and_end(self):
|
|
text = boost_converter().render()
|
|
assert ".backanno" in text
|
|
assert ".end" in text
|
|
|
|
|
|
class TestInstrumentationAmplifierNetlist:
|
|
def test_returns_netlist(self):
|
|
n = instrumentation_amplifier()
|
|
assert isinstance(n, Netlist)
|
|
|
|
def test_component_count(self):
|
|
n = instrumentation_amplifier()
|
|
# V1, V2, Vpos, Vneg, X1, X2, R1, R1b, Rgain, X3, R2, R3, R2b, R3b = 14
|
|
assert len(n.components) == 14
|
|
|
|
def test_render_contains_key_components(self):
|
|
text = instrumentation_amplifier().render()
|
|
assert "X1" in text
|
|
assert "X2" in text
|
|
assert "X3" in text
|
|
assert "Rgain" in text
|
|
assert "LT1001" in text
|
|
assert ".ac" in text
|
|
|
|
def test_custom_params(self):
|
|
n = instrumentation_amplifier(r1="20k", r_gain="1k")
|
|
text = n.render()
|
|
assert "20k" in text
|
|
assert "1k" in text
|
|
|
|
def test_has_backanno_and_end(self):
|
|
text = instrumentation_amplifier().render()
|
|
assert ".backanno" in text
|
|
assert ".end" in text
|
|
|
|
|
|
class TestCurrentMirrorNetlist:
|
|
def test_returns_netlist(self):
|
|
n = current_mirror()
|
|
assert isinstance(n, Netlist)
|
|
|
|
def test_component_count(self):
|
|
n = current_mirror()
|
|
# Vcc, Rref, Rload, Q1, Q2 = 5 components
|
|
assert len(n.components) == 5
|
|
|
|
def test_render_contains_key_components(self):
|
|
text = current_mirror().render()
|
|
assert "Q1" in text
|
|
assert "Q2" in text
|
|
assert "Rref" in text
|
|
assert "Rload" in text
|
|
assert "2N2222" in text
|
|
assert ".op" in text
|
|
assert ".tran" in text
|
|
|
|
def test_custom_params(self):
|
|
n = current_mirror(r_ref="4.7k", r_load="2.2k", vcc="5")
|
|
text = n.render()
|
|
assert "4.7k" in text
|
|
assert "2.2k" in text
|
|
assert "5" in text
|
|
|
|
def test_has_backanno_and_end(self):
|
|
text = current_mirror().render()
|
|
assert ".backanno" in text
|
|
assert ".end" in text
|
|
|
|
|
|
class TestTransimpedanceAmplifierNetlist:
|
|
def test_returns_netlist(self):
|
|
n = transimpedance_amplifier()
|
|
assert isinstance(n, Netlist)
|
|
|
|
def test_component_count(self):
|
|
n = transimpedance_amplifier()
|
|
# I1, Vpos, Vneg, Rf, Cf, X1 = 6 components
|
|
assert len(n.components) == 6
|
|
|
|
def test_render_contains_key_components(self):
|
|
text = transimpedance_amplifier().render()
|
|
assert "I1" in text
|
|
assert "Rf" in text
|
|
assert "Cf" in text
|
|
assert "X1" in text
|
|
assert "LT1001" in text
|
|
assert ".ac" in text
|
|
|
|
def test_custom_params(self):
|
|
n = transimpedance_amplifier(rf="1Meg", cf="0.5p", i_source="10u")
|
|
text = n.render()
|
|
assert "1Meg" in text
|
|
assert "0.5p" in text
|
|
assert "10u" in text
|
|
|
|
def test_has_backanno_and_end(self):
|
|
text = transimpedance_amplifier().render()
|
|
assert ".backanno" in text
|
|
assert ".end" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ASC generator template tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSallenKeyLowpassAsc:
|
|
def test_returns_schematic(self):
|
|
sch = generate_sallen_key_lowpass()
|
|
assert isinstance(sch, AscSchematic)
|
|
|
|
def test_render_valid(self):
|
|
text = generate_sallen_key_lowpass().render()
|
|
assert text.startswith("Version 4\n")
|
|
assert "SHEET" in text
|
|
assert len(text) > 100
|
|
|
|
def test_contains_expected_symbols(self):
|
|
text = generate_sallen_key_lowpass().render()
|
|
assert "SYMBOL res" in text
|
|
assert "SYMBOL cap" in text
|
|
assert "SYMBOL OpAmps/UniversalOpamp2" in text
|
|
assert "SYMBOL voltage" in text
|
|
|
|
def test_custom_params(self):
|
|
text = generate_sallen_key_lowpass(r1="4.7k", c1="22n").render()
|
|
assert "4.7k" in text
|
|
assert "22n" in text
|
|
|
|
def test_has_simulation_directive(self):
|
|
text = generate_sallen_key_lowpass().render()
|
|
assert ".ac" in text
|
|
|
|
|
|
class TestBoostConverterAsc:
|
|
def test_returns_schematic(self):
|
|
sch = generate_boost_converter()
|
|
assert isinstance(sch, AscSchematic)
|
|
|
|
def test_render_valid(self):
|
|
text = generate_boost_converter().render()
|
|
assert text.startswith("Version 4\n")
|
|
assert "SHEET" in text
|
|
assert len(text) > 100
|
|
|
|
def test_contains_expected_symbols(self):
|
|
text = generate_boost_converter().render()
|
|
assert "SYMBOL ind" in text
|
|
assert "SYMBOL nmos" in text
|
|
assert "SYMBOL diode" in text
|
|
assert "SYMBOL cap" in text
|
|
assert "SYMBOL res" in text
|
|
|
|
def test_custom_params(self):
|
|
text = generate_boost_converter(ind="22u", r_load="100").render()
|
|
assert "22u" in text
|
|
assert "100" in text
|
|
|
|
def test_has_simulation_directive(self):
|
|
text = generate_boost_converter().render()
|
|
assert ".tran" in text
|
|
|
|
|
|
class TestInstrumentationAmpAsc:
|
|
def test_returns_schematic(self):
|
|
sch = generate_instrumentation_amp()
|
|
assert isinstance(sch, AscSchematic)
|
|
|
|
def test_render_valid(self):
|
|
text = generate_instrumentation_amp().render()
|
|
assert text.startswith("Version 4\n")
|
|
assert "SHEET" in text
|
|
assert len(text) > 100
|
|
|
|
def test_contains_expected_symbols(self):
|
|
text = generate_instrumentation_amp().render()
|
|
# Should have 3 opamps
|
|
assert text.count("SYMBOL OpAmps/UniversalOpamp2") == 3
|
|
# Should have multiple resistors
|
|
assert text.count("SYMBOL res") >= 7
|
|
|
|
def test_custom_params(self):
|
|
text = generate_instrumentation_amp(r1="20k", r_gain="1k").render()
|
|
assert "20k" in text
|
|
assert "1k" in text
|
|
|
|
def test_has_simulation_directive(self):
|
|
text = generate_instrumentation_amp().render()
|
|
assert ".ac" in text
|
|
|
|
|
|
class TestCurrentMirrorAsc:
|
|
def test_returns_schematic(self):
|
|
sch = generate_current_mirror()
|
|
assert isinstance(sch, AscSchematic)
|
|
|
|
def test_render_valid(self):
|
|
text = generate_current_mirror().render()
|
|
assert text.startswith("Version 4\n")
|
|
assert "SHEET" in text
|
|
assert len(text) > 100
|
|
|
|
def test_contains_expected_symbols(self):
|
|
text = generate_current_mirror().render()
|
|
# Should have 2 NPN transistors
|
|
assert text.count("SYMBOL npn") == 2
|
|
assert "SYMBOL res" in text
|
|
assert "SYMBOL voltage" in text
|
|
|
|
def test_custom_params(self):
|
|
text = generate_current_mirror(r_ref="4.7k", r_load="2.2k").render()
|
|
assert "4.7k" in text
|
|
assert "2.2k" in text
|
|
|
|
def test_has_simulation_directive(self):
|
|
text = generate_current_mirror().render()
|
|
assert ".op" in text
|
|
assert ".tran" in text
|
|
|
|
|
|
class TestTransimpedanceAmpAsc:
|
|
def test_returns_schematic(self):
|
|
sch = generate_transimpedance_amp()
|
|
assert isinstance(sch, AscSchematic)
|
|
|
|
def test_render_valid(self):
|
|
text = generate_transimpedance_amp().render()
|
|
assert text.startswith("Version 4\n")
|
|
assert "SHEET" in text
|
|
assert len(text) > 100
|
|
|
|
def test_contains_expected_symbols(self):
|
|
text = generate_transimpedance_amp().render()
|
|
assert "SYMBOL OpAmps/UniversalOpamp2" in text
|
|
assert "SYMBOL res" in text
|
|
assert "SYMBOL cap" in text
|
|
|
|
def test_custom_params(self):
|
|
text = generate_transimpedance_amp(rf="1Meg", cf="0.5p").render()
|
|
assert "1Meg" in text
|
|
assert "0.5p" in text
|
|
|
|
def test_has_simulation_directive(self):
|
|
text = generate_transimpedance_amp().render()
|
|
assert ".ac" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parametrized cross-cutting tests for all 5 new netlist templates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNewNetlistTemplatesCommon:
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
sallen_key_lowpass,
|
|
boost_converter,
|
|
instrumentation_amplifier,
|
|
current_mirror,
|
|
transimpedance_amplifier,
|
|
],
|
|
)
|
|
def test_returns_netlist(self, factory):
|
|
assert isinstance(factory(), Netlist)
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
sallen_key_lowpass,
|
|
boost_converter,
|
|
instrumentation_amplifier,
|
|
current_mirror,
|
|
transimpedance_amplifier,
|
|
],
|
|
)
|
|
def test_has_backanno_and_end(self, factory):
|
|
text = factory().render()
|
|
assert ".backanno" in text
|
|
assert ".end" in text
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
sallen_key_lowpass,
|
|
boost_converter,
|
|
instrumentation_amplifier,
|
|
current_mirror,
|
|
transimpedance_amplifier,
|
|
],
|
|
)
|
|
def test_has_components(self, factory):
|
|
n = factory()
|
|
assert len(n.components) > 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
sallen_key_lowpass,
|
|
boost_converter,
|
|
instrumentation_amplifier,
|
|
current_mirror,
|
|
transimpedance_amplifier,
|
|
],
|
|
)
|
|
def test_has_sim_directive(self, factory):
|
|
text = factory().render()
|
|
sim_types = [".tran", ".ac", ".dc", ".op", ".noise", ".tf"]
|
|
assert any(sim in text.lower() for sim in sim_types), (
|
|
f"No simulation directive found in {factory.__name__}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parametrized cross-cutting tests for all 5 new ASC templates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNewAscTemplatesCommon:
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
generate_sallen_key_lowpass,
|
|
generate_boost_converter,
|
|
generate_instrumentation_amp,
|
|
generate_current_mirror,
|
|
generate_transimpedance_amp,
|
|
],
|
|
)
|
|
def test_returns_schematic(self, factory):
|
|
assert isinstance(factory(), AscSchematic)
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
generate_sallen_key_lowpass,
|
|
generate_boost_converter,
|
|
generate_instrumentation_amp,
|
|
generate_current_mirror,
|
|
generate_transimpedance_amp,
|
|
],
|
|
)
|
|
def test_render_nonempty(self, factory):
|
|
text = factory().render()
|
|
assert len(text) > 50
|
|
assert "SYMBOL" in text
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory",
|
|
[
|
|
generate_sallen_key_lowpass,
|
|
generate_boost_converter,
|
|
generate_instrumentation_amp,
|
|
generate_current_mirror,
|
|
generate_transimpedance_amp,
|
|
],
|
|
)
|
|
def test_has_version_and_sheet(self, factory):
|
|
text = factory().render()
|
|
assert "Version 4" in text
|
|
assert "SHEET" in text
|