The picker listed a hard-coded product list and treated any *.json beside sensor.json as a
configuration, so a folder of stray JSON could be offered as a sensor. Discovery now scans the
shipped root and a writable user root beside it, keeps the SDK's verdict for every entry
(listing rejected ones with their reason) and flags duplicate profile_ids instead of shadowing
them; verified by shared/tests and apps/studio/tests.
Opening a recording while the session that recorded it was still live never reached
``stored_result()``, so the panel reported no result over a recording whose result was on
disk. The guard now follows the cache read, results move to the app's derived-data root
(swept at startup, the old in-recording root ignored), and the panel states which of the
four no-result states it is in, with the pass's own reason.
Verified: apps/studio 462/0/18, shared 431/0/1, autocal 358, ui_check 628/0, both gates
clean, real-GL snapshots per state.
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.