record the full experiment: video, CSV and metadata

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.
This commit is contained in:
ykcheungab
2026-09-13 13:14:10 +08:00
parent f501fa93f3
commit 817f830565
12 changed files with 1531 additions and 16 deletions
+278
View File
@@ -0,0 +1,278 @@
"""Experiment recording: directory layout, CSV contract, metadata, failure handling.
Scratch files are created inside the repo (``shared/tests/_scratch``) rather than
in the system temp directory, because some sandboxes deny writes outside the
workspace. The directory is removed after every test.
"""
from __future__ import annotations
import csv
import json
import shutil
import time
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from orisys_app_shared.record import (
EVENTS_HEADER,
FORCES_HEADER,
FRAMES_HEADER,
ExperimentRecorder,
RecordConfig,
format_value,
)
SCRATCH = Path(__file__).resolve().parent / "_scratch"
def _packet(index: int, *, valid: bool = True, raw_available: bool = True) -> SdkPacket:
nan = float("nan")
frame = np.full((24, 32, 3), index % 255, np.uint8)
return SdkPacket(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0 + index / 30.0,
fps=30.0,
process_ms=1.0,
normal_force=2.0 if valid else nan,
shear_x=0.5 if valid else nan,
shear_y=-0.5 if valid else nan,
normal_force_raw=(9.0 + index * 0.1) if valid and raw_available else nan,
shear_x_raw=0.4 if valid and raw_available else nan,
shear_y_raw=-0.4 if valid and raw_available else nan,
force_unit="N",
force_calibrated=True,
session_id=1,
frame_id=index,
valid=valid,
invalid_reason="" if valid else "read failed",
raw_available=raw_available,
)
@pytest.fixture
def root():
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
yield SCRATCH
shutil.rmtree(SCRATCH, ignore_errors=True)
def _record(root, packets, config=None, events=()):
recorder = ExperimentRecorder(config or RecordConfig(video=True, fps=30.0))
directory = recorder.start(
root=root,
session_id=7,
metadata={"device": {"sensor_type": "box", "sn": "SN-1"}},
calibration_id="/cfg/sensor.json",
)
for index in packets:
recorder.on_packet(_packet(index))
for kind, detail in events:
recorder.log_event(kind, detail)
report = recorder.stop()
return directory, report
def test_directory_layout_and_headers(root):
directory, report = _record(root, range(12))
assert report.ok, report.problems
names = sorted(path.name for path in directory.iterdir())
assert names == ["events.csv", "forces.csv", "frames.csv", "raw_0001.mp4", "session.json"]
for name, header in (
("forces.csv", FORCES_HEADER),
("frames.csv", FRAMES_HEADER),
("events.csv", EVENTS_HEADER),
):
with (directory / name).open(encoding="utf-8") as handle:
assert tuple(next(csv.reader(handle))) == header, name
def test_unavailable_values_are_empty_not_zero(root):
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=1, metadata={}, calibration_id="cal-1")
recorder.on_packet(_packet(0))
recorder.on_packet(_packet(1, valid=False))
recorder.on_packet(_packet(2, raw_available=False))
recorder.stop()
with (directory / "forces.csv").open(encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
failed = rows[1]
assert failed["valid"] == "0"
for column in ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N"):
assert failed[column] == "", "a failed readout was written as a number"
no_raw = rows[2]
assert no_raw["FN_raw"] == "" and no_raw["FX_raw"] == "", "raw was not left empty"
assert no_raw["FN_N"] == "2.0", "calibrated values must still be recorded"
assert no_raw["valid"] == "1"
def test_full_precision_is_preserved():
value = 0.1 + 0.2
assert format_value(value) == repr(value) != "0.3"
assert format_value(float("nan")) == ""
def test_forces_and_frame_index_share_frame_id_and_time_base(root):
directory, _ = _record(root, range(20))
with (directory / "forces.csv").open(encoding="utf-8") as handle:
forces = list(csv.DictReader(handle))
with (directory / "frames.csv").open(encoding="utf-8") as handle:
frames = list(csv.DictReader(handle))
assert len(forces) == len(frames) == 20
for force_row, frame_row in zip(forces, frames):
assert force_row["frame_id"] == frame_row["frame_id"]
assert force_row["t_rel_s"] == frame_row["t_rel_s"], "time bases diverged"
assert frame_row["in_video"] == "1"
assert frame_row["segment"] == "1"
assert frames[0]["t_rel_s"] == "0.0"
def test_segments_roll_and_are_indexed(root):
config = RecordConfig(video=True, fps=30.0, segment_seconds=0.05)
recorder = ExperimentRecorder(config)
directory = recorder.start(root=root, session_id=1, metadata={})
for index in range(40):
recorder.on_packet(_packet(index))
time.sleep(0.004)
recorder.stop()
segments = sorted(path.name for path in directory.glob("raw_*.mp4"))
assert len(segments) >= 2, "long recordings must be split"
with (directory / "frames.csv").open(encoding="utf-8") as handle:
used = {row["segment"] for row in csv.DictReader(handle)}
assert len(used) >= 2, "the frame index must record which segment each frame went to"
def test_data_only_mode_writes_no_video(root):
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=1, metadata={})
for index in range(10):
recorder.on_packet(_packet(index))
report = recorder.stop()
assert not list(directory.glob("*.mp4"))
assert report.stats.frames == 10
assert report.stats.video_frames == 0
def test_lossless_frames_are_optional(root):
recorder = ExperimentRecorder(RecordConfig(video=False, lossless_frames=True))
directory = recorder.start(root=root, session_id=1, metadata={})
for index in range(5):
recorder.on_packet(_packet(index))
recorder.stop()
images = sorted((directory / "frames").glob("*.png"))
assert len(images) == 5, "the lossless sequence was requested but not written"
def test_queue_overflow_is_counted_and_alarmed(root):
alarms: list[str] = []
# A queue that holds one item, with a writer we deliberately stall.
recorder = ExperimentRecorder(RecordConfig(video=True, max_queue=16))
recorder.set_alarm_handler(alarms.append)
directory = recorder.start(root=root, session_id=1, metadata={})
# Push far more than the queue can hold without letting the writer catch up.
for index in range(4000):
recorder.on_packet(_packet(index))
report = recorder.stop()
stats = report.stats
assert stats.dropped_data + stats.dropped_video > 0 or stats.frames == 4000, (
"overflow must either be counted or not happen at all"
)
if stats.dropped_data or stats.dropped_video:
assert alarms, "an overflow must raise an alarm, not pass silently"
assert any("overflow" in message for message in alarms)
def test_encode_failure_is_reported_and_stops_recording(root):
alarms: list[str] = []
recorder = ExperimentRecorder(RecordConfig(video=True, codec="ZZZZ"))
recorder.set_alarm_handler(alarms.append)
recorder.start(root=root, session_id=1, metadata={})
for index in range(20):
recorder.on_packet(_packet(index))
report = recorder.stop()
assert report.stats.failed, "a codec that cannot open must not be ignored"
assert report.stats.write_errors > 0
assert alarms, "the encoder failure was never reported"
assert not report.ok
def test_metadata_contains_the_documented_fields(root):
directory, _ = _record(
root,
range(4),
events=(("zeroing", "operator"), ("calibration_applied", "box")),
)
meta = json.loads((directory / "session.json").read_text(encoding="utf-8"))
for key in ("format_version", "session_id", "started_at", "ended_at", "video",
"events", "stats", "files", "device"):
assert key in meta, key
assert meta["session_id"] == 7
assert meta["ended_at"], "session.json must be finalised at stop"
assert meta["calibration_id"] == "/cfg/sensor.json"
assert meta["video"]["lossy_note"], "the lossy-MP4 caveat must be recorded"
assert [event["kind"] for event in meta["events"]] == ["zeroing", "calibration_applied"]
assert all(event["at"] for event in meta["events"])
assert meta["stats"]["frames"] == 4
assert meta["stats"]["duration_s"] > 0
def test_metadata_exists_even_if_the_run_never_stops(root):
"""A crash must still leave identifiable metadata behind."""
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=3, metadata={"device": {"sn": "X"}})
meta = json.loads((directory / "session.json").read_text(encoding="utf-8"))
assert meta["session_id"] == 3
assert meta["ended_at"] is None
recorder.stop()
def test_on_packet_never_blocks_the_caller(root):
recorder = ExperimentRecorder(RecordConfig(video=False, max_queue=32))
recorder.start(root=root, session_id=1, metadata={})
# Fill well past the queue bound; each call must still return promptly.
started = time.perf_counter()
for index in range(5000):
recorder.on_packet(_packet(index))
elapsed = time.perf_counter() - started
assert elapsed < 5.0, f"on_packet blocked acquisition for {elapsed:.2f}s"
recorder.stop()
if __name__ == "__main__": # pragma: no cover - manual runner
failures = 0
for name, function in sorted(globals().items()):
if not name.startswith("test_") or not callable(function):
continue
try:
if name in ("test_full_precision_is_preserved",):
function()
else:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
function(SCRATCH)
shutil.rmtree(SCRATCH, ignore_errors=True)
print(f" ok {name}")
except Exception as exc: # noqa: BLE001
failures += 1
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
print(f"failures: {failures}")
raise SystemExit(1 if failures else 0)