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.
Phase 1 of the review asked for one directory per experiment, a force CSV,
video of the pre-processing image, and metadata that ties them together. The
record channel from the previous change already kept the whole session; this
adds the on-disk format.
What
- Add record/experiment.py: ExperimentRecorder writes one directory per
session containing session.json, forces.csv, frames.csv, events.csv,
raw_NNNN.mp4 (segmented) and an optional lossless PNG sequence.
- forces.csv uses the reviewed column order
(session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N,
valid, calibration_id) and leaves a cell empty when a value is unavailable:
an uncalibrated newton value or a failed readout is never written as 0.
- frames.csv is the frame-index mapping: frame_id, t_rel_s, acquisition wall
clock, segment, segment_frame, in_video. forces.csv and frames.csv share the
packet frame_id and the same t_rel_s origin, so video and data can be
aligned after dropped or segmented frames.
- session.json carries format version, session id, start/end time in UTC,
device and config (with a sha256 of the config file), app/shared/SDK
versions, calibration reference, axis convention, filter parameters, video
codec and the lossy-MP4 caveat, plus the event log and end-of-run statistics.
It is written once at start so a crash still leaves identifiable metadata.
- Add a record control to the toolbar: mode (video+data / data only) and a
start/stop toggle that stays enabled while recording.
- Log zeroing, calibration application and session end as timestamped events.
Why
- The recorder is fed from the worker sink list on the acquisition thread and
only appends to a bounded queue, so disk I/O can never stall acquisition.
- On_packet copies the raw frame instead of holding a reference: the packet
frame may alias a buffer the SDK reuses between frames.
- A queue overflow is counted and alarmed (rate limited), and a write or encode
failure is counted, reported and after 5 consecutive errors stops the
recording with failed=true rather than silently losing data.
Verification
- shared/tests/test_experiment_recorder.py: 12 cases covering the directory
layout, the CSV contract, frame-id/time-base agreement, segment rolling,
data-only and lossless modes, queue overflow accounting, encoder failure,
metadata fields, crash-before-stop, and that on_packet never blocks.
- tools/ui_check.py gains an "experiment record" section (169 checks, 0
failures overall), driving a recording through the UI and asserting the
layout, the empty-cell rule and the event sequence.
- apps/studio/tests/test_ui_smoke.py mirrors those invariants.
- docs/record-channel.md documents the format and the remaining phase-2 work.
Not in this change: CSV export of a selected time range, full-session curve
browsing, calibration diagnostics (outlier exclusion, fit residuals) and
PyOpenGL 3D.