- 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
219 lines
8.1 KiB
Python
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
|