mcltspice/tests/conftest.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

337 lines
9.6 KiB
Python

"""Shared fixtures for mcp-ltspice test suite.
All fixtures produce synthetic data -- no LTspice or Wine required.
"""
from pathlib import Path
import numpy as np
import pytest
from mcp_ltspice.raw_parser import RawFile, Variable
from mcp_ltspice.schematic import Component, Flag, Schematic, Text, Wire
# ---------------------------------------------------------------------------
# Time-domain fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_rate() -> float:
"""Default sample rate: 100 kHz."""
return 100_000.0
@pytest.fixture
def duration() -> float:
"""Default signal duration: 10 ms (enough for 1 kHz signals)."""
return 0.01
@pytest.fixture
def time_array(sample_rate, duration) -> np.ndarray:
"""Uniformly spaced time array."""
n = int(sample_rate * duration)
return np.linspace(0, duration, n, endpoint=False)
@pytest.fixture
def sine_1khz(time_array) -> np.ndarray:
"""1 kHz sine wave, 1 V peak."""
return np.sin(2 * np.pi * 1000 * time_array)
@pytest.fixture
def dc_signal() -> np.ndarray:
"""Constant 3.3 V DC signal (1000 samples)."""
return np.full(1000, 3.3)
@pytest.fixture
def step_signal(time_array) -> np.ndarray:
"""Unit step at t = duration/2 with exponential rise (tau = duration/10)."""
t = time_array
mid = t[-1] / 2
tau = t[-1] / 10
sig = np.where(t >= mid, 1.0 - np.exp(-(t - mid) / tau), 0.0)
return sig
# ---------------------------------------------------------------------------
# Frequency-domain / AC fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def ac_frequency() -> np.ndarray:
"""Log-spaced frequency array: 1 Hz to 10 MHz, 500 points."""
return np.logspace(0, 7, 500)
@pytest.fixture
def lowpass_response(ac_frequency) -> np.ndarray:
"""First-order lowpass magnitude in dB (fc ~ 1 kHz)."""
fc = 1000.0
mag = 1.0 / np.sqrt(1.0 + (ac_frequency / fc) ** 2)
return 20.0 * np.log10(mag)
@pytest.fixture
def lowpass_complex(ac_frequency) -> np.ndarray:
"""First-order lowpass as complex transfer function (fc ~ 1 kHz)."""
fc = 1000.0
s = 1j * ac_frequency / fc
return 1.0 / (1.0 + s)
# ---------------------------------------------------------------------------
# Stepped / multi-run fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def stepped_time() -> np.ndarray:
"""Time axis for 3 runs, each 0..1 ms with 100 points per run."""
runs = []
for _ in range(3):
runs.append(np.linspace(0, 1e-3, 100, endpoint=False))
return np.concatenate(runs)
@pytest.fixture
def stepped_data(stepped_time) -> np.ndarray:
"""Two variables (time + V(out)) across 3 runs."""
n = len(stepped_time)
data = np.zeros((2, n))
data[0] = stepped_time
# Each run has a different amplitude sine wave
for run_idx in range(3):
start = run_idx * 100
end = start + 100
t_run = data[0, start:end]
data[1, start:end] = (run_idx + 1) * np.sin(2 * np.pi * 1000 * t_run)
return data
# ---------------------------------------------------------------------------
# Mock RawFile fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_rawfile(time_array, sine_1khz) -> RawFile:
"""A simple transient-analysis RawFile with time and V(out)."""
n = len(time_array)
data = np.zeros((2, n))
data[0] = time_array
data[1] = sine_1khz
return RawFile(
title="Test Circuit",
date="2026-01-01",
plotname="Transient Analysis",
flags=["real"],
variables=[
Variable(0, "time", "time"),
Variable(1, "V(out)", "voltage"),
],
points=n,
data=data,
)
@pytest.fixture
def mock_rawfile_stepped(stepped_data) -> RawFile:
"""A stepped RawFile with 3 runs."""
n = stepped_data.shape[1]
return RawFile(
title="Stepped Sim",
date="2026-01-01",
plotname="Transient Analysis",
flags=["real", "stepped"],
variables=[
Variable(0, "time", "time"),
Variable(1, "V(out)", "voltage"),
],
points=n,
data=stepped_data,
n_runs=3,
run_boundaries=[0, 100, 200],
)
@pytest.fixture
def mock_rawfile_ac(ac_frequency, lowpass_complex) -> RawFile:
"""An AC-analysis RawFile with complex frequency-domain data."""
n = len(ac_frequency)
data = np.zeros((2, n), dtype=np.complex128)
data[0] = ac_frequency.astype(np.complex128)
data[1] = lowpass_complex
return RawFile(
title="AC Sim",
date="2026-01-01",
plotname="AC Analysis",
flags=["complex"],
variables=[
Variable(0, "frequency", "frequency"),
Variable(1, "V(out)", "voltage"),
],
points=n,
data=data,
)
# ---------------------------------------------------------------------------
# Netlist / Schematic string fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_netlist_str() -> str:
"""A basic SPICE netlist string for an RC lowpass."""
return (
"* RC Lowpass Filter\n"
"V1 in 0 AC 1\n"
"R1 in out 1k\n"
"C1 out 0 100n\n"
".ac dec 100 1 1meg\n"
".backanno\n"
".end\n"
)
@pytest.fixture
def sample_asc_str() -> str:
"""A minimal .asc schematic string for an RC lowpass."""
return (
"Version 4\n"
"SHEET 1 880 680\n"
"WIRE 80 96 176 96\n"
"WIRE 176 176 272 176\n"
"FLAG 80 176 0\n"
"FLAG 272 240 0\n"
"FLAG 176 176 out\n"
"SYMBOL voltage 80 80 R0\n"
"SYMATTR InstName V1\n"
"SYMATTR Value AC 1\n"
"SYMBOL res 160 80 R0\n"
"SYMATTR InstName R1\n"
"SYMATTR Value 1k\n"
"SYMBOL cap 256 176 R0\n"
"SYMATTR InstName C1\n"
"SYMATTR Value 100n\n"
"TEXT 80 296 Left 2 !.ac dec 100 1 1meg\n"
)
# ---------------------------------------------------------------------------
# Touchstone fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_s2p_content() -> str:
"""Synthetic .s2p file content (MA format, GHz)."""
lines = [
"! Two-port S-parameter data",
"! Freq S11(mag) S11(ang) S21(mag) S21(ang) S12(mag) S12(ang) S22(mag) S22(ang)",
"# GHZ S MA R 50",
"1.0 0.5 -30 0.9 -10 0.1 170 0.4 -40",
"2.0 0.6 -50 0.8 -20 0.12 160 0.5 -60",
"3.0 0.7 -70 0.7 -30 0.15 150 0.55 -80",
]
return "\n".join(lines) + "\n"
@pytest.fixture
def tmp_s2p_file(sample_s2p_content, tmp_path) -> Path:
"""Write synthetic .s2p content to a temp file and return path."""
p = tmp_path / "test.s2p"
p.write_text(sample_s2p_content)
return p
# ---------------------------------------------------------------------------
# Schematic object fixtures (for DRC and diff tests)
# ---------------------------------------------------------------------------
@pytest.fixture
def valid_schematic() -> Schematic:
"""A schematic with ground, components, wires, and sim directive."""
sch = Schematic()
sch.flags = [
Flag(80, 176, "0"),
Flag(272, 240, "0"),
Flag(176, 176, "out"),
]
sch.wires = [
Wire(80, 96, 176, 96),
Wire(176, 176, 272, 176),
]
sch.components = [
Component(name="V1", symbol="voltage", x=80, y=80, rotation=0, mirror=False,
attributes={"Value": "AC 1"}),
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
Component(name="C1", symbol="cap", x=256, y=176, rotation=0, mirror=False,
attributes={"Value": "100n"}),
]
sch.texts = [
Text(80, 296, ".ac dec 100 1 1meg", type="spice"),
]
return sch
@pytest.fixture
def schematic_no_ground() -> Schematic:
"""A schematic missing a ground node."""
sch = Schematic()
sch.flags = [Flag(176, 176, "out")]
sch.wires = [Wire(80, 96, 176, 96)]
sch.components = [
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
]
sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
return sch
@pytest.fixture
def schematic_no_sim() -> Schematic:
"""A schematic missing a simulation directive."""
sch = Schematic()
sch.flags = [Flag(80, 176, "0")]
sch.wires = [Wire(80, 96, 176, 96)]
sch.components = [
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
]
sch.texts = []
return sch
@pytest.fixture
def schematic_duplicate_names() -> Schematic:
"""A schematic with duplicate component names."""
sch = Schematic()
sch.flags = [Flag(80, 176, "0")]
sch.wires = [Wire(80, 96, 176, 96)]
sch.components = [
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
Component(name="R1", symbol="res", x=320, y=80, rotation=0, mirror=False,
attributes={"Value": "2.2k"}),
]
sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
return sch
@pytest.fixture
def ltspice_available():
"""Skip test if LTspice is not available."""
from mcp_ltspice.config import validate_installation
ok, msg = validate_installation()
if not ok:
pytest.skip(f"LTspice not available: {msg}")
return True