rm3100/tests/test_logger.py

423 lines
16 KiB
Python
Raw Normal View History

"""The capture loop: miss counting, flagging, and the configuration warnings.
sample_loop() is the subtlest code in the repo -- it decides how many
measurements passed unseen, and getting it wrong slips sample_index against chip
time in a way nothing downstream can detect. It is driven here by a scripted
sensor against a fake clock, so a stall of an exact size can be injected and the
resulting rows checked exactly.
"""
import queue
import pytest
import capture
import logger
import rm3100
from conftest import FakeSensor, grid_events
PERIOD = 0.01 # 100 Hz, chosen so stalls are easy to state in periods
def drain(q):
"""Every row the loop queued, in order."""
rows = []
while not q.empty():
rows.append(q.get_nowait())
return rows
def run_loop(sensor, fake_clock, duration, dt=PERIOD, maxsize=0):
"""Run sample_loop against a fake sensor. Returns (rows, stats)."""
fake_clock(sensor)
q = queue.Queue(maxsize=maxsize)
stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
"truncated": False}
logger.sample_loop(sensor, q, duration, dt, stats)
return drain(q), stats
def indices(rows):
return [r[0] for r in rows]
def flags(rows):
return [r[5] for r in rows]
# --------------------------------------------------------------------------
# The clean case
# --------------------------------------------------------------------------
def test_a_clean_run_loses_nothing(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 60))
rows, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["missed"] == 0
assert stats["ambiguous"] == 0
assert all(f == "" for f in flags(rows))
assert len(rows) > 40
def test_the_sample_index_is_contiguous_from_zero(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 60))
rows, _ = run_loop(sensor, fake_clock, duration=0.5)
assert indices(rows) == list(range(len(rows)))
def test_every_measurement_is_read_exactly_once(fake_clock):
"""No duplicates: DRC1 clears DRDY on the results read, so a re-read cannot
return the same measurement -- and the loop must not ask for one."""
sensor = FakeSensor(grid_events(PERIOD, 60))
rows, _ = run_loop(sensor, fake_clock, duration=0.5)
x_values = [r[2] for r in rows]
assert len(set(x_values)) == len(x_values)
assert x_values == sorted(x_values) # grid_events counts upward
def test_the_timestamp_is_the_bracket_midpoint(fake_clock):
"""Not the read time: the measurement completed somewhere in the bracket."""
sensor = FakeSensor(grid_events(PERIOD, 40))
rows, _ = run_loop(sensor, fake_clock, duration=0.3)
times = [r[1] for r in rows]
gaps = [b - a for a, b in zip(times, times[1:])]
# Resolved only to the poll interval, which is what a real bracket gives too.
assert all(g == pytest.approx(PERIOD, abs=1e-3) for g in gaps)
def test_the_worst_bracket_is_recorded(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 40))
_, stats = run_loop(sensor, fake_clock, duration=0.3)
# No stall, so the bracket is one poll interval and nowhere near a period.
assert 0 < stats["max_bracket"] < PERIOD / 2
def test_a_stale_drdy_at_start_is_discarded(fake_clock):
"""BIST leaves DRDY high over zeroed registers; sample 0 must be fresh."""
sensor = FakeSensor(grid_events(PERIOD, 40, start=0.0, counts=(0, 0, 0)))
sensor.pending = (0, 0, 0) # as if left set by whatever ran before
rows, _ = run_loop(sensor, fake_clock, duration=0.2)
assert rows
assert rows[0][2:5] != (0, 0, 0) or sensor.reads[0] == (0, 0, 0)
# The discard happened: one more read than rows written.
assert len(sensor.reads) == len(rows) + 1
def test_duration_zero_keeps_running(fake_clock):
"""0 means 'until Ctrl-C', so the deadline must be infinite, not immediate.
Nothing inside the loop ends it, so the sensor is what stops the test --
which is also the proof that the loop was still going.
"""
sensor = FakeSensor(grid_events(PERIOD, 200), max_polls=2000)
fake_clock(sensor)
q = queue.Queue()
stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
"truncated": False}
with pytest.raises(FakeSensor.Exhausted):
logger.sample_loop(sensor, q, 0.0, PERIOD, stats)
# It kept sampling until the sensor gave out, rather than returning at once.
assert q.qsize() >= 50
# --------------------------------------------------------------------------
# Losing measurements
# --------------------------------------------------------------------------
def test_a_two_period_stall_records_one_placeholder(fake_clock):
"""Two completions inside one bracket: one was read, one was lost."""
sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 2.1 * PERIOD)])
rows, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["missed"] == 1
assert stats["ambiguous"] == 0
placeholders = [r for r in rows if logger.WARN_MISSED in r[5]]
assert len(placeholders) == 1
# A placeholder carries no data and keeps the index contiguous.
assert placeholders[0][2:5] == (None, None, None)
assert indices(rows) == list(range(len(rows)))
def test_a_longer_stall_records_every_lost_grid_point(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 80), stalls=[(0.2, 4.1 * PERIOD)])
rows, stats = run_loop(sensor, fake_clock, duration=0.7)
assert stats["missed"] == 3
assert sum(logger.WARN_MISSED in r[5] for r in rows) == 3
assert indices(rows) == list(range(len(rows)))
def test_placeholders_sit_on_the_grid_not_bunched_at_the_end(fake_clock):
"""They mark where the lost measurements actually completed."""
sensor = FakeSensor(grid_events(PERIOD, 80), stalls=[(0.2, 4.1 * PERIOD)])
rows, _ = run_loop(sensor, fake_clock, duration=0.7)
lost = [r for r in rows if logger.WARN_MISSED in r[5]]
gaps = [b[1] - a[1] for a, b in zip(lost, lost[1:])]
assert all(g == pytest.approx(PERIOD, rel=0.05) for g in gaps)
def test_a_late_read_within_one_period_is_not_a_miss(fake_clock):
"""The case that motivated the bracket: a host stall that lost nothing.
An interval-based estimator would insert a spurious placeholder here and
slip the index for the rest of the run.
"""
sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 1.1 * PERIOD)])
rows, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["missed"] == 0
assert all(logger.WARN_MISSED not in f for f in flags(rows))
def test_the_stall_shows_up_in_the_worst_bracket(fake_clock):
"""Losing nothing is not the same as having had margin."""
sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 1.1 * PERIOD)])
_, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["max_bracket"] > 0.7 * PERIOD
# --------------------------------------------------------------------------
# Ambiguity
# --------------------------------------------------------------------------
def test_a_gap_near_a_half_period_is_flagged_ambiguous(fake_clock):
"""1.35 periods rounds to one, so no placeholder -- but it is a guess."""
sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 1.35 * PERIOD)])
rows, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["ambiguous"] == 1
assert stats["missed"] == 0
flagged = [r for r in rows if logger.WARN_AMBIGUOUS in r[5]]
assert len(flagged) == 1
# It rides on the real sample ending the gap, which keeps its data.
assert flagged[0][2] is not None
assert logger.WARN_MISSED not in flagged[0][5]
def test_an_ambiguous_gap_that_did_lose_something_flags_both(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 2.5 * PERIOD)])
rows, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["ambiguous"] == 1
assert stats["missed"] == 2
placeholders = [r for r in rows if logger.WARN_MISSED in r[5]]
assert placeholders
assert all(logger.WARN_AMBIGUOUS in r[5] for r in placeholders)
def test_a_gap_landing_squarely_on_a_period_is_not_ambiguous(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 2.02 * PERIOD)])
_, stats = run_loop(sensor, fake_clock, duration=0.5)
assert stats["ambiguous"] == 0
assert stats["missed"] == 1
def test_the_ambiguity_margin_is_the_documented_quarter_period():
assert logger.AMBIGUITY_MARGIN == 0.25
# --------------------------------------------------------------------------
# Backpressure
# --------------------------------------------------------------------------
def test_a_full_queue_truncates_rather_than_holing_the_index(fake_clock):
"""A dropped row would break contiguity and make the file unloadable."""
sensor = FakeSensor(grid_events(PERIOD, 200))
rows, stats = run_loop(sensor, fake_clock, duration=1.5, maxsize=8)
assert stats["truncated"] is True
assert len(rows) == 8
assert indices(rows) == list(range(8))
def test_an_ample_queue_never_truncates(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 60))
_, stats = run_loop(sensor, fake_clock, duration=0.5, maxsize=10_000)
assert stats["truncated"] is False
# --------------------------------------------------------------------------
# calibrate_period
# --------------------------------------------------------------------------
def test_calibrate_period_recovers_the_true_period(fake_clock):
"""The nominal value is 6-9% out on real hardware; the fit has to beat that."""
sensor = FakeSensor(grid_events(PERIOD, 400))
fake_clock(sensor)
dt, host_cost = logger.calibrate_period(sensor, PERIOD * 1.07, seconds=1.0)
assert dt == pytest.approx(PERIOD, rel=1e-3)
assert host_cost > 0
def test_calibrate_period_bootstraps_when_the_nominal_is_badly_wrong(fake_clock):
"""When the cycle count governs, the TMRC table value is out by a factor.
A threshold built on it rejects every interval, so the period is seeded from
observation instead.
"""
sensor = FakeSensor(grid_events(PERIOD, 400))
fake_clock(sensor)
dt, _ = logger.calibrate_period(sensor, PERIOD / 8, seconds=1.0)
assert dt == pytest.approx(PERIOD, rel=1e-3)
def test_calibrate_period_reports_a_plausible_host_cost(fake_clock):
sensor = FakeSensor(grid_events(PERIOD, 400))
fake_clock(sensor)
_, host_cost = logger.calibrate_period(sensor, PERIOD, seconds=1.0)
# One poll plus one read, and nothing else charged to the sample.
assert host_cost == pytest.approx(
sensor.poll_cost + sensor.read_cost, rel=0.5)
assert host_cost < PERIOD
def test_calibrate_period_gives_up_without_enough_clean_samples(fake_clock):
"""Returning None is itself the answer: the rate is not sustainable."""
sensor = FakeSensor(grid_events(PERIOD, 4))
fake_clock(sensor)
assert logger.calibrate_period(sensor, PERIOD, seconds=0.5) == (None, None)
# --------------------------------------------------------------------------
# End to end: sample_loop -> writer_thread -> capture.load
# --------------------------------------------------------------------------
def test_a_logged_run_reloads_through_capture(tmp_path, fake_clock):
"""The two halves of the format contract, checked against each other."""
sensor = FakeSensor(grid_events(PERIOD, 400), stalls=[(0.5, 2.1 * PERIOD)])
fake_clock(sensor)
q = queue.Queue()
stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
"truncated": False}
logger.sample_loop(sensor, q, 2.0, PERIOD, stats)
q.put(logger._SENTINEL)
path = tmp_path / "run.csv"
meta = {
"rm3100_capture": 1,
"nominal_rate_hz": 1 / PERIOD,
"cycle_count": 100,
"tesla_per_count": repr(rm3100.tesla_per_count(100)),
"calibrated_period_s": repr(PERIOD),
"note": "synthetic",
}
logger.writer_thread(q, str(path), meta, stats)
cap = capture.load(str(path))
assert len(cap.sample_index) == stats["rows"]
assert cap.missed.sum() == stats["missed"] == 1
assert cap.meta["note"] == "synthetic"
assert cap.true_rate_hz == pytest.approx(1 / PERIOD, rel=1e-3)
def test_the_writer_writes_zeros_not_blanks_for_a_placeholder(tmp_path):
"""x/y/z must parse as integers on every row; the flag marks the empty ones."""
q = queue.Queue()
q.put((0, 1_700_000_000.0, 10, 20, 30, ""))
q.put((1, 1_700_000_000.01, None, None, None, logger.WARN_MISSED))
q.put(logger._SENTINEL)
stats = {"rows": 0, "missed": 1}
path = tmp_path / "w.csv"
logger.writer_thread(q, str(path), {
"rm3100_capture": 1,
"tesla_per_count": repr(rm3100.tesla_per_count(100))}, stats)
lines = path.read_text().splitlines()
assert lines[-1] == f"1,1700000000.010000,0,0,0,{logger.WARN_MISSED}"
assert stats["rows"] == 2
def test_the_header_is_one_line_per_key(tmp_path):
handle_path = tmp_path / "h.csv"
with open(handle_path, "w") as handle:
logger.write_header(handle, {"a": 1, "b": "two"})
assert handle_path.read_text() == "# a: 1\n# b: two\n"
# --------------------------------------------------------------------------
# Configuration warnings -- each one guards against a silent capture defect
# --------------------------------------------------------------------------
def warnings_for(capsys, cfg, bus_speed=750, requested_rate=None):
logger.print_plan(cfg, bus_speed, requested_rate)
return capsys.readouterr().err
def test_the_default_configuration_warns_about_nothing(capsys):
assert warnings_for(capsys, rm3100.plan()) == ""
def test_a_thin_dither_margin_is_warned_about(capsys):
err = warnings_for(capsys, rm3100.plan(cycle_count=40))
assert "below the recommended" in err
assert "characterize.py" in err # and says how to settle it
def test_an_idling_sensor_is_warned_about(capsys):
err = warnings_for(capsys, rm3100.plan(cycle_count=50, tmrc=0x99))
assert "idle" in err
def test_a_rate_that_could_not_be_met_is_warned_about(capsys):
err = warnings_for(capsys, rm3100.plan(rate_hz=5000), requested_rate=5000)
assert "was requested but this configuration runs" in err
def test_sampling_below_twice_mains_is_warned_about(capsys):
"""The one error post-processing cannot undo."""
err = warnings_for(capsys, rm3100.plan(rate_hz=32), requested_rate=32)
assert "Nyquist" in err and "mains" in err
assert "4.0" in err # 60 Hz folds to ~4 Hz at 32 Hz
def test_an_extrapolated_cycle_count_is_warned_about(capsys):
err = warnings_for(capsys, rm3100.plan(cycle_count=1000))
assert "extrapolated" in err
assert str(rm3100.MAX_SPEC_CYCLE_COUNT) in err
def test_a_bus_that_cannot_keep_up_is_warned_about_with_a_fix(capsys):
err = warnings_for(capsys, rm3100.plan(), bus_speed=20)
assert "of every period" in err
assert "kHz would fit" in err
def test_the_plan_derivation_is_printed_for_checking(capsys):
logger.print_plan(rm3100.plan(), 750, None)
out = capsys.readouterr().out
for field in ("cycle count", "sensor ceiling", "TMRC", "predicted rate",
"resolution", "duty", "bus 750 kHz"):
assert field in out
def test_the_requested_rate_is_echoed_when_given(capsys):
logger.print_plan(rm3100.plan(rate_hz=50), 750, 50)
assert "requested" in capsys.readouterr().out
# --------------------------------------------------------------------------
# Small helpers
# --------------------------------------------------------------------------
def test_set_thread_nice_reports_what_took_effect():
"""Lowering own priority needs no privilege, so this must work anywhere."""
got = logger.set_thread_nice(logger.WRITER_NICE)
assert got in (logger.WRITER_NICE, None)
def test_raising_priority_without_privilege_returns_none():
if logger.set_thread_nice(0) is None:
pytest.skip("thread priority is unavailable on this platform")
import os
if os.geteuid() == 0:
pytest.skip("running as root, where the raise succeeds")
assert logger.set_thread_nice(logger.SAMPLER_NICE) is None
def test_restore_ownership_is_a_no_op_for_a_normal_user(tmp_path):
path = tmp_path / "f.csv"
path.write_text("x")
logger.restore_ownership(str(path)) # must not raise
assert path.read_text() == "x"