mcltspice/tests/test_waveform_math.py
2026-02-10 23:35:53 -07:00

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 mcp_ltspice.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