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