mcltspice/tests/test_integration.py
Ryan Malloy 9b418a06c5 Add SVG plotting, circuit tuning, 5 new templates, fix prompts
- 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
2026-02-11 05:13:50 -07:00

219 lines
8.1 KiB
Python

"""Integration tests that run actual LTspice simulations.
These tests require LTspice and Wine to be installed. Skip with:
pytest -m 'not integration'
"""
import tempfile
from pathlib import Path
import numpy as np
import pytest
from mcp_ltspice.asc_generator import (
generate_colpitts_oscillator as generate_colpitts_asc,
)
from mcp_ltspice.asc_generator import (
generate_common_emitter_amp as generate_ce_amp_asc,
)
from mcp_ltspice.asc_generator import (
generate_non_inverting_amp as generate_noninv_amp_asc,
)
from mcp_ltspice.asc_generator import (
generate_rc_lowpass as generate_rc_lowpass_asc,
)
from mcp_ltspice.runner import run_simulation
from mcp_ltspice.waveform_math import compute_bandwidth, compute_rms
@pytest.mark.integration
class TestRCLowpass:
"""End-to-end test: RC lowpass filter -> simulate -> verify -3dB point."""
async def test_rc_lowpass_bandwidth(self, ltspice_available):
"""Generate RC lowpass (R=1k, C=100n), simulate, verify fc ~ 1.6 kHz."""
# Generate schematic
sch = generate_rc_lowpass_asc(r="1k", c="100n")
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "rc_lowpass.asc"
sch.save(asc_path)
# Simulate
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None, "No raw data produced"
# Find frequency and V(out) variables
raw = result.raw_data
freq_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "frequency":
freq_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert freq_idx is not None, (
f"No frequency variable. Variables: {[v.name for v in raw.variables]}"
)
assert vout_idx is not None, (
f"No V(out) variable. Variables: {[v.name for v in raw.variables]}"
)
# Extract data
freq = np.abs(raw.data[freq_idx])
mag_complex = raw.data[vout_idx]
mag_db = 20.0 * np.log10(np.maximum(np.abs(mag_complex), 1e-30))
# Compute bandwidth
bw = compute_bandwidth(freq, mag_db)
# Expected: fc = 1/(2*pi*1000*100e-9) ~ 1591 Hz
# Allow 20% tolerance for simulation differences
expected_fc = 1.0 / (2 * np.pi * 1000 * 100e-9)
assert bw["bandwidth_hz"] is not None, "Could not compute bandwidth"
assert abs(bw["bandwidth_hz"] - expected_fc) / expected_fc < 0.2, (
f"Bandwidth {bw['bandwidth_hz']:.0f} Hz too far from expected {expected_fc:.0f} Hz"
)
@pytest.mark.integration
class TestNonInvertingAmp:
"""End-to-end test: non-inverting amp -> simulate -> verify gain."""
async def test_noninv_amp_gain(self, ltspice_available):
"""Generate non-inverting amp (Rf=100k, Rin=10k), verify gain ~ 11 (20.8 dB)."""
sch = generate_noninv_amp_asc(rin="10k", rf="100k")
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "noninv_amp.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
freq_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "frequency":
freq_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert freq_idx is not None
assert vout_idx is not None
_freq = np.abs(raw.data[freq_idx]) # noqa: F841 — kept for debug
mag = np.abs(raw.data[vout_idx])
mag_db = 20.0 * np.log10(np.maximum(mag, 1e-30))
# At low frequency, gain should be 1 + 100k/10k = 11 = 20.83 dB
# Use first few points (low frequency)
low_freq_gain_db = float(np.mean(mag_db[:5]))
expected_gain_db = 20 * np.log10(11) # 20.83 dB
assert abs(low_freq_gain_db - expected_gain_db) < 2.0, (
f"Low-freq gain {low_freq_gain_db:.1f} dB, expected ~{expected_gain_db:.1f} dB"
)
@pytest.mark.integration
class TestCommonEmitterAmp:
"""End-to-end test: CE amplifier -> simulate transient -> verify output exists."""
async def test_ce_amp_output(self, ltspice_available):
"""Generate CE amp, simulate transient, verify output has AC content."""
sch = generate_ce_amp_asc()
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "ce_amp.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
time_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "time":
time_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert time_idx is not None
assert vout_idx is not None
sig = np.real(raw.data[vout_idx])
# Output should not be DC-only -- check peak-to-peak > threshold
pp = float(np.max(sig) - np.min(sig))
assert pp > 0.01, (
f"Output appears DC-only (peak-to-peak={pp:.4f}V). "
"Expected amplified AC signal."
)
# RMS should be non-trivial
rms = float(compute_rms(sig))
assert rms > 0.01, f"Output RMS too low: {rms:.4f}V"
@pytest.mark.integration
class TestColpittsOscillator:
"""End-to-end test: Colpitts oscillator -> simulate -> verify oscillation."""
async def test_colpitts_oscillation(self, ltspice_available):
"""Generate Colpitts oscillator, verify oscillation near expected frequency."""
sch = generate_colpitts_asc()
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "colpitts.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
time_idx = None
# Look for collector voltage
vcol_idx = None
for var in raw.variables:
if var.name.lower() == "time":
time_idx = var.index
elif "collector" in var.name.lower() or var.name.lower() == "v(collector)":
vcol_idx = var.index
assert time_idx is not None
if vcol_idx is None:
# Fall back to any voltage signal that isn't supply
for var in raw.variables:
if var.name.lower().startswith("v(") and var.name.lower() not in (
"v(vcc)",
"v(time)",
):
vcol_idx = var.index
break
assert vcol_idx is not None, (
f"No suitable signal found. Variables: {[v.name for v in raw.variables]}"
)
sig = np.real(raw.data[vcol_idx])
# Oscillator output should have significant AC content
pp = float(np.max(sig) - np.min(sig))
assert pp > 0.1, (
f"Output peak-to-peak {pp:.3f}V too small -- oscillator may not have started"
)
# Expected frequency: f = 1/(2*pi*sqrt(L*C1*C2/(C1+C2)))
# With L=1u, C1=C2=100p: Cseries = 50p
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
# This is quite high, but we just verify oscillation exists