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:
ykcheungab
2026-09-13 13:34:26 +08:00
parent 817f830565
commit 92da51217c
13 changed files with 1576 additions and 75 deletions
+97
View File
@@ -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