Replay left the 3D pane on the rest mesh or a sparse overlay, and Analyze skipped the setup dialog unless the file was re-picked. Live Run no longer starts a chain on a video path. Verified with Studio tests for flush, overlay mask, Analyze open, live-video refusal, and locales.
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.
Replay moved the cursor onto the result's timeline, so the recording's own
picture was not offered at all and ``use_recording()`` had no caller. Adopting a
result now keeps the cursor on the recording, opens the panel on a derived view,
and serves scrubs, playback and display changes from the cache (Part B §9).
Verified: apps/studio 455 items / 0 failed / 18 skipped, shared 427 / 0 / 1,
autocal 358, ui_check 628 checks / 0 failures, lint gate clean.
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.
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.
Replay re-ran the SDK chain on every seek: 0.48-0.51 wall-seconds per video
second, about 4.8 minutes for a ten-minute recording. The new
orisys_app_shared.analysis package runs that pass once and stores the flow
samples a replay draws - 4,034 B per frame at the shipped finger grid instead of
the chain's 1,176,000 B; the result contract is in
apps/studio/docs/replay-analysis.md.
Verified: shared/tests 416 passed / 1 skipped (15 new) and apps/studio/tests
unchanged at 425 items / 0 failed / 18 skipped; tools/lint_gate.py clean; a
264-frame pass costs 0.5036 wall-s per video-s against 0.5052 for the bare chain
measured the same day.
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.
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.
Studio had a colour contract with a mechanical audit but no layout equivalent,
which is where the recurring visual defects live: window flashing, the label
under the 3D view, the platform-not-moving report, and the 2D/3D sync work all
passed a 3,408-assertion tool suite because that suite asserts widget state, not
the visible result.
apps/studio/docs/ui-layout.md states the contract: ViewLayoutController owns the
top-level arrangement and each panel owns its internal layout; every path that
changes the view must go through apply_preset(); each view's pane priority and
degradation order; and which numeric geometry arguments are legitimate (a 1px
separator is not the same defect as a magic width on a flexible panel).
It also records the one transition that is broken today.
on_cal_panel_toggled(False) sets view_preset = "live" directly instead of
calling apply_preset("live"), so the replay -> calibration -> calibration-off
sequence leaves the rail on the replay source, the run button in playback role,
and the previous view's splitter sizes. The live branch does call apply_preset,
which is what makes the asymmetry easy to miss.
Three claimed enforcement gaps are stated so the contract is not mistaken for
something already enforced: audit_palette reads theme JSON and never scans
source for the hex literals ui-palette.md forbids; the containment test compares
width and height without x/y, so a shifted or overlapping child passes; and the
GL pixel suite skips invisibly without a real context.
Verified: doc_lint default and --all both exit 0; the new document is registered
in the workspace inventory (63 collected, 63 inventoried, 0 missing) and linked
from README.md beside the palette reference. No source file changed.
The workspace-root scratch cleanup (2026-09-27) deleted .repro/, the _review_*/
probe bundles and _probe_dataarea/. These tracked documents cited files inside
those directories, so their references are now explicitly marked as historical
and, where the evidence survived, repointed:
- record-channel.md section 7: the ten reproduction conditions are recorded in the
archived review (docs/archive/reviews/phase12-review.md); reproduce.py is gone.
- studio-3d-stages.md: the _review_scene3d_20260914 review is archived under
docs/archive/reviews/; the probe_stage6_* scripts and the .repro/ scan scripts
are marked deleted. The perf report's stale .repro package path is explained in
a note instead of editing the committed probe report, which feeds
compile_scene.py --lock-from-probe.
- studio-architecture.md: the scan scripts that generated the diagrams are gone;
the committed .svg/.png remain the surviving artefacts.
Also ignore .scratch/, the new per-task scratch directory (see
D:\orisys_ws\AGENTS.md). No source file is touched by this commit.
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 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.