"""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