Browse a long recording from disk instead of loading it into memory

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.
This commit is contained in:
ykcheungab
2026-09-13 16:54:26 +08:00
parent 22cc59c402
commit 73592f085f
40 changed files with 6398 additions and 1070 deletions
@@ -14,20 +14,25 @@ from orisys_app_shared.acquisition.constants import (
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
# The packet and the geometry come from ``models``, so the finger worker does not
# depend on the box worker; only genuinely shared helpers are imported from it.
from orisys_app_shared.acquisition.models import (
FixedCameraMode,
SdkPacket,
ensure_bgr as _ensure_bgr,
is_file_source as _is_file_source,
load_fixed_mode,
orisys_importable,
)
from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S,
HANDOVER_TIMEOUT_S,
STOP_JOIN_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S,
FixedCameraMode,
SdkPacket,
_ensure_bgr,
_is_file_source,
calibration_digest,
load_fixed_mode,
loaded_calibration_version,
loaded_path,
orisys_importable,
process_chain,
read_forces_from_sensor,
)
@@ -65,13 +70,17 @@ class FingerAcquisitionWorker(RecordSinkMixin):
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None,
on_reset_applied: Optional[Callable[[int, float, int], None]] = None,
):
self._init_sinks()
self._on_error = on_error
self._on_status = on_status
#: Called from the acquisition thread once new coefficients are loaded.
#: Called from the acquisition thread once new coefficients are loaded,
#: with ``(path, version, frame_id, mono_t, session_id)``.
self._on_calibration_applied = on_calibration_applied
#: Called from the acquisition thread once a queued zeroing took effect.
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
@@ -86,6 +95,8 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._end_reason: Optional[str] = None
self._phase: str = "idle"
self._pending_calibration: Optional[str] = None
#: A zeroing requested from another thread, applied by this one.
self._pending_reset: bool = False
self._mono_t0: float = 0.0
self._calibration_version: str = ""
@@ -129,6 +140,11 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._latest = None
self._end_reason = None
self._phase = "connecting"
# This session starts with no calibration of its own: an uncalibrated
# runtime must not inherit the previous session's version, and a stale
# zeroing request must not fire into the new session's frames.
self._calibration_version = ""
self._pending_reset = False
self._session_id += 1
session = self._session_id
self._stop.clear()
@@ -171,22 +187,54 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._phase = "idle"
def reset(self) -> None:
"""Request a tracker reset, applied by the acquisition thread.
The runtime's tactile reset from the GUI thread would race the frame the
loop is processing, so the zeroing happens at a frame boundary on the
acquisition thread, which reports the frame and time it took effect.
"""
with self._lock:
runtime = self._runtimes.get(self._session_id)
if runtime is None:
raise RuntimeError("Finger runtime is not running")
runtime.tactile.reset()
if self._session_id <= 0:
raise RuntimeError("Finger runtime is not running")
self._pending_reset = True
def _consume_pending_reset(self, runtime, frame_id: int, mono_t: float) -> None:
"""Apply a queued reset and report the frame it actually took effect at."""
with self._lock:
pending = self._pending_reset
self._pending_reset = False
session = self._session_id
if not pending or runtime is None:
return
tactile = getattr(runtime, "tactile", None)
target = tactile if callable(getattr(tactile, "reset", None)) else None
if target is None:
sensor = getattr(tactile, "raw_sensor", None)
target = sensor if callable(getattr(sensor, "reset", None)) else None
if target is None:
raise RuntimeError("runtime does not support reset")
target.reset()
if self._on_reset_applied is not None:
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
def set_pending_calibration(self, path: str | Path | None) -> None:
"""Queue a force_calibration.json path for the acquisition thread."""
with self._lock:
self._pending_calibration = str(path) if path is not None else None
def _consume_pending_calibration(self, sensor, frame_id: int = 0) -> None:
"""Apply queued coefficients and report confirmation, not intent."""
def _consume_pending_calibration(
self, sensor, frame_id: int = 0, mono_t: float = 0.0
) -> None:
"""Apply queued coefficients and report confirmation, not intent.
A rejected document leaves the version alone on purpose: the sensor still
has the coefficients it had. What never happens is inventing a version for
a device that has no calibration — that is the empty string.
"""
with self._lock:
path = self._pending_calibration
self._pending_calibration = None
session = self._session_id
if path is None or sensor is None:
return
if not hasattr(sensor, "set_calibration"):
@@ -195,7 +243,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
version = calibration_digest(path)
self._calibration_version = version
if self._on_calibration_applied is not None:
self._on_calibration_applied(str(path), version, int(frame_id))
self._on_calibration_applied(
str(path), version, int(frame_id), float(mono_t), int(session)
)
def _set_end_reason(self, reason: str) -> None:
with self._lock:
@@ -287,12 +337,13 @@ class FingerAcquisitionWorker(RecordSinkMixin):
# name those coefficients from the first frame, not the request path.
tactile = getattr(runtime, "tactile", None)
loaded = loaded_calibration_version(tactile) if tactile else ""
if loaded:
self._calibration_version = loaded
if self._on_calibration_applied is not None:
self._on_calibration_applied(
str(loaded_path(tactile) or ""), loaded, 0
)
# Assigned even when empty: an uncalibrated device reports *no*
# calibration, never the previous session's version.
self._calibration_version = loaded
if loaded and self._on_calibration_applied is not None:
self._on_calibration_applied(
str(loaded_path(tactile) or ""), loaded, 0, 0.0, int(session)
)
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
@@ -341,20 +392,26 @@ class FingerAcquisitionWorker(RecordSinkMixin):
none_streak_start = None
tactile = runtime.tactile
compute_error = ""
# The frame's acquisition time, so an event that happens on this
# frame is dated when it happened rather than when it was queued.
frame_mono = max(0.0, time.perf_counter() - self._mono_t0)
try:
self._consume_pending_calibration(tactile, frame_id)
self._consume_pending_calibration(tactile, frame_id, frame_mono)
except Exception as exc: # noqa: BLE001
message = f"calibration rejected: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id:
self._on_error(message)
compute_error = message
try:
tactile.compute_deformation(decompose=True, check_motion=False)
tactile.compute_contact()
except Exception as exc: # noqa: BLE001 - reported on the packet
compute_error = compute_error or (
f"compute: {type(exc).__name__}: {exc}"
)
# Applied between two frames on this thread, then reported.
self._consume_pending_reset(runtime, frame_id, frame_mono)
except Exception as exc: # noqa: BLE001
message = f"reset failed: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id:
self._on_error(message)
# The same chain the offline depth replay runs on the tactile.
chain_depth, chain_error = process_chain(tactile)
compute_error = compute_error or chain_error
t1 = time.perf_counter()
frame_times.append(t1)
cutoff = t1 - 1.0
@@ -395,7 +452,11 @@ class FingerAcquisitionWorker(RecordSinkMixin):
stale_forces=bool(compute_error),
raw_available=bool(forces.get("raw_available", True)),
flow=forces["flow"],
depth=forces.get("depth"),
depth=(
forces.get("depth")
if forces.get("depth") is not None
else chain_depth
),
valid_mask=runtime.valid_mask,
)
with self._lock:
@@ -0,0 +1,131 @@
"""The data shapes shared by the acquisition workers, the record channel and the UI.
They live here rather than in one of the workers so that neither worker has to
import the other, and so the record channel can describe a packet without
importing a device loop. The workers re-export these names for compatibility.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class FixedCameraMode:
"""The camera geometry a session runs with."""
width: int
height: int
fps: float
config_ref: str
def label(self) -> str:
return f"{self.width}x{self.height} @{self.fps:g}"
@dataclass
class SdkPacket:
"""One acquired frame: the images, the force readout and its provenance."""
raw_frame: np.ndarray
processed_frame: np.ndarray
timestamp: float
fps: float
process_ms: float
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_unit: str = "raw"
force_calibrated: bool = False
#: Which axes actually have a calibration curve loaded. The sensor-level
#: ``force_calibrated`` flag cannot express a partially calibrated device.
calibrated_axes: tuple[str, ...] = ()
session_id: int = 0
#: Monotonic per-session frame index; the join key for video, CSV and
#: calibration records.
frame_id: int = 0
#: Monotonic, session-relative acquisition time. Used for ordering so a wall
#: clock that steps backwards cannot reorder history. ``None`` means the
#: producer did not measure one, and the wall clock is used instead — a
#: default of ``0.0`` would have made every frame look simultaneous.
mono_t: Optional[float] = None
#: Wall clock at hand-off, for metadata only. ``None`` means unmeasured.
wall_t: Optional[float] = None
#: False when the force readout failed. Invalid packets carry NaN forces and
#: must never be averaged into calibration data or written as zeros.
valid: bool = True
invalid_reason: str = ""
#: True when the compute chain failed for this frame. The SDK's force fields
#: then still hold the value computed for an earlier frame, so they must not
#: be recorded as this frame's measurement.
stale_forces: bool = False
#: Content hash of the calibration actually in effect when this frame was
#: acquired; changes mid-session when new coefficients are applied.
calibration_version: str = ""
#: False when the sensor exposes no RAW channels; the raw fields are then
#: NaN rather than a copy of the calibrated values.
raw_available: bool = True
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
"""Resolve a config reference (a file path or a product name) to its geometry."""
ref = str(config_ref).strip()
path = Path(ref)
if path.is_file():
data = json.loads(path.read_text(encoding="utf-8"))
resolved = str(path.resolve())
else:
import orisys
data = orisys.configs.load_config(ref)
resolved = ref
camera = data.get("camera") or {}
raw = camera.get("resolution_raw") or [1920, 1080]
fps = float(camera.get("fps") or 30.0)
return FixedCameraMode(
width=int(raw[0]),
height=int(raw[1]),
fps=fps,
config_ref=resolved,
)
def ensure_bgr(img: np.ndarray) -> np.ndarray:
"""Three-channel BGR view of whatever the sensor hands over."""
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def is_file_source(video_source: str | int) -> bool:
"""True when the source is a file (a replay) rather than a live camera."""
return isinstance(video_source, str)
def orisys_importable() -> tuple[bool, str]:
"""Whether the SDK can be imported, with the reason when it cannot."""
try:
import orisys # noqa: F401
return True, getattr(__import__("orisys"), "__version__", "unknown")
except Exception as exc: # noqa: BLE001
return False, str(exc)
@@ -19,6 +19,14 @@ from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.acquisition.models import (
FixedCameraMode,
SdkPacket,
ensure_bgr as _ensure_bgr,
is_file_source as _is_file_source,
load_fixed_mode,
orisys_importable,
)
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
VIDEO_EOF_TIMEOUT_S = 0.5
@@ -29,105 +37,22 @@ STOP_JOIN_TIMEOUT_S = 5.0
#: previous session that is still winding down before opening the device.
HANDOVER_TIMEOUT_S = 60.0
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
# definitions live in ``models`` so neither worker depends on the other.
__all__ = [
"FixedCameraMode",
"SdkPacket",
"SdkAcquisitionWorker",
"calibration_digest",
"load_fixed_mode",
"loaded_calibration_version",
"loaded_path",
"orisys_importable",
"process_chain",
"read_depth",
"read_forces_from_sensor",
]
@dataclass(frozen=True)
class FixedCameraMode:
width: int
height: int
fps: float
config_ref: str
def label(self) -> str:
return f"{self.width}x{self.height} @{self.fps:g}"
@dataclass
class SdkPacket:
raw_frame: np.ndarray
processed_frame: np.ndarray
timestamp: float
fps: float
process_ms: float
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_unit: str = "raw"
force_calibrated: bool = False
#: Which axes actually have a calibration curve loaded. The sensor-level
#: ``force_calibrated`` flag cannot express a partially calibrated device.
calibrated_axes: tuple[str, ...] = ()
session_id: int = 0
#: Monotonic per-session frame index; the join key for video, CSV and
#: calibration records.
frame_id: int = 0
#: Monotonic, session-relative acquisition time. Used for ordering so a wall
#: clock that steps backwards cannot reorder history. ``None`` means the
#: producer did not measure one, and the wall clock is used instead — a
#: default of ``0.0`` would have made every frame look simultaneous.
mono_t: Optional[float] = None
#: Wall clock at hand-off, for metadata only. ``None`` means unmeasured.
wall_t: Optional[float] = None
#: False when the force readout failed. Invalid packets carry NaN forces and
#: must never be averaged into calibration data or written as zeros.
valid: bool = True
invalid_reason: str = ""
#: True when the compute chain failed for this frame. The SDK's force fields
#: then still hold the value computed for an earlier frame, so they must not
#: be recorded as this frame's measurement.
stale_forces: bool = False
#: Content hash of the calibration actually in effect when this frame was
#: acquired; changes mid-session when new coefficients are applied.
calibration_version: str = ""
#: False when the sensor exposes no RAW channels; the raw fields are then
#: NaN rather than a copy of the calibrated values.
raw_available: bool = True
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
def orisys_importable() -> tuple[bool, str]:
try:
import orisys # noqa: F401
return True, getattr(__import__("orisys"), "__version__", "unknown")
except Exception as exc: # noqa: BLE001
return False, str(exc)
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
ref = str(config_ref).strip()
path = Path(ref)
if path.is_file():
data = json.loads(path.read_text(encoding="utf-8"))
resolved = str(path.resolve())
else:
import orisys
data = orisys.configs.load_config(ref)
resolved = ref
camera = data.get("camera") or {}
raw = camera.get("resolution_raw") or [1920, 1080]
fps = float(camera.get("fps") or 30.0)
return FixedCameraMode(
width=int(raw[0]),
height=int(raw[1]),
fps=fps,
config_ref=resolved,
)
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool:
@@ -147,6 +72,43 @@ def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) ->
return False
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
"""Run the live processing chain for one already-read frame.
``get_img`` alone is not the pipeline: the SDK's depth map is produced by
``compute_deformation`` + ``compute_contact``. The offline depth replay calls
this same function, so it cannot drift from what was recorded live.
Returns ``(depth, error)``; the depth is ``None`` when the chain failed or the
sensor exposes none.
"""
compute_error = ""
compute_deformation = getattr(sensor, "compute_deformation", None)
compute_contact = getattr(sensor, "compute_contact", None)
if callable(compute_deformation) and callable(compute_contact):
# Same calls and arguments as the acquisition loop.
try:
compute_deformation(decompose=True, check_motion=False)
compute_contact()
except Exception as exc: # noqa: BLE001 - reported to the caller
compute_error = f"compute: {type(exc).__name__}: {exc}"
else:
compute_error = f"no processing chain on {type(sensor).__name__}"
return read_depth(sensor), compute_error
def read_depth(sensor) -> Optional[np.ndarray]:
"""The current depth channel of ``sensor``, or ``None`` when unavailable."""
try:
info = sensor.info
if not hasattr(info, "DEPTH"):
return None
value = sensor.read_info(info.DEPTH)
except Exception: # noqa: BLE001 - reported as "no depth for this frame"
logger.exception("Could not read the depth channel")
return None
return None if value is None else np.asarray(value)
def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
"""Read one force sample.
@@ -284,10 +246,6 @@ def _calibrated_axes(sensor) -> tuple[str, ...]:
return tuple(axes)
def _is_file_source(video_source: str | int) -> bool:
return isinstance(video_source, str)
def calibration_digest(path: str | Path) -> str:
"""Content hash of a calibration document — the calibration's identity.
@@ -352,14 +310,20 @@ class SdkAcquisitionWorker(RecordSinkMixin):
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None,
on_reset_applied: Optional[Callable[[int, float, int], None]] = None,
):
self._init_sinks()
self._on_error = on_error
self._on_status = on_status
#: Called from the acquisition thread once new coefficients are actually
#: loaded, with ``(path, version, frame_id)``. Confirmation, not intent.
#: loaded, with ``(path, version, frame_id, mono_t, session_id)``.
#: Confirmation, not intent — and it carries the frame and time so the
#: recorded event is dated when it happened.
self._on_calibration_applied = on_calibration_applied
#: Called from the acquisition thread once a queued zeroing took effect,
#: with ``(frame_id, mono_t, session_id)``.
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
@@ -373,6 +337,8 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._end_reason: Optional[str] = None
self._phase: str = "idle"
self._pending_calibration: Optional[str] = None
#: A zeroing requested from another thread, applied by this one.
self._pending_reset: bool = False
#: Monotonic origin for ``SdkPacket.mono_t``, reset per session.
self._mono_t0: float = 0.0
#: Content hash of the calibration currently in effect.
@@ -418,6 +384,11 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._latest = None
self._end_reason = None
self._phase = "connecting"
# The previous session's coefficients say nothing about this one: an
# uncalibrated device must not inherit a version it never loaded, and
# an old reset request must not fire into the new session's frames.
self._calibration_version = ""
self._pending_reset = False
self._session_id += 1
session = self._session_id
self._stop.clear()
@@ -463,28 +434,57 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._phase = "idle"
def reset(self) -> None:
"""Request a tracker reset, applied by the acquisition thread.
``Sensor.reset()`` from the GUI thread would race the frame the loop is
processing, so the actual zeroing happens at a frame boundary on the
acquisition thread — which then reports the frame and the time it took
effect. The confirmation callback is ``on_reset_applied``.
"""
with self._lock:
sensor = self._sensors.get(self._session_id)
if sensor is None:
raise RuntimeError("Sensor is not running")
sensor.reset()
if self._session_id <= 0:
raise RuntimeError("Sensor is not running")
self._pending_reset = True
def _consume_pending_reset(self, sensor, frame_id: int, mono_t: float) -> None:
"""Apply a queued reset and report the frame it actually took effect at."""
with self._lock:
pending = self._pending_reset
self._pending_reset = False
session = self._session_id
if not pending or sensor is None:
return
reset = getattr(sensor, "reset", None)
if not callable(reset):
raise RuntimeError("sensor does not support reset")
reset()
if self._on_reset_applied is not None:
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
def set_pending_calibration(self, path: str | Path | None) -> None:
"""Queue a force_calibration.json path for the acquisition thread."""
with self._lock:
self._pending_calibration = str(path) if path is not None else None
def _consume_pending_calibration(self, sensor, frame_id: int = 0) -> None:
def _consume_pending_calibration(
self, sensor, frame_id: int = 0, mono_t: float = 0.0
) -> None:
"""Apply queued coefficients and report **confirmation**, not intent.
``Sensor.set_calibration`` loads with ``strict=True`` and raises on an
invalid document, so success is observable. The old code swallowed that
exception, which is why the app logged "calibration applied" before
anything had actually been loaded.
A rejection leaves the version alone on purpose: the sensor still has the
coefficients it had, so the previous version is still the true one. What
never happens is inventing a version for a device that has no calibration
— that is the empty string, set when the session opened.
"""
with self._lock:
path = self._pending_calibration
self._pending_calibration = None
session = self._session_id
if path is None or sensor is None:
return
if not hasattr(sensor, "set_calibration"):
@@ -493,7 +493,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
version = calibration_digest(path)
self._calibration_version = version
if self._on_calibration_applied is not None:
self._on_calibration_applied(str(path), version, int(frame_id))
self._on_calibration_applied(
str(path), version, int(frame_id), float(mono_t), int(session)
)
def _set_end_reason(self, reason: str) -> None:
with self._lock:
@@ -574,12 +576,13 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# here. Without this the record would name the path passed on the
# command line instead of the calibration that was applied.
loaded = loaded_calibration_version(sensor)
if loaded:
self._calibration_version = loaded
if self._on_calibration_applied is not None:
self._on_calibration_applied(
str(loaded_path(sensor) or ""), loaded, 0
)
# Assigned even when empty: an uncalibrated device must report *no*
# calibration, never the previous session's version.
self._calibration_version = loaded
if loaded and self._on_calibration_applied is not None:
self._on_calibration_applied(
str(loaded_path(sensor) or ""), loaded, 0, 0.0, int(session)
)
self._status(f"Streaming {mode.label()}")
except Exception as exc:
self._set_end_reason("error")
@@ -627,23 +630,32 @@ class SdkAcquisitionWorker(RecordSinkMixin):
none_streak_start = None
compute_error = ""
# The frame's acquisition time, so an event that happens on this
# frame is dated when it happened rather than when it was queued.
frame_mono = max(0.0, time.perf_counter() - self._mono_t0)
try:
# Applying new coefficients is reported separately from the
# compute chain: a rejected document is an operator error,
# not a numerical failure.
self._consume_pending_calibration(sensor, frame_id)
self._consume_pending_calibration(sensor, frame_id, frame_mono)
except Exception as exc: # noqa: BLE001
message = f"calibration rejected: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id:
self._on_error(message)
compute_error = message
try:
sensor.compute_deformation(decompose=True, check_motion=False)
sensor.compute_contact()
except Exception as exc: # noqa: BLE001 - reported on the packet
compute_error = compute_error or (
f"compute: {type(exc).__name__}: {exc}"
)
# The zeroing is applied *here*, between two frames, and only
# then reported — the same confirmation rule as the
# calibration.
self._consume_pending_reset(sensor, frame_id, frame_mono)
except Exception as exc: # noqa: BLE001
message = f"reset failed: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id:
self._on_error(message)
# The same chain the offline depth replay runs. The depth it
# reads is the packet's depth, so the channel is read once.
chain_depth, chain_error = process_chain(sensor)
compute_error = compute_error or chain_error
t1 = time.perf_counter()
frame_times.append(t1)
cutoff = t1 - 1.0
@@ -683,7 +695,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
stale_forces=bool(compute_error),
raw_available=bool(forces.get("raw_available", True)),
flow=forces["flow"],
depth=forces.get("depth"),
depth=forces.get("depth") if forces.get("depth") is not None else chain_depth,
)
with self._lock:
self._latest = packet
@@ -8,10 +8,10 @@ from __future__ import annotations
import math
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import Any, Literal, Mapping
from typing import Any, Iterable, Literal, Mapping
from orisys.calibration import (
CalSample as SdkCalSample,
@@ -95,8 +95,6 @@ def predict_channel(cal, channel: str, raw: float) -> float:
triple = [0.0, 0.0, 0.0]
triple[_CONVERT_SLOT[channel]] = float(raw)
return float(cal.convert(triple[0], triple[1], triple[2])[_CONVERT_SLOT[channel]])
@dataclass
class CalSessionStore:
"""In-memory calibration points for FN / FX / FY."""
@@ -176,12 +174,8 @@ def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
}
def build_force_calibration_v2(
store: CalSessionStore,
*,
meta: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Build schema v2 JSON. Returns (document, skipped_channel_warnings)."""
def _meta_blocks(meta: dict[str, Any] | None) -> tuple[dict, dict, dict, str]:
"""Split a flat export-meta dict into the SDK's identity/pipeline/extra."""
meta = dict(meta or {})
identity = {
"sensor_type": meta.get("sensor_type", ""),
@@ -194,9 +188,9 @@ def build_force_calibration_v2(
"config_ref": meta.get("config_ref"),
}
extra = {
k: v
for k, v in meta.items()
if k
key: value
for key, value in meta.items()
if key
not in (
"sensor_type",
"sn",
@@ -207,30 +201,255 @@ def build_force_calibration_v2(
"samples",
)
}
return identity, pipeline, extra, str(meta.get("origin") or "user")
def aggregate_samples(rows: Iterable[CalSample]) -> list[CalSample]:
"""n-weighted merge of repeated reference loads, sorted by load.
Sampling the same load twice is normal and deliberate; the model has to see
the combined mean and the scatter *between* the samples, not whichever
sample happened to be last.
"""
merged: list[CalSample] = []
for sample in rows:
for index, existing in enumerate(merged):
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
merged[index] = merge_samples(existing, sample)
break
else:
merged.append(sample)
merged.sort(key=lambda item: item.weight_n)
return merged
def training_samples(points) -> tuple[dict[str, list[CalSample]], list, list]:
"""Split points into the training build and the held-out validation set.
This is the **single** filter: a point that is excluded, invalid or has no
reference load never reaches a model, and a validation point is only ever
measured against one. Callers must not re-implement it.
"""
training: list = []
validation: list = []
for point in points or ():
if bool(getattr(point, "excluded", False)):
continue
try:
load = float(point.reference_load_n)
raw_mean = float(point.loaded_stats().mean)
except (AttributeError, TypeError, ValueError):
continue
if not math.isfinite(load) or not math.isfinite(raw_mean):
continue
if bool(getattr(point, "is_validation", False)):
validation.append(point)
else:
training.append(point)
by_channel: dict[str, list[CalSample]] = {key: [] for key in CHANNEL_KEYS}
for point in training:
stats = point.loaded_stats()
std = float(stats.std)
by_channel[str(point.channel)].append(
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(stats.mean),
n=int(stats.n or 0),
std=std if math.isfinite(std) else 0.0,
)
)
return (
{key: aggregate_samples(rows) for key, rows in by_channel.items()},
training,
validation,
)
@dataclass(frozen=True)
class PreparedCalibration:
"""One preparation of a calibration session: samples -> model -> diagnostics.
The diagnostics and the exported document come from *this* object, so the
numbers on screen describe exactly the model that is written to disk. Two
separate preparations (one for the panel, one for the fit) is how the
displayed residual and the applied curve drifted apart in the first place.
"""
model: Any = None
samples: dict[str, list[CalSample]] = field(default_factory=dict)
training: list = field(default_factory=list)
validation: list = field(default_factory=list)
#: ``id(point)`` -> prediction error at that point (predicted - reference).
residuals: dict[int, float] = field(default_factory=dict)
#: ``id(point)`` -> absolute error of a held-out point.
validation_errors: dict[int, float] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
meta: dict = field(default_factory=dict)
@property
def ok(self) -> bool:
return self.model is not None
def channel_samples(self, key: str) -> list[CalSample]:
return list(self.samples.get(key) or [])
def method(self, channel: str):
curve = self.curve(channel)
return str(getattr(curve, "method", "") or "") if curve is not None else None
def breakpoints(self, channel: str) -> int:
curve = self.curve(channel)
return len(getattr(curve, "points", ()) or ()) if curve is not None else 0
def curve(self, channel: str):
if self.model is None:
return None
return self.model.get(channel)
def predict(self, channel: str, raw: float) -> float:
if self.model is None:
return float("nan")
return predict_channel(self.model, channel, raw)
def points_in_fit(self, channel: str) -> int:
return len(self.samples.get(channel) or [])
def validation_points(self, channel: str) -> int:
return sum(1 for point in self.validation if point.channel == channel)
def max_residual(self, channel: str) -> float:
values = [
abs(value)
for point in self.training
if point.channel == channel
for value in [self.residuals.get(id(point))]
if value is not None and math.isfinite(value)
]
return max(values) if values else float("nan")
def max_validation_error(self, channel: str) -> float:
values = [
value
for point in self.validation
if point.channel == channel
for value in [self.validation_errors.get(id(point))]
if value is not None and math.isfinite(value)
]
return max(values) if values else float("nan")
def to_document(self) -> dict[str, Any]:
"""The schema-v2 document for this prepared model."""
if self.model is None:
raise ValueError(
"No complete channels to build (each needs ≥2 points). "
+ "; ".join(self.warnings)
)
return self.model.to_dict()
def prepare_from_samples(
samples: dict[str, list[CalSample]],
*,
meta: dict[str, Any] | None = None,
) -> PreparedCalibration:
"""Build the model and its diagnostics from prepared training samples.
Repeated loads are merged here too, so a caller that hands in raw (unmerged)
samples still gets the same model as one that hands in points.
"""
merged = {
key: aggregate_samples(samples.get(key) or []) for key in CHANNEL_KEYS
}
identity, pipeline, extra, origin = _meta_blocks(meta)
warnings: list[str] = []
model = None
try:
cal = sdk_build(
store.to_sdk_samples(),
model = sdk_build(
{key: [sample.to_sdk() for sample in rows] for key, rows in merged.items()},
identity=identity,
pipeline=pipeline,
origin=str(meta.get("origin") or "user"),
origin=origin,
extra_meta=extra,
)
except Exception as exc:
# Match previous API: raise ValueError with channel warnings when empty.
raise ValueError(str(exc)) from exc
except Exception as exc: # noqa: BLE001 - surfaced as warnings + no model
warnings.append(str(exc))
if model is not None:
try:
warnings.extend(sdk_check(model).warnings)
except Exception as exc: # noqa: BLE001
warnings.append(f"check failed: {exc}")
for key in CHANNEL_KEYS:
if not model.has(key):
warnings.append(_missing_channel_message(key, merged.get(key) or []))
return PreparedCalibration(
model=model,
samples=merged,
warnings=warnings,
meta=dict(meta or {}),
)
result = sdk_check(cal)
warnings = list(result.warnings)
for key in CHANNEL_KEYS:
if not cal.has(key):
rows = store.channel(key)
if rows:
msg = f"{key}: need at least 2 points (have {len(rows)})"
else:
msg = f"{key}: no samples"
if msg not in warnings:
warnings.append(msg)
return cal.to_dict(), warnings
def _missing_channel_message(key: str, rows: list[CalSample]) -> str:
if rows:
return f"{key}: need at least 2 points (have {len(rows)})"
return f"{key}: no samples"
def prepare_calibration(
points,
*,
meta: dict[str, Any] | None = None,
) -> PreparedCalibration:
"""The one entry point: points -> training samples -> model -> diagnostics."""
samples, training, validation = training_samples(points)
prepared = prepare_from_samples(samples, meta=meta)
residuals: dict[int, float] = {}
for point in training:
predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean))
if math.isfinite(predicted):
residuals[id(point)] = predicted - float(point.reference_load_n)
validation_errors: dict[int, float] = {}
for point in validation:
predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean))
if math.isfinite(predicted):
validation_errors[id(point)] = abs(predicted - float(point.reference_load_n))
return replace(
prepared,
training=list(training),
validation=list(validation),
residuals=residuals,
validation_errors=validation_errors,
)
def build_export_model(samples: dict[str, list[CalSample]]):
"""The SDK model for ``samples`` — the same preparation the panel uses."""
return prepare_from_samples(samples).model
def build_force_calibration_v2(
source,
*,
meta: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Serialise the prepared model. Returns (document, warnings).
``source`` is a :class:`PreparedCalibration` (the normal path — the document
is exactly the model that was diagnosed) or, for older callers, a
:class:`CalSessionStore`, which is prepared here with ``meta``.
"""
prepared = (
source
if isinstance(source, PreparedCalibration)
else prepare_from_samples(
{key: source.channel(key) for key in CHANNEL_KEYS}, meta=meta
)
)
if prepared.model is None:
# Match the previous API: raise ValueError carrying the channel warnings.
raise ValueError("; ".join(prepared.warnings) or "no complete channels")
return prepared.to_document(), list(prepared.warnings)
def write_force_calibration(
@@ -245,15 +464,18 @@ def write_force_calibration(
def export_force_calibration_v2(
store: CalSessionStore,
source,
*,
export_dir: str | Path,
sensor_type: str,
meta: dict[str, Any] | None = None,
local_bundle_dir: str | Path | None = None,
) -> tuple[Path, Path | None, list[str]]:
"""Write timestamped export and optional local bundle force_calibration.json."""
doc, warnings = build_force_calibration_v2(store, meta=meta)
"""Write timestamped export and optional local bundle force_calibration.json.
``source`` is the prepared calibration (preferred) or a session store.
"""
doc, warnings = build_force_calibration_v2(source, meta=meta)
if not doc.get("channels"):
raise ValueError(
"No complete channels to export (each needs ≥2 points). "
@@ -23,123 +23,39 @@ from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Literal, Optional
from typing import Callable, Optional
import numpy as np
from orisys_app_shared.force.session import NA, SessionSample, summarise
from orisys_app_shared.force.models import (
CHANNEL_KEYS,
MAX_TRACE_POINTS,
NA,
SAMPLE_FIELDS,
STATUS_CANCELLED,
STATUS_FEW_SAMPLES,
STATUS_OK,
AxisStats,
CalibrationPoint,
ChannelKey,
trace_dt,
trace_from,
)
from orisys_app_shared.force.session import SessionSample, summarise
try: # pragma: no cover - the module is a plain data definition
from orisys_app_shared.force.calibration_session import SAMPLE_FIELDS
except Exception: # noqa: BLE001 - a partial install must not break sampling
SAMPLE_FIELDS = (
"frame_id",
"t_rel_s",
"fn_raw",
"fx_raw",
"fy_raw",
"fn_n",
"fx_n",
"fy_n",
"valid",
)
ChannelKey = Literal["fn", "fx", "fy"]
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
STATUS_OK = "ok"
STATUS_FEW_SAMPLES = "few_samples"
STATUS_CANCELLED = "cancelled"
@dataclass(frozen=True)
class AxisStats:
"""Mean / standard deviation / valid count for one axis."""
mean: float = NA
std: float = NA
n: int = 0
def as_dict(self) -> dict:
return {"mean": self.mean, "std": self.std, "n": self.n}
@dataclass
class CalibrationPoint:
"""One recorded calibration point, with all three axes captured."""
channel: ChannelKey
reference_load_n: float
direction: str = "load"
round_index: int = 0
note: str = ""
started_at: str = ""
ended_at: str = ""
duration_s: float = 0.0
requested_duration_s: float = 0.0
n_total: int = 0
n_valid: int = 0
raw: dict[str, AxisStats] = field(default_factory=dict)
newtons: dict[str, AxisStats] = field(default_factory=dict)
status: str = STATUS_OK
#: Downsampled per-axis raw trace, so a row can be inspected after the fact.
trace: dict[str, list[float]] = field(default_factory=dict)
trace_dt_s: float = 0.0
#: **Audit** series: every sample of the point, in
#: :data:`~orisys_app_shared.force.calibration_session.SAMPLE_FIELDS` order.
#: The trace above is a preview and must never be the only surviving copy.
samples: list[tuple] = field(default_factory=list)
#: Diagnostics, all operator-controlled. Excluded points leave the fit;
#: validation points are held out of it and used to test it.
excluded: bool = False
is_validation: bool = False
#: Filled in by :meth:`CalibrationRecorder.fit`.
residual_n: float = NA
def loaded_stats(self) -> AxisStats:
return self.raw.get(self.channel, AxisStats())
def axis_mean(self, channel: str) -> float:
stats = self.raw.get(channel)
return stats.mean if stats is not None else NA
def state_key(self) -> str:
if self.excluded:
return "cal.state_excluded"
if self.is_validation:
return "cal.state_validation"
return "cal.state_ok"
def as_dict(self) -> dict:
return {
"channel": self.channel,
"reference_load_n": self.reference_load_n,
# Only the loaded axis has a known reference load; the other two are
# intentionally absent rather than zero.
"direction": self.direction,
"round_index": self.round_index,
"note": self.note,
"started_at": self.started_at,
"ended_at": self.ended_at,
"duration_s": self.duration_s,
"requested_duration_s": self.requested_duration_s,
"n_total": self.n_total,
"n_valid": self.n_valid,
"status": self.status,
"excluded": self.excluded,
"is_validation": self.is_validation,
"residual_n": self.residual_n,
"raw": {key: stats.as_dict() for key, stats in self.raw.items()},
"newtons": {key: stats.as_dict() for key, stats in self.newtons.items()},
# The audit series is part of the point's identity, not a UI extra:
# without it the only surviving copy of the samples is the
# downsampled trace.
"sample_fields": list(SAMPLE_FIELDS),
"n_samples": len(self.samples),
"samples": [list(row) for row in self.samples],
}
# Re-exported: the models live in ``force.models`` so the session file format can
# describe a point without importing the sampling state machine.
__all__ = [
"AxisStats",
"CalibrationPoint",
"CalibrationRecorder",
"FitResult",
"SAMPLE_FIELDS",
"trace_dt",
"trace_from",
]
@dataclass(frozen=True)
@@ -177,10 +93,6 @@ class FitResult:
}
#: Cap on stored trace samples per axis; peaks are preserved when reducing.
MAX_TRACE_POINTS = 400
class CalibrationRecorder:
"""Owns the calibration sampling lifecycle."""
@@ -358,8 +270,8 @@ class CalibrationRecorder:
raw={"fn": axis_stats(2), "fx": axis_stats(3), "fy": axis_stats(4)},
newtons={"fn": axis_stats(5), "fx": axis_stats(6), "fy": axis_stats(7)},
status=STATUS_OK if valid_block.shape[0] >= 2 else STATUS_FEW_SAMPLES,
trace=_trace_from(valid_block),
trace_dt_s=_trace_dt(valid_block),
trace=trace_from(valid_block),
trace_dt_s=trace_dt(valid_block),
# Every sample is kept, including the invalid ones: a point that was
# mostly invalid is itself a finding. ``samples`` holds the original
# tuples so ``frame_id`` stays an integer.
@@ -456,94 +368,71 @@ class CalibrationRecorder:
with self._lock:
return [point for point in self.points if point.channel == channel]
def fit(self, channel: ChannelKey) -> FitResult:
def prepared(self, *, meta: Optional[dict] = None):
"""The session prepared into one model plus its diagnostics.
Everything that shows or writes a calibration goes through this, so the
panel, the fit summary and the exported file cannot disagree about which
points were used.
"""
from orisys_app_shared.force.calibration_io import prepare_calibration
with self._lock:
points = list(self.points)
return prepare_calibration(points, meta=meta)
def fit(self, channel: ChannelKey, prepared=None) -> FitResult:
"""Diagnose the model that export and application would actually use.
The residual and validation figures used to come from a private
least-squares line while the exported file carried a piecewise curve, so
the numbers on screen did not describe the calibration that would be
applied. Both now come from the exported curve. Excluded points are
dropped, validation points are held out of the build and only measured.
applied. Both now come from the one prepared model (see
:func:`~orisys_app_shared.force.calibration_io.prepare_calibration`):
excluded points are dropped, validation points are held out of the build
and only measured, and repeated loads are merged before the build.
"""
from orisys_app_shared.force.calibration_io import (
CalSample,
build_export_model,
predict_channel,
)
if prepared is None:
prepared = self.prepared()
points = self.points_for(channel)
usable = [
point
for point in points
if not point.excluded and not point.is_validation
and np.isfinite(point.reference_load_n)
and np.isfinite(point.loaded_stats().mean)
]
for point in points:
point.residual_n = NA
if len(usable) < 2:
return FitResult(channel=channel, n_points=len(usable))
value = prepared.residuals.get(id(point))
point.residual_n = NA if value is None else float(value)
samples: dict[str, list[CalSample]] = {key: [] for key in CHANNEL_KEYS}
for point in usable:
samples[point.channel].append(
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(point.loaded_stats().mean),
n=int(point.loaded_stats().n),
std=float(point.loaded_stats().std)
if np.isfinite(point.loaded_stats().std)
else 0.0,
)
)
try:
model = build_export_model(samples)
curve = model.get(channel)
except Exception:
# Without a buildable model there is nothing honest to report.
return FitResult(channel=channel, n_points=len(usable))
if curve is None:
return FitResult(channel=channel, n_points=len(usable))
curve = prepared.curve(channel)
n_points = prepared.points_in_fit(channel)
if curve is None or n_points < 2:
return FitResult(channel=channel, n_points=n_points)
residuals = []
for point in usable:
predicted = predict_channel(model, point.channel, point.loaded_stats().mean)
residual = predicted - float(point.reference_load_n)
point.residual_n = float(residual)
residuals.append(residual)
validation = [
point
for point in points
if not point.excluded and point.is_validation
and np.isfinite(point.reference_load_n)
and np.isfinite(point.loaded_stats().mean)
]
validation_error = NA
if validation:
errors = [
abs(
predict_channel(model, point.channel, point.loaded_stats().mean)
- float(point.reference_load_n)
)
for point in validation
]
validation_error = float(max(errors))
raws = np.asarray([point.loaded_stats().mean for point in usable], dtype=float)
loads = np.asarray([point.reference_load_n for point in usable], dtype=float)
raws = np.asarray(
[
float(point.loaded_stats().mean)
for point in prepared.training
if point.channel == channel
],
dtype=float,
)
loads = np.asarray(
[
float(point.reference_load_n)
for point in prepared.training
if point.channel == channel
],
dtype=float,
)
linear_gain = linear_offset = NA
if not np.allclose(raws, raws[0]):
if raws.size >= 2 and not np.allclose(raws, raws[0]):
linear_gain, linear_offset = (float(v) for v in np.polyfit(raws, loads, 1))
return FitResult(
channel=channel,
n_points=len(usable),
max_residual_n=float(np.max(np.abs(residuals))),
validation_error_n=validation_error,
validation_points=len(validation),
n_points=n_points,
max_residual_n=prepared.max_residual(channel),
validation_error_n=prepared.max_validation_error(channel),
validation_points=prepared.validation_points(channel),
method=str(getattr(curve, "method", "") or ""),
breakpoints=len(getattr(curve, "points", ()) or ()),
breakpoints=prepared.breakpoints(channel),
linear_gain=linear_gain,
linear_offset=linear_offset,
)
@@ -554,37 +443,3 @@ class CalibrationRecorder:
self._rounds[channel] = index + 1
return index
def _trace_from(valid_block: np.ndarray) -> dict[str, list[float]]:
"""Downsampled raw trace per axis, bounded by ``MAX_TRACE_POINTS``."""
if valid_block.size == 0:
return {}
traces: dict[str, list[float]] = {}
for key, column in (("fn", 2), ("fx", 3), ("fy", 4)):
values = valid_block[:, column]
values = values[np.isfinite(values)]
if values.size == 0:
continue
if values.size > MAX_TRACE_POINTS:
# Keep the shape of the trace, including its extremes.
edges = np.linspace(0, values.size, MAX_TRACE_POINTS + 1, dtype=int)
reduced = [
values[start:max(start + 1, stop)]
for start, stop in zip(edges[:-1], edges[1:])
]
values = np.asarray(
[
bucket[int(np.argmax(np.abs(bucket)))]
for bucket in reduced
if bucket.size
]
)
traces[key] = [float(value) for value in values]
return traces
def _trace_dt(valid_block: np.ndarray) -> float:
if valid_block.shape[0] < 2:
return 0.0
span = float(valid_block[-1, 1] - valid_block[0, 1])
return span / max(1, valid_block.shape[0] - 1)
@@ -23,23 +23,19 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Optional
from orisys_app_shared.force.models import (
SAMPLE_FIELDS,
AxisStats,
CalibrationPoint,
trace_dt,
trace_from,
)
logger = logging.getLogger(__name__)
#: Format version of the calibration session document.
SESSION_FORMAT_VERSION = "1.0"
SESSION_REQUIRED = ("format", "format_version", "created_at", "points")
#: Columns of each sample row inside a point, in order.
SAMPLE_FIELDS = (
"frame_id",
"t_rel_s",
"fn_raw",
"fx_raw",
"fy_raw",
"fn_n",
"fx_n",
"fy_n",
"valid",
)
POINT_REQUIRED = ("channel", "reference_load_n")
@@ -59,6 +55,29 @@ class CalibrationSessionRecord:
def ok(self) -> bool:
return bool(self.points) and not self.warnings
def identity(self) -> dict:
"""The device this session belongs to.
A calibration record is evidence about *one* device: its sensor type, its
serial number and the configuration it was taken with. Reopening it must
not restamp it with whatever device happens to be connected now.
"""
return {
"sensor_type": str(self.meta.get("sensor_type") or ""),
"sn": str(self.meta.get("sn") or ""),
"config_ref": str(self.meta.get("config_ref") or ""),
"created_at": self.created_at,
}
def describe(self) -> str:
identity = self.identity()
parts = [identity["sensor_type"] or "?"]
if identity["sn"]:
parts.append(f"SN {identity['sn']}")
if identity["config_ref"]:
parts.append(identity["config_ref"])
return " / ".join(parts)
def _jsonable(value: Any) -> Any:
"""NaN/Inf are not valid JSON; they are written as ``null``."""
@@ -147,11 +166,6 @@ def read_calibration_session(path: str | Path) -> CalibrationSessionRecord:
if version != SESSION_FORMAT_VERSION:
warnings.append(f"format_version {version or '?'} is not {SESSION_FORMAT_VERSION}")
from orisys_app_shared.force.calibration_recorder import (
CalibrationPoint,
AxisStats,
)
points = []
for index, raw in enumerate(data.get("points") or []):
if not isinstance(raw, dict):
@@ -228,9 +242,39 @@ def _point_from_dict(raw: dict, CalibrationPoint, AxisStats):
values = [float("nan") if value is None else value for value in row]
rows.append(tuple(values))
point.samples = rows
# The full series is the audit data, so everything a diagnostic view needs is
# rebuilt from it: the per-axis trace, its sample interval and, when the
# stored duration is missing, the real sampling span.
restore_diagnostics(point)
return point
def restore_diagnostics(point) -> None:
"""Rebuild the three-axis trace and the sampling span from the samples."""
if not point.samples:
return
try:
import numpy as np
except Exception: # noqa: BLE001 - a partial install must still reopen points
return
width = max(len(row) for row in point.samples)
padded = [tuple(row) + (float("nan"),) * (width - len(row)) for row in point.samples]
block = np.asarray(padded, dtype=np.float64)
if block.shape[1] < len(SAMPLE_FIELDS):
return
valid = block[:, 8] > 0.5
valid_block = block[valid]
if not point.trace and valid_block.size:
point.trace = trace_from(valid_block)
if not point.trace_dt_s and valid_block.size:
point.trace_dt_s = trace_dt(valid_block)
if not point.duration_s and block.shape[0] > 1:
span = float(block[-1, 1] - block[0, 1])
if span == span and span >= 0.0:
point.duration_s = span
def _optional_float(value: Any) -> float:
if value is None:
return float("nan")
@@ -0,0 +1,170 @@
"""The calibration data shapes, independent of sampling and of file storage.
They live here so the sampling state machine and the session file format can both
describe a calibration point without importing each other. ``calibration_recorder``
and ``calibration_session`` re-export these names for compatibility.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
import numpy as np
#: Stands in for "this value is not available".
NA = float("nan")
ChannelKey = Literal["fn", "fx", "fy"]
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
STATUS_OK = "ok"
STATUS_FEW_SAMPLES = "few_samples"
STATUS_CANCELLED = "cancelled"
#: Columns of one audit sample row, in order. A calibration record is only
#: auditable if this layout is fixed: it is what a reopened point is rebuilt from.
SAMPLE_FIELDS = (
"frame_id",
"t_rel_s",
"fn_raw",
"fx_raw",
"fy_raw",
"fn_n",
"fx_n",
"fy_n",
"valid",
)
#: Index of each sample column, so callers do not hard-code positions.
SAMPLE_INDEX = {name: index for index, name in enumerate(SAMPLE_FIELDS)}
#: Cap on stored trace samples per axis; peaks are preserved when reducing.
MAX_TRACE_POINTS = 400
@dataclass(frozen=True)
class AxisStats:
"""Mean / standard deviation / valid count for one axis."""
mean: float = NA
std: float = NA
n: int = 0
def as_dict(self) -> dict:
return {"mean": self.mean, "std": self.std, "n": self.n}
@dataclass
class CalibrationPoint:
"""One recorded calibration point, with all three axes captured."""
channel: ChannelKey
reference_load_n: float
direction: str = "load"
round_index: int = 0
note: str = ""
started_at: str = ""
ended_at: str = ""
duration_s: float = 0.0
requested_duration_s: float = 0.0
n_total: int = 0
n_valid: int = 0
raw: dict[str, AxisStats] = field(default_factory=dict)
newtons: dict[str, AxisStats] = field(default_factory=dict)
status: str = STATUS_OK
#: Downsampled per-axis raw trace, so a row can be inspected after the fact.
trace: dict[str, list[float]] = field(default_factory=dict)
trace_dt_s: float = 0.0
#: **Audit** series: every sample of the point, in :data:`SAMPLE_FIELDS` order.
#: The trace above is a preview and must never be the only surviving copy.
samples: list[tuple] = field(default_factory=list)
#: Diagnostics, all operator-controlled. Excluded points leave the fit;
#: validation points are held out of it and used to test it.
excluded: bool = False
is_validation: bool = False
#: Filled in by :meth:`CalibrationRecorder.fit`.
residual_n: float = NA
def loaded_stats(self) -> AxisStats:
return self.raw.get(self.channel, AxisStats())
def axis_mean(self, channel: str) -> float:
stats = self.raw.get(channel)
return stats.mean if stats is not None else NA
def state_key(self) -> str:
if self.excluded:
return "cal.state_excluded"
if self.is_validation:
return "cal.state_validation"
return "cal.state_ok"
def as_dict(self) -> dict:
return {
"channel": self.channel,
"reference_load_n": self.reference_load_n,
# Only the loaded axis has a known reference load; the other two are
# intentionally absent rather than zero.
"direction": self.direction,
"round_index": self.round_index,
"note": self.note,
"started_at": self.started_at,
"ended_at": self.ended_at,
"duration_s": self.duration_s,
"requested_duration_s": self.requested_duration_s,
"n_total": self.n_total,
"n_valid": self.n_valid,
"status": self.status,
"excluded": self.excluded,
"is_validation": self.is_validation,
"residual_n": self.residual_n,
"raw": {key: stats.as_dict() for key, stats in self.raw.items()},
"newtons": {key: stats.as_dict() for key, stats in self.newtons.items()},
# The audit series is part of the point's identity, not a UI extra:
# without it the only surviving copy of the samples is the
# downsampled trace.
"sample_fields": list(SAMPLE_FIELDS),
"n_samples": len(self.samples),
"samples": [list(row) for row in self.samples],
}
def trace_from(valid_block: np.ndarray) -> dict[str, list[float]]:
"""Downsampled raw trace per axis, bounded by ``MAX_TRACE_POINTS``.
One downsampling rule, whether the point was just recorded or read back from
a saved session.
"""
if valid_block.size == 0:
return {}
traces: dict[str, list[float]] = {}
for key, column in (("fn", 2), ("fx", 3), ("fy", 4)):
values = valid_block[:, column]
values = values[np.isfinite(values)]
if values.size == 0:
continue
if values.size > MAX_TRACE_POINTS:
# Keep the shape of the trace, including its extremes.
edges = np.linspace(0, values.size, MAX_TRACE_POINTS + 1, dtype=int)
reduced = [
values[start:max(start + 1, stop)]
for start, stop in zip(edges[:-1], edges[1:])
]
values = np.asarray(
[
bucket[int(np.argmax(np.abs(bucket)))]
for bucket in reduced
if bucket.size
]
)
traces[key] = [float(value) for value in values]
return traces
def trace_dt(valid_block: np.ndarray) -> float:
"""The sample interval of a trace, from the block it was taken from."""
if valid_block.shape[0] < 2:
return 0.0
span = float(valid_block[-1, 1] - valid_block[0, 1])
return span / max(1, valid_block.shape[0] - 1)
+14 -6
View File
@@ -29,6 +29,8 @@ from typing import Iterable, Iterator, Optional
import numpy as np
from orisys_app_shared.record.sample import plottable_axis_mask
#: Stands in for "this value is not available / not valid".
NA = float("nan")
@@ -127,14 +129,20 @@ class SessionSlice:
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""``(times, values, mask)`` for drawing, under one shared rule.
A point is drawn only when the sample is **valid** *and* the value for the
requested unit is finite. Filtering on finiteness alone let a stale
number — left behind by a failed compute step — reappear in one view
after another view had already excluded it.
``mask`` is per axis ``(n, 3)``: a slice is drawn only where the sample is
**valid** *and that axis* is finite. Filtering on finiteness alone let a
stale number — left behind by a failed compute step — reappear in one view
after another view had already excluded it, and an all-axes rule hid a
valid FN whenever the device had no shear calibration.
"""
values = self.value_for_unit(unit)
mask = self.valid & np.isfinite(values).all(axis=1)
return self.times[mask], values[mask], mask
return self.times, values, plottable_axis_mask(values, self.valid)
def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]:
"""``(times, values)`` for one channel, with the other axes ignored."""
values = self.value_for_unit(unit)
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
@dataclass(frozen=True)
@@ -0,0 +1,228 @@
"""One interface for reading curve data, whatever it is stored in.
The live charts read a bounded in-memory session store; the replay reads a
recording on disk. Both have to answer the same questions — what is the extent,
what is in this interval, what does the whole session look like as an envelope —
so the plotting code asks a *source* instead of knowing which one it has.
A source never hands over more than the caller's point budget, and it always
reports what it had to do to fit: ``decimated`` means the values are a view, not
every sample.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import numpy as np
from orisys_app_shared.force.session import NA, SessionSlice
@dataclass(frozen=True)
class SourceStats:
"""What the selection readout shows, for any source."""
duration_s: float = 0.0
samples: int = 0
peaks: tuple[float, float, float] = (NA, NA, NA)
means: tuple[float, float, float] = (NA, NA, NA)
stds: tuple[float, float, float] = (NA, NA, NA)
@dataclass
class SourceWindow:
"""Rows for one interval, in the shape the charts already consume."""
times: np.ndarray
values: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
unit: str = "raw"
decimated: bool = False
def __len__(self) -> int:
return int(self.times.size)
def plottable_axis(self, index: int):
from orisys_app_shared.record.sample import plottable_axis_mask
mask = plottable_axis_mask(self.values, self.valid)[:, index]
return self.times[mask], self.values[mask, index]
def statistics(self) -> SourceStats:
mask = np.isfinite(self.values) & self.valid[:, None]
peaks: list[float] = []
means: list[float] = []
stds: list[float] = []
for index in range(3):
column = self.values[:, index][mask[:, index]]
if column.size == 0:
peaks.append(NA)
means.append(NA)
stds.append(NA)
continue
peaks.append(float(np.nanmax(column)))
means.append(float(np.nanmean(column)))
stds.append(float(np.nanstd(column)))
span = float(self.times[-1] - self.times[0]) if self.times.size > 1 else 0.0
return SourceStats(
duration_s=span,
samples=int(mask.any(axis=1).sum()),
peaks=tuple(peaks),
means=tuple(means),
stds=tuple(stds),
)
class CurveSource(Protocol):
"""What the charts need from wherever the data lives."""
kind: str
def extent(self) -> tuple[float, float]: ...
def window(self, t0: float, t1: float, *, max_points: int | None = None) -> SourceWindow: ...
def envelope(self, *, buckets: int = 1200) -> SourceWindow: ...
def duration_s(self) -> float: ...
class StoreCurveSource:
"""The live session store: already in memory, bounded by its own blocks."""
kind = "memory"
def __init__(self, store=None) -> None:
self.store = store
def set_store(self, store) -> None:
self.store = store
def extent(self) -> tuple[float, float]:
if self.store is None:
return (0.0, 0.0)
return self.store.extent()
def duration_s(self) -> float:
if self.store is None:
return 0.0
first, last = self.store.extent()
return max(0.0, last - first)
def _slice(self, t0: float, t1: float, max_points: int | None, unit: str) -> SessionSlice:
if self.store is None:
empty = np.empty(0)
return SessionSlice(
times=empty,
raw=np.empty((0, 3)),
newtons=np.empty((0, 3)),
valid=np.empty(0, dtype=bool),
frame_ids=np.empty(0, dtype=np.int64),
)
return self.store.visible(
t0, t1, max_points=max_points or 0, unit=unit
)
def window(
self,
t0: float,
t1: float,
*,
max_points: int | None = None,
unit: str = "raw",
) -> SourceWindow:
slice_ = self._slice(t0, t1, max_points, unit)
return SourceWindow(
times=slice_.times,
values=slice_.value_for_unit(unit),
valid=slice_.valid,
frame_ids=slice_.frame_ids,
unit=unit,
decimated=bool(max_points and slice_.times.size >= int(max_points)),
)
def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow:
"""The whole session, decimated — the store's own bounded view."""
if self.store is None:
return self.window(0.0, 0.0)
first, last = self.store.extent()
return self.window(first, last, max_points=int(buckets) * 2, unit=unit)
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
if self.store is None:
return SourceStats()
stats = self.store.statistics(t0, t1, unit=unit)
return SourceStats(
duration_s=stats.duration_s,
samples=stats.samples,
peaks=stats.peaks,
means=stats.means,
stds=stats.stds,
)
class RecordingCurveSource:
"""A recording on disk: interval reads plus a cached envelope."""
kind = "disk"
def __init__(self, reader) -> None:
self.reader = reader
def extent(self) -> tuple[float, float]:
return self.reader.index().extent()
def duration_s(self) -> float:
return self.reader.index().duration_s()
def window(
self,
t0: float,
t1: float,
*,
max_points: int | None = None,
unit: str = "raw",
) -> SourceWindow:
window = self.reader.window(t0, t1, unit=unit, max_points=max_points)
return SourceWindow(
times=window.times,
values=window.value_for_unit(unit),
valid=window.valid,
frame_ids=window.frame_ids,
unit=unit,
decimated=window.decimated,
)
def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow:
"""The whole-session envelope, from the index's cached overview."""
overview = self.reader.overview(unit=unit, buckets=int(buckets))
keep = ~np.isnan(overview.times)
times = overview.times[keep]
if times.size == 0:
return self.window(0.0, 0.0, unit=unit)
# The envelope is drawn as a band: the low and high of each bucket. It is
# reported as a plain window so callers need only one shape.
values = np.where(
np.isnan(overview.highs[keep]), overview.lows[keep], overview.highs[keep]
)
return SourceWindow(
times=times,
values=values,
valid=np.ones(times.size, dtype=bool),
frame_ids=np.full(times.size, -1, dtype=np.int64),
unit=unit,
decimated=True,
)
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
stats = self.reader.statistics(t0, t1, unit=unit)
return SourceStats(
duration_s=stats.duration_s,
samples=stats.samples,
peaks=stats.peaks,
means=stats.means,
stds=stats.stds,
)
+84 -26
View File
@@ -1,46 +1,104 @@
"""Force unit selection for display."""
"""Force unit selection for display.
The decision is made **per axis** from the standardised measurement
(:class:`~orisys_app_shared.record.sample.RecordSample`), not from a device-level
``force_calibrated`` flag. A device can legitimately have only its normal-force
axis calibrated: the old rule then either showed every axis in raw (losing the
one axis that really was in newtons) or, worse, showed raw counts in the newton
panels. An axis without a curve is now simply **unavailable** in newton mode and
reported as such.
"""
from __future__ import annotations
from dataclasses import dataclass
from orisys_app_shared.record.sample import AXES
#: Index of each axis inside the triplets, matching ``AXES``.
AXIS_INDEX: dict[str, int] = {axis: index for index, axis in enumerate(AXES)}
@dataclass(frozen=True)
class DisplayForces:
fn: float
fx: float
fy: float
"""What the live view should show for one sample."""
#: One value per axis, in ``AXES`` order. NaN means "not available".
values: tuple[float, float, float]
#: The unit the values actually are (``N`` only when they are newtons).
unit: str
#: Per axis: is this value a real measurement in ``unit``?
available: tuple[bool, bool, bool]
#: The requested unit was newton but no axis had a curve, so raw is shown.
using_raw_fallback: bool
#: What the operator asked for (``N``/``raw``), for the fallback message.
requested_unit: str
def value(self, axis: str) -> float:
return self.values[AXIS_INDEX[axis]]
def is_available(self, axis: str) -> bool:
return self.available[AXIS_INDEX[axis]]
def unavailable_axes(self) -> tuple[str, ...]:
return tuple(
axis for axis in AXES if not self.available[AXIS_INDEX[axis]]
)
def select_display_forces(
*,
units_pref: str,
force_calibrated: bool,
fn: float,
fx: float,
fy: float,
fn_raw: float,
fx_raw: float,
fy_raw: float,
) -> DisplayForces:
"""Return values for plots/labels given newton/raw preference."""
def _finite(value: float) -> bool:
try:
return value == value and value not in (float("inf"), float("-inf"))
except TypeError: # pragma: no cover - defensive
return False
def select_display_forces(*, units_pref: str, sample) -> DisplayForces:
"""Pick the values to display for one standardised measurement.
``sample`` is a
:class:`~orisys_app_shared.record.sample.RecordSample`, so the same conversion
that feeds the CSV and the session store also decides what the live charts
show: there is no second interpretation of what a value means.
"""
want_newton = str(units_pref or "newton").lower().startswith("n")
if want_newton and force_calibrated:
return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False)
if want_newton and not force_calibrated:
raw = tuple(sample.raw_for(axis) for axis in AXES)
newton = tuple(sample.newton_for(axis) for axis in AXES)
requested = "N" if want_newton else "raw"
if not want_newton:
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
values=raw,
unit="raw",
available=tuple(_finite(value) for value in raw),
using_raw_fallback=False,
requested_unit=requested,
)
values: list[float] = []
available: list[bool] = []
for index, _axis in enumerate(AXES):
if _finite(newton[index]):
values.append(newton[index])
available.append(True)
else:
# Not "zero" and not the raw count: there is no newton value at all.
values.append(float("nan"))
available.append(False)
if not any(available):
# Nothing is calibrated: showing raw is honest as long as it is labelled.
return DisplayForces(
values=raw,
unit="raw",
available=tuple(_finite(value) for value in raw),
using_raw_fallback=True,
requested_unit=requested,
)
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
unit="raw",
values=tuple(values),
unit="N",
available=tuple(available),
using_raw_fallback=False,
requested_unit=requested,
)
@@ -1,5 +1,9 @@
"""Experiment recording (video + data + metadata) and replay."""
from orisys_app_shared.record.calibration_snapshot import (
list_calibration_snapshots,
store_calibration_snapshot,
)
from orisys_app_shared.record.depth import DepthRecomputer, DepthResult
from orisys_app_shared.record.experiment import (
EVENTS_HEADER,
@@ -34,5 +38,7 @@ __all__ = [
"RecordingInfo",
"RecordingReader",
"format_value",
"list_calibration_snapshots",
"list_recordings",
"store_calibration_snapshot",
]
@@ -0,0 +1,60 @@
"""Store the coefficients a recording was made with, next to the recording.
The hash of a calibration tells you *which* coefficients were applied; it does not
let you check what they were, and the original export file can be overwritten or
deleted afterwards. So the recording keeps its own copy:
```
outputs/recordings/<stamp>_session<NNNN>/
calibration/
force_calibration_<hash12>.json
```
The copy is content-addressed by the digest, so applying the same calibration
twice in one session stores one file, and two different calibrations can never
collide.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
#: Directory inside a recording that holds the coefficient snapshots.
SNAPSHOT_DIRNAME = "calibration"
def snapshot_name(version: str, source: Path) -> str:
"""The snapshot's file name: content-addressed, with the original extension."""
digest = str(version or "").strip()
stem = f"force_calibration_{digest[:12]}" if digest else f"force_calibration_{source.stem}"
return f"{stem}{source.suffix or '.json'}"
def store_calibration_snapshot(directory: str | Path, source: str | Path, version: str) -> str:
"""Copy ``source`` into ``directory/calibration``. Returns the relative name.
Raises on I/O failure; the caller reports it and keeps recording.
"""
base = Path(directory) / SNAPSHOT_DIRNAME
base.mkdir(parents=True, exist_ok=True)
origin = Path(source)
target = base / snapshot_name(version, origin)
if not target.is_file() or target.stat().st_size != origin.stat().st_size:
# Same content-addressed name means the same coefficients; a size check
# is enough to notice a truncated previous copy.
shutil.copy2(origin, target)
return f"{SNAPSHOT_DIRNAME}/{target.name}"
def list_calibration_snapshots(directory: str | Path) -> list[str]:
"""Every snapshot already stored in a recording, sorted."""
base = Path(directory) / SNAPSHOT_DIRNAME
if not base.is_dir():
return []
return sorted(
f"{SNAPSHOT_DIRNAME}/{path.name}" for path in base.iterdir() if path.is_file()
)
+264 -163
View File
@@ -25,6 +25,7 @@ from typing import Optional
import numpy as np
from orisys_app_shared.record.depth_backends import open_replay
from orisys_app_shared.record.playback import RecordingReader
logger = logging.getLogger(__name__)
@@ -40,25 +41,72 @@ class DepthResult:
t_rel_s: float = 0.0
#: What a replay could not reproduce exactly (never silently empty).
warnings: tuple[str, ...] = ()
#: Which recording this answer belongs to (the replay window compares it).
record_id: str = ""
@property
def ok(self) -> bool:
return self.depth is not None and not self.error
#: The replay started at a **reset**: the algorithm's state was cleared there, so
#: the frames since that point determine it exactly.
STATE_EXACT = "exact"
#: The replay is preceded by the recorded pre-roll: the intended reconstruction.
STATE_PREROLL = "preroll"
#: Neither: the state entering the target frame is unknown and may differ from
#: what the live run had.
STATE_APPROXIMATE = "approximate"
@dataclass(frozen=True)
class ReplayStep:
"""One leg of a replay: read ``source`` up to ``to_frame``.
``reset_after`` is the frame at which the algorithm's state was cleared live
(an operator ``zeroing``). The replay reaches that frame, then calls the
processor's ``reset()`` — which is what the live run did — and keeps reading.
"""
source: Path
to_frame: int
reset_after: Optional[int] = None
#: How many SDK reads a faithful replay may cost before it is declared
#: unrecoverable (5 minutes at 30 fps). A replay that re-reads an hour of video
#: to answer one cursor move is not a feature; saying so is.
MAX_REPLAY_FRAMES = 9000
@dataclass(frozen=True)
class ReplayPlan:
"""Where the SDK must start reading to reproduce one frame's state."""
segment: Path
#: Frame within ``segment`` to step to (1-based, as the SDK reads them).
stop_at: int
#: Frames to feed before ``stop_at`` to rebuild the algorithm's state.
#: The legs of the replay, in order. The last one ends at ``stop_at``.
steps: tuple[ReplayStep, ...] = ()
#: Frame within the final segment the caller asked for (1-based).
stop_at: int = 0
#: Frames to feed from another file before the first step.
preroll: Path | None = None
preroll_frames: int = 0
#: The ``zeroing`` frame the plan is anchored to, when there is one.
reset_frame_id: Optional[int] = None
#: ``reset`` / ``preroll`` / ``segment_start``.
anchor: str = "segment_start"
#: :data:`STATE_EXACT`, :data:`STATE_PREROLL` or :data:`STATE_APPROXIMATE`.
state: str = STATE_APPROXIMATE
warnings: tuple[str, ...] = ()
error: str = ""
@property
def frames_to_read(self) -> int:
"""How many SDK reads a faithful recomputation costs."""
return int(self.preroll_frames) + sum(
max(0, int(step.to_frame)) for step in self.steps
)
class DepthRecomputer:
"""Recompute depth maps for one recording, on demand."""
@@ -69,6 +117,12 @@ class DepthRecomputer:
self._segment: Optional[Path] = None
#: How many frames the open sensor has already consumed in ``_segment``.
self._position = 0
#: The depth of ``_position``, so asking for the same frame twice is a
#: cache hit instead of a second (impossible) read.
self._last_depth: Optional[np.ndarray] = None
#: True when an earlier frame had to be re-read without a reset boundary
#: to clean up after it, so the state may not match the live run.
self._rewound_without_anchor = False
self.last_error = ""
self.last_warnings: list[str] = []
@@ -121,46 +175,87 @@ class DepthRecomputer:
"""Decide where replay must start so ``frame`` is reproducible.
The SDK's depth is the output of a stateful chain (optical-flow tracker,
temporal filters), so a frame cannot be recomputed in isolation. Two
things make the state recoverable:
temporal filters), so a frame cannot be recomputed in isolation. What the
plan states explicitly:
* an operator **reset** clears the tracker, so replaying from the reset
frame reproduces the state exactly — no earlier history needed;
* the **pre-roll** recorded with the session holds the frames acquired
just before recording started, which is the only history a reset-free
start has.
* **where** to start (``start_at``, plus a pre-roll file when there is one);
* which **reset boundary** the replay is anchored to, if any;
* whether the state is actually recoverable — :data:`STATE_EXACT` (the
replay starts at a reset), :data:`STATE_PREROLL` (the recorded pre-roll
precedes it) or :data:`STATE_APPROXIMATE` (neither: the depth map is
still produced, and the caller is told it may differ from live).
Anything else is replayed as faithfully as possible *and reported*: the
caller is told the state may differ instead of being shown a depth map
that pretends to be the live one.
Finding a reset event is **not** by itself a recovered state: the reset
frame has to exist in the index *and* in the video, and it has to be in
the same segment, because the SDK cannot carry its state across a segment
boundary.
"""
warnings: list[str] = []
segment_path = self.reader.segment_path(frame.segment)
if segment_path is None:
return ReplayPlan(
segment=Path(), stop_at=0, error="segment_missing"
)
return ReplayPlan(segment=Path(), error="segment_missing")
stop_at = max(1, int(frame.segment_frame))
reset_frame = self.reader.last_reset_frame(frame.frame_id)
if reset_frame is not None:
origin = self.reader.frame_of(reset_frame)
if origin is not None and origin.segment == frame.segment:
# Exact: the tracker state at ``frame`` is fully determined by
# the frames since the reset in this same segment.
return ReplayPlan(
if origin is None:
warnings.append("reset_frame_not_indexed")
elif origin.dropped or not origin.in_video:
# The boundary frame is not in the video, so the state at that
# point cannot be rebuilt: replay from here is an approximation.
warnings.append("reset_frame_not_in_video")
elif origin.segment == frame.segment:
plan = ReplayPlan(
segment=segment_path,
stop_at=max(1, int(frame.segment_frame)),
warnings=(),
steps=(
ReplayStep(
source=segment_path,
to_frame=stop_at,
reset_after=int(origin.segment_frame),
),
),
stop_at=stop_at,
reset_frame_id=int(reset_frame),
anchor="reset",
state=STATE_EXACT,
)
if origin is not None:
# The reset is in an earlier segment. The SDK opens one video per
# sensor, so the state cannot be carried across the boundary; the
# replay restarts at this segment and says so.
warnings.append("state_across_segment")
if plan.frames_to_read > MAX_REPLAY_FRAMES:
warnings.append("state_too_far_back")
else:
return plan
else:
# The reset is in an earlier segment. The processor survives a
# source swap, so the state *can* be carried: read the tail of
# the reset's segment, then continue in the target's.
reset_segment = self.reader.segment_path(origin.segment)
last_of_reset_segment = self._last_segment_frame(origin.segment)
if reset_segment is None or last_of_reset_segment is None:
warnings.append("state_across_segment")
else:
plan = ReplayPlan(
segment=segment_path,
steps=(
ReplayStep(
source=reset_segment,
to_frame=int(last_of_reset_segment),
reset_after=int(origin.segment_frame),
),
ReplayStep(source=segment_path, to_frame=stop_at),
),
stop_at=stop_at,
reset_frame_id=int(reset_frame),
anchor="reset",
state=STATE_EXACT,
)
if plan.frames_to_read <= MAX_REPLAY_FRAMES:
return plan
# Too expensive: report it rather than freeze the window.
warnings.append("state_too_far_back")
start = self.reader.segment_start_frame(frame.segment)
if start is None:
return ReplayPlan(segment=Path(), stop_at=0, error="frame_not_indexed")
return ReplayPlan(segment=Path(), error="frame_not_indexed")
preroll = self.reader.preroll_segment()
preroll_frames = self.reader.preroll_frames() if preroll is not None else 0
@@ -169,19 +264,39 @@ class DepthRecomputer:
# one are the only history that can rebuild the tracker.
return ReplayPlan(
segment=segment_path,
stop_at=max(1, int(frame.segment_frame)),
steps=(ReplayStep(source=segment_path, to_frame=stop_at),),
stop_at=stop_at,
preroll=preroll,
preroll_frames=int(preroll_frames),
anchor="preroll",
state=STATE_PREROLL,
warnings=tuple(warnings),
)
if int(frame.segment_frame) > 1:
warnings.append("state_approximate")
# No reset and no pre-roll: the state entering this frame is unknown.
warnings.append("state_approximate")
return ReplayPlan(
segment=segment_path,
stop_at=max(1, int(frame.segment_frame)),
warnings=tuple(warnings),
steps=(
ReplayStep(
source=segment_path,
to_frame=stop_at,
),
),
stop_at=stop_at,
anchor="segment_start",
state=STATE_APPROXIMATE,
warnings=tuple(dict.fromkeys(warnings)),
)
def _last_segment_frame(self, segment: int) -> Optional[int]:
"""The highest indexed frame number inside ``segment``."""
frames = [
int(entry.segment_frame)
for entry in self.reader.info().frames
if int(entry.segment) == int(segment)
]
return max(frames) if frames else None
# ----------------------------------------------------------------- recompute
def depth_at(self, t_rel_s: float) -> DepthResult:
"""Recompute the depth map for the frame at ``t_rel_s``."""
@@ -219,32 +334,95 @@ class DepthRecomputer:
logger.exception("Depth recomputation failed")
self.last_error = f"{type(exc).__name__}: {exc}"
return DepthResult(
error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
error=self.last_error,
frame_id=frame.frame_id,
t_rel_s=frame.t_rel_s,
warnings=plan.warnings,
record_id=self.record_id(),
)
self.last_warnings = list(plan.warnings)
if self._rewound_without_anchor and "state_approximate" not in self.last_warnings:
# The plan looked recoverable, but the read path had to restart a file
# without a reset boundary to clear the tracker afterwards.
self.last_warnings.append("state_approximate_after_rewind")
warnings = tuple(dict.fromkeys(self.last_warnings))
if depth is None:
self.last_error = "depth_not_available"
return DepthResult(
error=self.last_error,
frame_id=frame.frame_id,
t_rel_s=frame.t_rel_s,
warnings=plan.warnings,
warnings=warnings,
record_id=self.record_id(),
)
self.last_error = ""
return DepthResult(
depth=depth,
frame_id=frame.frame_id,
t_rel_s=frame.t_rel_s,
warnings=plan.warnings,
warnings=warnings,
record_id=self.record_id(),
)
def record_id(self) -> str:
"""Which recording these answers belong to."""
return str(getattr(self.reader, "directory", "") or "")
# ------------------------------------------------------------------ playback
def _replay(self, plan: ReplayPlan) -> Optional[np.ndarray]:
"""Read the pre-roll (if any), then step to the planned frame."""
"""Execute the plan's steps, keeping the processor and swapping inputs.
The **processor is kept** across a source change: the SDK's tracker lives
on the object, so disposing of it would dispose of the state the pre-roll
(or the reset boundary) just rebuilt. A reset boundary is re-applied by
calling ``reset()`` where the operator's ``zeroing`` happened.
"""
self._rewound_without_anchor = False
if plan.preroll is not None and plan.preroll_frames > 0:
self._feed(plan.preroll, int(plan.preroll_frames))
if self._segment != plan.segment or self._position > plan.stop_at:
self._reopen_segment(plan.segment)
return self._advance_to(int(plan.stop_at))
depth: Optional[np.ndarray] = None
for step in plan.steps:
depth = self._run_step(plan, step)
return depth
def _run_step(self, plan: ReplayPlan, step: ReplayStep) -> Optional[np.ndarray]:
target = max(1, int(step.to_frame))
if self._sensor is None or self._segment != step.source:
if self._sensor is None or not self._swap_source(step.source):
self._open_segment(step.source)
elif self._position > target:
# Re-reading an earlier frame can only be done from the start of the
# file. Without a reset boundary to clean up after it, the tracker has
# now seen frames it should not have.
if not self._swap_source(step.source):
self._open_segment(step.source)
if plan.anchor != "reset":
self._rewound_without_anchor = True
last: Optional[np.ndarray] = (
self._last_depth if self._position == target else None
)
while self._position < target:
depth = self._read_one()
if depth is None:
break
last = depth
if step.reset_after is not None and self._position == int(step.reset_after):
# The operator's zeroing: the live run cleared the tracker here.
if not self._reset_processor():
self.last_warnings.append("reset_not_applied")
return last
def _reset_processor(self) -> bool:
replay = self._sensor
reset = getattr(replay, "reset", None)
if not callable(reset):
return False
try:
return bool(reset())
except Exception: # noqa: BLE001 - reported as a replay warning
logger.exception("Could not re-apply the reset boundary")
return False
def _depth_from_segment(
self, segment: Path, segment_frame: int
@@ -253,28 +431,55 @@ class DepthRecomputer:
The SDK's video source only advances, so stepping costs one read per
frame — and the position is tracked, so asking for frame 2 and then
frame 3 reads two frames and then one, instead of two more.
frame 3 reads two frames and then one, instead of two more. Asking twice
for the *same* frame is answered from the cached depth of that frame.
"""
target = max(1, int(segment_frame))
if self._segment != segment or self._position > target:
self._reopen_segment(segment)
return self._advance_to(target)
if self._segment != segment:
self._open_segment(segment)
return self._run_step(
ReplayPlan(segment=Path(segment), stop_at=target),
ReplayStep(source=Path(segment), to_frame=target),
)
# --------------------------------------------------------------------- video
def sensor_type(self) -> str:
device = self.reader.session().get("device") or {}
return str(device.get("sensor_type") or "box")
def _reopen_segment(self, segment: Path) -> None:
def _open_segment(self, segment: Path) -> None:
"""Open a segment on a **new** processor (the previous one is closed)."""
self._close()
if self.sensor_type() == "finger":
self._sensor = _FingerReplay(
segment, self.resolved_config(), self._finger_export_dir()
)
else:
self._sensor = _BoxReplay(segment, self.resolved_config())
self._sensor = open_replay(
self.sensor_type(),
Path(segment),
self.resolved_config(),
self._finger_export_dir(),
)
self._segment = Path(segment)
self._position = 0
self._last_depth = None
def _swap_source(self, segment: Path) -> bool:
"""Replace only the video source of the open processor.
Returns False when the backend cannot do it, in which case the caller
falls back to a full reopen (and the algorithm state is lost, which the
plan already reports as approximate).
"""
replay = self._sensor
if replay is None:
return False
try:
if not replay.set_source(Path(segment)):
return False
except Exception: # noqa: BLE001 - reported by the plan's warnings
logger.exception("Could not swap the replayed video source")
return False
self._segment = Path(segment)
self._position = 0
self._last_depth = None
return True
def _finger_export_dir(self):
"""The finger pipeline assets, resolved exactly as the live run did."""
@@ -292,29 +497,17 @@ class DepthRecomputer:
"""Consume ``frames`` frames from another file to rebuild state."""
if frames <= 0:
return
if self._sensor is None or self._segment != segment:
self._reopen_segment(segment)
if self._sensor is None:
self._open_segment(segment)
elif self._segment != segment:
if not self._swap_source(segment):
self._open_segment(segment)
for _ in range(int(frames)):
if self._read_one() is None:
break
def _advance_to(self, target: int) -> Optional[np.ndarray]:
last_depth = None
while self._position < int(target):
depth = self._read_one()
if depth is None:
break
last_depth = depth
return last_depth
def _read_one(self) -> Optional[np.ndarray]:
"""Read one frame through the **live** processing chain.
``get_img``/``read_source`` alone is not the live pipeline: the SDK's
depth map is produced by ``compute_deformation`` + ``compute_contact``
(and, for the finger, the unrolling step), so skipping them would replay
a different algorithm than the one that produced the recording.
"""
"""Read one frame through the **live** processing chain (see ``process_chain``)."""
replay = self._sensor
if replay is None:
return None
@@ -322,12 +515,14 @@ class DepthRecomputer:
if depth is None:
return None
self._position += 1
self._last_depth = depth
return depth
def _close(self) -> None:
replay, self._sensor = self._sensor, None
self._segment = None
self._position = 0
self._last_depth = None
if replay is None:
return
try:
@@ -343,97 +538,3 @@ class DepthRecomputer:
def __exit__(self, *_exc) -> None:
self.close()
class _BoxReplay:
"""The box sensor's live chain, opened on one video file."""
def __init__(self, segment: Path, config_ref: str) -> None:
import orisys
self._sensor = orisys.Sensor(
str(segment),
config_name=config_ref,
verbose=False,
backend="auto",
)
def read(self) -> Optional[np.ndarray]:
image = self._sensor.get_img()
if image is None:
return None
compute_deformation = getattr(self._sensor, "compute_deformation", None)
compute_contact = getattr(self._sensor, "compute_contact", None)
if callable(compute_deformation) and callable(compute_contact):
# Same calls and arguments as the acquisition loop.
compute_deformation(decompose=True, check_motion=False)
compute_contact()
else:
logger.warning(
"Sensor %r has no processing chain; replay depth is not the live algorithm",
type(self._sensor).__name__,
)
info = self._sensor.info
if not hasattr(info, "DEPTH"):
return None
value = self._sensor.read_info(info.DEPTH)
return None if value is None else np.asarray(value)
def close(self) -> None:
self._sensor.disconnect()
class _FingerReplay:
"""The finger's live chain: unroll the source frame, then process the tactile."""
def __init__(self, segment: Path, config_ref: str, export_dir) -> None:
from orisys.finger import FingerRuntime
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
kwargs: dict = {
"config": config_ref,
"video": str(segment),
"verbose": False,
}
if export_dir is not None:
kwargs["export_dir"] = str(export_dir)
self._runtime = FingerRuntime.open(**kwargs)
self._runtime.warmup(color=False)
tactile = getattr(self._runtime, "tactile", None)
if tactile is not None and hasattr(tactile, "configure"):
try:
# The same filter settings the acquisition loop applies.
tactile.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
except Exception: # noqa: BLE001 - the defaults still work
logger.exception("Could not configure the replayed tactile sensor")
def read(self) -> Optional[np.ndarray]:
ok, source_frame = self._runtime.read_source()
if not ok or source_frame is None:
return None
unrolled = self._runtime.process_frame(source_frame, color=False)
if unrolled is None:
return None
tactile = self._runtime.tactile
compute_deformation = getattr(tactile, "compute_deformation", None)
compute_contact = getattr(tactile, "compute_contact", None)
if callable(compute_deformation) and callable(compute_contact):
compute_deformation(decompose=True, check_motion=False)
compute_contact()
else:
logger.warning("Finger tactile sensor has no processing chain")
info = tactile.info
if not hasattr(info, "DEPTH"):
return None
value = tactile.read_info(info.DEPTH)
return None if value is None else np.asarray(value)
def close(self) -> None:
self._runtime.close()
@@ -0,0 +1,194 @@
"""Replay backends: the live processing chain, pointed at a video file.
Each backend keeps **one** processor for its lifetime. The tracker state lives on
that object, so a replay must be able to change the *input source* without
throwing the processor away — that is what makes a pre-roll, or a zeroing that
happened in an earlier segment, reproducible at all.
The processing itself is not duplicated here: the reads go through
``acquisition.sdk_worker.process_chain``, the same function the live workers run.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
class BoxReplay:
"""The box sensor's live chain, opened on one video file."""
def __init__(self, segment: Path, config_ref: str) -> None:
import orisys
self._config_ref = config_ref
self._sensor = orisys.Sensor(
str(segment),
config_name=config_ref,
verbose=False,
backend="auto",
)
def set_source(self, segment: Path) -> bool:
"""Point the same processor at another file.
The tracker lives on the ``Sensor`` object, so re-opening the sensor would
throw away the state the pre-roll just rebuilt. Only the capture is
replaced, and the tracker's reference frame is preserved.
"""
sensor = self._sensor
open_source = getattr(sensor, "_open_video_source", None)
if not callable(open_source):
return False
initial = getattr(sensor, "initial_img", None)
try:
import orisys
sensor.vid_src = orisys.capture.normalize_source(str(segment))
except Exception: # noqa: BLE001 - fall back to assigning the path
sensor.vid_src = str(segment)
sensor._is_live_camera = False
open_source(False, None)
if initial is not None:
# The tracker's reference frame belongs to the session, not to the
# file: replacing it would silently reset what "no contact" means.
sensor.initial_img = initial
return True
def read(self) -> Optional[np.ndarray]:
from orisys_app_shared.acquisition.sdk_worker import process_chain
image = self._sensor.get_img()
if image is None:
return None
# The same function the acquisition loop runs.
depth, error = process_chain(self._sensor)
if error:
logger.warning("Replay processing chain reported: %s", error)
return depth
def reset(self) -> bool:
"""Clear the tracker, exactly as the operator's zeroing did live."""
reset = getattr(self._sensor, "reset", None)
if not callable(reset):
return False
reset()
return True
def close(self) -> None:
self._sensor.disconnect()
class FingerReplay:
"""The finger's live chain: unroll the source frame, then process the tactile."""
def __init__(self, segment: Path, config_ref: str, export_dir) -> None:
from orisys.finger import FingerRuntime
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
self._config_ref = config_ref
self._export_dir = export_dir
kwargs: dict = {
"config": config_ref,
"video": str(segment),
"verbose": False,
}
if export_dir is not None:
kwargs["export_dir"] = str(export_dir)
self._runtime = FingerRuntime.open(**kwargs)
self._runtime.warmup(color=False)
tactile = getattr(self._runtime, "tactile", None)
if tactile is not None and hasattr(tactile, "configure"):
try:
# The same filter settings the acquisition loop applies.
tactile.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
except Exception: # noqa: BLE001 - the defaults still work
logger.exception("Could not configure the replayed tactile sensor")
def set_source(self, segment: Path) -> bool:
"""Point the same runtime at another video, keeping the tactile pipeline."""
try:
from orisys.capture import (
CaptureConfig,
ReconnectPolicy,
is_live_camera,
normalize_source,
open_capture,
)
runtime = self._runtime
video = normalize_source(str(segment))
camera = dict(getattr(runtime.geometry, "config", {}).get("camera", {}))
capture_config = CaptureConfig.from_camera_dict(
video,
camera,
reconnect=ReconnectPolicy(enabled=False),
verbose=False,
background=False,
)
capture_source = open_capture(capture_config, start_background=False)
if not capture_source.is_open():
return False
if is_live_camera(video):
capture_source.start_background()
previous = runtime.capture_source
runtime.capture_source = capture_source
runtime.capture = capture_source.cap
runtime.capture_worker = capture_source
if previous is not None:
try:
previous.close()
except Exception: # noqa: BLE001
logger.exception("Could not close the previous finger source")
return True
except Exception: # noqa: BLE001 - reported as an approximate replay
logger.exception("Could not swap the finger video source")
return False
def read(self) -> Optional[np.ndarray]:
from orisys_app_shared.acquisition.sdk_worker import process_chain
ok, source_frame = self._runtime.read_source()
if not ok or source_frame is None:
return None
unrolled = self._runtime.process_frame(source_frame, color=False)
if unrolled is None:
return None
depth, error = process_chain(self._runtime.tactile)
if error:
logger.warning("Replay processing chain reported: %s", error)
return depth
def reset(self) -> bool:
"""Clear the tactile tracker, as the operator's zeroing did live."""
tactile = getattr(self._runtime, "tactile", None)
target = tactile if callable(getattr(tactile, "reset", None)) else None
if target is None:
sensor = getattr(tactile, "raw_sensor", None)
target = sensor if callable(getattr(sensor, "reset", None)) else None
if target is None:
return False
target.reset()
return True
def close(self) -> None:
self._runtime.close()
def open_replay(sensor_type: str, segment: Path, config_ref: str, export_dir=None):
"""The backend for a recorded sensor type."""
if str(sensor_type) == "finger":
return FingerReplay(segment, config_ref, export_dir)
return BoxReplay(segment, config_ref)
+263 -166
View File
@@ -41,6 +41,7 @@ import cv2
import numpy as np
from orisys_app_shared.record.sample import RecordSample, to_record_sample
from orisys_app_shared.record.video_writer import VideoSegments
logger = logging.getLogger(__name__)
@@ -87,7 +88,8 @@ MAX_CONSECUTIVE_ERRORS = 5
ALARM_EVERY_N_DROPS = 1000
#: How many lost-row gaps may be remembered for the frame index.
GAP_BACKLOG = 4096
#: How many data rows may be parked while the item queue is full of video.
#: Hard cap on the ordered stream. Past this the producer has to give up a row,
#: and it says so by writing a gap marker instead.
ROW_BACKLOG = 8192
@@ -115,6 +117,9 @@ class RecordConfig:
fps: float = 30.0
codec: str = "mp4v"
segment_seconds: float = 300.0
#: Stream depth at which **video** starts being shed. Data rows are not
#: dropped by this limit: a row is tiny, and losing one because the encoder
#: fell behind is exactly the defect this bound must not reintroduce.
max_queue: int = 2048
#: Byte budget for the video queue. The item count alone is not a bound: at
#: 1920x1080 BGR, 2048 queued frame copies would be ~11.9 GiB.
@@ -206,14 +211,16 @@ class ExperimentRecorder:
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))
#: Bounded side channel for frames whose data row was lost to a full
#: queue. It is tiny, so it never grows with the recording.
#: One ordered stream for rows and events. It is **unbounded** on count:
#: the producer checks ``ROW_BACKLOG``/``max_queue`` itself, so a slow
#: encoder can no longer make the *queue* quietly delete a data row, and
#: rows are written in the order they were acquired.
self._queue: queue.Queue = queue.Queue()
#: Stream positions of frames whose data row was lost. Kept out of the
#: stream because there is no room for them there; flushed in order.
self._gaps: deque = deque(maxlen=GAP_BACKLOG)
#: Rows that did not fit in the item queue because it was full of video.
#: They are tiny, so a small backlog covers any realistic stall.
self._row_backlog: deque = deque(maxlen=ROW_BACKLOG)
self._queued_bytes = 0
self._seq = 0
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
@@ -242,6 +249,8 @@ class ExperimentRecorder:
self._preroll_segment: Optional[str] = None
#: Problems the writer found while closing, carried into the report.
self._writer_problems: list[str] = []
#: Coefficient files stored inside the recording directory.
self._calibration_snapshots: list[dict] = []
# ------------------------------------------------------------------ state
@property
@@ -318,7 +327,20 @@ class ExperimentRecorder:
self._state = STATE_STARTING
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)
try:
directory.mkdir(parents=True, exist_ok=True)
except Exception as exc: # noqa: BLE001 - reported to the caller
# A directory that cannot be created is a *terminal* failure, not a
# stuck ``starting``: the operator can fix the path and press record
# again, and the recorder must be willing to try.
with self._lock:
self._stats.failed = True
self._stats.last_error = f"could not create {directory}: {exc}"
self._state = STATE_FAILED
self._accepting = False
self._directory = None
self._alarm(f"could not create the recording directory: {exc}")
raise
with self._lock:
self._directory = directory
self._stats = RecordStats()
@@ -335,9 +357,10 @@ class ExperimentRecorder:
self._preroll_frames = 0
self._preroll_segment = None
self._writer_problems = []
self._calibration_snapshots = []
# Stale bookkeeping from a previous run must not leak into this one.
self._gaps.clear()
self._row_backlog.clear()
self._seq = 0
self._queued_bytes = 0
# Accept immediately: see ``_accepts_packets``.
self._accepting = True
@@ -370,26 +393,26 @@ class ExperimentRecorder:
self._state = STATE_STOPPING
self._accepting = False
self._stop.set()
try:
self._queue.put_nowait(("stop", None))
except queue.Full: # pragma: no cover - writer will drain and see the flag
pass
# The stop marker is an ordinary item in the stream: everything acquired
# before it is written before it, and the writer stops after it.
self._enqueue(("stop", self._next_seq(), None))
thread = self._thread
if wait and thread is not None and thread.is_alive():
thread.join(timeout=timeout)
if thread is not None and thread.is_alive():
# Still writing: do not claim the experiment was recorded.
report = RecordReport(
# Either we were asked not to wait, or the writer missed the deadline.
# A finished recording is only ever reported by the thread that closed
# the files, so this is explicitly *not* a completion — and it is not
# cached as the report either, so a later stop() still sees the real
# one (``recorder.report`` stays None until then).
return RecordReport(
directory=self._directory,
stats=self.stats(),
problems=[
"the recording writer did not stop within "
f"{timeout:g}s; the files are not closed yet"
"the recording writer has not finished closing the files yet"
],
completed=False,
)
self._report = report
return report
self._thread = None
return self._report or self._finalise()
@@ -398,13 +421,34 @@ class ExperimentRecorder:
A session can outlive the coefficients it started with, so the version is
per packet, not per session: the acquisition thread stamps every packet
with the calibration that was loaded when it was acquired.
with the calibration that was loaded when it was acquired. An empty
version is meaningful — it says the device has no calibration loaded.
"""
with self._lock:
self._calibration_version = str(version or "")
if self.is_recording:
self.log_event("calibration_version", self._calibration_version)
def note_calibration_snapshot(
self,
*,
version: str,
name: str,
frame_id: int = -1,
mono_t: Optional[float] = None,
) -> None:
"""Remember a coefficient file stored inside the recording directory."""
entry = {
"version": str(version or ""),
"file": str(name or ""),
"frame_id": int(frame_id),
"mono_t": None if mono_t is None else round(float(mono_t), 6),
"at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
}
with self._lock:
self._calibration_snapshots.append(entry)
logger.info("Calibration snapshot stored: %s", name)
def log_event(
self,
kind: str,
@@ -433,21 +477,29 @@ class ExperimentRecorder:
"kind": str(kind),
"detail": str(detail or ""),
}
try:
self._queue.put_nowait(("event", event))
except queue.Full:
# Events ride the same ordered stream as the rows, so an operator action
# cannot overtake the samples it was taken between.
if self._queue.qsize() >= ROW_BACKLOG:
# The stream is saturated: an event is the cheapest thing to give up,
# but the loss is counted rather than silent.
self._note_drop("event")
return
self._enqueue(("event", self._next_seq(), event))
# ----------------------------------------------------------- sink interface
def on_packet(self, packet) -> None:
"""Called on the acquisition thread: enqueue, never do I/O here.
A data row is small and a frame is large, so video is what gives way.
When the byte budget is exhausted the **frame** is shed, and when even
the item queue is full the frame is shed too and the row is parked in a
small backlog. Losing the row because the video could not keep up is what
made a video overflow look like a video-only loss while CSV rows quietly
disappeared.
A data row is small and a frame is large, so video is what gives way —
and the two are decided **separately**:
* the rows go into one ordered, unbounded stream (bounded only by
``ROW_BACKLOG``, at which point the loss is counted and marked);
* the frames are bounded by a byte budget, and a frame that would exceed
it is shed *before* it is queued, so nothing has to be rolled back.
The stream being ordered is what makes the files ordered: rows are written
in the order they were acquired, no matter how far behind the encoder is.
"""
if not self._accepts_packets():
with self._lock:
@@ -462,36 +514,53 @@ class ExperimentRecorder:
with self._lock:
self._last_t = sample.mono_t
# Depth is only consulted to decide the *video*: a row is never refused
# because the encoder is behind, only when the stream itself is full.
depth = self._queue.qsize()
if depth >= ROW_BACKLOG:
self._note_gap(sample)
return
frame = None
if self.config.video or self.config.lossless_frames:
candidate = self._capture_frame(packet)
size = 0 if candidate is None else int(candidate.nbytes)
with self._lock:
affordable = (self._queued_bytes + size) <= max(
1024, int(self.config.max_queue_bytes)
)
if candidate is None:
pass
elif affordable:
frame = candidate
with self._lock:
self._queued_bytes += size
else:
self._shed_video("video byte budget reached")
if candidate is not None:
size = int(candidate.nbytes)
if depth >= max(16, int(self.config.max_queue)):
self._shed_video("record queue overflow")
elif not self._affordable(size):
self._shed_video("video byte budget reached")
else:
frame = candidate
# Reserved here and released exactly once, by the writer that
# consumes it (see ``_handle_frame``).
with self._lock:
self._queued_bytes += size
self._enqueue(("frame", self._next_seq(), sample, frame))
def _next_seq(self) -> int:
with self._lock:
self._seq += 1
return self._seq
def _affordable(self, size: int) -> bool:
with self._lock:
return (self._queued_bytes + int(size)) <= max(
1024, int(self.config.max_queue_bytes)
)
def _enqueue(self, item) -> bool:
"""Append to the ordered stream. False when the stream is full.
The queue itself is unbounded — its depth is bounded by ``ROW_BACKLOG``
and checked by the callers — so a slow writer can never make the producer
drop data behind its back.
"""
try:
self._queue.put_nowait(("frame", (sample, frame)))
except queue.Full:
if frame is not None:
# The queue is full of video: the row is tiny and must survive.
frame = None
self._shed_video("record queue overflow")
with self._lock:
room = len(self._row_backlog) < self._row_backlog.maxlen
if room:
self._row_backlog.append(sample)
if not room:
# Even the row backlog is full: the hole is at least marked.
self._note_gap(sample)
self._queue.put_nowait(item)
return True
except queue.Full: # pragma: no cover - the queue is unbounded
return False
def _shed_video(self, reason: str) -> None:
with self._lock:
@@ -505,14 +574,26 @@ class ExperimentRecorder:
)
def _note_gap(self, sample) -> None:
"""Remember a frame whose row was lost, so the replay can show the hole."""
"""Remember a frame whose row was lost, so the replay can show the hole.
The gap carries the stream position it happened at. That is what lets the
writer put the hole in the *right place* in ``frames.csv`` instead of
appending it after newer frames (which would make the index unsorted).
"""
with self._lock:
self._stats.dropped_data += 1
if len(self._gaps) >= self._gaps.maxlen:
# The gap backlog is bounded, so a long overflow loses the marker.
self._stats.unrecorded_gaps += 1
return
self._gaps.append((sample.frame_id, sample.mono_t, sample.wall_t))
self._gaps.append(
(
self._seq + 1,
sample.frame_id,
sample.mono_t,
sample.wall_t,
)
)
@staticmethod
def _capture_frame(packet):
@@ -574,6 +655,7 @@ class ExperimentRecorder:
session_id=self._session_id,
calibration_id=self._calibration_id,
preroll=self._preroll,
on_problem=self._record_failure,
)
# Written once the pre-roll file exists, so session.json never names
# a file that was not created.
@@ -583,26 +665,40 @@ class ExperimentRecorder:
self._preroll = []
self._write_session_json(final=False)
with self._lock:
self._state = STATE_RECORDING
self._accepting = True
# A stop *or a failure* that arrived while the files were opening
# must not be undone here: accepting packets again after ``stop``
# would make a finished recording look alive, and overwriting
# ``failed`` with ``recording`` would hide a dead writer. The
# stream is still drained — those rows were accepted.
if self._stop.is_set() or self._state in (
STATE_STOPPING,
STATE_FAILED,
):
self._accepting = False
else:
self._state = STATE_RECORDING
self._accepting = True
while True:
# Parked rows and gaps first: a stop request must not strand the
# data this session already accepted.
self._drain_rows(writer)
self._drain_gaps(writer)
# Gaps first, but only those that happened *before* the item about
# to be written: the hole must appear in the frame index where it
# belongs, not after newer frames.
if self._stop.is_set() and self._queue.empty():
self._flush_gaps(writer, None)
break
try:
kind, payload = self._queue.get(timeout=0.2)
item = self._queue.get(timeout=0.2)
except queue.Empty:
writer.maybe_flush()
continue
kind = item[0]
seq = item[1]
self._flush_gaps(writer, seq)
if kind == "stop":
break
if kind == "event":
self._handle_event(writer, payload)
self._handle_event(writer, item[2])
continue
sample, frame = payload
_kind, _seq, sample, frame = item
self._handle_frame(writer, sample, frame)
writer.maybe_flush()
except Exception as exc: # noqa: BLE001 - reported through stats
@@ -617,32 +713,31 @@ class ExperimentRecorder:
self._accepting = False
problems: list[str] = []
if writer is not None:
# A packet accepted just before the stop flag may have left its
# row or gap behind; write those before the file is closed.
self._drain_rows(writer)
self._drain_gaps(writer)
# A row or gap accepted just before the stop flag may still be in
# the stream; write those before the file is closed.
self._flush_gaps(writer, None)
try:
problems = writer.close()
except Exception as exc: # noqa: BLE001
problems = [f"close failed: {type(exc).__name__}: {exc}"]
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
# Closing problems are failures of the same recording and are counted
# exactly once, through the same path as a failed row, flush or event.
for problem in problems:
self._record_failure(problem)
self._alarm(problem)
with self._lock:
if problems or failure:
self._stats.failed = True
if not self._stats.last_error:
self._stats.last_error = problems[0] if problems else failure
if failure and not self._stats.last_error:
self._stats.last_error = failure
# The writer's own findings (a flush that failed, a pre-roll that
# could not be stored) belong in the report: an operator reading
# only the terminal report must not have to grep the log.
self._writer_problems = list(problems)
failed = self._stats.failed
self._state = STATE_FAILED if failed else STATE_STOPPED
for problem in problems:
self._alarm(problem)
# Only now, with the files closed, is the report trustworthy.
self._report = self._finalise(completed=True)
@@ -657,6 +752,10 @@ class ExperimentRecorder:
def _handle_frame(self, writer, sample, frame) -> None:
try:
if frame is not None:
# The one release for the reservation taken in ``on_packet``.
# A frame is enqueued once and consumed once, so this cannot
# double-count; doing it *before* the write also means a failed
# write does not leak the budget for a frame that is gone.
with self._lock:
self._queued_bytes = max(0, self._queued_bytes - int(frame.nbytes))
writer.write_sample(sample, frame)
@@ -668,22 +767,21 @@ class ExperimentRecorder:
except Exception as exc: # noqa: BLE001
self._record_error(exc)
def _drain_rows(self, writer) -> None:
"""Write rows parked while the item queue was full of video."""
while True:
with self._lock:
if not self._row_backlog:
return
sample = self._row_backlog.popleft()
self._handle_frame(writer, sample, None)
def _flush_gaps(self, writer, upto_seq) -> None:
"""Write the frame-index rows for frames whose data row was lost.
def _drain_gaps(self, writer) -> None:
"""Write the frame-index rows for frames whose data row was lost."""
Gaps are written in stream order and only up to ``upto_seq`` (``None``
flushes the rest), so ``frames.csv`` stays sorted by time even when the
loss happened while the writer was far behind.
"""
while True:
with self._lock:
if not self._gaps:
return
frame_id, mono_t, wall_t = self._gaps.popleft()
seq, frame_id, mono_t, wall_t = self._gaps[0]
if upto_seq is not None and seq > upto_seq:
return
self._gaps.popleft()
try:
writer.write_gap(frame_id, mono_t, wall_t)
with self._lock:
@@ -693,18 +791,32 @@ class ExperimentRecorder:
return
def _record_error(self, exc: Exception) -> None:
self._record_failure(f"{type(exc).__name__}: {exc}")
def _record_failure(self, message: str, *, fatal: bool = False) -> None:
"""One failed write, flush, close or metadata update.
Every failure enters the state machine here, so the counters, the fatal
transition and the report cannot disagree about what happened. A writer
that kept its own count (a failed ``maybe_flush`` used to) left the
recording looking healthy until the very end.
``fatal`` marks a failure that ends the recording on its own: a recording
whose ``session.json`` could not be written is not a recording anyone can
identify afterwards.
"""
with self._lock:
self._stats.write_errors += 1
self._stats.last_error = f"{type(exc).__name__}: {exc}"
self._stats.last_error = str(message)
self._consecutive_errors += 1
fatal = self._consecutive_errors >= MAX_CONSECUTIVE_ERRORS
fatal = fatal or self._consecutive_errors >= MAX_CONSECUTIVE_ERRORS
if fatal:
self._stats.failed = True
# Stop accepting immediately: consuming packets that cannot be
# written only hides the loss behind a "recording" state.
self._accepting = False
self._state = STATE_FAILED
self._alarm(f"recording write failed: {exc}")
self._alarm(f"recording write failed: {message}")
if fatal:
self._stop.set()
@@ -712,6 +824,10 @@ class ExperimentRecorder:
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)
# Metadata **before** the report is assembled: a session.json that could
# not be written is part of what happened, and used to be discovered only
# after the report had already been built from the older statistics.
self._write_session_json(final=completed)
stats = self.stats()
problems: list[str] = []
if stats.dropped_data or stats.dropped_video:
@@ -734,8 +850,6 @@ class ExperimentRecorder:
for problem in self._writer_problems:
if problem not in problems:
problems.append(problem)
# ``ended_at`` is only written once the files are actually closed.
self._write_session_json(final=completed)
return RecordReport(
directory=self._directory,
stats=stats,
@@ -763,10 +877,11 @@ class ExperimentRecorder:
)
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}")
# The same single failure path as a write: it is counted, it ends the
# recording, and it reaches the report.
self._record_failure(
f"session.json: {type(exc).__name__}: {exc}", fatal=True
)
def _build_metadata(self, *, final: bool) -> dict[str, Any]:
with self._lock:
@@ -783,6 +898,9 @@ class ExperimentRecorder:
"video": self.config.as_dict(),
"events": events,
"stats": stats,
# The coefficients the session actually ran with: the file itself is
# copied into the recording, so a re-export cannot rewrite history.
"calibration_snapshots": [dict(entry) for entry in self._calibration_snapshots],
# The replay needs these to know whether the algorithm state at the
# start of the recording can be rebuilt at all.
"preroll_frames": int(self._preroll_frames),
@@ -802,16 +920,36 @@ class ExperimentRecorder:
class _SessionWriter:
"""Owns the file handles. Only ever touched by the writer thread."""
def _note_problem(self, message: str) -> None:
"""Record a failure here *and* hand it to the recorder at once."""
self.write_errors += 1
if len(self._problems) < 5:
self._problems.append(message)
if self._on_problem is not None:
try:
self._on_problem(message)
except Exception: # noqa: BLE001 - reporting must not break writing
logger.exception("Recording problem handler failed")
def __init__(
self, *, directory, config, session_id, calibration_id, preroll=()
self,
*,
directory,
config,
session_id,
calibration_id,
preroll=(),
on_problem=None,
) -> None:
self.directory = Path(directory)
self.config = config
self.session_id = session_id
self.calibration_id = calibration_id
#: ``on_problem(message)`` reports a failure to the recorder's state
#: machine *immediately*. A private counter here was invisible until the
#: files were closed, so a failing flush looked like a healthy recording.
self._on_problem = on_problem
self.write_errors = 0
self.segments = 0
self.video_frames = 0
self.png_frames = 0
self.gap_rows = 0
self.bytes_written = 0
@@ -820,12 +958,14 @@ class _SessionWriter:
self.preroll_segment: Optional[str] = None
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] = []
#: The video side, in its own component: one rolling segment at a time.
self._video = VideoSegments(
self.directory,
codec=self.config.codec,
fps=self.config.fps,
segment_seconds=self.config.segment_seconds,
)
self._forces = self._open_csv("forces.csv", FORCES_HEADER)
self._frames = self._open_csv("frames.csv", FRAMES_HEADER)
@@ -863,22 +1003,8 @@ class _SessionWriter:
kept.append(frame)
used += size
kept.reverse()
self._frame_shape = (int(kept[0].shape[1]), int(kept[0].shape[0]))
name = "preroll_0001.mp4"
path = self.directory / name
fourcc = cv2.VideoWriter_fourcc(*self.config.codec)
writer = cv2.VideoWriter(
str(path), fourcc, max(1.0, float(self.config.fps)), self._frame_shape
)
if not writer.isOpened():
raise OSError(f"could not open video writer for {name}")
try:
for frame in kept:
writer.write(frame)
self.preroll_frames += 1
finally:
writer.release()
self.preroll_segment = name if self.preroll_frames else None
self.preroll_frames = self._video.write_sequence(kept)
self.preroll_segment = "preroll_0001.mp4" if self.preroll_frames else None
def _open_csv(self, name: str, header):
handle = (self.directory / name).open("w", newline="", encoding="utf-8")
@@ -957,16 +1083,15 @@ class _SessionWriter:
self.gap_rows += 1
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)
return self._video.write(frame)
@property
def video_frames(self) -> int:
return self._video.video_frames
@property
def segments(self) -> int:
return self._video.segments
def _write_lossless(self, frame) -> None:
self.png_frames += 1
@@ -989,33 +1114,6 @@ class _SessionWriter:
)
)
# ------------------------------------------------------------------ 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
@@ -1024,23 +1122,22 @@ class _SessionWriter:
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}")
self._note_problem(f"flush failed: {exc}")
def close(self) -> list[str]:
self._close_segment()
try:
self._video.close()
except Exception as exc: # noqa: BLE001 - the CSVs still have to close
self._note_problem(f"video close failed: {exc}")
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}")
self._note_problem(f"close failed: {exc}")
try:
self.bytes_written = sum(
path.stat().st_size for path in self.directory.glob("*") if path.is_file()
path.stat().st_size for path in self.directory.rglob("*") if path.is_file()
)
except Exception: # noqa: BLE001
self.bytes_written = 0
@@ -0,0 +1,453 @@
"""A time index over a recorded ``forces.csv``, so history is read on demand.
The replay used to load every row of a recording into Python lists and then into
arrays. That is fine for a minute and hopeless for the multi-hour recordings the
store is designed for, and it made the charts the only place where "the whole
session" had to exist in memory at once.
This index keeps the *small* things in memory — one time per row, one validity
flag, one frame id, plus periodic byte offsets — and reads the *values* only for
the interval being drawn. It also builds a multi-level overview (per-bucket
min/max/count) while streaming the file once, which is what a whole-session strip
needs and what it cannot get by sampling a few thousand rows.
Nothing here touches Qt, and nothing here trusts the file to be in order: a
legacy recording is sorted at load time (see ``playback.RecordingReader``).
"""
from __future__ import annotations
import csv
import logging
from bisect import bisect_left, bisect_right
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Iterator, Optional
import numpy as np
logger = logging.getLogger(__name__)
#: How many rows to read between two recorded byte offsets. Larger means a
#: cheaper interval read but a longer scan to reach the interval.
CHECKPOINT_ROWS = 4096
#: Default number of overview buckets for the whole-session strip.
DEFAULT_BUCKETS = 1200
_FORCE_COLUMNS = ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
def _parse(text: str) -> float:
text = (text or "").strip()
if not text:
# An empty cell means "not available"; it must never become 0.
return float("nan")
try:
return float(text)
except ValueError:
return float("nan")
@dataclass
class ForceWindow:
"""Rows inside one interval — the same shape a session slice has."""
times: np.ndarray
raw: np.ndarray
newtons: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
#: True when the rows were decimated to satisfy the caller's point budget.
decimated: bool = False
def __len__(self) -> int:
return int(self.times.size)
def value_for_unit(self, unit: str) -> np.ndarray:
if str(unit).upper().startswith("N"):
return self.newtons
return self.raw
def plottable(self, unit: str):
"""``(times, values, mask)`` with a per-axis mask — the shared rule."""
from orisys_app_shared.record.sample import plottable_axis_mask
values = self.value_for_unit(unit)
return self.times, values, plottable_axis_mask(values, self.valid)
def plottable_axis(self, unit: str, index: int):
from orisys_app_shared.record.sample import plottable_axis_mask
values = self.value_for_unit(unit)
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
def statistics(self, unit: str) -> "WindowStats":
values = self.value_for_unit(unit)
mask = np.isfinite(values) & self.valid[:, None]
peaks: list[float] = []
means: list[float] = []
stds: list[float] = []
for index in range(3):
column = values[:, index][mask[:, index]]
if column.size == 0:
peaks.append(float("nan"))
means.append(float("nan"))
stds.append(float("nan"))
continue
peaks.append(float(np.nanmax(column)))
means.append(float(np.nanmean(column)))
stds.append(float(np.nanstd(column)))
span = 0.0
if self.times.size > 1:
span = float(self.times[-1] - self.times[0])
return WindowStats(
duration_s=span,
samples=int(mask.any(axis=1).sum()),
peaks=tuple(peaks),
means=tuple(means),
stds=tuple(stds),
)
@dataclass(frozen=True)
class WindowStats:
duration_s: float = 0.0
samples: int = 0
peaks: tuple[float, float, float] = (float("nan"),) * 3
means: tuple[float, float, float] = (float("nan"),) * 3
stds: tuple[float, float, float] = (float("nan"),) * 3
@dataclass
class Overview:
"""A whole-session envelope, cheap enough to keep in memory."""
times: np.ndarray
lows: np.ndarray # (buckets, 3)
highs: np.ndarray # (buckets, 3)
counts: np.ndarray # (buckets,)
unit: str = "raw"
def __len__(self) -> int:
return int(self.times.size)
@dataclass
class _Checkpoint:
row: int
time: float
offset: int
class RecordingIndex:
"""Index one recording directory's ``forces.csv`` (and its validity flags)."""
def __init__(self, directory: str | Path, *, checkpoint_rows: int = CHECKPOINT_ROWS):
self.directory = Path(directory)
self.checkpoint_rows = max(1, int(checkpoint_rows))
self.times = np.empty(0, dtype=np.float64)
self.valid = np.empty(0, dtype=bool)
self.frame_ids = np.empty(0, dtype=np.int64)
self._checkpoints: list[_Checkpoint] = []
self._rows = 0
self._byte_size = 0
self._built = False
# ------------------------------------------------------------------ building
@property
def path(self) -> Path:
return self.directory / "forces.csv"
@property
def rows(self) -> int:
return self._rows
@property
def byte_size(self) -> int:
"""Size of the data file the index describes."""
return self._byte_size
def build(self) -> "RecordingIndex":
"""One streaming pass: times, flags, frame ids and byte offsets."""
path = self.path
times: list[float] = []
valid: list[bool] = []
frame_ids: list[int] = []
checkpoints: list[_Checkpoint] = []
if path.is_file():
with path.open("rb") as handle:
header = handle.readline().decode("utf-8", "replace").strip()
columns = {name: index for index, name in enumerate(header.split(","))}
time_column = columns.get("t_rel_s", 2)
valid_column = columns.get("valid", 9)
frame_column = columns.get("frame_id", 1)
offset = handle.tell()
for row, line in enumerate(handle):
next_offset = handle.tell()
text = line.decode("utf-8", "replace").rstrip("\r\n")
if row % self.checkpoint_rows == 0:
checkpoints.append(
_Checkpoint(row=row, time=_time_of(text), offset=offset)
)
fields = next(csv.reader([text])) if text else []
if len(fields) > max(time_column, valid_column, frame_column):
try:
times.append(float(fields[time_column]))
valid.append(fields[valid_column].strip() == "1")
frame_ids.append(int(fields[frame_column]))
except (TypeError, ValueError):
times.append(float("nan"))
valid.append(False)
frame_ids.append(-1)
offset = next_offset
self.times = np.asarray(times, dtype=np.float64)
self.valid = np.asarray(valid, dtype=bool)
self.frame_ids = np.asarray(frame_ids, dtype=np.int64)
self._checkpoints = checkpoints
self._rows = int(self.times.size)
try:
self._byte_size = path.stat().st_size
except OSError:
self._byte_size = 0
self._built = True
return self
def ensure_built(self) -> "RecordingIndex":
if not self._built:
self.build()
return self
# ------------------------------------------------------------------- queries
def extent(self) -> tuple[float, float]:
if self._rows == 0:
return (0.0, 0.0)
return (float(self.times[0]), float(self.times[-1]))
def duration_s(self) -> float:
first, last = self.extent()
return max(0.0, last - first)
def row_range(self, t0: float, t1: float) -> tuple[int, int]:
"""Half-open row range covering ``[t0, t1]``."""
self.ensure_built()
if self._rows == 0:
return (0, 0)
if float(t1) < float(t0):
t0, t1 = t1, t0
start = max(0, bisect_left(self.times.tolist(), float(t0)) - 1)
stop = min(self._rows, bisect_right(self.times.tolist(), float(t1)))
return (start, max(start, stop))
def _checkpoint_before(self, row: int) -> _Checkpoint:
chosen = _Checkpoint(row=0, time=0.0, offset=0)
for checkpoint in self._checkpoints:
if checkpoint.row <= row:
chosen = checkpoint
else:
break
return chosen
def read_rows(
self,
start: int,
stop: int,
*,
max_points: int | None = None,
unit: str = "raw",
) -> ForceWindow:
"""Read ``[start, stop)`` by seeking to the nearest checkpoint.
With ``max_points`` the rows are decimated **while streaming**: each
bucket contributes the extreme row of every axis in ``unit``, so a long
interval never has to exist in memory in full. The extremes are taken in
both the raw and the requested unit's axis, which is why a shear spike
survives a flat normal channel.
"""
self.ensure_built()
start = max(0, min(int(start), self._rows))
stop = max(start, min(int(stop), self._rows))
if stop <= start:
return _empty_window()
path = self.path
if not path.is_file():
return _empty_window()
newton_unit = str(unit).upper().startswith("N")
budget = int(max_points) if max_points else 0
total = stop - start
bucket = (
max(1, total // max(1, budget // 6))
if budget and total > budget * 2
else 1
)
checkpoint = self._checkpoint_before(start)
pending: list[tuple] = []
picked: list[tuple] = []
def flush() -> None:
if not pending:
return
if bucket == 1:
picked.extend(pending)
else:
values = np.asarray(
[row[3] if newton_unit else row[2] for row in pending],
dtype=np.float64,
)
for axis in range(3):
column = values[:, axis]
finite = np.isfinite(column)
if not finite.any():
continue
ordered = np.where(finite, column, -np.inf)
for index in {int(np.argmin(ordered)), int(np.argmax(ordered))}:
picked.append(pending[index])
pending.clear()
with path.open("rb") as handle:
# Every checkpoint offset points at a *data* row: the build records
# ``tell()`` after the header, so there is nothing to skip here.
handle.seek(checkpoint.offset)
for row, line in enumerate(handle, start=checkpoint.row):
if row >= stop:
break
if row < start:
continue
fields = next(csv.reader([line.decode("utf-8", "replace")]))
if len(fields) < 11:
continue
try:
t_rel = float(fields[2])
frame_id = int(fields[1])
except (TypeError, ValueError):
continue
values = [_parse(fields[index]) for index in (3, 4, 5, 6, 7, 8)]
pending.append(
(
frame_id,
t_rel,
tuple(values[:3]),
tuple(values[3:]),
fields[9].strip() == "1",
)
)
if len(pending) >= bucket:
flush()
flush()
if not picked:
return _empty_window()
# A row can be the extreme of several axes; keep one copy, in time order.
unique = {row[0]: row for row in picked}
rows = sorted(unique.values(), key=lambda row: row[1])
return ForceWindow(
times=np.asarray([row[1] for row in rows], dtype=np.float64),
raw=np.asarray([row[2] for row in rows], dtype=np.float64).reshape(-1, 3),
newtons=np.asarray([row[3] for row in rows], dtype=np.float64).reshape(-1, 3),
valid=np.asarray([row[4] for row in rows], dtype=bool),
frame_ids=np.asarray([row[0] for row in rows], dtype=np.int64),
decimated=bucket > 1,
)
def iter_rows(self, start: int = 0, stop: Optional[int] = None) -> Iterator[tuple]:
"""Yield ``(row, frame_id, t_rel_s, raw3, newtons3, valid)`` in file order."""
self.ensure_built()
path = self.path
if not path.is_file():
return
stop = self._rows if stop is None else min(int(stop), self._rows)
with path.open("rb") as handle:
handle.readline() # header
for row, line in enumerate(handle):
if row >= stop:
return
if row < start:
continue
fields = next(csv.reader([line.decode("utf-8", "replace")]))
if len(fields) < 11:
continue
try:
frame_id = int(fields[1])
t_rel = float(fields[2])
except (TypeError, ValueError):
continue
values = [_parse(fields[index]) for index in (3, 4, 5, 6, 7, 8)]
yield (
row,
frame_id,
t_rel,
tuple(values[:3]),
tuple(values[3:]),
fields[9].strip() == "1",
)
# ------------------------------------------------------------------ overview
def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview:
"""A per-bucket envelope of the whole recording, in the active unit.
Built by streaming the file once and folding each row into its bucket, so
a peak in a bucket the strip would otherwise skip is still visible.
"""
self.ensure_built()
buckets = max(1, int(buckets))
newton = str(unit).upper().startswith("N")
if self._rows == 0:
return Overview(
times=np.empty(0),
lows=np.empty((0, 3)),
highs=np.empty((0, 3)),
counts=np.empty(0, dtype=np.int64),
unit="N" if newton else "raw",
)
first, last = self.extent()
span = max(1e-9, last - first)
lows = np.full((buckets, 3), np.nan)
highs = np.full((buckets, 3), np.nan)
counts = np.zeros(buckets, dtype=np.int64)
starts = np.full(buckets, np.nan)
for _row, _frame_id, t_rel, raw, newtons, is_valid in self.iter_rows():
if not is_valid:
continue
bucket = int((t_rel - first) / span * (buckets - 1) + 0.5)
bucket = max(0, min(buckets - 1, bucket))
values = newtons if newton else raw
for axis in range(3):
value = values[axis]
if value != value: # NaN
continue
current_low = lows[bucket, axis]
current_high = highs[bucket, axis]
lows[bucket, axis] = value if current_low != current_low else min(current_low, value)
highs[bucket, axis] = value if current_high != current_high else max(current_high, value)
if starts[bucket] != starts[bucket]:
starts[bucket] = t_rel
counts[bucket] += 1
return Overview(
times=starts,
lows=lows,
highs=highs,
counts=counts,
unit="N" if newton else "raw",
)
def _time_of(text: str) -> float:
fields = next(csv.reader([text])) if text else []
if len(fields) > 2:
try:
return float(fields[2])
except (TypeError, ValueError):
return float("nan")
return float("nan")
def _empty_window() -> ForceWindow:
return ForceWindow(
times=np.empty(0, dtype=np.float64),
raw=np.empty((0, 3)),
newtons=np.empty((0, 3)),
valid=np.empty(0, dtype=bool),
frame_ids=np.empty(0, dtype=np.int64),
)
+233 -24
View File
@@ -21,6 +21,15 @@ from typing import Iterator, Optional
import numpy as np
from orisys_app_shared.record.index import (
CHECKPOINT_ROWS,
DEFAULT_BUCKETS,
ForceWindow,
Overview,
RecordingIndex,
)
from orisys_app_shared.record.sample import plottable_axis_mask
logger = logging.getLogger(__name__)
#: Column names as written by the recorder (index == position in forces.csv).
@@ -65,9 +74,21 @@ class RecordingInfo:
frame_ids: np.ndarray
dropped_rows: int = 0
frames_dropped: int = 0
#: The index rows were in time order as written (False = a legacy record).
frames_out_of_order: bool = True
#: The force rows were in time order as written.
forces_out_of_order: bool = True
#: True when the arrays above are a decimated view of a long recording.
decimated: bool = False
#: Total rows in ``forces.csv`` (the decimated view has fewer).
total_rows: int = 0
#: The recording's real duration, from the frame index, never from the view.
total_duration_s: float = 0.0
@property
def duration_s(self) -> float:
if self.total_duration_s:
return float(self.total_duration_s)
if self.times.size == 0:
return 0.0
return float(self.times[-1])
@@ -78,26 +99,51 @@ class RecordingInfo:
return self.raw
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""``(times, values, mask)`` — the same validity rule the live charts use.
"""``(times, values, mask)`` — the same rule the live charts use.
The replay used to filter on finiteness alone, so a stale value left by a
failed compute step could reappear here after another view had already
excluded it.
excluded it. ``mask`` is per axis: a single-axis calibration is normal and
must not hide the axis that *is* calibrated.
"""
values = self.value_for_unit(unit)
mask = self.valid & np.isfinite(values).all(axis=1)
return self.times[mask], values[mask], mask
return self.times, values, plottable_axis_mask(values, self.valid)
def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]:
"""``(times, values)`` for one channel, with the other axes ignored."""
values = self.value_for_unit(unit)
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
class RecordingReader:
"""Open one experiment directory and serve replay queries from it."""
"""Open one experiment directory and serve replay queries from it.
def __init__(self, directory: str | Path) -> None:
``max_rows`` bounds what :meth:`info` keeps in memory: a long recording is
loaded as a decimated view (peaks preserved) and marked ``decimated``, while
:meth:`window` and :meth:`overview` read the interval being drawn from disk
through :class:`~orisys_app_shared.record.index.RecordingIndex`. That is what
lets the replay show an hour-long recording without holding all of it.
"""
#: Above this many rows ``info()`` decimates instead of loading everything.
DEFAULT_MAX_ROWS = 200_000
def __init__(
self,
directory: str | Path,
*,
max_rows: int | None = None,
checkpoint_rows: int = CHECKPOINT_ROWS,
) -> None:
self.directory = Path(directory)
if not self.directory.is_dir():
raise FileNotFoundError(f"Not a recording directory: {self.directory}")
self._info: Optional[RecordingInfo] = None
self._events: Optional[list[dict]] = None
self.max_rows = int(max_rows or self.DEFAULT_MAX_ROWS)
self._index = RecordingIndex(self.directory, checkpoint_rows=checkpoint_rows)
self._overviews: dict[tuple[str, int], Overview] = {}
# ------------------------------------------------------------------ loading
def info(self) -> RecordingInfo:
@@ -105,6 +151,10 @@ class RecordingReader:
self._info = self._load()
return self._info
def index(self) -> RecordingIndex:
"""The time index, built once and shared with the windows."""
return self._index.ensure_built()
def session(self) -> dict:
path = self.directory / "session.json"
if not path.is_file():
@@ -116,8 +166,30 @@ class RecordingReader:
return {}
def _load(self) -> RecordingInfo:
frames = self._load_frames()
times, raw, newtons, valid, frame_ids, dropped = self._load_forces()
frames, frames_ordered = self._load_frames()
index = self.index()
total_rows = index.rows
decimated = total_rows > self.max_rows > 0
if decimated:
window = self.window_all(
max_points=self.max_rows
)
times = window.times
raw = window.raw
newtons = window.newtons
valid = window.valid
frame_ids = window.frame_ids
forces_ordered = True
dropped = 0
else:
(times, raw, newtons, valid, frame_ids, dropped, forces_ordered) = (
self._load_forces()
)
total_duration = 0.0
if frames:
total_duration = float(frames[-1].t_rel_s)
elif times.size:
total_duration = float(times[-1])
return RecordingInfo(
directory=self.directory,
session=self.session(),
@@ -129,12 +201,26 @@ class RecordingReader:
frame_ids=frame_ids,
dropped_rows=dropped,
frames_dropped=sum(1 for frame in frames if frame.dropped),
frames_out_of_order=frames_ordered,
forces_out_of_order=forces_ordered,
decimated=decimated,
total_rows=total_rows,
total_duration_s=total_duration,
)
def _load_frames(self) -> list[RecordingFrame]:
def _load_frames(self) -> tuple[list[RecordingFrame], bool]:
"""Load the frame index in time order.
Recordings made before the ordered-write fix (see
``ExperimentRecorder``) can contain a frame index whose rows are not in
time order, because the row backlog used to be drained before the queue.
``frame_at`` does a binary search over these times, so an unsorted index
would silently answer with the wrong frame. The rows are sorted here and
the fact is reported as a replay warning.
"""
path = self.directory / "frames.csv"
if not path.is_file():
return []
return [], False
rows: list[RecordingFrame] = []
with path.open(encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle):
@@ -152,13 +238,26 @@ class RecordingReader:
)
except (KeyError, TypeError, ValueError):
continue
return rows
ordered = _is_time_ordered([frame.t_rel_s for frame in rows])
if not ordered:
rows.sort(key=lambda frame: frame.t_rel_s)
return rows, ordered
def _load_forces(self):
path = self.directory / "forces.csv"
if not path.is_file():
# A directory with no data (an empty or half-written recording) is
# readable: it simply has no samples, and nothing is out of order.
empty = np.empty(0, dtype=np.float64)
return empty, np.empty((0, 3)), np.empty((0, 3)), np.empty(0, bool), np.empty(0, int), 0
return (
empty,
np.empty((0, 3)),
np.empty((0, 3)),
np.empty(0, bool),
np.empty(0, int),
0,
True,
)
def parse(text: str) -> float:
text = (text or "").strip()
@@ -191,23 +290,42 @@ class RecordingReader:
(parse(row.get("FN_N")), parse(row.get("FX_N")), parse(row.get("FY_N")))
)
valid.append((row.get("valid") or "0").strip() == "1")
return (
np.asarray(times, dtype=np.float64),
np.asarray(raw, dtype=np.float64).reshape(-1, 3),
np.asarray(newtons, dtype=np.float64).reshape(-1, 3),
np.asarray(valid, dtype=bool),
np.asarray(frame_ids, dtype=np.int64),
dropped,
)
times_arr = np.asarray(times, dtype=np.float64)
raw_arr = np.asarray(raw, dtype=np.float64).reshape(-1, 3)
newtons_arr = np.asarray(newtons, dtype=np.float64).reshape(-1, 3)
valid_arr = np.asarray(valid, dtype=bool)
ids_arr = np.asarray(frame_ids, dtype=np.int64)
# Older recordings can have rows out of time order (the writer used to
# drain a backlog before the queue). Every window query below assumes
# sorted time, so sort once here and report it instead of answering
# ``searchsorted`` on unsorted input.
ordered = _is_time_ordered(times)
if not ordered and times_arr.size:
order = np.argsort(times_arr, kind="stable")
times_arr = times_arr[order]
raw_arr = raw_arr[order]
newtons_arr = newtons_arr[order]
valid_arr = valid_arr[order]
ids_arr = ids_arr[order]
return times_arr, raw_arr, newtons_arr, valid_arr, ids_arr, dropped, ordered
# ------------------------------------------------------------------ queries
def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]:
"""The recorded frame at or just before ``t_rel_s``."""
"""The recorded frame at or just before ``t_rel_s``.
The index is sorted at load time, so the binary search is valid; the
fallback walk keeps a hand-edited or truncated file from answering with
the wrong frame.
"""
frames = self.info().frames
if not frames:
return None
times = [frame.t_rel_s for frame in frames]
index = int(np.searchsorted(np.asarray(times), float(t_rel_s), side="right")) - 1
times = np.asarray([frame.t_rel_s for frame in frames], dtype=np.float64)
if not _is_time_ordered(times.tolist()):
candidates = [frame for frame in frames if frame.t_rel_s <= float(t_rel_s)]
return max(candidates, key=lambda frame: frame.t_rel_s) if candidates else frames[0]
index = int(np.searchsorted(times, float(t_rel_s), side="right")) - 1
index = max(0, min(index, len(frames) - 1))
return frames[index]
@@ -318,6 +436,47 @@ class RecordingReader:
mask = (info.times >= float(t0)) & (info.times <= float(t1))
return info.times[mask], info.raw[mask]
# ------------------------------------------------------ on-demand history
def window(
self,
t0: float,
t1: float,
*,
unit: str = "raw",
max_points: int | None = None,
) -> ForceWindow:
"""The recorded rows inside ``[t0, t1]``, read from disk.
This is the interval read: it seeks to the nearest checkpoint in the
index and scans forward, so asking for ten seconds of an hour-long
recording costs ten seconds of parsing, not an hour. ``max_points``
decimates *while* reading, so the interval never has to exist in memory
in full.
"""
start, stop = self.index().row_range(t0, t1)
return self.index().read_rows(
start, stop, max_points=max_points, unit=unit
)
def window_all(self, *, unit: str = "raw", max_points: int | None = None) -> ForceWindow:
"""The whole recording as one window, decimated while streaming."""
return self.window(
*self.index().extent(), unit=unit, max_points=max_points
)
def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview:
"""A whole-session envelope, cached per (unit, bucket count)."""
key = (str(unit), int(buckets))
if key not in self._overviews:
self._overviews[key] = self.index().overview(
unit=str(unit), buckets=int(buckets)
)
return self._overviews[key]
def statistics(self, t0: float, t1: float, *, unit: str = "raw"):
"""Statistics of an interval, computed from the interval's own rows."""
return self.window(t0, t1, unit=unit).statistics(unit)
# ------------------------------------------------------------------ policy
def depth_policy(self) -> dict:
return dict(self.info().session.get("depth") or {})
@@ -372,6 +531,8 @@ class RecordingReader:
warnings.append("rows_skipped")
if self.info().frames_dropped:
warnings.append("frames_dropped")
if not self.info().frames_out_of_order or not self.info().forces_out_of_order:
warnings.append("out_of_order")
return warnings
def iter_forces(self, batch: int = 4096) -> Iterator[tuple[np.ndarray, np.ndarray]]:
@@ -400,8 +561,56 @@ class RecordingReader:
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
def _is_time_ordered(values) -> bool:
"""True when ``values`` never decreases (an empty or single value is ordered)."""
previous = None
for value in values:
if previous is not None and float(value) < float(previous):
return False
previous = value
return True
def decimate_window(window: ForceWindow, max_points: int, *, unit: str = "raw") -> ForceWindow:
"""Reduce a window to about ``max_points``, keeping every axis' extremes.
The same rule the live store uses: each bucket contributes the extreme row of
**every plotted axis** in the active unit, so a shear spike survives a flat
normal channel. The result is marked ``decimated``.
"""
total = len(window)
max_points = max(4, int(max_points))
if total <= max_points:
return window
values = window.value_for_unit(unit)
def index_of(row: int) -> int:
return int(window.frame_ids[row]) if total else row
bucket = max(1, total // max(1, max_points // 6))
picked: set[int] = set()
for start in range(0, total, bucket):
stop = min(total, start + bucket)
for axis in range(3):
column = values[start:stop, axis]
finite = np.isfinite(column)
if not finite.any():
continue
ordered = np.where(finite, column, -np.inf)
picked.add(start + int(np.argmin(ordered)))
picked.add(start + int(np.argmax(ordered)))
if not picked:
return window
rows = sorted(picked)
return ForceWindow(
times=window.times[rows],
raw=window.raw[rows],
newtons=window.newtons[rows],
valid=window.valid[rows],
frame_ids=window.frame_ids[rows],
decimated=True,
)
def list_recordings(root: str | Path) -> list[Path]:
"""Every recording directory under ``root``, newest first."""
base = Path(root)
if not base.is_dir():
return []
+27 -5
View File
@@ -140,11 +140,16 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample:
valid = bool(getattr(packet, "valid", True))
reason = str(getattr(packet, "invalid_reason", "") or "")
stale = bool(getattr(packet, "stale_forces", False))
# The packet's own version wins: it was stamped by the acquisition thread at
# the moment of acquisition, so it is the only value that stays true when a
# calibration is applied in the middle of a session.
version = str(
getattr(packet, "calibration_version", "") or calibration_version or ""
# A packet that *carries* a version owns it, including the empty one: the
# acquisition thread stamps every frame, and an empty stamp is the device
# saying "no calibration loaded". Falling back to the session default there
# would resurrect coefficients that are no longer in effect. A packet from an
# older producer that has no version field at all still gets the default.
packet_version = getattr(packet, "calibration_version", None)
version = (
str(calibration_version or "")
if packet_version is None
else str(packet_version)
)
axes = calibrated_axes_of(packet)
@@ -214,3 +219,20 @@ def plottable_mask(values: Iterable[float], valid: Iterable[bool]) -> list[bool]
bool(is_valid) and math.isfinite(float(value))
for value, is_valid in zip(values, valid)
]
def plottable_axis_mask(values, valid) -> "np.ndarray":
"""``(n, axes)`` mask: one column per axis, decided for that axis alone.
The rule has to be per axis. A single-axis calibration is legitimate — the
device may have a normal-force curve and no shear curves — and an all-axes
rule hid a perfectly good FN because FX and FY had no newton value.
"""
import numpy as np
block = np.asarray(values, dtype=np.float64).reshape(-1, 3)
validity = np.asarray(valid, dtype=bool).reshape(-1)
finite = np.isfinite(block)
if validity.size != block.shape[0]:
return finite
return finite & validity[:, None]
@@ -0,0 +1,97 @@
"""The video side of a recording: one rolling MP4 segment at a time.
Kept apart from the CSV writer on purpose. The two have different failure modes —
a full disk breaks both, but a codec that refuses to open only breaks this one —
and the recorder's byte budget is about *these* frames, not about the rows.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Optional
import cv2
logger = logging.getLogger(__name__)
class VideoSegments:
"""Writes raw frames into ``raw_NNNN.mp4`` files, rolling on a time budget."""
def __init__(self, directory: Path, *, codec: str, fps: float, segment_seconds: float):
self.directory = Path(directory)
self.codec = str(codec)
self.fps = max(1.0, float(fps))
self.segment_seconds = float(segment_seconds)
self.segments = 0
self.video_frames = 0
self.frame_shape: Optional[tuple[int, int]] = None
self._writer: Optional[cv2.VideoWriter] = None
self._index = 0
self._frames_in_segment = 0
self._started = 0.0
# ------------------------------------------------------------------ writing
def write(self, frame) -> tuple[int, int]:
"""Append one frame. Returns ``(segment, frame_in_segment)``."""
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._writer is None:
self._open_segment()
self._writer.write(frame)
self._frames_in_segment += 1
self.video_frames += 1
return (self._index, self._frames_in_segment)
def write_sequence(self, frames) -> int:
"""Write a run of frames as one standalone segment. Returns the count."""
frames = [frame for frame in frames if frame is not None]
if not frames:
return 0
self.frame_shape = (int(frames[0].shape[1]), int(frames[0].shape[0]))
name = "preroll_0001.mp4"
writer = self._open_writer(self.directory / name)
written = 0
try:
for frame in frames:
writer.write(frame)
written += 1
finally:
writer.release()
return written
# ----------------------------------------------------------------- segments
def _should_roll(self) -> bool:
if self._writer is None:
return False
if self.segment_seconds <= 0:
return False
return (time.perf_counter() - self._started) >= self.segment_seconds
def _open_writer(self, path: Path) -> cv2.VideoWriter:
fourcc = cv2.VideoWriter_fourcc(*self.codec)
writer = cv2.VideoWriter(str(path), fourcc, self.fps, self.frame_shape)
if not writer.isOpened():
raise OSError(f"could not open video writer for {path.name}")
return writer
def _open_segment(self) -> None:
self._index += 1
path = self.directory / f"raw_{self._index:04d}.mp4"
self._writer = self._open_writer(path)
self._frames_in_segment = 0
self._started = time.perf_counter()
self.segments = self._index
def _close_segment(self) -> None:
if self._writer is None:
return
self._writer.release()
self._writer = None
def close(self) -> None:
self._close_segment()