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.
279 lines
9.1 KiB
Python
279 lines
9.1 KiB
Python
"""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
|