subject: fix calibration cancel, unit mixing, FN clamping, export/apply and depth scale

This commit is contained in:
ykcheungab
2026-09-13 12:35:05 +08:00
parent db228b6f57
commit f501fa93f3
9 changed files with 725 additions and 78 deletions
+226
View File
@@ -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)