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.
This commit is contained in:
parent
dd6db95021
commit
181d1cb925
122
tests/test_tuning.py
Normal file
122
tests/test_tuning.py
Normal file
@ -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."
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user