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.
A replay could draw arrows but not depth, and the recording view said there was
nothing to draw even when a result existed. Depth is now quantised per frame
(14 B empty, 486 B at the worst frame seen), read from the cache by the same
draw path live uses, and the raw-recording note appears only when no result
exists. Format version 2; see apps/studio/docs/replay-analysis.md.
Replay re-ran the SDK chain on every seek; Part A's pass stores the flow
samples once. Opening a recording now reuses a compatible result or runs the
pass behind a progress/cancel surface, and one cursor with two modes
(recorded CSV, analysed) feeds the charts, the 2D canvas and the 3D pane by
frame id. A threshold, colour or idle-dot change redraws from the stored
frames and writes nothing; a cancelled pass is reported and never played.
Verified: apps/studio/tests 440 items / 0 failed / 18 skipped (15 new, no
existing assertion changed); shared 417 / 0 / 1; autocal 358.
The replay view disabled a control whose display reset is exactly what an
operator wants there, and the bare "Reset" hid that the live one zeroes the
sensor tracker; the label, tooltip and status line now name the target, and
Ctrl+R follows the view. Verified with the studio suite, ui_check (628
checks) and real-GL rail snapshots of both views.
The unrolled canvas put the finger's +x where the 3D default view puts screen-left,
and the 3D flow arrows rasterised as 0.23 px hairlines because their stroke was 2 %
of their own sub-millimetre length. The 2D presentation is now the derived half turn
(persisted, set from the plot-settings dialog) and the 3D stroke is one canvas pixel
— the width the 2D canvas draws — verified numerically and with real-GL snapshots.
The default view came from the mesh's own face normal and sat 24.6 degrees off the
design export's frame; re-applying any preset also adopted its baked distance, so the
part overflowed the viewport (measured 2.16 x 2.12 of it). Presets are now the frame's
own views, every preset is reframed through the pane's existing fit, and that fit
projects the model's bounds instead of a bounding sphere. Verified: the frame axes map
to screen up, left and viewer to 1e-6, the model fills 0.60-0.95 of the viewport at two
sizes, and the recompiled package keeps its geometry, domain and source-geometry ids.
ui-layout.md named the arrangement's owners but nothing knew which elements exist, so a
view switch could silently take a running session's tracker reset away: docs/ui-elements.json
now states all 195 by region, pane and view, ui/elements.py loads it, and
tools/ui_elements_dump.py drafts it.
Verified: apps/studio 393 items (13 new) and ui_check 600/0, autocal 358, shared 390 with its
2 known wall-clock failures, lint and doc gates clean; a removed element, a renamed
objectName and a new named widget each fail the suite.
What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
small things — one time, one validity flag, one frame id per row plus a byte
offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
read_rows() seeks to the nearest checkpoint and scans forward, and overview()
folds every row into its bucket during the same pass to build a per-axis
min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
the extreme row of every axis in the requested unit, so a long interval never
has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
itself decimated, with total_rows and a duration taken from the frame index
rather than from the view; window()/window_all()/overview()/statistics() read
the interval being asked about. list/window semantics unchanged for small
recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
interval + cached envelope) implement one interface — extent, window,
envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
being shown (bounded by CHART_MAX_POINTS), and says so when the view is
decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
be overwritten by the writer's own transition to "recording" (found by a flaky
test; the state check now covers STATE_FAILED).
Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.
Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
the index reports its rows and reads a bounded interval; a large recording is
marked decimated with its true duration and total rows; a small one is not; an
interval read sees the exact rows including a single peak; a decimated window
keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
spike and is cached; the disk and memory sources agree on extent, values and
statistics; the whole session can be read without holding it all. Shared
suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
count, that an interval read spans only the interval, the envelope size and
total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
the unified source, and lists what is still hardware work.
Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
board (needs the hardware), and range CSV export.
What
- record: one packet->row conversion (record/sample.py). A failed compute chain
records no force value at all, a newton value is written only for an axis that
really has a calibration curve, and mono_t (not the wall clock) orders history
with a per-row calibration hash.
- record lifecycle: explicit starting/recording/stopping/stopped/failed states
owned by the writer thread; stop() reports completed=False on timeout and does
not write ended_at; consecutive write failures stop acceptance immediately.
- record capacity: byte budget for the video queue (512 MiB default); a full
queue sheds the *frame* and parks the tiny data row, so a video overflow no
longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted
as dropped_gaps / unrecorded_gaps.
- calibration identity: sha256 content hash instead of a path, minted from the
calibration the sensor actually loaded (reported before the first frame) and
confirmed by the acquisition thread; events are timestamped when they happen.
- calibration diagnostics: the fit summary, residual and validation error now
come from the model that export/apply would use (piecewise_linear); validation
points are held out of the build; repeated reference loads are merged with an
n-weighted mean and pooled std.
- calibration record: every point keeps its full three-axis sample series
(frame id, time, raw, newtons, valid) plus diagnostics, written to an
independent calibration_session_*.json that can be reopened for inspection;
save record / export coefficients / apply to device are three separate actions.
- depth replay: the cursor's frame is bound to the depth result by request
sequence and frame id, so a moved cursor never shows another frame's map; the
SDK is stepped from its current position instead of re-reading from the start;
the live chain (box deformation+contact, finger unroll+tactile) is replayed
with the exact resolved config path; algorithm state is rebuilt from a reset
marker or from the recorded pre-roll, and says so when it cannot be.
Why
The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw
counts into the newton columns, recording a stale value after a failed compute,
losing CSV rows when the video queue filled, reporting success while a writer was
still running, diagnosing a model nobody applies, applying a calibration before
the device confirmed it, dropping the samples behind a calibration point, showing
a stale depth map under a moved cursor and recomputing depth of the wrong frame.
Verification
- D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped
(log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept
with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points.
- apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice).
- shared suites: 91 tests, 0 failures (new: test_record_semantics,
test_record_lifecycle, test_calibration_model, test_calibration_record,
test_depth_replay).
- apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness
artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the
shim cannot drive), unchanged from before this change.
- compileall clean; locale parity zh/en + t() key coverage: 0 failures.
Not in this change
- P2#10 disk-based browsing of a long recording and range CSV export (next
round, per your choice).
- Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL
three-dimensional view.
- Hardware verification: no camera attached, so the SDK's real processing chain
and the pre-roll's effect on a live tracker were exercised with fakes only.
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.