Rename package from mcp-ltspice/mcp_ltspice to mcltspice throughout: source directory, imports, pyproject.toml, tests, and README. Remove startup banner prints from main() since FastMCP handles its own banner and stdout is the MCP JSON-RPC transport. Point repo URL at git.supported.systems/MCP/mcltspice.
185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
"""Tests for waveform_math module: RMS, peak-to-peak, FFT, THD, bandwidth, settling, rise time."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from mcltspice.waveform_math import (
|
|
compute_bandwidth,
|
|
compute_fft,
|
|
compute_peak_to_peak,
|
|
compute_rise_time,
|
|
compute_rms,
|
|
compute_settling_time,
|
|
compute_thd,
|
|
)
|
|
|
|
|
|
class TestComputeRms:
|
|
def test_dc_signal_exact(self, dc_signal):
|
|
"""RMS of a DC signal equals its DC value."""
|
|
rms = compute_rms(dc_signal)
|
|
assert rms == pytest.approx(3.3, abs=1e-10)
|
|
|
|
def test_sine_vpk_over_sqrt2(self, sine_1khz):
|
|
"""RMS of a pure sine (1 V peak) should be 1/sqrt(2)."""
|
|
rms = compute_rms(sine_1khz)
|
|
assert rms == pytest.approx(1.0 / np.sqrt(2), rel=0.01)
|
|
|
|
def test_empty_signal(self):
|
|
"""RMS of an empty array is 0."""
|
|
assert compute_rms(np.array([])) == 0.0
|
|
|
|
def test_single_sample(self):
|
|
"""RMS of a single sample equals abs(sample)."""
|
|
assert compute_rms(np.array([5.0])) == pytest.approx(5.0)
|
|
|
|
def test_complex_signal(self):
|
|
"""RMS uses only the real part of complex data."""
|
|
sig = np.array([3.0 + 4j, 3.0 + 4j])
|
|
rms = compute_rms(sig)
|
|
assert rms == pytest.approx(3.0)
|
|
|
|
|
|
class TestComputePeakToPeak:
|
|
def test_sine_wave(self, sine_1khz):
|
|
"""Peak-to-peak of a 1 V peak sine should be ~2 V."""
|
|
result = compute_peak_to_peak(sine_1khz)
|
|
assert result["peak_to_peak"] == pytest.approx(2.0, rel=0.01)
|
|
assert result["max"] == pytest.approx(1.0, rel=0.01)
|
|
assert result["min"] == pytest.approx(-1.0, rel=0.01)
|
|
assert result["mean"] == pytest.approx(0.0, abs=0.01)
|
|
|
|
def test_dc_signal(self, dc_signal):
|
|
"""Peak-to-peak of a DC signal is 0."""
|
|
result = compute_peak_to_peak(dc_signal)
|
|
assert result["peak_to_peak"] == pytest.approx(0.0)
|
|
|
|
def test_empty_signal(self):
|
|
result = compute_peak_to_peak(np.array([]))
|
|
assert result["peak_to_peak"] == 0.0
|
|
|
|
|
|
class TestComputeFft:
|
|
def test_known_sine_peak_at_correct_freq(self, time_array, sine_1khz):
|
|
"""A 1 kHz sine should produce a dominant peak at 1 kHz."""
|
|
result = compute_fft(time_array, sine_1khz)
|
|
assert result["fundamental_freq"] == pytest.approx(1000, rel=0.05)
|
|
assert result["dc_offset"] == pytest.approx(0.0, abs=0.01)
|
|
|
|
def test_dc_offset_detection(self, time_array):
|
|
"""A signal with DC offset should report correct dc_offset."""
|
|
offset = 2.5
|
|
sig = offset + np.sin(2 * np.pi * 1000 * time_array)
|
|
result = compute_fft(time_array, sig)
|
|
assert result["dc_offset"] == pytest.approx(offset, rel=0.05)
|
|
|
|
def test_short_signal(self):
|
|
"""Very short signals return empty results."""
|
|
result = compute_fft(np.array([0.0]), np.array([1.0]))
|
|
assert result["frequencies"] == []
|
|
assert result["fundamental_freq"] == 0.0
|
|
|
|
def test_zero_dt(self):
|
|
"""Time array with zero duration returns gracefully."""
|
|
result = compute_fft(np.array([1.0, 1.0]), np.array([1.0, 2.0]))
|
|
assert result["frequencies"] == []
|
|
|
|
|
|
class TestComputeThd:
|
|
def test_pure_sine_low_thd(self, time_array, sine_1khz):
|
|
"""A pure sine wave should have very low THD."""
|
|
result = compute_thd(time_array, sine_1khz)
|
|
assert result["thd_percent"] < 1.0
|
|
assert result["fundamental_freq"] == pytest.approx(1000, rel=0.05)
|
|
|
|
def test_clipped_sine_high_thd(self, time_array, sine_1khz):
|
|
"""A hard-clipped sine should have significantly higher THD."""
|
|
clipped = np.clip(sine_1khz, -0.5, 0.5)
|
|
result = compute_thd(time_array, clipped)
|
|
# Clipping at 50% introduces substantial harmonics
|
|
assert result["thd_percent"] > 10.0
|
|
|
|
def test_short_signal(self):
|
|
result = compute_thd(np.array([0.0]), np.array([1.0]))
|
|
assert result["thd_percent"] == 0.0
|
|
|
|
|
|
class TestComputeBandwidth:
|
|
def test_lowpass_cutoff(self, ac_frequency, lowpass_response):
|
|
"""Lowpass with fc=1kHz should report bandwidth near 1 kHz."""
|
|
result = compute_bandwidth(ac_frequency, lowpass_response)
|
|
assert result["bandwidth_hz"] == pytest.approx(1000, rel=0.1)
|
|
assert result["type"] == "lowpass"
|
|
|
|
def test_all_above_cutoff(self, ac_frequency):
|
|
"""If all magnitudes are above -3dB level, bandwidth spans entire range."""
|
|
flat = np.zeros_like(ac_frequency)
|
|
result = compute_bandwidth(ac_frequency, flat)
|
|
assert result["bandwidth_hz"] > 0
|
|
|
|
def test_short_input(self):
|
|
result = compute_bandwidth(np.array([1.0]), np.array([0.0]))
|
|
assert result["bandwidth_hz"] == 0.0
|
|
|
|
def test_bandpass_shape(self):
|
|
"""A peaked response should be detected as bandpass."""
|
|
fc = 10_000.0
|
|
Q = 5.0 # Q factor => BW = fc/Q = 2000 Hz
|
|
bw_expected = fc / Q
|
|
freq = np.logspace(2, 6, 2000)
|
|
# Second-order bandpass: H(s) = (s/wn/Q) / (s^2/wn^2 + s/wn/Q + 1)
|
|
wn = 2 * np.pi * fc
|
|
s = 1j * 2 * np.pi * freq
|
|
H = (s / wn / Q) / (s**2 / wn**2 + s / wn / Q + 1)
|
|
mag_db = 20.0 * np.log10(np.abs(H))
|
|
result = compute_bandwidth(freq, mag_db)
|
|
assert result["type"] == "bandpass"
|
|
assert result["bandwidth_hz"] == pytest.approx(bw_expected, rel=0.15)
|
|
|
|
|
|
class TestComputeSettlingTime:
|
|
def test_already_settled(self, time_array, dc_signal):
|
|
"""A constant signal is already settled at t=0."""
|
|
t = np.linspace(0, 0.01, len(dc_signal))
|
|
result = compute_settling_time(t, dc_signal, final_value=3.3)
|
|
assert result["settled"] is True
|
|
assert result["settling_time"] == 0.0
|
|
|
|
def test_step_response(self, time_array, step_signal):
|
|
"""Step response should settle after the transient."""
|
|
result = compute_settling_time(time_array, step_signal, final_value=1.0)
|
|
assert result["settled"] is True
|
|
assert result["settling_time"] > 0
|
|
|
|
def test_never_settles(self, time_array, sine_1khz):
|
|
"""An oscillating signal never settles to a DC value."""
|
|
result = compute_settling_time(time_array, sine_1khz, final_value=0.5)
|
|
assert result["settled"] is False
|
|
|
|
def test_short_signal(self):
|
|
result = compute_settling_time(np.array([0.0]), np.array([1.0]))
|
|
assert result["settled"] is False
|
|
|
|
|
|
class TestComputeRiseTime:
|
|
def test_fast_step(self):
|
|
"""A fast rising step should have a short rise time."""
|
|
t = np.linspace(0, 1e-3, 10000)
|
|
# Step with very fast exponential rise
|
|
sig = np.where(t > 0.1e-3, 1.0 - np.exp(-(t - 0.1e-3) / 20e-6), 0.0)
|
|
result = compute_rise_time(t, sig)
|
|
assert result["rise_time"] > 0
|
|
# 10-90% rise time of RC = ~2.2 * tau
|
|
assert result["rise_time"] == pytest.approx(2.2 * 20e-6, rel=0.2)
|
|
|
|
def test_no_swing(self):
|
|
"""Flat signal has zero rise time."""
|
|
t = np.linspace(0, 1, 100)
|
|
sig = np.ones(100) * 5.0
|
|
result = compute_rise_time(t, sig)
|
|
assert result["rise_time"] == 0.0
|
|
|
|
def test_short_signal(self):
|
|
result = compute_rise_time(np.array([0.0]), np.array([0.0]))
|
|
assert result["rise_time"] == 0.0
|