subject: fix calibration cancel, unit mixing, FN clamping, export/apply and depth scale
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Record-channel behaviour: full-session storage and calibration sampling.
|
||||
|
||||
These cover the guarantees the architect's review asked for:
|
||||
|
||||
* a failed readout is never averaged in as a zero,
|
||||
* the stored session is not limited to what the plots happened to show,
|
||||
* both raw and calibrated values are kept, so switching units cannot
|
||||
reinterpret old data,
|
||||
* calibration sampling is driven by the acquisition stream rather than by any
|
||||
widget being visible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.force.calibration_recorder import (
|
||||
STATUS_FEW_SAMPLES,
|
||||
STATUS_OK,
|
||||
CalibrationRecorder,
|
||||
)
|
||||
from orisys_app_shared.force.session import NA, SessionSample, SessionStore
|
||||
|
||||
|
||||
def _sample(index: int, *, valid: bool = True, rate: float = 30.0) -> SessionSample:
|
||||
if not valid:
|
||||
return SessionSample(frame_id=index, t_rel_s=index / rate, valid=False)
|
||||
return SessionSample(
|
||||
frame_id=index,
|
||||
t_rel_s=index / rate,
|
||||
fn_raw=float(index),
|
||||
fx_raw=1.0,
|
||||
fy_raw=-1.0,
|
||||
fn_n=index / 10.0,
|
||||
fx_n=0.1,
|
||||
fy_n=-0.1,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- SessionStore
|
||||
|
||||
|
||||
def test_store_keeps_whole_session_and_reports_evictions():
|
||||
store = SessionStore(max_samples=100, block_size=16)
|
||||
store.reset(session_id=3)
|
||||
for index in range(250):
|
||||
store.append(_sample(index))
|
||||
|
||||
assert store.session_id == 3
|
||||
assert store.count <= 100
|
||||
assert store.dropped > 0, "evictions must be visible instead of silent"
|
||||
assert store.data().truncated is True
|
||||
|
||||
|
||||
def test_store_keeps_raw_and_newton_side_by_side():
|
||||
store = SessionStore()
|
||||
store.reset()
|
||||
store.append(_sample(0))
|
||||
store.append(_sample(1))
|
||||
|
||||
slice_ = store.data()
|
||||
assert not (slice_.value_for_unit("N") == slice_.value_for_unit("raw")).all()
|
||||
assert slice_.value_for_unit("raw")[1][0] == 1.0
|
||||
assert slice_.value_for_unit("N")[1][0] == 0.1
|
||||
|
||||
|
||||
def test_invalid_samples_are_stored_as_invalid_not_zero():
|
||||
store = SessionStore()
|
||||
store.reset()
|
||||
store.append(_sample(0))
|
||||
store.append(_sample(1, valid=False))
|
||||
|
||||
slice_ = store.data()
|
||||
assert slice_.valid.tolist() == [True, False]
|
||||
assert math.isnan(slice_.raw[1][0]), "a failed readout must not become 0.0"
|
||||
assert store.invalid == 1
|
||||
# And it must not leak into anything that computes statistics.
|
||||
assert [t for t, *_ in store.iter_valid("raw")] == [0.0]
|
||||
|
||||
|
||||
def test_store_window_queries_are_relative_to_the_first_sample():
|
||||
store = SessionStore()
|
||||
store.reset()
|
||||
for index in range(120):
|
||||
store.append(_sample(index))
|
||||
|
||||
tail = store.tail(1.0)
|
||||
assert int(tail.frame_ids[-1]) == 119, "tail must end at the newest sample"
|
||||
assert float(tail.times[-1]) - float(tail.times[0]) <= 1.0 + 1e-9
|
||||
assert len(tail) == pytest.approx(30, abs=1) # inclusive boundary
|
||||
|
||||
first_second = store.window(0.0, 1.0)
|
||||
assert int(first_second.frame_ids[0]) == 0
|
||||
assert float(first_second.times[-1]) <= 1.0 + 1e-9
|
||||
assert float(first_second.times[0]) == 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------- CalibrationRecorder
|
||||
|
||||
|
||||
def _recorder(**kwargs) -> CalibrationRecorder:
|
||||
return CalibrationRecorder(**kwargs)
|
||||
|
||||
|
||||
def test_recorder_excludes_failed_samples_from_the_statistics():
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0)
|
||||
for index in range(50):
|
||||
recorder.feed(_sample(index))
|
||||
for index in range(3):
|
||||
recorder.feed(_sample(100 + index, valid=False))
|
||||
|
||||
point = recorder.finish_now()
|
||||
assert point is not None
|
||||
assert point.n_total == 53
|
||||
assert point.n_valid == 50, "failed readouts must not be counted as samples"
|
||||
assert point.status == STATUS_OK
|
||||
# 9.7-style values are absent here; the mean must come only from 0..49.
|
||||
assert point.loaded_stats().mean == pytest.approx(24.5, abs=0.01)
|
||||
|
||||
|
||||
def test_recorder_captures_all_three_axes_and_keeps_diagnostics():
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0, direction="unload")
|
||||
for index in range(10):
|
||||
recorder.feed(_sample(index))
|
||||
point = recorder.finish_now()
|
||||
|
||||
assert set(point.raw) == {"fn", "fx", "fy"}
|
||||
assert point.raw["fx"].mean == pytest.approx(1.0)
|
||||
assert point.raw["fy"].mean == pytest.approx(-1.0)
|
||||
assert point.newtons["fn"].mean == pytest.approx(0.45, abs=0.01)
|
||||
assert point.direction == "unload"
|
||||
# Only the loaded axis has a known reference load.
|
||||
assert point.reference_load_n == 1.0
|
||||
|
||||
|
||||
def test_recorder_marks_too_few_samples_instead_of_guessing():
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fx", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
point = recorder.finish_now()
|
||||
assert point.status == STATUS_FEW_SAMPLES
|
||||
|
||||
|
||||
def test_recorder_finishes_on_the_deadline_from_the_ui_thread():
|
||||
clock = {"now": 0.0}
|
||||
recorder = _recorder(clock=lambda: clock["now"])
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
|
||||
assert recorder.tick() is None, "must not finish before the deadline"
|
||||
clock["now"] = 2.0
|
||||
point = recorder.tick()
|
||||
assert point is not None
|
||||
assert recorder.is_recording is False
|
||||
assert recorder.tick() is None
|
||||
|
||||
|
||||
def test_recorder_is_fed_from_a_foreign_thread():
|
||||
"""The acquisition thread feeds it; nothing here touches Qt."""
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=0.05)
|
||||
|
||||
def producer():
|
||||
for index in range(5000):
|
||||
recorder.feed(_sample(index))
|
||||
|
||||
thread = threading.Thread(target=producer)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
time.sleep(0.06)
|
||||
point = recorder.tick()
|
||||
assert point is not None
|
||||
assert point.n_valid == 5000
|
||||
|
||||
|
||||
def test_recorder_cancel_discards_the_point():
|
||||
cancelled = []
|
||||
recorder = _recorder(on_cancelled=lambda: cancelled.append(True))
|
||||
recorder.start(channel="fy", reference_load_n=3.0, duration_s=5.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.cancel()
|
||||
|
||||
assert recorder.is_recording is False
|
||||
assert cancelled == [True]
|
||||
assert recorder.tick() is None
|
||||
assert recorder.points == []
|
||||
|
||||
|
||||
def test_recorder_round_index_advances_per_channel():
|
||||
recorder = _recorder()
|
||||
for _ in range(2):
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
recorder.finish_now()
|
||||
recorder.start(channel="fx", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
point = recorder.finish_now()
|
||||
|
||||
assert [p.round_index for p in recorder.points] == [0, 1, 0]
|
||||
assert point.channel == "fx"
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||
failures = 0
|
||||
for name, function in sorted(globals().items()):
|
||||
if not name.startswith("test_") or not callable(function):
|
||||
continue
|
||||
try:
|
||||
function()
|
||||
print(f" ok {name}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"failures: {failures}")
|
||||
raise SystemExit(1 if failures else 0)
|
||||
Reference in New Issue
Block a user