diff --git a/tests/test_output_format.py b/tests/test_output_format.py new file mode 100644 index 0000000..56cab11 --- /dev/null +++ b/tests/test_output_format.py @@ -0,0 +1,52 @@ +"""Tests for the JSON output-formatting helpers.""" + +import math + +import numpy as np + +from mcltspice.output_format import compact_list, downsample_indices, round_sig + + +class TestRoundSig: + def test_significant_figures(self): + assert round_sig(123456.789, 3) == 123000.0 + assert round_sig(0.00123456, 3) == 0.00123 + + def test_zero_and_nonfinite_pass_through(self): + assert round_sig(0.0) == 0.0 + assert math.isinf(round_sig(float("inf"))) + assert math.isnan(round_sig(float("nan"))) + + +class TestCompactList: + def test_decimal_places(self): + assert compact_list([1.23456, 2.98765], decimal_places=2) == [1.23, 2.99] + + def test_sig_figs(self): + assert compact_list([123456.0], sig_figs=3) == [123000.0] + + def test_no_rounding_returns_floats(self): + out = compact_list([1, 2, 3]) + assert out == [1.0, 2.0, 3.0] + assert all(isinstance(v, float) for v in out) + + +class TestDownsampleIndices: + def test_no_downsample_when_under_limit(self): + idx = downsample_indices(50, 100) + assert np.array_equal(idx, np.arange(50)) + + def test_stride_for_transient(self): + idx = downsample_indices(1000, 100) + assert len(idx) <= 100 + # plain stride: evenly spaced + assert idx[0] == 0 + assert idx[1] - idx[0] == idx[2] - idx[1] + + def test_peak_preserving_keeps_a_narrow_spike(self): + # A flat magnitude with one sharp spike: peak-preserving must keep it. + n = 1000 + mag = np.ones(n, dtype=complex) + mag[497] = 1000.0 # narrow resonance peak + idx = downsample_indices(n, 50, signals=[mag]) + assert 497 in idx # spike survived downsampling diff --git a/tests/test_waveform_query.py b/tests/test_waveform_query.py new file mode 100644 index 0000000..7ecc0e7 --- /dev/null +++ b/tests/test_waveform_query.py @@ -0,0 +1,77 @@ +"""Tests for extract_waveform / analyze_signal -- RawFile queries sans simulator.""" + +import numpy as np + +from mcltspice.raw_parser import RawFile, Variable +from mcltspice.waveform_query import analyze_signal, extract_waveform + + +def _ac_raw() -> RawFile: + freq = np.logspace(0, 6, 300) + hout = 1.0 / (1.0 + 1j * (freq / 1000.0)) + data = np.array([freq.astype(complex), hout]) + return RawFile( + title="", date="", plotname="AC Analysis", flags=["complex"], + variables=[Variable(0, "frequency", "frequency"), Variable(1, "V(out)", "voltage")], + points=len(freq), data=data, + ) + + +def _tran_raw() -> RawFile: + t = np.linspace(0, 0.01, 800) + v = 2.0 * np.sin(2 * np.pi * 1000 * t) + return RawFile( + title="", date="", plotname="Transient Analysis", flags=["real"], + variables=[Variable(0, "time", "time"), Variable(1, "V(out)", "voltage")], + points=len(t), data=np.array([t, v]), + ) + + +class TestExtractWaveform: + def test_transient_values(self): + r = extract_waveform(_tran_raw(), ["V(out)"], max_points=100) + assert r["x_axis_name"] == "time" + assert r["returned_points"] <= 100 + assert "values" in r["signals"]["V(out)"] + + def test_ac_gives_magnitude_and_phase(self): + r = extract_waveform(_ac_raw(), ["V(out)"]) + assert r["x_axis_name"] == "frequency" + sig = r["signals"]["V(out)"] + assert "magnitude_db" in sig and "phase_degrees" in sig + + def test_x_range_filter(self): + full = extract_waveform(_tran_raw(), ["V(out)"], max_points=10000)["returned_points"] + clipped = extract_waveform( + _tran_raw(), ["V(out)"], max_points=10000, x_min=0.002, x_max=0.004 + )["returned_points"] + assert clipped < full + + def test_empty_range_error(self): + r = extract_waveform(_tran_raw(), ["V(out)"], x_min=1e12) + assert "error" in r and "range" in r["error"] + + def test_run_on_non_stepped_error(self): + r = extract_waveform(_tran_raw(), ["V(out)"], run=2) + assert "error" in r and "stepped" in r["error"] + + +class TestAnalyzeSignal: + def test_rms_and_peak_to_peak(self): + r = analyze_signal(_tran_raw(), "V(out)", ["rms", "peak_to_peak"]) + assert r["signal"] == "V(out)" + assert abs(r["rms"] - 2.0 / np.sqrt(2)) < 0.05 # RMS of a 2 V amplitude sine + assert abs(r["peak_to_peak"]["peak_to_peak"] - 4.0) < 0.05 + + def test_fft_and_thd_present(self): + r = analyze_signal(_tran_raw(), "V(out)", ["fft", "thd"]) + assert "fft" in r and "thd" in r + + def test_missing_signal_error(self): + r = analyze_signal(_tran_raw(), "V(ghost)", ["rms"]) + assert "error" in r and "V(ghost)" in r["error"] + + def test_complex_signal_uses_magnitude(self): + # AC (complex) signal: analysis should run on |signal| without raising + r = analyze_signal(_ac_raw(), "V(out)", ["rms"]) + assert "rms" in r and r["rms"] >= 0