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

200 lines
6.2 KiB
Python

"""Tests for the pure-SVG waveform plot generation module."""
import numpy as np
import pytest
from mcp_ltspice.svg_plot import (
_format_freq,
_nice_ticks,
plot_bode,
plot_spectrum,
plot_timeseries,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sine_wave():
"""A 1 kHz sine wave sampled at 100 kHz for 10 ms."""
t = np.linspace(0, 0.01, 1000, endpoint=False)
v = np.sin(2 * np.pi * 1000 * t)
return t, v
@pytest.fixture()
def bode_data():
"""Simple first-order lowpass Bode response (fc = 1 kHz)."""
freq = np.logspace(1, 6, 500)
fc = 1e3
mag_db = -10 * np.log10(1 + (freq / fc) ** 2)
phase_deg = -np.degrees(np.arctan(freq / fc))
return freq, mag_db, phase_deg
@pytest.fixture()
def spectrum_data():
"""Synthetic FFT spectrum with a peak at 1 kHz."""
freq = np.logspace(1, 5, 300)
mag_db = -60 * np.ones_like(freq)
peak_idx = np.argmin(np.abs(freq - 1e3))
mag_db[peak_idx] = 0.0
return freq, mag_db
# ---------------------------------------------------------------------------
# Timeseries
# ---------------------------------------------------------------------------
class TestPlotTimeseries:
def test_basic(self, sine_wave):
"""A simple sine wave produces a valid SVG with expected elements."""
t, v = sine_wave
svg = plot_timeseries(t, v)
assert svg.startswith("<svg")
assert "<path" in svg
assert "Time Domain" in svg
def test_empty_arrays(self):
"""Empty input should not crash and should return a valid SVG."""
svg = plot_timeseries([], [])
assert svg.startswith("<svg")
assert "</svg>" in svg
def test_custom_title_and_labels(self, sine_wave):
"""Custom title and ylabel should appear in the SVG output."""
t, v = sine_wave
svg = plot_timeseries(t, v, title="My Signal", ylabel="Current (A)")
assert "My Signal" in svg
assert "Current (A)" in svg
def test_svg_dimensions(self, sine_wave):
"""The width/height attributes should match the requested size."""
t, v = sine_wave
svg = plot_timeseries(t, v, width=1024, height=768)
assert 'width="1024"' in svg
assert 'height="768"' in svg
# ---------------------------------------------------------------------------
# Bode
# ---------------------------------------------------------------------------
class TestPlotBode:
def test_magnitude_only(self, bode_data):
"""Bode plot without phase produces a valid SVG with one trace."""
freq, mag_db, _ = bode_data
svg = plot_bode(freq, mag_db)
assert svg.startswith("<svg")
assert "<path" in svg
assert "Bode Plot" in svg
def test_with_phase(self, bode_data):
"""Bode plot with phase should contain two <path> elements (mag + phase)."""
freq, mag_db, phase_deg = bode_data
svg = plot_bode(freq, mag_db, phase_deg)
assert svg.startswith("<svg")
# Two traces -- magnitude and phase
assert svg.count("<path") >= 2
# Phase subplot label
assert "Phase (deg)" in svg
def test_log_axis_ticks(self, bode_data):
"""Log frequency axis should contain tick labels at powers of 10."""
freq, mag_db, _ = bode_data
svg = plot_bode(freq, mag_db)
# Expect at least some frequency labels like "100", "1k", "10k", "100k"
found = sum(1 for lbl in ("100", "1k", "10k", "100k") if lbl in svg)
assert found >= 2, f"Expected log tick labels in SVG; found {found}"
# ---------------------------------------------------------------------------
# Spectrum
# ---------------------------------------------------------------------------
class TestPlotSpectrum:
def test_basic(self, spectrum_data):
"""A simple spectrum produces a valid SVG."""
freq, mag_db = spectrum_data
svg = plot_spectrum(freq, mag_db)
assert svg.startswith("<svg")
assert "<path" in svg
assert "FFT Spectrum" in svg
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class TestNiceTicks:
def test_simple_range(self):
ticks = _nice_ticks(0, 10, n_ticks=5)
assert len(ticks) >= 3
assert ticks[0] <= 0
assert ticks[-1] >= 10
def test_equal_values(self):
"""When vmin == vmax, return a single-element list."""
ticks = _nice_ticks(5, 5)
assert ticks == [5]
def test_negative_range(self):
ticks = _nice_ticks(-100, -20, n_ticks=5)
assert ticks[0] <= -100
assert ticks[-1] >= -20
def test_small_range(self):
ticks = _nice_ticks(0.001, 0.005, n_ticks=5)
assert all(0 <= t <= 0.01 for t in ticks)
class TestFormatFreq:
def test_hz(self):
assert _format_freq(1) == "1"
assert _format_freq(10) == "10"
assert _format_freq(100) == "100"
def test_khz(self):
assert _format_freq(1000) == "1k"
assert _format_freq(10000) == "10k"
assert _format_freq(100000) == "100k"
def test_mhz(self):
assert _format_freq(1e6) == "1M"
assert _format_freq(10e6) == "10M"
def test_ghz(self):
assert _format_freq(1e9) == "1G"
def test_zero(self):
assert _format_freq(0) == "0"
class TestSvgDimensions:
def test_timeseries_dimensions(self):
t = np.linspace(0, 1, 100)
v = np.sin(t)
svg = plot_timeseries(t, v, width=640, height=480)
assert 'width="640"' in svg
assert 'height="480"' in svg
def test_bode_dimensions(self):
freq = np.logspace(1, 5, 50)
mag = np.zeros(50)
svg = plot_bode(freq, mag, width=900, height=600)
assert 'width="900"' in svg
assert 'height="600"' in svg
def test_spectrum_dimensions(self):
freq = np.logspace(1, 5, 50)
mag = np.zeros(50)
svg = plot_spectrum(freq, mag, width=1000, height=500)
assert 'width="1000"' in svg
assert 'height="500"' in svg