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:
@@ -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)
|
||||
Reference in New Issue
Block a user