mcltspice/tests/test_svg_plot.py
Ryan Malloy 8f0b9ad46a Add axis control, point limits, and dimension params to plot_waveform
Expose x_min/x_max/y_min/y_max, max_points, width/height, and title
override through the MCP tool. Data is clipped to X range before
stride-based downsampling for better zoomed resolution. All params
default to None/current behavior for backward compatibility.
2026-02-14 18:04:40 -07:00

279 lines
9.1 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,
)
# ---------------------------------------------------------------------------
# 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