- 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
337 lines
9.6 KiB
Python
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
|