add session browsing, calibration diagnostics and the 2D layout preset
Phase 2 of the review: browse the whole session instead of only the rolling window, diagnose a calibration from the recorded points, and provide the reviewed two-panel layout. What - SessionStore gains extent(), visible(t0, t1, max_points) and statistics(t0, t1, unit). visible() walks only the blocks that overlap the window and decimates by taking each bucket's minimum and maximum, so a spike survives decimation and browsing a long session does not copy all of it. - ForcePlotsPanel gains follow/browse modes, a whole-session overview strip with the current window, pan and wheel zoom, a rubber-band zoom toggle, a shared time cursor across all three channels, and a selection readout (duration, valid samples, per-axis peak/mean/std). - CalibrationPoint records a bounded per-axis trace, an excluded flag, a validation flag and its fit residual. CalibrationRecorder gains set_excluded/set_validation/fit; the fit is a least-squares line over the non-excluded, non-validation points with per-point residuals and an independent validation error. - The calibration table becomes the reviewed diagnostic view (reference load | FN | FX | FY | stability | state) with the residual shown next to the loaded axis, plus exclude/restore and mark-validation actions and a fit summary line. Double clicking a row opens the point's three-axis trace. - Add a layout preset: free layout or 2D mode (depth map on the left, force charts on the right, calibration folded away), persisted in preferences. Why - Browsing is a display pause, not a capture pause: the record channel keeps running while the charts show an older window. - Excluding a point removes it from the fit and from the exported calibration but keeps it on record, so the evidence for the decision is not destroyed. - The non-loaded axes are recorded but never given a reference load: their true load is unknown during a single-axis calibration, so it stays empty. Verification - shared/tests/test_record_channel.py grows to 18 cases, covering extent, peak-preserving decimation, windowed reads, inverted/empty windows, unit-aware statistics and the time origin. - tools/ui_check.py gains "session browsing" and "layout preset" sections (223 checks, 0 failures overall). - apps/studio/tests/test_ui_smoke.py covers the same invariants. Not in this change: synchronised playback of video, depth and curves. It needs a decision on how depth is stored for replay (per-frame PNG, reduced-rate PNG, or offline recomputation); the cursor plumbing (cursor_moved / set_cursor) is already in place for it.
This commit is contained in:
@@ -16,6 +16,7 @@ import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.force.calibration_recorder import (
|
||||
@@ -100,6 +101,102 @@ def test_store_window_queries_are_relative_to_the_first_sample():
|
||||
assert float(first_second.times[0]) == 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------- session browsing
|
||||
|
||||
|
||||
def _ramp_store(count: int = 3000, *, spike_at: int = 1500) -> SessionStore:
|
||||
store = SessionStore(max_samples=20_000, block_size=64)
|
||||
store.reset(session_id=1)
|
||||
for index in range(count):
|
||||
fn = index * 0.01
|
||||
if index == spike_at:
|
||||
fn = 99.0
|
||||
store.append(
|
||||
SessionSample(
|
||||
frame_id=index,
|
||||
t_rel_s=index / 30.0,
|
||||
fn_raw=fn,
|
||||
fx_raw=1.0,
|
||||
fy_raw=-1.0,
|
||||
fn_n=fn / 10.0,
|
||||
fx_n=0.1,
|
||||
fy_n=-0.1,
|
||||
valid=(index % 10) != 3,
|
||||
)
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def test_extent_covers_the_stored_session():
|
||||
store = _ramp_store(600)
|
||||
first, last = store.extent()
|
||||
assert first == 0.0
|
||||
assert last == pytest.approx(599 / 30.0, abs=1e-6)
|
||||
assert SessionStore().extent() == (0.0, 0.0)
|
||||
|
||||
|
||||
def test_visible_decimates_but_keeps_the_peak():
|
||||
store = _ramp_store()
|
||||
full = store.data()
|
||||
window = store.visible(0.0, 100.0, max_points=200)
|
||||
|
||||
assert len(window) <= 400, "decimation did not bound the point count"
|
||||
assert len(window) < len(full), "a long window should be decimated"
|
||||
assert float(window.value_for_unit("raw")[:, 0].max()) == 99.0, (
|
||||
"decimation averaged away the spike"
|
||||
)
|
||||
assert bool((np.diff(window.times) >= 0).all()), "decimation broke time order"
|
||||
|
||||
|
||||
def test_visible_reads_only_the_requested_window():
|
||||
store = _ramp_store()
|
||||
window = store.visible(10.0, 20.0, max_points=100_000)
|
||||
assert float(window.times.min()) == pytest.approx(10.0, abs=1 / 30.0)
|
||||
assert float(window.times.max()) == pytest.approx(20.0, abs=1 / 30.0)
|
||||
assert len(window) == pytest.approx(301, abs=1)
|
||||
|
||||
|
||||
def test_visible_survives_an_empty_or_inverted_window():
|
||||
store = _ramp_store(120)
|
||||
assert len(store.visible(500.0, 600.0)) == 0
|
||||
# An inverted window is normalised rather than returning nothing.
|
||||
assert len(store.visible(20.0, 10.0)) == len(store.visible(10.0, 20.0))
|
||||
|
||||
|
||||
def test_statistics_report_duration_peak_mean_and_std():
|
||||
store = _ramp_store(600)
|
||||
stats = store.statistics(0.0, 20.0, unit="raw")
|
||||
|
||||
assert stats.samples == 540, "invalid samples must not be counted"
|
||||
assert stats.duration_s == pytest.approx(20.0, abs=0.05)
|
||||
# Last sample inside the window is index 599, so the peak is index * 0.01.
|
||||
assert stats.peaks[0] == pytest.approx(5.99, abs=0.02)
|
||||
assert stats.means[1] == pytest.approx(1.0)
|
||||
assert stats.stds[1] == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_statistics_follow_the_display_unit():
|
||||
store = _ramp_store(600)
|
||||
raw = store.statistics(0.0, 20.0, unit="raw")
|
||||
newtons = store.statistics(0.0, 20.0, unit="N")
|
||||
assert newtons.peaks[0] == pytest.approx(raw.peaks[0] / 10.0, abs=0.01)
|
||||
|
||||
|
||||
def test_statistics_of_an_empty_selection_are_blank_not_zero():
|
||||
store = _ramp_store(120)
|
||||
stats = store.statistics(900.0, 1000.0)
|
||||
assert stats.samples == 0
|
||||
assert all(math.isnan(value) for value in stats.means)
|
||||
|
||||
|
||||
def test_origin_time_matches_the_first_sample():
|
||||
store = _ramp_store(10)
|
||||
assert store.origin_time() == pytest.approx(0.0) # _sample starts at t=0
|
||||
store.reset()
|
||||
store.append(SessionSample(frame_id=0, t_rel_s=5000.0, fn_raw=1.0))
|
||||
assert store.origin_time() == pytest.approx(5000.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------- CalibrationRecorder
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user