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
+88 -7
View File
@@ -1,9 +1,11 @@
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
import ui_check
@@ -199,7 +201,7 @@ def test_toolbar_run_button_is_labelled(make_window):
window = _show(make_window(), (1400, 880))
label = window.toolbar.btn_run.text()
assert label.strip()
assert label != "â–¶"
assert label != "â–¶"
window.toolbar.set_run_checked(True)
assert window.toolbar.btn_run.text() != label
@@ -264,14 +266,92 @@ def test_cal_panel_records_and_lists_samples(make_window):
cal.set_running(True)
assert cal.btn_record.isEnabled()
assert cal.btn_record.text()
assert cal.table.columnCount() == 6, "the diagnostic columns changed"
from orisys_app_shared.force.calibration_io import CalSample
cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02))
cal._refresh_table()
assert cal.table.rowCount() == 1
_add_calibration_point(window, load=1.0, raw=100.2)
_add_calibration_point(window, load=2.0, raw=400.0) # off-trend
_add_calibration_point(window, load=3.0, raw=300.6)
assert cal.table.rowCount() == 3
assert not cal.empty_hint.isVisible()
headers = [
cal.table.horizontalHeaderItem(i).text() for i in range(cal.table.columnCount())
]
from orisys_app_shared.i18n import t
assert headers == [
t("cal.col_weight"),
t("cal.col_fn"),
t("cal.col_fx"),
t("cal.col_fy"),
t("cal.col_stability"),
t("cal.col_state"),
]
def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40) -> None:
"""Drive one point through the recorder and into the panel's table."""
from orisys_app_shared.force.session import SessionSample
window.cal_recorder.start(channel="fn", reference_load_n=load, duration_s=1.0)
for index in range(samples):
window.cal_recorder.feed(
SessionSample(
frame_id=index,
t_rel_s=index / 100.0,
fn_raw=raw,
fx_raw=0.4,
fy_raw=-0.2,
valid=True,
)
)
window.cal_panel.on_record_finished(window.cal_recorder.finish_now())
def test_calibration_diagnostics_drive_the_fit(make_window):
window = _show(make_window(), (1400, 880))
window.cal_panel.set_running(True)
_add_calibration_point(window, load=1.0, raw=100.2)
_add_calibration_point(window, load=2.0, raw=400.0)
_add_calibration_point(window, load=3.0, raw=300.6)
_add_calibration_point(window, load=5.0, raw=500.9)
recorder = window.cal_recorder
fit = recorder.fit("fn")
assert fit.usable and fit.n_points == 4
worst = fit.max_residual_n
assert worst > 0.05, "the off-trend point produced no residual"
assert window.cal_panel.fit_label.text()
window.cal_panel.table.setCurrentCell(1, 0)
window.cal_panel._on_toggle_excluded()
assert recorder.fit("fn").max_residual_n < worst, "exclusion did not tighten the fit"
assert recorder.points[1].excluded
# An excluded point leaves the export store but stays on record.
assert len(window.cal_panel._store.channel("fn")) == 3
window.cal_panel.table.setCurrentCell(2, 0)
window.cal_panel._on_toggle_validation()
held_out = recorder.fit("fn")
assert held_out.validation_points == 1
assert recorder.points[2].is_validation
assert np.isnan(recorder.points[2].residual_n), "validation point entered the fit"
def test_calibration_point_dialog_shows_three_axes(make_window):
window = _show(make_window(), (1400, 880))
_add_calibration_point(window, load=1.0, raw=100.2)
point = window.cal_recorder.points[0]
assert set(point.trace) == {"fn", "fx", "fy"}
assert point.trace_dt_s > 0
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
dialog = CalibrationPointDialog(point, theme="dark")
assert dialog.windowTitle()
assert len(dialog.plot_widget.ci.items) == 3, "not one trace per axis"
dialog.close()
def test_force_plots_render_demo_data(make_window):
window = _show(make_window(), (1440, 880))
@@ -516,3 +596,4 @@ def test_recording_leaves_unavailable_values_empty(make_window, record_root):
_finish(window, stuck)