344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""Capture loading: the header contract, the flag columns, and hostile files.
|
|
|
|
capture.py is the only reader of a capture, so every guarantee the format claims
|
|
-- contiguous index, parseable counts, recoverable time base -- has to be checked
|
|
here or nowhere.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import capture
|
|
import rm3100
|
|
|
|
# The writer lives in synthetic.py because test_compare.py needs it too:
|
|
# one definition of what a valid capture looks like, not two that drift.
|
|
from synthetic import CYCLE_COUNT, DT, write_capture
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The happy path
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_load_round_trips_a_clean_capture(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=500))
|
|
assert len(cap.sample_index) == 500
|
|
assert cap.cycle_count == CYCLE_COUNT
|
|
assert not cap.missed.any()
|
|
assert not cap.ambiguous.any()
|
|
|
|
|
|
def test_counts_are_converted_with_the_header_constant(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv"))
|
|
assert cap.lsb_nt == pytest.approx(
|
|
rm3100.tesla_per_count(CYCLE_COUNT) * rm3100.NT_PER_TESLA)
|
|
# x_raw was written around 1000 counts.
|
|
assert cap.x.mean() == pytest.approx(1000 * cap.lsb_nt, rel=0.01)
|
|
|
|
|
|
def test_total_is_the_norm_of_the_three_axes(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv"))
|
|
assert cap.total == pytest.approx(
|
|
np.sqrt(cap.x**2 + cap.y**2 + cap.z**2))
|
|
|
|
|
|
def test_true_period_is_recovered_from_the_host_clock(tmp_path):
|
|
"""The whole point of the fit: the nominal rate is wrong and the file says so."""
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", dt=DT, nominal_hz=282.0))
|
|
# Microsecond-resolution timestamps are the only error left in the slope.
|
|
assert cap.dt_true == pytest.approx(DT, rel=1e-7)
|
|
assert cap.true_rate_hz == pytest.approx(250.0, rel=1e-7)
|
|
|
|
|
|
def test_the_epoch_offset_does_not_degrade_the_fit(tmp_path):
|
|
"""polyfit does not centre the ordinate, so the fit has to do it itself.
|
|
|
|
Fitting raw epoch seconds against a millisecond slope costs six significant
|
|
digits to cancellation; a capture starting at t=0 and the same capture
|
|
starting in 2023 must recover the same period.
|
|
"""
|
|
at_zero = capture.load(write_capture(tmp_path / "zero.csv", rows=300,
|
|
start_time=0.0))
|
|
at_epoch = capture.load(write_capture(tmp_path / "epoch.csv", rows=300,
|
|
start_time=1_700_000_000.0))
|
|
assert at_epoch.dt_true == pytest.approx(at_zero.dt_true, rel=1e-7)
|
|
|
|
|
|
def test_rate_error_is_signed_like_the_rate_not_the_period(tmp_path):
|
|
"""Chip slower than nominal must read negative, matching the reported Hz."""
|
|
slow = capture.load(write_capture(tmp_path / "slow.csv", dt=1 / 250.0,
|
|
nominal_hz=282.0))
|
|
fast = capture.load(write_capture(tmp_path / "fast.csv", dt=1 / 300.0,
|
|
nominal_hz=282.0))
|
|
assert slow.rate_error < 0
|
|
assert fast.rate_error > 0
|
|
assert slow.rate_error == pytest.approx(250.0 / 282.0 - 1, rel=1e-6)
|
|
|
|
|
|
def test_elapsed_bases_differ_by_exactly_the_rate_error(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv"))
|
|
assert cap.elapsed[0] == 0.0
|
|
ratio = cap.elapsed[-1] / cap.elapsed_nominal[-1]
|
|
assert ratio == pytest.approx(cap.dt_true / cap.dt_nominal)
|
|
|
|
|
|
def test_duration_spans_the_capture(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=500, dt=DT))
|
|
assert cap.duration == pytest.approx(499 * DT)
|
|
|
|
|
|
def test_axes_lists_three_axes_plus_the_derived_total(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv"))
|
|
assert [k for k, _ in cap.axes()] == ["x", "y", "z", "total"]
|
|
|
|
|
|
def test_note_reaches_the_summary(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv", header={"note": "3V0 LDO"})
|
|
assert "3V0 LDO" in capture.load(path).summary()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# MISSED and AMBIGUOUS
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_missed_rows_are_flagged_and_interpolated(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv", rows=200,
|
|
flags={50: capture.WARN_MISSED})
|
|
cap = capture.load(path)
|
|
assert cap.missed.sum() == 1
|
|
assert cap.missed[50]
|
|
# The placeholder's zeros must not survive into the data.
|
|
assert cap.x[50] == pytest.approx((cap.x[49] + cap.x[51]) / 2, rel=0.01)
|
|
|
|
|
|
def test_consecutive_missed_rows_interpolate_across_the_whole_gap(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv", rows=200,
|
|
flags={i: capture.WARN_MISSED for i in (60, 61, 62)})
|
|
cap = capture.load(path)
|
|
assert cap.missed.sum() == 3
|
|
assert cap.x[59] < cap.x[62] or cap.x[59] > cap.x[62] # finite, not zero
|
|
assert np.all(np.abs(cap.x[60:63]) > 0)
|
|
|
|
|
|
def test_ambiguous_is_independent_of_missed(tmp_path):
|
|
"""A gap can round to zero losses and still be a coin toss."""
|
|
path = write_capture(tmp_path / "c.csv", rows=200, flags={
|
|
70: capture.WARN_AMBIGUOUS,
|
|
80: f"{capture.WARN_MISSED} {capture.WARN_AMBIGUOUS}",
|
|
})
|
|
cap = capture.load(path)
|
|
assert cap.ambiguous.sum() == 2
|
|
assert cap.missed.sum() == 1
|
|
assert cap.ambiguous[70] and not cap.missed[70] # real row, flagged
|
|
assert cap.ambiguous[80] and cap.missed[80] # placeholder, both
|
|
|
|
|
|
def test_summary_reports_both_flag_kinds(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv", rows=200, flags={
|
|
70: capture.WARN_AMBIGUOUS, 80: capture.WARN_MISSED})
|
|
text = capture.load(path).summary()
|
|
assert "lost measurement" in text
|
|
assert "AMBIGUOUS" in text
|
|
|
|
|
|
def test_flag_order_within_the_column_does_not_matter(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv", rows=200, flags={
|
|
90: f"{capture.WARN_AMBIGUOUS} {capture.WARN_MISSED}"})
|
|
cap = capture.load(path)
|
|
assert cap.missed[90] and cap.ambiguous[90]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Drift detection
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_a_uniform_capture_is_not_drift_limited(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=500))
|
|
assert not cap.drift_limited
|
|
assert cap.residual_sd < cap.dt_true
|
|
|
|
|
|
def test_a_rate_that_changes_mid_run_is_drift_limited(tmp_path):
|
|
"""One slope cannot describe two rates; that has to be visible."""
|
|
path = tmp_path / "drift.csv"
|
|
write_capture(path, rows=400)
|
|
lines = path.read_text().splitlines()
|
|
head = [l for l in lines if l.startswith("#")]
|
|
body = [l for l in lines if not l.startswith("#")]
|
|
out = head + [body[0]]
|
|
t = 1_700_000_000.0
|
|
for i, row in enumerate(body[1:]):
|
|
fields = row.split(",")
|
|
# Second half runs 30% slower -- far more than any real oscillator, but
|
|
# this is testing the detector, not the sensor.
|
|
t += DT if i < 200 else DT * 1.3
|
|
out.append(",".join([fields[0], f"{t:.6f}"] + fields[2:]))
|
|
path.write_text("\n".join(out) + "\n")
|
|
|
|
cap = capture.load(str(path))
|
|
assert cap.drift_limited
|
|
assert "single-rate model does not fit" in cap.summary()
|
|
|
|
|
|
def test_read_jitter_is_reported_apart_from_the_fit_residual(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=500))
|
|
assert cap.read_jitter >= 0.0
|
|
assert cap.residual_sd >= 0.0
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# restrict()
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_restrict_refits_on_the_window(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=1000))
|
|
window = cap.restrict(0.5, 1.5)
|
|
assert len(window.sample_index) < len(cap.sample_index)
|
|
assert window.elapsed[0] >= 0.0
|
|
assert window.dt_true == pytest.approx(cap.dt_true, rel=1e-6)
|
|
|
|
|
|
def test_restrict_accepts_an_open_ended_window(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=1000))
|
|
assert len(cap.restrict(start=0.5).sample_index) < 1000
|
|
assert len(cap.restrict(end=1.0).sample_index) < 1000
|
|
|
|
|
|
def test_restrict_refuses_a_window_too_small_to_analyse(tmp_path):
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=500))
|
|
with pytest.raises(capture.CaptureError, match="too few samples|too few"):
|
|
cap.restrict(0.0, 0.01)
|
|
|
|
|
|
def test_restrict_carries_the_flags_through(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv", rows=1000,
|
|
flags={500: capture.WARN_MISSED})
|
|
cap = capture.load(path)
|
|
window = cap.restrict(400 * DT, 600 * DT)
|
|
assert window.missed.sum() == 1
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Rejected files -- each guarantee the format makes, tested by breaking it
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_a_file_without_the_capture_header_is_rejected(tmp_path):
|
|
path = tmp_path / "old.csv"
|
|
path.write_text("sample_index,system_time_unix,x_raw,y_raw,z_raw,warning\n"
|
|
+ "".join(f"{i},{i * 0.004},1,2,3,\n" for i in range(100)))
|
|
with pytest.raises(capture.CaptureError, match="no capture header"):
|
|
capture.load(str(path))
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["cycle_count", "tesla_per_count",
|
|
"nominal_rate_hz"])
|
|
def test_a_header_missing_a_required_key_is_rejected(tmp_path, key):
|
|
path = write_capture(tmp_path / "c.csv", drop_header_key=key)
|
|
with pytest.raises(capture.CaptureError, match=f"header missing.*{key}"):
|
|
capture.load(path)
|
|
|
|
|
|
def test_too_few_samples_to_analyse_is_rejected(tmp_path):
|
|
path = write_capture(tmp_path / "short.csv", rows=10)
|
|
with pytest.raises(capture.CaptureError, match="too few"):
|
|
capture.load(path)
|
|
|
|
|
|
def test_a_capture_without_the_warning_column_is_rejected(tmp_path):
|
|
path = tmp_path / "nowarn.csv"
|
|
body = "".join(f"{i},{1_700_000_000 + i * 0.004:.6f},1,2,3\n"
|
|
for i in range(100))
|
|
path.write_text(f"# rm3100_capture: 1\n# nominal_rate_hz: 282\n"
|
|
f"# cycle_count: 100\n"
|
|
f"# tesla_per_count: {rm3100.tesla_per_count(100)!r}\n"
|
|
"sample_index,system_time_unix,x_raw,y_raw,z_raw\n" + body)
|
|
with pytest.raises(capture.CaptureError, match="no 'warning' column"):
|
|
capture.load(str(path))
|
|
|
|
|
|
def test_a_non_contiguous_index_is_rejected(tmp_path):
|
|
"""A hole means the grid is broken, so the whole time base is unusable."""
|
|
path = tmp_path / "holed.csv"
|
|
write_capture(path, rows=200)
|
|
lines = path.read_text().splitlines()
|
|
del lines[100] # drop one data row
|
|
path.write_text("\n".join(lines) + "\n")
|
|
with pytest.raises(capture.CaptureError, match="not contiguous"):
|
|
capture.load(str(path))
|
|
|
|
|
|
def test_a_decreasing_index_is_rejected(tmp_path):
|
|
path = write_capture(tmp_path / "back.csv", rows=100, index_step=-1,
|
|
index_from=500)
|
|
with pytest.raises(capture.CaptureError, match="not contiguous"):
|
|
capture.load(path)
|
|
|
|
|
|
def test_an_all_missed_capture_is_rejected(tmp_path):
|
|
path = write_capture(tmp_path / "empty.csv", rows=100,
|
|
flags={i: capture.WARN_MISSED for i in range(100)})
|
|
with pytest.raises(capture.CaptureError, match="every row is a lost"):
|
|
capture.load(path)
|
|
|
|
|
|
def test_a_missing_file_raises_oserror_not_captureerror(tmp_path):
|
|
with pytest.raises(OSError):
|
|
capture.load(str(tmp_path / "nope.csv"))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Malformed and adversarial input
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_a_header_line_without_a_colon_is_ignored(tmp_path):
|
|
path = write_capture(tmp_path / "c.csv",
|
|
extra_lines=["# just a comment with no key"])
|
|
cap = capture.load(path)
|
|
assert "just a comment with no key" not in str(cap.meta)
|
|
|
|
|
|
def test_an_index_starting_away_from_zero_still_loads(tmp_path):
|
|
"""A capture cut from the middle of a run is still a valid grid."""
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=200,
|
|
index_from=1_000_000))
|
|
assert cap.sample_index[0] == 1_000_000
|
|
assert cap.elapsed[0] == pytest.approx(1_000_000 * cap.dt_true)
|
|
|
|
|
|
def test_a_huge_index_does_not_lose_precision(tmp_path):
|
|
"""int64 indices must not silently become floats in the fit."""
|
|
cap = capture.load(write_capture(tmp_path / "c.csv", rows=200,
|
|
index_from=2**40))
|
|
assert cap.sample_index.dtype == np.int64
|
|
assert int(cap.sample_index[-1] - cap.sample_index[0]) == 199
|
|
|
|
|
|
def test_a_non_integer_raw_count_is_rejected(tmp_path):
|
|
path = tmp_path / "bad.csv"
|
|
write_capture(path, rows=100)
|
|
text = path.read_text().replace(",1000,", ",not-a-number,", 1)
|
|
path.write_text(text)
|
|
with pytest.raises(ValueError):
|
|
capture.load(str(path))
|
|
|
|
|
|
def test_a_note_containing_delimiters_survives_the_header(tmp_path):
|
|
"""The header is 'key: value' split on the first colon only."""
|
|
path = write_capture(tmp_path / "c.csv",
|
|
header={"note": "supply: 3.0 V, run #2"})
|
|
assert capture.load(path).meta["note"] == "supply: 3.0 V, run #2"
|
|
|
|
|
|
def test_an_unknown_warning_flag_is_ignored(tmp_path):
|
|
"""The column generalises to future flags, so today's reader must not choke."""
|
|
path = write_capture(tmp_path / "c.csv", rows=200,
|
|
flags={40: "SOMETHING_NEW"})
|
|
cap = capture.load(path)
|
|
assert not cap.missed.any()
|
|
assert not cap.ambiguous.any()
|
|
|
|
|
|
def test_a_flag_substring_does_not_count_as_the_flag(tmp_path):
|
|
"""Flags are whitespace-separated tokens, not substrings."""
|
|
path = write_capture(tmp_path / "c.csv", rows=200, flags={40: "NOTMISSED"})
|
|
assert not capture.load(path).missed.any()
|