From 181d1cb925cf8aead6b986e3f6349233490cb6ba Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 20 Jun 2026 01:43:44 -0600 Subject: [PATCH] Test tuning.py pure logic without a simulator 15 tests over build_effective_params, extract_metrics (AC + transient via hand-built RawFiles), evaluate_targets (all comparison operators + unmeasurable), and make_suggestions. This logic previously required a full LTspice run to exercise. --- tests/test_tuning.py | 122 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/test_tuning.py diff --git a/tests/test_tuning.py b/tests/test_tuning.py new file mode 100644 index 0000000..8480ce0 --- /dev/null +++ b/tests/test_tuning.py @@ -0,0 +1,122 @@ +"""Tests for the pure tuning logic extracted from tune_circuit. + +These run without LTspice -- they feed hand-built RawFiles and metric dicts +directly into the pure functions. +""" + +import numpy as np +import pytest + +from mcltspice.raw_parser import RawFile, Variable +from mcltspice.tuning import ( + UnknownParamError, + build_effective_params, + evaluate_targets, + extract_metrics, + make_suggestions, +) + + +def _ac_rawfile(corner_hz: float = 1000.0) -> RawFile: + """A single-pole RC lowpass AC response, frequency + complex V(out).""" + freq = np.logspace(0, 6, 601) + h = 1.0 / (1.0 + 1j * (freq / corner_hz)) + data = np.array([freq.astype(complex), h]) + return RawFile( + title="ac", date="", plotname="AC Analysis", flags=["complex"], + variables=[Variable(0, "frequency", "frequency"), Variable(1, "V(out)", "voltage")], + points=len(freq), data=data, + ) + + +def _tran_rawfile() -> RawFile: + """A transient run: time + a 1 kHz sine on V(out).""" + t = np.linspace(0, 0.01, 1000) + v = 2.0 * np.sin(2 * np.pi * 1000 * t) + data = np.array([t, v]) + return RawFile( + title="tran", date="", plotname="Transient Analysis", flags=["real"], + variables=[Variable(0, "time", "time"), Variable(1, "V(out)", "voltage")], + points=len(t), data=data, + ) + + +class TestBuildEffectiveParams: + def test_merges_overrides(self): + result = build_effective_params({"r": "1k", "c": "100n"}, {"r": "2k"}) + assert result == {"r": "2k", "c": "100n"} + + def test_none_overrides_returns_defaults_copy(self): + defaults = {"r": "1k"} + result = build_effective_params(defaults, None) + assert result == {"r": "1k"} + assert result is not defaults # must be a copy + + def test_unknown_key_raises(self): + with pytest.raises(UnknownParamError) as exc: + build_effective_params({"r": "1k"}, {"bogus": "5"}) + assert exc.value.key == "bogus" + assert exc.value.valid_params == ["r"] + + +class TestExtractMetrics: + def test_missing_signal_returns_none(self): + assert extract_metrics(_ac_rawfile(), "V(ghost)") is None + + def test_ac_metrics(self): + metrics, is_ac = extract_metrics(_ac_rawfile(corner_hz=1000.0), "V(out)") + assert is_ac is True + assert "bandwidth_hz" in metrics + assert metrics["bandwidth_hz"] == pytest.approx(1000.0, rel=0.05) + assert metrics["gain_db"] == pytest.approx(0.0, abs=0.1) # 0 dB DC + + def test_transient_metrics(self): + metrics, is_ac = extract_metrics(_tran_rawfile(), "V(out)") + assert is_ac is False + assert metrics["rms"] == pytest.approx(2.0 / np.sqrt(2), rel=0.02) + assert metrics["peak_to_peak"] == pytest.approx(4.0, rel=0.02) + + +class TestEvaluateTargets: + def test_no_targets(self): + assert evaluate_targets({"bandwidth_hz": 1000.0}, None) == (True, {}) + + def test_greater_than_met_and_unmet(self): + met, results = evaluate_targets({"bw": 1000.0}, {"bw": ">500"}) + assert met is True and results["bw"]["met"] is True + met2, results2 = evaluate_targets({"bw": 1000.0}, {"bw": ">5000"}) + assert met2 is False and results2["bw"]["met"] is False + + def test_less_than(self): + met, _ = evaluate_targets({"ripple": 0.05}, {"ripple": "<0.1"}) + assert met is True + + def test_approx_within_ten_percent(self): + assert evaluate_targets({"f": 1050.0}, {"f": "~1000"})[0] is True # 5% off -> met + assert evaluate_targets({"f": 1200.0}, {"f": "~1000"})[0] is False # 20% off -> unmet + + def test_bare_number_is_approx(self): + assert evaluate_targets({"f": 1000.0}, {"f": "1000"})[0] is True + + def test_unmeasurable_metric(self): + met, results = evaluate_targets({"rms": 1.0}, {"bandwidth_hz": ">500"}) + assert met is False + assert results["bandwidth_hz"]["status"] == "unmeasurable" + + +class TestMakeSuggestions: + def test_met_yields_no_suggestions(self): + _, results = evaluate_targets({"bw": 1000.0}, {"bw": ">500"}) + assert make_suggestions(results, True) == [] + + def test_bandwidth_increase_hint(self): + _, results = evaluate_targets({"bandwidth_hz": 100.0}, {"bandwidth_hz": ">5000"}) + out = make_suggestions(results, False) + assert any("increase bandwidth" in s for s in out) + + def test_unmeasurable_falls_back_to_generic(self): + _, results = evaluate_targets({"rms": 1.0}, {"bandwidth_hz": ">500"}) + out = make_suggestions(results, False) + assert out == [ + "Adjust component values toward the target. Use smaller steps for fine-tuning." + ]