mcltspice/tests/test_svg_plot.py
Ryan Malloy b0db898ab4 Fix plot_waveform data indexing bug, add multi-signal overlay and adaptive axis labels
Row-major data from raw_parser was indexed as column-major, producing
garbled plots for digital waveforms. Also adds signals parameter for
overlaying multiple time-domain traces with legend, and adaptive X-axis
labels (ns/µs/ms/s) based on time span.
2026-02-14 20:16:10 -07:00

376 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the pure-SVG waveform plot generation module."""
import numpy as np
import pytest
from mcltspice.svg_plot import (
_format_freq,
_nice_ticks,
plot_bode,
plot_spectrum,
plot_timeseries,
plot_timeseries_multi,
)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Axis range overrides
# ---------------------------------------------------------------------------
class TestAxisRangeOverrides:
def test_timeseries_custom_axis_range(self, sine_wave):
"""Explicit x/y ranges produce valid SVG without crashing."""
t, v = sine_wave
svg = plot_timeseries(
t, v, x_min=0.002, x_max=0.005, y_min=-0.5, y_max=0.5,
)
assert svg.startswith("<svg")
assert "<path" in svg
assert "</svg>" in svg
def test_bode_custom_freq_range(self, bode_data):
"""Bode plot with explicit frequency range includes ticks in range."""
freq, mag_db, phase_deg = bode_data
svg = plot_bode(
freq, mag_db, phase_deg,
x_min=100.0, x_max=100_000.0,
y_min=-40.0, y_max=0.0,
)
assert svg.startswith("<svg")
assert "<path" in svg
# Tick labels should include values within 100 Hz 100 kHz
found = sum(1 for lbl in ("1k", "10k") if lbl in svg)
assert found >= 1, f"Expected freq tick labels in range; found {found}"
def test_spectrum_custom_range(self, spectrum_data):
"""Spectrum with explicit ranges produces valid SVG."""
freq, mag_db = spectrum_data
svg = plot_spectrum(
freq, mag_db,
x_min=100.0, x_max=50_000.0,
y_min=-80.0, y_max=10.0,
)
assert svg.startswith("<svg")
assert "<path" in svg
def test_partial_range_override(self, sine_wave):
"""Setting only y_min while leaving others auto should work."""
t, v = sine_wave
svg = plot_timeseries(t, v, y_min=-2.0)
assert svg.startswith("<svg")
assert "<path" in svg
def test_none_defaults_match_current(self, sine_wave):
"""Passing None for all range params produces identical output."""
t, v = sine_wave
svg_default = plot_timeseries(t, v)
svg_explicit_none = plot_timeseries(
t, v,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
def test_bode_none_defaults_match_current(self, bode_data):
"""Bode with explicit None params matches default output."""
freq, mag_db, phase_deg = bode_data
svg_default = plot_bode(freq, mag_db, phase_deg)
svg_explicit_none = plot_bode(
freq, mag_db, phase_deg,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
def test_spectrum_none_defaults_match_current(self, spectrum_data):
"""Spectrum with explicit None params matches default output."""
freq, mag_db = spectrum_data
svg_default = plot_spectrum(freq, mag_db)
svg_explicit_none = plot_spectrum(
freq, mag_db,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
# ---------------------------------------------------------------------------
# Adaptive X-axis label
# ---------------------------------------------------------------------------
class TestAdaptiveXLabel:
def test_timeseries_adaptive_xlabel_ms(self):
"""A 2 ms span should produce 'Time (ms)' label."""
t = np.linspace(0, 0.002, 200)
v = np.sin(2 * np.pi * 1000 * t)
svg = plot_timeseries(t, v)
assert "Time (ms)" in svg
def test_timeseries_adaptive_xlabel_us(self):
"""A 50 µs span should produce 'Time (µs)' label."""
t = np.linspace(0, 50e-6, 200)
v = np.sin(2 * np.pi * 50e3 * t)
svg = plot_timeseries(t, v)
assert "Time (\u00b5s)" in svg or "Time (µs)" in svg
def test_timeseries_adaptive_xlabel_s(self):
"""A 2 s span should produce 'Time (s)' label."""
t = np.linspace(0, 2, 200)
v = np.sin(2 * np.pi * t)
svg = plot_timeseries(t, v)
assert "Time (s)" in svg
def test_timeseries_adaptive_xlabel_ns(self):
"""A 500 ns span should produce 'Time (ns)' label."""
t = np.linspace(0, 500e-9, 200)
v = np.sin(2 * np.pi * 1e6 * t)
svg = plot_timeseries(t, v)
assert "Time (ns)" in svg
# ---------------------------------------------------------------------------
# Multi-signal overlay
# ---------------------------------------------------------------------------
class TestPlotTimeseriesMulti:
@pytest.fixture()
def two_traces(self):
"""Two signals with different amplitudes over 10 ms."""
t = np.linspace(0, 0.01, 500)
v1 = np.sin(2 * np.pi * 1000 * t)
v2 = 0.5 * np.cos(2 * np.pi * 1000 * t) + 2.0
return t, [("V(sig1)", v1), ("V(sig2)", v2)]
def test_timeseries_multi_basic(self, two_traces):
"""Two traces produce valid SVG with at least 2 <path> elements."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
assert svg.startswith("<svg")
assert "</svg>" in svg
assert svg.count("<path") >= 2
def test_timeseries_multi_legend(self, two_traces):
"""Legend should contain both trace names."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
assert "V(sig1)" in svg
assert "V(sig2)" in svg
def test_timeseries_multi_unified_yrange(self, two_traces):
"""Y axis should cover both traces — sig2 peaks at ~2.5, sig1 at ~1.0."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
# The SVG should not clip — both traces' paths should be present
path_count = svg.count("<path")
assert path_count >= 2
def test_timeseries_multi_colors(self, two_traces):
"""Paths should use distinct stroke colors."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
# First trace is blue (#2563eb), second is red (#dc2626)
assert "#2563eb" in svg
assert "#dc2626" in svg
def test_timeseries_multi_single_trace(self):
"""Single trace through multi function produces valid SVG with one path."""
t = np.linspace(0, 0.001, 100)
v = np.sin(2 * np.pi * 5000 * t)
svg = plot_timeseries_multi(t, [("V(out)", v)])
assert svg.startswith("<svg")
assert "<path" in svg
assert "V(out)" in svg
def test_timeseries_multi_adaptive_xlabel(self, two_traces):
"""Multi plot should also get adaptive xlabel (10 ms → 'Time (ms)')."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
assert "Time (ms)" in svg