650 lines
25 KiB
Python
650 lines
25 KiB
Python
"""Cross-capture analysis: the maths that turns several captures into a claim.
|
|
|
|
Everything here is checked against a planted answer rather than against a
|
|
recorded file, because the point of each function is that it recovers something
|
|
specific -- a scale factor, an oscillator frequency, a tone at a chosen fraction
|
|
of the sample rate -- and only a synthetic capture knows what that was.
|
|
|
|
The argument parsing is tested just as hard. `LABEL=path` has to survive a path
|
|
containing '=', and --band and --supply have to reject nonsense rather than
|
|
quietly produce a figure that means nothing.
|
|
"""
|
|
|
|
import argparse
|
|
import math
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import capture
|
|
import characterize as ch
|
|
import compare
|
|
import rm3100
|
|
from synthetic import field_counts, write_capture
|
|
|
|
# A field well off the axes, so no component sits near zero except where a test
|
|
# puts one there deliberately.
|
|
FIELD = (-40000.0, 16000.0, 12000.0)
|
|
|
|
|
|
def make(path, rows=4096, cycle_count=100, dt=1 / 300.0, mean=FIELD,
|
|
noise_nt=20.0, seed=0, tones=(), **kwargs):
|
|
counts = field_counts(rows, mean, cycle_count, noise_nt, seed, tones)
|
|
return capture.load(write_capture(path, rows=rows, dt=dt,
|
|
cycle_count=cycle_count, counts=counts,
|
|
**kwargs))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# white_sd and band_stats
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_white_sd_recovers_the_sd_of_white_noise():
|
|
v = np.random.default_rng(1).normal(0, 7.0, 200_000)
|
|
assert ch.white_sd(v) == pytest.approx(7.0, rel=0.02)
|
|
|
|
|
|
def test_white_sd_ignores_a_ramp_that_dominates_the_plain_sd():
|
|
"""The whole reason it exists: sd measures drift, this does not."""
|
|
rng = np.random.default_rng(2)
|
|
noise = rng.normal(0, 5.0, 50_000)
|
|
drifting = noise + np.linspace(0, 500, 50_000)
|
|
assert drifting.std() > 100 # sd is all ramp
|
|
assert ch.white_sd(drifting) == pytest.approx(5.0, rel=0.03)
|
|
|
|
|
|
def test_white_sd_of_a_constant_is_zero_and_of_one_sample_is_defined():
|
|
assert ch.white_sd(np.ones(500)) == 0.0
|
|
assert ch.white_sd(np.array([1.0])) == 0.0
|
|
|
|
|
|
def test_band_stats_median_matches_the_white_noise_level():
|
|
fs = 300.0
|
|
v = np.random.default_rng(3).normal(0, 10.0, 100_000)
|
|
# A flat spectrum of sd s over a one-sided band of fs/2 sits at this ASD.
|
|
expected = 10.0 / np.sqrt(fs / 2)
|
|
median, _, _, _ = ch.band_stats(v, fs, (3.0, 100.0))
|
|
assert median == pytest.approx(expected, rel=0.05)
|
|
|
|
|
|
def test_band_stats_finds_a_planted_line_and_reports_its_frequency():
|
|
fs, n = 300.0, 100_000
|
|
t = np.arange(n) / fs
|
|
v = np.random.default_rng(4).normal(0, 1.0, n) + 50 * np.cos(2 * np.pi * 40 * t)
|
|
_, _, peak, peak_hz = ch.band_stats(v, fs, (3.0, 100.0))
|
|
assert peak_hz == pytest.approx(40.0, abs=0.5)
|
|
assert peak > 20
|
|
|
|
|
|
def test_band_stats_returns_nan_when_the_band_holds_no_bins():
|
|
"""A band above Nyquist has nothing in it, and must say so, not guess."""
|
|
v = np.random.default_rng(5).normal(0, 1.0, 10_000)
|
|
median, rms, peak, peak_hz = ch.band_stats(v, 300.0, (200.0, 250.0))
|
|
assert all(math.isnan(x) for x in (median, rms, peak, peak_hz))
|
|
|
|
|
|
def test_band_for_scales_with_the_rate():
|
|
lo, hi = ch.band_for(300.0)
|
|
assert lo == ch.BAND_LO_HZ
|
|
assert hi == pytest.approx(ch.BAND_NYQUIST_FRACTION * 150.0)
|
|
|
|
|
|
def test_the_band_stays_inside_the_decimation_filter_passband():
|
|
"""The constants have to hold this relationship or comparisons are biased.
|
|
|
|
A band reaching past the anti-alias corner scores the decimated path partway
|
|
down a rolloff, which reads as a quieter sensor rather than a narrower
|
|
filter.
|
|
"""
|
|
assert ch.BAND_NYQUIST_FRACTION / 2 < ch.DECIMATE_CUTOFF_FRACTION < 0.5
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# fir_lowpass
|
|
# --------------------------------------------------------------------------
|
|
|
|
def response(h, cycles_per_sample):
|
|
n = np.arange(len(h)) - (len(h) - 1) / 2
|
|
return abs(np.sum(h * np.exp(-2j * np.pi * cycles_per_sample * n)))
|
|
|
|
|
|
def test_fir_lowpass_has_unit_gain_at_dc():
|
|
"""Decimating must not rescale the field."""
|
|
h = ch.fir_lowpass(0.1, 257)
|
|
assert h.sum() == pytest.approx(1.0)
|
|
assert response(h, 0.0) == pytest.approx(1.0)
|
|
|
|
|
|
def test_fir_lowpass_passes_below_the_corner_and_stops_above_it():
|
|
h = ch.fir_lowpass(0.1, 513)
|
|
assert response(h, 0.05) == pytest.approx(1.0, abs=0.01)
|
|
assert response(h, 0.09) == pytest.approx(1.0, abs=0.02)
|
|
# Blackman buys a deep stopband; anything near -74 dB or better will do.
|
|
assert 20 * np.log10(response(h, 0.15)) < -60
|
|
assert 20 * np.log10(response(h, 0.30)) < -60
|
|
|
|
|
|
def test_fir_lowpass_is_linear_phase():
|
|
h = ch.fir_lowpass(0.1, 129)
|
|
assert h == pytest.approx(h[::-1])
|
|
|
|
|
|
def test_fir_lowpass_forces_an_odd_length_so_the_delay_is_a_whole_sample():
|
|
assert len(ch.fir_lowpass(0.1, 128)) == 129
|
|
|
|
|
|
@pytest.mark.parametrize("cutoff", [0.0, -0.1, 0.5, 0.6])
|
|
def test_fir_lowpass_rejects_a_cutoff_outside_the_open_unit_band(cutoff):
|
|
with pytest.raises(ValueError, match="cutoff"):
|
|
ch.fir_lowpass(cutoff, 129)
|
|
|
|
|
|
def test_fir_lowpass_rejects_a_length_too_short_to_filter():
|
|
with pytest.raises(ValueError, match="too short"):
|
|
ch.fir_lowpass(0.1, 1)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# decimate
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_boxcar_decimation_divides_white_noise_sd_by_root_k():
|
|
v = np.random.default_rng(6).normal(0, 12.0, 400_000)
|
|
out = ch.decimate(v, 4, "boxcar")
|
|
assert len(out) == 100_000
|
|
assert out.std() == pytest.approx(12.0 / 2, rel=0.02)
|
|
|
|
|
|
def test_decimation_preserves_the_spectral_density_of_white_noise():
|
|
"""The claim the whole recommendation rests on: same floor, fewer samples."""
|
|
fs = 300.0
|
|
v = np.random.default_rng(7).normal(0, 12.0, 200_000)
|
|
band = (3.0, 20.0)
|
|
before, _, _, _ = ch.band_stats(v, fs, band)
|
|
for method in ("boxcar", "fir"):
|
|
after, _, _, _ = ch.band_stats(ch.decimate(v, 4, method), fs / 4, band)
|
|
assert after == pytest.approx(before, rel=0.05), method
|
|
|
|
|
|
def test_decimate_by_one_is_a_no_op():
|
|
v = np.random.default_rng(8).normal(0, 1.0, 100)
|
|
assert ch.decimate(v, 1) == pytest.approx(v)
|
|
|
|
|
|
def test_the_fir_removes_an_out_of_band_tone_that_the_boxcar_folds_in():
|
|
"""The difference that matters: a boxcar has a poor stopband, so it aliases.
|
|
|
|
A tone at 0.3 cycles/sample is above the decimated Nyquist of 0.125 and
|
|
folds to 1/5 of the new rate. The chip's own integration is a boxcar, which
|
|
is exactly why sampling slowly cannot reject what sampling fast and
|
|
filtering can.
|
|
"""
|
|
n = 40_000
|
|
tone = 300.0 * np.cos(2 * np.pi * 0.3 * np.arange(n))
|
|
v = np.random.default_rng(9).normal(0, 20.0, n) + tone
|
|
|
|
folded = ch.sample_locked_lines(ch.decimate(v, 4, "boxcar"))
|
|
assert any(l.numerator == 1 and l.period == 5 for l in folded)
|
|
|
|
filtered = ch.sample_locked_lines(ch.decimate(v, 4, "fir"))
|
|
assert not any(l.numerator == 1 and l.period == 5 for l in filtered)
|
|
|
|
|
|
@pytest.mark.parametrize("k", [0, -2])
|
|
def test_decimate_rejects_a_factor_below_one(k):
|
|
with pytest.raises(ValueError, match="at least 1"):
|
|
ch.decimate(np.zeros(100), k)
|
|
|
|
|
|
def test_decimate_rejects_an_unknown_method():
|
|
with pytest.raises(ValueError, match="unknown decimation method"):
|
|
ch.decimate(np.zeros(100), 2, "bilinear")
|
|
|
|
|
|
def test_decimate_refuses_rather_than_returning_filter_transient():
|
|
"""Too few samples for the filter is a failure, not a short answer."""
|
|
with pytest.raises(ValueError, match="too few"):
|
|
ch.decimate(np.zeros(200), 4, "fir")
|
|
|
|
|
|
def test_boxcar_refuses_when_there_is_not_even_one_full_group():
|
|
with pytest.raises(ValueError, match="cannot be decimated"):
|
|
ch.decimate(np.zeros(3), 4, "boxcar")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# sample_locked_lines
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_sample_locked_lines_recovers_a_planted_tone_and_its_amplitude():
|
|
n = 60_000
|
|
v = (np.random.default_rng(10).normal(0, 20.0, n)
|
|
+ 3.0 * np.cos(2 * np.pi * 0.25 * np.arange(n)))
|
|
found = ch.sample_locked_lines(v)
|
|
quarter = [l for l in found if (l.numerator, l.period) == (1, 4)]
|
|
assert quarter, "a 3 nT tone under 20 nT of noise should still be found"
|
|
assert quarter[0].amplitude == pytest.approx(3.0, rel=0.15)
|
|
assert quarter[0].sigma > 10
|
|
|
|
|
|
def test_sample_locked_lines_recovers_a_tone_at_nyquist():
|
|
n = 60_000
|
|
v = (np.random.default_rng(11).normal(0, 20.0, n)
|
|
+ 2.0 * (-1.0) ** np.arange(n))
|
|
half = [l for l in ch.sample_locked_lines(v)
|
|
if (l.numerator, l.period) == (1, 2)]
|
|
assert half and half[0].amplitude == pytest.approx(2.0, rel=0.15)
|
|
|
|
|
|
@pytest.mark.parametrize("seed", range(6))
|
|
def test_sample_locked_lines_stays_silent_on_white_noise(seed):
|
|
v = np.random.default_rng(100 + seed).normal(0, 20.0, 60_000)
|
|
assert ch.sample_locked_lines(v) == []
|
|
|
|
|
|
def test_sample_locked_lines_is_not_fooled_by_drift():
|
|
n = 60_000
|
|
t = np.linspace(0, 1, n)
|
|
v = np.random.default_rng(12).normal(0, 5.0, n) + 4000 * t ** 3 - 900 * t
|
|
assert ch.sample_locked_lines(v) == []
|
|
|
|
|
|
def test_sample_locked_lines_reports_each_frequency_once():
|
|
"""A period-4 tone is also period-8 and period-12; those add nothing."""
|
|
n = 60_000
|
|
v = (np.random.default_rng(13).normal(0, 10.0, n)
|
|
+ 5.0 * np.cos(2 * np.pi * 0.25 * np.arange(n)))
|
|
fractions = [(l.numerator, l.period) for l in ch.sample_locked_lines(v)]
|
|
assert (2, 8) not in fractions and (3, 12) not in fractions
|
|
assert len(fractions) == len(set(fractions))
|
|
# Every reported fraction is in lowest terms.
|
|
assert all(math.gcd(j, p) == 1 for j, p in fractions)
|
|
|
|
|
|
def test_sample_locked_lines_returns_strongest_first():
|
|
n = 60_000
|
|
index = np.arange(n)
|
|
v = (np.random.default_rng(14).normal(0, 10.0, n)
|
|
+ 6.0 * np.cos(2 * np.pi * 0.25 * index)
|
|
+ 2.0 * np.cos(2 * np.pi * index / 3))
|
|
found = ch.sample_locked_lines(v)
|
|
assert [l.sigma for l in found] == sorted((l.sigma for l in found),
|
|
reverse=True)
|
|
|
|
|
|
def test_sample_locked_lines_abstains_on_a_series_too_short_to_fold():
|
|
assert ch.sample_locked_lines(np.arange(10.0)) == []
|
|
|
|
|
|
def test_sample_locked_lines_abstains_on_a_constant():
|
|
assert ch.sample_locked_lines(np.ones(1000)) == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# trimmed
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_trimmed_drops_the_requested_seconds_from_both_ends(tmp_path):
|
|
cap = make(tmp_path / "c.csv", rows=3000, dt=1 / 100.0) # 30 s
|
|
kept, note = ch.trimmed(cap, 5.0)
|
|
assert kept.duration == pytest.approx(cap.duration - 10.0, abs=0.05)
|
|
assert "trimmed 5 s" in note
|
|
|
|
|
|
def test_trimmed_is_a_no_op_when_no_seconds_are_asked_for(tmp_path):
|
|
cap = make(tmp_path / "c.csv", rows=3000, dt=1 / 100.0)
|
|
kept, note = ch.trimmed(cap, 0.0)
|
|
assert kept is cap and note == ""
|
|
|
|
|
|
def test_trimmed_refuses_rather_than_gutting_a_short_capture(tmp_path):
|
|
"""A deliberately short capture is legitimate; silently emptying it is not."""
|
|
cap = make(tmp_path / "c.csv", rows=1000, dt=1 / 100.0) # 10 s
|
|
kept, note = ch.trimmed(cap, 30.0)
|
|
assert kept is cap
|
|
assert "not trimming" in note
|
|
|
|
|
|
def test_trimmed_rejects_a_negative_window(tmp_path):
|
|
cap = make(tmp_path / "c.csv", rows=3000, dt=1 / 100.0)
|
|
with pytest.raises(capture.CaptureError, match="negative"):
|
|
ch.trimmed(cap, -5.0)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# fit_rate_model
|
|
# --------------------------------------------------------------------------
|
|
|
|
def planted_period(cycle_count, count_rate, overhead):
|
|
return rm3100.AXES * (cycle_count / count_rate + overhead)
|
|
|
|
|
|
def test_fit_rate_model_is_exact_from_two_cycle_counts(tmp_path):
|
|
count_rate, overhead = 88_000.0, 40e-6
|
|
caps = [make(tmp_path / f"{cc}.csv", rows=3000, cycle_count=cc,
|
|
dt=planted_period(cc, count_rate, overhead))
|
|
for cc in (100, 400)]
|
|
fitted_rate, fitted_overhead = compare.fit_rate_model(caps)
|
|
assert fitted_rate == pytest.approx(count_rate, rel=1e-4)
|
|
assert fitted_overhead == pytest.approx(overhead, rel=1e-3)
|
|
|
|
|
|
def test_fit_rate_model_least_squares_over_three_cycle_counts(tmp_path):
|
|
count_rate, overhead = 92_000.0, 38e-6
|
|
caps = [make(tmp_path / f"{cc}.csv", rows=3000, cycle_count=cc,
|
|
dt=planted_period(cc, count_rate, overhead))
|
|
for cc in (100, 200, 400)]
|
|
fitted_rate, fitted_overhead = compare.fit_rate_model(caps)
|
|
assert fitted_rate == pytest.approx(count_rate, rel=1e-3)
|
|
assert fitted_overhead == pytest.approx(overhead, rel=1e-2)
|
|
|
|
|
|
def test_fit_rate_model_refuses_one_cycle_count(tmp_path):
|
|
"""Two unknowns need two points; guessing one would look like a result."""
|
|
caps = [make(tmp_path / f"{i}.csv", rows=1000, cycle_count=100,
|
|
dt=1 / 300.0) for i in range(3)]
|
|
with pytest.raises(ValueError, match="two distinct cycle counts"):
|
|
compare.fit_rate_model(caps)
|
|
|
|
|
|
def test_fit_rate_model_rejects_captures_that_do_not_follow_the_model(tmp_path):
|
|
"""A higher cycle count that samples faster is not this chip."""
|
|
caps = [make(tmp_path / "a.csv", rows=1000, cycle_count=100, dt=1 / 100.0),
|
|
make(tmp_path / "b.csv", rows=1000, cycle_count=400, dt=1 / 300.0)]
|
|
with pytest.raises(ValueError, match="not positive"):
|
|
compare.fit_rate_model(caps)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# gain_and_movement, axis_ratio_spread
|
|
# --------------------------------------------------------------------------
|
|
|
|
def vector(mean):
|
|
mean = np.asarray(mean, dtype=float)
|
|
return {"mean": mean, "field": float(np.linalg.norm(mean))}
|
|
|
|
|
|
def test_gain_and_movement_reads_a_pure_scale_change_exactly():
|
|
a = vector([-40000, 16000, 12000])
|
|
b = vector(np.array(a["mean"]) * 1.07)
|
|
scale, residual, angle = compare.gain_and_movement(a, b)
|
|
assert scale == pytest.approx(1.07)
|
|
assert residual == pytest.approx(0.0, abs=1e-12)
|
|
assert angle == pytest.approx(0.0, abs=1e-6)
|
|
|
|
|
|
def test_gain_and_movement_reads_a_pure_rotation_as_movement():
|
|
a = vector([40000, 0, 0])
|
|
b = vector([40000 * math.cos(math.radians(20)),
|
|
40000 * math.sin(math.radians(20)), 0])
|
|
scale, residual, angle = compare.gain_and_movement(a, b)
|
|
assert angle == pytest.approx(20.0, abs=1e-6)
|
|
assert residual == pytest.approx(math.sin(math.radians(20)), rel=1e-6)
|
|
assert residual > compare.RATIO_SPREAD_OK
|
|
|
|
|
|
def test_gain_and_movement_survives_an_axis_crossing_zero():
|
|
"""The case that defeats a per-axis ratio: Z changes sign between runs."""
|
|
a = vector([-40000, 16000, -6000])
|
|
b = vector([-39000, 16400, +8000])
|
|
scale, residual, angle = compare.gain_and_movement(a, b)
|
|
assert math.isfinite(scale) and math.isfinite(residual)
|
|
assert 0.0 < residual < 1.0
|
|
assert angle > 1.0
|
|
|
|
|
|
def test_axis_ratio_spread_is_zero_for_a_pure_scale():
|
|
a = vector([-40000, 16000, 12000])
|
|
b = vector(np.array(a["mean"]) * 1.07)
|
|
spread = compare.axis_ratio_spread(a, b, b["mean"] / a["mean"])
|
|
assert spread == pytest.approx(0.0, abs=1e-9)
|
|
|
|
|
|
def test_axis_ratio_spread_abstains_when_an_axis_carries_no_field():
|
|
a = vector([40000, 100, 5])
|
|
b = vector([41000, -90, 4])
|
|
assert compare.axis_ratio_spread(a, b, b["mean"] / a["mean"]) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# labels, conditions, supplies, bands
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_split_label_reads_an_explicit_label(tmp_path):
|
|
path = tmp_path / "c.csv"
|
|
path.write_text("x")
|
|
assert compare.split_label(f"LDO/cc100={path}") == ("LDO/cc100", str(path))
|
|
|
|
|
|
def test_split_label_leaves_an_unlabelled_path_alone():
|
|
assert compare.split_label("a.csv") == (None, "a.csv")
|
|
|
|
|
|
def test_split_label_prefers_an_existing_path_containing_an_equals(tmp_path):
|
|
"""A filename may contain '='; an existing file wins over a label reading."""
|
|
path = tmp_path / "run=2.csv"
|
|
path.write_text("x")
|
|
assert compare.split_label(str(path)) == (None, str(path))
|
|
|
|
|
|
def test_split_label_rejects_an_empty_label():
|
|
with pytest.raises(ValueError, match="empty label"):
|
|
compare.split_label("=a.csv")
|
|
|
|
|
|
def test_split_label_keeps_the_labelled_reading_when_neither_exists():
|
|
"""So the error names the path the user meant, not the whole argument."""
|
|
assert compare.split_label("LDO=missing.csv") == ("LDO", "missing.csv")
|
|
|
|
|
|
def test_condition_and_variant_split_on_the_first_separator():
|
|
assert compare.condition_of("LDO/cc100") == "LDO"
|
|
assert compare.variant_of("LDO/cc100") == "cc100"
|
|
assert compare.condition_of("LDO/a/b") == "LDO"
|
|
assert compare.variant_of("LDO/a/b") == "a/b"
|
|
|
|
|
|
def test_a_label_without_a_separator_is_all_condition():
|
|
assert compare.condition_of("plain.csv") == "plain.csv"
|
|
assert compare.variant_of("plain.csv") == ""
|
|
|
|
|
|
def test_conditions_come_back_in_command_line_order():
|
|
records = [("Zeta/cc100", "", {}), ("Alpha/cc100", "", {}),
|
|
("Zeta/cc400", "", {})]
|
|
assert compare.conditions_in_order(records) == ["Zeta", "Alpha"]
|
|
|
|
|
|
def test_parse_supply_reads_a_condition_and_volts():
|
|
assert compare.parse_supply("LDO=3.0") == ("LDO", 3.0)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["LDO", "=3.0", "LDO=", "LDO=abc",
|
|
"LDO=0", "LDO=-3", "LDO=1e9"])
|
|
def test_parse_supply_rejects_nonsense(bad):
|
|
with pytest.raises(argparse.ArgumentTypeError):
|
|
compare.parse_supply(bad)
|
|
|
|
|
|
def test_parse_band_reads_a_pair():
|
|
assert compare.parse_band("3,30") == (3.0, 30.0)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["3", "3,30,300", "30,3", "-1,30", "0,30",
|
|
"3,3", "a,b"])
|
|
def test_parse_band_rejects_nonsense(bad):
|
|
with pytest.raises(argparse.ArgumentTypeError):
|
|
compare.parse_band(bad)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# decimation pairing and band selection
|
|
# --------------------------------------------------------------------------
|
|
|
|
def entry(label, fs, cycle_count):
|
|
return (label, {"fs": fs, "cycle_count": cycle_count})
|
|
|
|
|
|
def test_decimation_pairs_finds_an_integer_cycle_count_ratio():
|
|
pairs = compare.decimation_pairs([entry("LDO/cc100", 300.0, 100),
|
|
entry("LDO/cc400", 75.0, 400)])
|
|
assert len(pairs) == 1
|
|
condition, (fast_label, _), (slow_label, _), k = pairs[0]
|
|
assert (condition, fast_label, slow_label, k) == ("LDO", "LDO/cc100",
|
|
"LDO/cc400", 4)
|
|
|
|
|
|
def test_decimation_pairs_ignores_a_non_integer_ratio():
|
|
assert compare.decimation_pairs([entry("LDO/a", 300.0, 100),
|
|
entry("LDO/b", 75.0, 405)]) == []
|
|
|
|
|
|
def test_decimation_pairs_does_not_cross_conditions():
|
|
"""Comparing a decimated LDO run to a native 3V3 one confounds the two."""
|
|
assert compare.decimation_pairs([entry("LDO/cc100", 300.0, 100),
|
|
entry("3V3/cc400", 75.0, 400)]) == []
|
|
|
|
|
|
def test_decimation_pairs_ignores_an_equal_cycle_count():
|
|
assert compare.decimation_pairs([entry("LDO/a", 300.0, 100),
|
|
entry("LDO/b", 299.0, 100)]) == []
|
|
|
|
|
|
def test_the_band_is_set_by_the_decimated_rate_not_the_slowest_capture(tmp_path):
|
|
"""Decimating by 4 lands below the natively-slow rate, and that binds."""
|
|
fast = make(tmp_path / "fast.csv", rows=2000, cycle_count=100, dt=1 / 300.0)
|
|
slow = make(tmp_path / "slow.csv", rows=2000, cycle_count=400, dt=1 / 76.0)
|
|
rates = compare.comparable_rates([("LDO/cc100", fast), ("LDO/cc400", slow)])
|
|
assert min(rates) == pytest.approx(75.0, rel=1e-3) # 300/4, not 76
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# End to end
|
|
# --------------------------------------------------------------------------
|
|
|
|
def four_captures(tmp_path, gain=1.0):
|
|
"""An interleaved two-condition, two-cycle-count session, as recorded."""
|
|
paths = {}
|
|
for condition, scale in (("LDO", 1.0), ("3V3", gain)):
|
|
for cc, dt in ((100, 1 / 300.0), (400, 1 / 76.0)):
|
|
mean = tuple(component * scale for component in FIELD)
|
|
path = tmp_path / f"{condition}_{cc}.csv"
|
|
counts = field_counts(4096, mean, cc, 20.0, seed=cc)
|
|
write_capture(path, rows=4096, dt=dt, cycle_count=cc, counts=counts)
|
|
paths[f"{condition}/cc{cc}"] = str(path)
|
|
return paths
|
|
|
|
|
|
def run(monkeypatch, capsys, argv):
|
|
monkeypatch.setattr("sys.argv", ["compare.py"] + argv)
|
|
assert compare.main() == 0
|
|
return capsys.readouterr()
|
|
|
|
|
|
def test_end_to_end_reports_every_section(tmp_path, monkeypatch, capsys):
|
|
paths = four_captures(tmp_path, gain=0.93)
|
|
out = run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]
|
|
+ ["--supply", "LDO=3.0", "--supply", "3V3=3.3"]).out
|
|
assert "timing model" in out
|
|
assert "filtering and decimating" in out
|
|
assert "matched cycle count" in out
|
|
assert "how far the sensor moved" in out
|
|
# A planted 7% scale change on |B| should come back as one.
|
|
assert "-7.0" in out or "-6.9" in out or "-7.1" in out
|
|
|
|
|
|
def test_the_supply_exponent_recovers_a_planted_power_law(tmp_path, monkeypatch,
|
|
capsys):
|
|
"""A gain that is exactly ratiometric must come back as V^-1."""
|
|
volts = {"LDO": 3.0, "3V3": 3.3}
|
|
argv = []
|
|
for condition, v in volts.items():
|
|
for cc, dt in ((100, 1 / 300.0), (400, 1 / 76.0)):
|
|
# Gain proportional to 1/V means the reading scales as 1/V too.
|
|
mean = tuple(c * volts["LDO"] / v for c in FIELD)
|
|
path = tmp_path / f"{condition}_{cc}.csv"
|
|
write_capture(path, rows=4096, dt=dt, cycle_count=cc,
|
|
counts=field_counts(4096, mean, cc, 5.0, seed=cc))
|
|
argv.append(f"{condition}/cc{cc}={path}")
|
|
argv += ["--supply", "LDO=3.0", "--supply", "3V3=3.3"]
|
|
out = run(monkeypatch, capsys, argv).out
|
|
assert "scales with the rail" in out
|
|
for line in out.splitlines():
|
|
if "|B| ~ V^" in line:
|
|
power = float(line.split("|B| ~ V^")[1].split()[0])
|
|
assert power == pytest.approx(-1.0, abs=0.02)
|
|
|
|
|
|
def test_the_supply_exponent_is_absent_without_rail_voltages(tmp_path,
|
|
monkeypatch,
|
|
capsys):
|
|
paths = four_captures(tmp_path, gain=0.93)
|
|
out = run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]).out
|
|
assert "scales with the rail" not in out
|
|
assert "x rail volts" not in out
|
|
|
|
|
|
def test_end_to_end_writes_a_figure(tmp_path, monkeypatch, capsys):
|
|
paths = four_captures(tmp_path)
|
|
output = tmp_path / "figure.png"
|
|
run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]
|
|
+ ["-o", str(output)])
|
|
assert output.exists() and output.stat().st_size > 10_000
|
|
|
|
|
|
def test_a_capture_that_will_not_load_is_skipped_not_fatal(tmp_path, monkeypatch,
|
|
capsys):
|
|
paths = four_captures(tmp_path)
|
|
broken = tmp_path / "broken.csv"
|
|
broken.write_text("this is not a capture\n")
|
|
out = run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]
|
|
+ [f"BAD/x={broken}"])
|
|
assert "skipping" in out.err
|
|
assert "timing model" in out.out
|
|
|
|
|
|
def test_no_loadable_capture_exits_rather_than_printing_nothing(tmp_path,
|
|
monkeypatch):
|
|
broken = tmp_path / "broken.csv"
|
|
broken.write_text("nope\n")
|
|
monkeypatch.setattr("sys.argv", ["compare.py", str(broken)])
|
|
with pytest.raises(SystemExit, match="nothing to compare"):
|
|
compare.main()
|
|
|
|
|
|
def test_a_supply_naming_no_capture_warns(tmp_path, monkeypatch, capsys):
|
|
paths = four_captures(tmp_path)
|
|
out = run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]
|
|
+ ["--supply", "NOSUCH=3.0"])
|
|
assert "names no capture" in out.err
|
|
|
|
|
|
def test_a_band_past_the_slowest_rolloff_warns(tmp_path, monkeypatch, capsys):
|
|
paths = four_captures(tmp_path)
|
|
out = run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]
|
|
+ ["--band", "3,60"])
|
|
assert "rolls off" in out.err
|
|
|
|
|
|
def test_trim_is_reported_and_shortens_every_capture(tmp_path, monkeypatch,
|
|
capsys):
|
|
paths = four_captures(tmp_path)
|
|
out = run(monkeypatch, capsys,
|
|
[f"{label}={path}" for label, path in paths.items()]
|
|
+ ["--trim", "1"]).out
|
|
assert out.count("trimmed 1 s from each end") == 4
|
|
|
|
|
|
def test_a_single_capture_still_produces_a_table(tmp_path, monkeypatch, capsys):
|
|
paths = four_captures(tmp_path)
|
|
out = run(monkeypatch, capsys, [next(iter(paths.values()))]).out
|
|
assert "capture" in out
|
|
# Nothing to contrast against, so those sections stay quiet.
|
|
assert "matched cycle count" not in out
|
|
assert "how far the sensor moved" not in out
|