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
+7
View File
@@ -68,6 +68,13 @@ def calibration_export_dir() -> Path:
return path
def recordings_dir() -> Path:
"""Root for one directory per recorded experiment."""
path = outputs_dir() / "recordings"
path.mkdir(parents=True, exist_ok=True)
return path
def preference_path() -> Path:
return app_root() / "preference.json"
@@ -0,0 +1,25 @@
"""Experiment recording (video + data + metadata)."""
from orisys_app_shared.record.experiment import (
EVENTS_HEADER,
FORCES_HEADER,
FRAMES_HEADER,
RECORD_FORMAT_VERSION,
ExperimentRecorder,
RecordConfig,
RecordReport,
RecordStats,
format_value,
)
__all__ = [
"EVENTS_HEADER",
"FORCES_HEADER",
"FRAMES_HEADER",
"RECORD_FORMAT_VERSION",
"ExperimentRecorder",
"RecordConfig",
"RecordReport",
"RecordStats",
"format_value",
]
@@ -0,0 +1,653 @@
"""Experiment recording: one directory per session.
Phase-1 record format, deliberately independent of the display path:
outputs/recordings/<session>/
session.json experiment metadata, events and end-of-run statistics
forces.csv one row per acquired frame
frames.csv frame-index mapping (frame id <-> video segment)
events.csv timestamped operator/system events
raw_0001.mp4 processed-input video, segmented (optional)
frames/ lossless PNG sequence (optional)
``on_packet`` is called from the acquisition thread, so it only copies the
scalars it needs (plus the raw frame when video is enabled) into a bounded queue
and returns. All file I/O happens on a writer thread.
Two rules from the architecture review are enforced here:
* the queue is bounded and **never blocks acquisition**; an overflow is counted
and surfaced rather than silently discarded;
* a write or encode failure is counted, reported, and after
:data:`MAX_CONSECUTIVE_ERRORS` stops the writer instead of pretending the
experiment was recorded.
"""
from __future__ import annotations
import csv
import json
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
import cv2
import numpy as np
logger = logging.getLogger(__name__)
RECORD_FORMAT_VERSION = "1.0"
#: Column order required by the review; do not reorder without a format bump.
FORCES_HEADER = (
"session_id",
"frame_id",
"t_rel_s",
"FN_raw",
"FX_raw",
"FY_raw",
"FN_N",
"FX_N",
"FY_N",
"valid",
"calibration_id",
)
FRAMES_HEADER = (
"frame_id",
"t_rel_s",
"acquired_at",
"segment",
"segment_frame",
"in_video",
"dropped",
)
EVENTS_HEADER = ("at", "t_rel_s", "kind", "detail")
MAX_CONSECUTIVE_ERRORS = 5
#: A sustained queue overflow reports at most every N drops.
ALARM_EVERY_N_DROPS = 1000
def format_value(value: float) -> str:
"""Full-precision CSV cell. ``NaN`` means "not available" and is left empty.
An uncalibrated newton value must be empty, never ``0``; a failed readout
must be empty too, otherwise it is indistinguishable from a real zero.
"""
try:
number = float(value)
except (TypeError, ValueError):
return ""
if not np.isfinite(number):
return ""
return repr(number)
@dataclass
class RecordConfig:
"""What to capture, and how."""
video: bool = True
lossless_frames: bool = False
fps: float = 30.0
codec: str = "mp4v"
segment_seconds: float = 300.0
max_queue: int = 2048
flush_interval_s: float = 1.0
def as_dict(self) -> dict:
return {
"video": bool(self.video),
"lossless_frames": bool(self.lossless_frames),
"fps": float(self.fps),
"codec": self.codec,
"segment_seconds": float(self.segment_seconds),
"max_queue": int(self.max_queue),
"lossy_note": (
"MP4 is a lossy container: this is the pre-processing camera "
"image, not a lossless record of the raw sensor data."
),
}
@dataclass
class RecordStats:
frames: int = 0
video_frames: int = 0
segments: int = 0
dropped_data: int = 0
dropped_video: int = 0
write_errors: int = 0
failed: bool = False
last_error: str = ""
duration_s: float = 0.0
bytes_written: int = 0
def as_dict(self) -> dict:
return {
"frames": self.frames,
"video_frames": self.video_frames,
"segments": self.segments,
"dropped_data": self.dropped_data,
"dropped_video": self.dropped_video,
"write_errors": self.write_errors,
"failed": self.failed,
"last_error": self.last_error,
"duration_s": self.duration_s,
"bytes_written": self.bytes_written,
}
@dataclass
class RecordReport:
"""Returned by :meth:`ExperimentRecorder.stop`."""
directory: Optional[Path]
stats: RecordStats
problems: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return bool(self.directory) and not self.stats.failed and not self.problems
class ExperimentRecorder:
"""Writes one experiment directory. Sink-compatible with the workers."""
def __init__(self, config: Optional[RecordConfig] = None) -> None:
self.config = config or RecordConfig()
self._queue: queue.Queue = queue.Queue(maxsize=max(16, self.config.max_queue))
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
self._writing = False
self._directory: Optional[Path] = None
self._stats = RecordStats()
self._session_id = 0
self._calibration_id = ""
self._metadata: dict[str, Any] = {}
self._events: list[dict] = []
self._t0: Optional[float] = None
self._last_t: Optional[float] = None
self._started_at = ""
self._consecutive_errors = 0
self._on_alarm = None
# ------------------------------------------------------------------ state
@property
def is_recording(self) -> bool:
with self._lock:
return self._writing
@property
def directory(self) -> Optional[Path]:
return self._directory
def stats(self) -> RecordStats:
with self._lock:
return RecordStats(**self._stats.as_dict())
def set_alarm_handler(self, handler) -> None:
"""``handler(message)`` is called from the writer thread on a problem."""
self._on_alarm = handler
# -------------------------------------------------------------- lifecycle
def start(
self,
*,
root: Path,
session_id: int,
metadata: dict[str, Any],
calibration_id: str = "",
) -> Path:
"""Create the experiment directory and start the writer thread."""
if self.is_recording:
raise RuntimeError("experiment recording is already running")
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
directory = Path(root) / f"{stamp}_session{session_id:04d}"
directory.mkdir(parents=True, exist_ok=True)
with self._lock:
self._directory = directory
self._stats = RecordStats()
self._session_id = int(session_id)
self._calibration_id = str(calibration_id or "")
self._metadata = dict(metadata)
self._events = []
self._t0 = None
self._last_t = None
self._consecutive_errors = 0
self._started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
self._writing = True
self._stop.clear()
self._drain_queue()
self._thread = threading.Thread(
target=self._run,
name=f"ExperimentRecorder-{session_id}",
daemon=True,
)
self._thread.start()
self._write_session_json(final=False)
logger.info("Recording experiment to %s", directory)
return directory
def stop(self, *, wait: bool = True, timeout: float = 20.0) -> RecordReport:
"""Flush and close. Returns what actually happened."""
with self._lock:
was_writing = self._writing
self._writing = False
if not was_writing:
return RecordReport(self._directory, self.stats())
self._stop.set()
try:
self._queue.put_nowait(("stop", None))
except queue.Full: # pragma: no cover - writer will drain and see the flag
pass
thread = self._thread
if wait and thread is not None and thread.is_alive():
thread.join(timeout=timeout)
self._thread = None
report = self._finalise()
return report
def log_event(self, kind: str, detail: str = "") -> None:
"""Record a timestamped event (zeroing, calibration applied, disconnect…)."""
if not self.is_recording:
return
event = {
"at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"t_rel_s": None,
"kind": str(kind),
"detail": str(detail or ""),
}
try:
self._queue.put_nowait(("event", event))
except queue.Full:
self._note_drop("event")
# ----------------------------------------------------------- sink interface
def on_packet(self, packet) -> None:
"""Called on the acquisition thread: enqueue, never do I/O here."""
if not self.is_recording:
return
timestamp = float(packet.timestamp)
if self._t0 is None:
self._t0 = timestamp
with self._lock:
self._last_t = timestamp
row = (
int(getattr(packet, "frame_id", 0)),
timestamp,
float(packet.normal_force_raw),
float(packet.shear_x_raw),
float(packet.shear_y_raw),
float(packet.normal_force),
float(packet.shear_x),
float(packet.shear_y),
1 if bool(getattr(packet, "valid", True)) else 0,
)
frame = None
if self.config.video or self.config.lossless_frames:
frame = self._capture_frame(packet)
try:
self._queue.put_nowait(("frame", (row, frame)))
except queue.Full:
# Never block acquisition. The loss is counted, not hidden.
self._note_drop("video" if frame is not None else "data")
@staticmethod
def _capture_frame(packet):
"""Copy the pre-processing frame.
The packet's ``raw_frame`` may alias a buffer the sensor reuses, so a
recording must take its own copy here rather than hold the reference.
"""
frame = getattr(packet, "raw_frame", None)
if frame is None:
return None
block = np.asarray(frame)
if block.size == 0:
return None
return block.copy()
# ----------------------------------------------------------------- internals
def _drain_queue(self) -> None:
while True:
try:
self._queue.get_nowait()
except queue.Empty:
return
def _note_drop(self, kind: str) -> None:
with self._lock:
if kind == "video":
self._stats.dropped_video += 1
else:
# Data rows and events are both record-channel content.
self._stats.dropped_data += 1
total = self._stats.dropped_video + self._stats.dropped_data
# Rate-limited: a sustained overflow must not flood the log or the UI.
if total == 1 or total % ALARM_EVERY_N_DROPS == 0:
self._alarm(f"record queue overflow ({kind}); {total} item(s) not written")
def _alarm(self, message: str) -> None:
logger.warning("Recording problem: %s", message)
handler = self._on_alarm
if handler is not None:
try:
handler(message)
except Exception: # noqa: BLE001 - reporting must not break recording
logger.exception("Recording alarm handler failed")
def _run(self) -> None:
writer = _SessionWriter(
directory=self._directory,
config=self.config,
session_id=self._session_id,
calibration_id=self._calibration_id,
)
try:
while True:
if self._stop.is_set() and self._queue.empty():
break
try:
kind, payload = self._queue.get(timeout=0.2)
except queue.Empty:
writer.maybe_flush()
continue
if kind == "stop":
break
if kind == "event":
self._handle_event(writer, payload)
continue
row, frame = payload
self._handle_frame(writer, row, frame)
writer.maybe_flush()
except Exception as exc: # noqa: BLE001 - reported through stats
logger.exception("Experiment writer stopped")
with self._lock:
self._stats.failed = True
self._stats.last_error = f"{type(exc).__name__}: {exc}"
self._alarm(f"recording stopped: {exc}")
finally:
problems = writer.close()
with self._lock:
self._stats.write_errors += writer.write_errors
self._stats.segments = writer.segments
self._stats.video_frames = writer.video_frames
self._stats.bytes_written = writer.bytes_written
if problems:
self._stats.failed = True
if not self._stats.last_error:
self._stats.last_error = problems[0]
for problem in problems:
self._alarm(problem)
def _handle_event(self, writer, event: dict) -> None:
if self._t0 is not None:
event["t_rel_s"] = round(float(time.time()) - self._t0, 6)
with self._lock:
self._events.append(event)
try:
writer.write_event(event)
except Exception as exc: # noqa: BLE001
self._record_error(exc)
def _handle_frame(self, writer, row, frame) -> None:
try:
writer.write_sample(row, frame)
with self._lock:
self._stats.frames += 1
self._stats.video_frames = writer.video_frames
self._stats.segments = writer.segments
self._consecutive_errors = 0
except Exception as exc: # noqa: BLE001
self._record_error(exc)
def _record_error(self, exc: Exception) -> None:
with self._lock:
self._stats.write_errors += 1
self._stats.last_error = f"{type(exc).__name__}: {exc}"
self._consecutive_errors += 1
fatal = self._consecutive_errors >= MAX_CONSECUTIVE_ERRORS
if fatal:
self._stats.failed = True
self._alarm(f"recording write failed: {exc}")
if fatal:
# Stop pretending we are recording rather than losing data quietly.
self._stop.set()
def _finalise(self) -> RecordReport:
with self._lock:
if self._t0 is not None and self._last_t is not None:
self._stats.duration_s = round(float(self._last_t - self._t0), 6)
stats = self.stats()
problems: list[str] = []
if stats.dropped_data or stats.dropped_video:
problems.append(
f"{stats.dropped_data} data and {stats.dropped_video} video frame(s) "
"were dropped by the bounded queue"
)
if stats.write_errors:
problems.append(f"{stats.write_errors} write error(s); last: {stats.last_error}")
self._write_session_json(final=True)
return RecordReport(directory=self._directory, stats=stats, problems=problems)
def _write_session_json(self, *, final: bool) -> None:
directory = self._directory
if directory is None:
return
payload = self._build_metadata(final=final)
target = directory / "session.json"
try:
# Written at start too, so a crash still leaves identifiable metadata.
temporary = target.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
temporary.replace(target)
except Exception as exc: # noqa: BLE001
with self._lock:
self._stats.write_errors += 1
self._stats.last_error = f"session.json: {type(exc).__name__}: {exc}"
self._alarm(f"could not write session.json: {exc}")
def _build_metadata(self, *, final: bool) -> dict[str, Any]:
with self._lock:
stats = self._stats.as_dict()
events = list(self._events)
payload: dict[str, Any] = {
"format_version": RECORD_FORMAT_VERSION,
"session_id": self._session_id,
"calibration_id": self._calibration_id,
"started_at": self._started_at,
"ended_at": (
datetime.now(timezone.utc).isoformat(timespec="seconds") if final else None
),
"video": self.config.as_dict(),
"events": events,
"stats": stats,
"files": {
"forces": "forces.csv",
"frames": "frames.csv",
"events": "events.csv",
"video": "raw_%04d.mp4" if self.config.video else None,
"lossless_frames": "frames/" if self.config.lossless_frames else None,
},
}
payload.update(self._metadata)
return payload
class _SessionWriter:
"""Owns the file handles. Only ever touched by the writer thread."""
def __init__(self, *, directory, config, session_id, calibration_id) -> None:
self.directory = Path(directory)
self.config = config
self.session_id = session_id
self.calibration_id = calibration_id
self.write_errors = 0
self.segments = 0
self.video_frames = 0
self.png_frames = 0
self.bytes_written = 0
self._last_flush = time.perf_counter()
self._t0: Optional[float] = None
self._segment: Optional[cv2.VideoWriter] = None
self._segment_index = 0
self._segment_frames = 0
self._segment_started = 0.0
self._frame_shape: Optional[tuple[int, int]] = None
self._problems: list[str] = []
self._forces = self._open_csv("forces.csv", FORCES_HEADER)
self._frames = self._open_csv("frames.csv", FRAMES_HEADER)
self._events = self._open_csv("events.csv", EVENTS_HEADER)
self._png_dir = None
if self.config.lossless_frames:
self._png_dir = self.directory / "frames"
self._png_dir.mkdir(parents=True, exist_ok=True)
def _open_csv(self, name: str, header):
handle = (self.directory / name).open("w", newline="", encoding="utf-8")
writer = csv.writer(handle)
writer.writerow(header)
return (handle, writer)
# ------------------------------------------------------------------ writing
def write_sample(self, row, frame) -> None:
"""Write one acquired frame: a forces row, the frame index, and video.
``forces.csv`` and ``frames.csv`` share the packet's ``frame_id`` and the
same ``t_rel_s`` origin, which is what lets video and data be aligned
afterwards even when frames were dropped or split into segments.
"""
frame_id, timestamp, fn_r, fx_r, fy_r, fn_n, fx_n, fy_n, valid = row
if self._t0 is None:
self._t0 = timestamp
t_rel = round(timestamp - self._t0, 6)
self._forces[1].writerow(
(
self.session_id,
frame_id,
repr(t_rel),
format_value(fn_r),
format_value(fx_r),
format_value(fy_r),
format_value(fn_n),
format_value(fx_n),
format_value(fy_n),
valid,
self.calibration_id,
)
)
segment = 0
segment_frame = 0
if frame is not None:
if self.config.video:
segment, segment_frame = self._write_video_frame(frame)
if self._png_dir is not None:
# The lossless sequence is independent of the MP4 setting.
self._write_lossless(frame)
self._frames[1].writerow(
(
frame_id,
repr(t_rel),
datetime.fromtimestamp(timestamp, timezone.utc).isoformat(
timespec="milliseconds"
),
segment,
segment_frame,
1 if segment else 0,
0,
)
)
def _write_video_frame(self, frame) -> tuple[int, int]:
if self._frame_shape is None:
self._frame_shape = (int(frame.shape[1]), int(frame.shape[0]))
if self._should_roll():
self._close_segment()
if self._segment is None:
self._open_segment()
self._segment.write(frame)
self._segment_frames += 1
self.video_frames += 1
return (self._segment_index, self._segment_frames)
def _write_lossless(self, frame) -> None:
self.png_frames += 1
target = self._png_dir / f"{self.png_frames:06d}.png"
if not cv2.imwrite(str(target), frame):
raise OSError(f"could not write {target.name}")
def write_event(self, event: dict) -> None:
self._events[1].writerow(
(event.get("at", ""), event.get("t_rel_s", ""), event["kind"], event["detail"])
)
# ------------------------------------------------------------------ segments
def _should_roll(self) -> bool:
if self._segment is None:
return False
if self.config.segment_seconds <= 0:
return False
return (time.perf_counter() - self._segment_started) >= self.config.segment_seconds
def _open_segment(self) -> None:
self._segment_index += 1
path = self.directory / f"raw_{self._segment_index:04d}.mp4"
fourcc = cv2.VideoWriter_fourcc(*self.config.codec)
fps = max(1.0, float(self.config.fps))
writer = cv2.VideoWriter(str(path), fourcc, fps, self._frame_shape)
if not writer.isOpened():
raise OSError(f"could not open video writer for {path.name}")
self._segment = writer
self._segment_frames = 0
self._segment_started = time.perf_counter()
self.segments = self._segment_index
def _close_segment(self) -> None:
if self._segment is None:
return
self._segment.release()
self._segment = None
def maybe_flush(self) -> None:
if (time.perf_counter() - self._last_flush) < self.config.flush_interval_s:
return
self._last_flush = time.perf_counter()
for handle, _writer in (self._forces, self._frames, self._events):
try:
handle.flush()
except Exception as exc: # noqa: BLE001
self.write_errors += 1
if len(self._problems) < 5:
self._problems.append(f"flush failed: {exc}")
def close(self) -> list[str]:
self._close_segment()
for handle, _writer in (self._forces, self._frames, self._events):
try:
handle.flush()
handle.close()
except Exception as exc: # noqa: BLE001
self.write_errors += 1
if len(self._problems) < 5:
self._problems.append(f"close failed: {exc}")
try:
self.bytes_written = sum(
path.stat().st_size for path in self.directory.glob("*") if path.is_file()
)
except Exception: # noqa: BLE001
self.bytes_written = 0
return self._problems
+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)