- 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
200 lines
6.2 KiB
Python
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
|