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
+280
View File
@@ -0,0 +1,280 @@
"""Calibration identity and operator events as the acquisition thread sees them.
The review's P1-7: the application event was written before the device had loaded
anything, the recorded ``calibration_id`` never changed mid-session, and an
uncalibrated device could keep claiming the coefficients of an earlier session.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record.calibration_snapshot import (
list_calibration_snapshots,
snapshot_name,
store_calibration_snapshot,
)
SCRATCH = Path(__file__).resolve().parent / "_scratch"
def _root() -> Path:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
return SCRATCH
class _Sensor:
"""A device with a swappable calibration, like the SDK's Sensor."""
def __init__(self, calibration=None, path=None):
self.calibration = calibration
self.calibration_path = path
self.applied: list[str] = []
self.resets = 0
def set_calibration(self, path):
if str(path) == "reject-me":
raise ValueError("bad document")
self.calibration = object()
self.calibration_path = Path(path)
self.applied.append(str(path))
def reset(self):
self.resets += 1
def _calibration_file(root: Path, name: str, body: str = '{"unit": "N"}') -> Path:
path = root / name
path.write_text(body, encoding="utf-8")
return path
def _worker(**kwargs) -> SdkAcquisitionWorker:
for key in ("on_calibration_applied", "on_reset_applied"):
kwargs.setdefault(key, None)
return SdkAcquisitionWorker(**kwargs)
# ------------------------------------------------------------------ worker state
def test_a_new_session_does_not_inherit_the_previous_version():
"""An uncalibrated device must report no calibration, not the last one."""
root = _root()
worker = _worker()
worker._calibration_version = "previous-session-hash"
worker._stop.set()
worker.start("clip.mp4", config_ref=str(_calibration_file(root, "sensor.json")))
assert worker._calibration_version == "", (
"the new session kept the previous session's calibration version"
)
worker.stop(wait=True)
def test_the_applied_version_is_the_content_hash_and_carries_the_frame():
root = _root()
applied: list[tuple] = []
worker = _worker(on_calibration_applied=lambda *args: applied.append(args))
path = _calibration_file(root, "force_calibration.json")
sensor = _Sensor()
worker._session_id = 4
worker.set_pending_calibration(path)
worker._consume_pending_calibration(sensor, frame_id=17, mono_t=0.5)
assert len(applied) == 1
got_path, version, frame_id, mono_t, session = applied[0]
assert got_path == str(path)
assert version and version == worker._calibration_version
assert version != str(path), "the path was used as the version"
assert (frame_id, mono_t, session) == (17, 0.5, 4)
assert sensor.applied == [str(path)]
def test_a_rejected_calibration_keeps_the_version_the_device_still_has():
root = _root()
applied: list[tuple] = []
worker = _worker(on_calibration_applied=lambda *args: applied.append(args))
sensor = _Sensor()
worker._session_id = 1
worker._calibration_version = "loaded-earlier"
worker.set_pending_calibration("reject-me")
with pytest.raises(ValueError):
worker._consume_pending_calibration(sensor, frame_id=3, mono_t=0.1)
assert not applied, "a rejected document was reported as applied"
assert worker._calibration_version == "loaded-earlier", (
"the device still has those coefficients, so the version is still true"
)
def test_a_zeroing_is_applied_by_the_worker_and_reported_with_its_frame():
root = _root()
events: list[tuple] = []
worker = _worker(on_reset_applied=lambda *args: events.append(args))
sensor = _Sensor()
worker._session_id = 2
worker.reset() # a request, not the zeroing itself
assert sensor.resets == 0, "the zeroing ran on the caller's thread"
worker._consume_pending_reset(sensor, frame_id=42, mono_t=1.4)
assert sensor.resets == 1
assert events == [(42, 1.4, 2)]
# The request is consumed once.
worker._consume_pending_reset(sensor, frame_id=43, mono_t=1.5)
assert events == [(42, 1.4, 2)]
def test_a_started_session_drops_a_stale_zeroing_request():
root = _root()
events: list[tuple] = []
worker = _worker(on_reset_applied=lambda *args: events.append(args))
worker._stop.set()
worker.start("clip.mp4", config_ref=str(_calibration_file(root, "sensor.json")))
sensor = _Sensor()
worker._consume_pending_reset(sensor, frame_id=1, mono_t=0.0)
assert sensor.resets == 0, "a reset requested before this session fired into it"
assert not events
worker.stop(wait=True)
# ------------------------------------------------------------------- snapshots
def test_a_snapshot_is_content_addressed_and_listed():
root = _root()
directory = root / "session"
source = _calibration_file(root, "force_calibration_box.json", '{"a": 1}')
name = store_calibration_snapshot(directory, source, "abcdef1234567890")
assert name == "calibration/force_calibration_abcdef123456.json"
assert (directory / name).is_file()
assert (directory / name).read_text(encoding="utf-8") == '{"a": 1}'
# Applying the same coefficients twice stores one file.
again = store_calibration_snapshot(directory, source, "abcdef1234567890")
assert again == name
assert list_calibration_snapshots(directory) == [name]
# A different calibration cannot collide with it.
other = _calibration_file(root, "force_calibration_box.json", '{"a": 2}')
second = store_calibration_snapshot(directory, other, "ffffff000000")
assert second != name
assert len(list_calibration_snapshots(directory)) == 2
assert snapshot_name("", source).startswith("force_calibration_")
def test_the_recording_metadata_names_the_coefficients_it_ran_with():
root = _root()
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=1, metadata={})
source = _calibration_file(root, "force_calibration_box.json", '{"a": 1}')
name = store_calibration_snapshot(directory, source, "abcdef1234567890")
recorder.note_calibration_snapshot(
version="abcdef1234567890", name=name, frame_id=12, mono_t=0.4
)
recorder.log_event(
"calibration_applied", name, mono_t=0.4, frame_id=12
)
recorder.stop()
meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8"))
snapshots = meta["calibration_snapshots"]
assert snapshots and snapshots[0]["version"] == "abcdef1234567890"
assert snapshots[0]["file"] == name
assert snapshots[0]["frame_id"] == 12
assert snapshots[0]["mono_t"] == pytest.approx(0.4)
event = next(e for e in meta["events"] if e["kind"] == "calibration_applied")
assert event["frame_id"] == 12
assert event["mono_t"] == pytest.approx(0.4), "the event lost its acquisition time"
assert event["at"], "the event lost its wall clock"
# The CSV carries the same acquisition time in the session's relative base.
# An event that predates the first frame has no base yet, so its relative
# time is empty — the absolute (session-relative) time is in the metadata.
with (Path(directory) / "events.csv").open(encoding="utf-8", newline="") as handle:
import csv as _csv
rows = list(_csv.DictReader(handle))
applied = next(row for row in rows if row["kind"] == "calibration_applied")
assert applied["frame_id"] == "12"
assert applied["t_rel_s"] == "", "an event before the first frame invented a base"
def test_an_event_during_the_recording_gets_the_session_relative_time():
import csv as _csv
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
root = _root()
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=1, metadata={})
frame = np.zeros((4, 4, 3), np.uint8)
recorder.on_packet(
SdkPacket(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0,
mono_t=0.5,
fps=30.0,
process_ms=1.0,
frame_id=7,
)
)
recorder.log_event("zeroing", "tracker reset", mono_t=0.9, frame_id=9)
recorder.stop()
with (Path(directory) / "events.csv").open(encoding="utf-8", newline="") as handle:
rows = list(_csv.DictReader(handle))
zeroing = next(row for row in rows if row["kind"] == "zeroing")
assert float(zeroing["t_rel_s"]) == pytest.approx(0.4, abs=1e-6)
assert zeroing["frame_id"] == "9"
def test_an_event_without_a_mono_time_still_gets_a_wall_clock():
root = _root()
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=1, metadata={})
recorder.log_event("operator_note", "no frame attached")
recorder.stop()
meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8"))
event = next(e for e in meta["events"] if e["kind"] == "operator_note")
assert event["at"], "the event lost its wall clock"
assert event["frame_id"] == -1
def test_the_recorded_row_uses_the_packet_version_not_the_session_default():
from orisys_app_shared.record.sample import to_record_sample
class Packet:
frame_id = 3
mono_t = 0.2
wall_t = 0.2
valid = True
calibration_version = ""
calibrated_axes = ("fn",)
force_calibrated = True
raw_available = True
# An empty packet version is the device saying "no calibration": the session
# default must not be pasted in behind its back.
assert to_record_sample(Packet(), calibration_version="session-default").calibration_version == ""
class LegacyPacket(Packet):
calibration_version = None # an older producer without the field
# ...but a packet that has no version field at all still gets the default.
assert (
to_record_sample(LegacyPacket(), calibration_version="session-default").calibration_version
== "session-default"
)
class StampedPacket(Packet):
calibration_version = "packet-hash"
assert (
to_record_sample(StampedPacket(), calibration_version="session-default").calibration_version
== "packet-hash"
)
+93
View File
@@ -8,6 +8,9 @@ model.
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from orisys_app_shared.force.calibration_io import (
@@ -17,10 +20,19 @@ from orisys_app_shared.force.calibration_io import (
build_force_calibration_v2,
merge_samples,
predict_channel,
write_force_calibration,
)
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample
SCRATCH = Path(__file__).resolve().parent / "_scratch"
def _root() -> Path:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
return SCRATCH
def _point(recorder, load: float, raw: float, *, channel: str = "fn"):
recorder.start(channel=channel, reference_load_n=load, duration_s=0.05)
@@ -151,3 +163,84 @@ def test_a_channel_with_one_point_is_not_exported():
store.aggregate("fn", CalSample(weight_n=1.0, raw_mean=10.0, n=10))
with pytest.raises(ValueError):
build_force_calibration_v2(store)
# ---------------------------------------------------------------- one preparation
def _session_with_duplicates_and_validation():
recorder = CalibrationRecorder()
points = [
_point(recorder, 1.0, 100.0),
_point(recorder, 2.0, 200.0),
# A deliberate repeat of load 2.0 at a different raw level.
_point(recorder, 2.0, 240.0),
_point(recorder, 4.0, 400.0),
_point(recorder, 3.0, 290.0),
]
recorder.set_validation(points[4], True)
return recorder, points
def test_duplicate_loads_are_merged_before_the_model_is_built():
_recorder, _points = _session_with_duplicates_and_validation()
prepared = _recorder.prepared()
assert prepared.samples["fn"], "no training samples"
loads = [sample.weight_n for sample in prepared.samples["fn"]]
assert loads == sorted(set(loads)), f"duplicate loads were not merged: {loads}"
assert len(loads) == 3
merged = next(sample for sample in prepared.samples["fn"] if sample.weight_n == 2.0)
assert merged.n == 16, "the repeat's samples were not combined"
assert merged.raw_mean == pytest.approx((200.0 + 240.0) / 2.0)
assert prepared.points_in_fit("fn") == 3
def test_the_exported_file_predicts_exactly_what_the_panel_shows():
"""Read the written document back and compare it with the diagnostics.
The panel's numbers must describe the file that is actually written, so the
test loads the file with the SDK (not with the prepared object) and predicts
every training and validation point from it.
"""
import orisys.calibration as sdk_calibration
recorder, points = _session_with_duplicates_and_validation()
prepared = recorder.prepared(meta={"sensor_type": "box", "sn": "SN9"})
assert prepared.ok
document = prepared.to_document()
path = _root() / "force_calibration.json"
write_force_calibration(document, path)
from_file = sdk_calibration.load(path, strict=True)
for point in points:
raw = float(point.loaded_stats().mean)
predicted = float(from_file.get("fn").convert(raw))
value = prepared.predict("fn", raw)
assert predicted == pytest.approx(value, abs=1e-9)
if point.is_validation:
assert prepared.validation_errors[id(point)] == pytest.approx(
abs(predicted - point.reference_load_n), abs=1e-9
)
else:
assert prepared.residuals[id(point)] == pytest.approx(
predicted - point.reference_load_n, abs=1e-9
)
# The validation point is measured, never built in.
assert prepared.validation_points("fn") == 1
assert prepared.max_validation_error("fn") > 0.0
exported_raws = sorted(point[0] for point in document["channels"]["normal"]["points"])
assert exported_raws == [100.0, 220.0, 400.0], exported_raws
def test_the_recorder_fit_and_the_preparation_agree():
recorder, _points = _session_with_duplicates_and_validation()
prepared = recorder.prepared()
fit = recorder.fit("fn", prepared=prepared)
assert fit.n_points == prepared.points_in_fit("fn")
assert fit.breakpoints == prepared.breakpoints("fn")
assert fit.max_residual_n == pytest.approx(prepared.max_residual("fn"))
assert fit.validation_error_n == pytest.approx(prepared.max_validation_error("fn"))
# ...and a fit that prepares on its own gets the same numbers.
assert recorder.fit("fn").as_dict() == fit.as_dict()
+79 -6
View File
@@ -31,15 +31,15 @@ def _root() -> Path:
return SCRATCH
def _recorded_point(index_offset: float = 0.0):
def _recorded_point(load: float = 2.0, offset: float = 0.0):
recorder = CalibrationRecorder()
recorder.start(channel="fn", reference_load_n=2.0, duration_s=0.05)
recorder.start(channel="fn", reference_load_n=load, duration_s=0.05)
for index in range(6):
recorder.feed(
SessionSample(
index,
index * 1.0 / 30.0,
fn_raw=float(index) + index_offset,
fn_raw=float(index) + offset,
fx_raw=1.0,
fy_raw=2.0,
fn_n=float(index) * 10.0,
@@ -126,9 +126,9 @@ def test_a_newer_format_is_loaded_with_a_warning():
def test_the_recorder_can_reopen_a_saved_session():
points = [
_recorded_point(),
_recorded_point(5.0),
_recorded_point(9.0),
_recorded_point(1.0),
_recorded_point(2.0, 5.0),
_recorded_point(3.0, 9.0),
]
path = write_calibration_session(_root() / "session.json", points)
restored = read_calibration_session(path).points
@@ -141,3 +141,76 @@ def test_the_recorder_can_reopen_a_saved_session():
fit = recorder.fit("fn")
assert fit.validation_points == 1
assert fit.n_points == 2
def test_reopened_points_at_one_load_are_one_calibrated_load():
"""Three points at the same reference load are one load, not a curve.
They are merged (n-weighted) before the build, so a session that only ever
sampled one load cannot produce a model — which is the honest answer.
"""
recorder = CalibrationRecorder()
recorder.load_points(
[_recorded_point(2.0), _recorded_point(2.0, 5.0), _recorded_point(2.0, 9.0)],
replace=True,
)
prepared = recorder.prepared()
assert len(prepared.training) == 3
assert len(prepared.samples["fn"]) == 1
assert prepared.samples["fn"][0].n == 15, "the valid counts were not summed"
assert prepared.model is None, "a single load produced a model"
assert recorder.fit("fn").n_points == 1
# ------------------------------------------------------- reopened diagnostics
def test_a_reopened_record_keeps_its_device_identity():
"""Device A's record stays A's, whatever is connected now."""
points = [_recorded_point(1.0), _recorded_point(2.0, 5.0)]
path = write_calibration_session(
_root() / "session.json",
points,
meta={
"sensor_type": "box",
"sn": "SN-A",
"config_ref": "D:/lab/deviceA/sensor.json",
},
)
record = read_calibration_session(path)
identity = record.identity()
assert identity["sensor_type"] == "box"
assert identity["sn"] == "SN-A"
assert identity["config_ref"] == "D:/lab/deviceA/sensor.json"
assert identity["created_at"] == record.created_at
assert "SN-A" in record.describe()
def test_the_three_axis_trace_is_rebuilt_from_the_samples():
"""A record whose trace was never written must still be inspectable."""
import json
point = _recorded_point(1.0)
document = build_session_document([point])
# Simulate a document that only kept the audit series (or an older writer
# that stored a truncated trace).
document["points"][0]["trace"] = {}
document["points"][0]["duration_s"] = 0.0
path = _root() / "session.json"
path.write_text(json.dumps(document), encoding="utf-8")
restored = read_calibration_session(path).points[0]
assert set(restored.trace) == {"fn", "fx", "fy"}, restored.trace
assert len(restored.trace["fn"]) == 5 # the valid samples
assert restored.trace_dt_s == pytest.approx(1.0 / 30.0, abs=1e-6)
assert restored.duration_s == pytest.approx(5.0 / 30.0, abs=1e-6)
def test_a_stored_trace_is_not_overwritten_on_reopen():
point = _recorded_point(1.0)
document = build_session_document([point])
document["points"][0]["trace"] = {"fn": [1.0, 2.0]}
path = _root() / "session.json"
path.write_text(json.dumps(document), encoding="utf-8")
restored = read_calibration_session(path).points[0]
assert restored.trace["fn"] == [1.0, 2.0]
+179 -3
View File
@@ -16,7 +16,12 @@ from unittest.mock import patch
import numpy as np
import pytest
from orisys_app_shared.record.depth import DepthRecomputer
from orisys_app_shared.record.depth import (
STATE_APPROXIMATE,
STATE_EXACT,
STATE_PREROLL,
DepthRecomputer,
)
SCRATCH = Path(__file__).resolve().parent / "_scratch"
@@ -36,6 +41,12 @@ class _CountingSensor:
_CountingSensor.opened.append({"args": args, "kwargs": kwargs})
self.n = 0
self.chain_calls = 0
self.initial_img = "reference-frame"
# The real Sensor opens its source in the constructor, so the first
# source is part of its history too.
self.vid_src = str(args[0]) if args else ""
self.sources: list[str] = [self.vid_src] if self.vid_src else []
self.resets = 0
self.info = SimpleNamespace(DEPTH=1)
def get_img(self):
@@ -48,9 +59,17 @@ class _CountingSensor:
def compute_contact(self):
self.chain_calls += 1
def reset(self):
self.resets += 1
def read_info(self, _channel):
return np.array([self.n])
def _open_video_source(self, _override, _box):
self.sources.append(str(self.vid_src))
# A real reopen restarts the file from its first frame.
self.n = 0
def disconnect(self):
return None
@@ -116,13 +135,18 @@ def test_stepping_continues_from_the_current_position():
computer.close()
def test_stepping_backwards_reopens_the_segment():
def test_stepping_backwards_reopens_the_source_but_keeps_the_processor():
_CountingSensor.opened.clear()
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
with patch("orisys.Sensor", _CountingSensor):
assert computer._depth_from_segment(Path("raw_0001.mp4"), 5).tolist() == [5]
# Reading an earlier frame restarts the file; the processor object (and
# therefore the tracker) is not thrown away.
assert computer._depth_from_segment(Path("raw_0001.mp4"), 2).tolist() == [2]
assert len(_CountingSensor.opened) == 2, "seeking backwards needs a fresh source"
sensor = computer._sensor._sensor
assert len(_CountingSensor.opened) == 1, "the processor was recreated"
assert len(sensor.sources) == 2
assert all(source.endswith("raw_0001.mp4") for source in sensor.sources)
computer.close()
@@ -165,11 +189,44 @@ def test_the_plan_starts_at_the_last_reset_in_the_same_segment():
computer = DepthRecomputer(reader)
plan = computer.plan_for(frames[8])
assert plan.stop_at == 9
assert plan.anchor == "reset"
assert plan.state == STATE_EXACT
assert plan.reset_frame_id == 6
assert len(plan.steps) == 1
step = plan.steps[0]
assert step.source == reader.directory / "raw_0001.mp4"
assert step.to_frame == 9
assert step.reset_after == 7, "the reset boundary is not re-applied by the plan"
assert plan.frames_to_read == 9
assert plan.preroll is None
assert plan.warnings == ()
computer.close()
def test_a_reset_that_is_not_in_the_video_is_not_a_recovered_state():
"""Finding the event is not the same as being able to start there."""
frames = [
SimpleNamespace(
frame_id=index,
t_rel_s=index / 30.0,
segment=1,
segment_frame=index + 1,
in_video=index != 6, # the reset frame never reached the video
dropped=False,
)
for index in range(10)
]
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
reader.last_reset_frame = lambda frame_id: 6
computer = DepthRecomputer(reader)
plan = computer.plan_for(frames[8])
assert plan.anchor != "reset"
assert plan.state == STATE_APPROXIMATE
assert "reset_frame_not_in_video" in plan.warnings
assert "state_approximate" in plan.warnings
computer.close()
def test_the_plan_uses_the_pre_roll_for_the_start_of_a_recording():
frames = [
SimpleNamespace(
@@ -195,6 +252,8 @@ def test_the_plan_uses_the_pre_roll_for_the_start_of_a_recording():
plan = computer.plan_for(frames[3])
assert plan.preroll is not None
assert plan.preroll_frames == 30
assert plan.anchor == "preroll"
assert plan.state == STATE_PREROLL
assert plan.warnings == ()
computer.close()
@@ -215,10 +274,127 @@ def test_a_segment_without_history_is_flagged_approximate():
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
computer = DepthRecomputer(reader)
plan = computer.plan_for(frames[2])
assert plan.state == STATE_APPROXIMATE
assert plan.anchor == "segment_start"
assert "state_approximate" in plan.warnings
computer.close()
def test_a_reset_in_an_earlier_segment_is_carried_across_by_swapping_the_source():
"""The processor survives the swap, so the state *can* cross a segment."""
frames = [
SimpleNamespace(
frame_id=index,
t_rel_s=index / 30.0,
segment=1 if index < 3 else 2,
segment_frame=index + 1 if index < 3 else index - 2,
in_video=True,
dropped=False,
)
for index in range(6)
]
reader = _reader(
_session("/cfg/sensor.json"),
segments=("raw_0001.mp4", "raw_0002.mp4"),
frames=frames,
)
reader.last_reset_frame = lambda frame_id: 1
computer = DepthRecomputer(reader)
plan = computer.plan_for(frames[5])
assert plan.anchor == "reset"
assert plan.state == STATE_EXACT
assert "state_across_segment" not in plan.warnings
assert [step.source.name for step in plan.steps] == ["raw_0001.mp4", "raw_0002.mp4"]
assert plan.steps[0].reset_after == 2
assert plan.steps[1].to_frame == 3
computer.close()
def test_a_replay_that_would_read_too_far_back_is_reported_not_attempted():
frames = [
SimpleNamespace(
frame_id=index,
t_rel_s=index / 30.0,
segment=1,
segment_frame=index + 1,
in_video=True,
dropped=False,
)
for index in range(20)
]
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
reader.last_reset_frame = lambda frame_id: 1
computer = DepthRecomputer(reader)
with patch("orisys_app_shared.record.depth.MAX_REPLAY_FRAMES", 5):
plan = computer.plan_for(frames[10])
assert "state_too_far_back" in plan.warnings
assert plan.state == STATE_APPROXIMATE
computer.close()
def test_the_same_frame_is_answered_from_the_cache():
"""A second request for the frame just read is a cache hit, not None."""
_CountingSensor.opened.clear()
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
with patch("orisys.Sensor", _CountingSensor):
first = computer._depth_from_segment(Path("raw_0001.mp4"), 3)
second = computer._depth_from_segment(Path("raw_0001.mp4"), 3)
sensor = computer._sensor._sensor
assert first.tolist() == [3]
assert second is not None and second.tolist() == [3]
assert sensor.n == 3, "the cached answer re-read the stream"
computer.close()
def test_the_processor_survives_a_pre_roll_to_segment_switch():
"""Only the video source is replaced; the tracker state must survive."""
_CountingSensor.opened.clear()
reader = _reader(_session("/cfg/sensor.json"))
preroll = reader.directory / "preroll_0001.mp4"
preroll.write_bytes(b"")
computer = DepthRecomputer(reader)
with patch("orisys.Sensor", _CountingSensor):
computer._feed(preroll, 2)
sensor = computer._sensor._sensor
assert sensor.n == 2, "the pre-roll frames were not fed"
# Feed the segment: same processor, new source, position restarted.
computer._feed(reader.directory / "raw_0001.mp4", 1)
assert len(_CountingSensor.opened) == 1, "the processor was recreated"
assert computer._sensor._sensor is sensor
assert len(sensor.sources) == 2
assert sensor.sources[0].endswith("preroll_0001.mp4")
assert sensor.sources[1].endswith("raw_0001.mp4")
assert sensor.initial_img == "reference-frame", "the tracker reference was reset"
computer.close()
def test_a_reset_boundary_is_re_applied_during_the_replay():
"""The live zeroing cleared the tracker; the replay must do the same."""
_CountingSensor.opened.clear()
frames = [
SimpleNamespace(
frame_id=index,
t_rel_s=index / 30.0,
segment=1,
segment_frame=index + 1,
in_video=True,
dropped=False,
)
for index in range(10)
]
reader = _reader_with_config(frames)
reader.last_reset_frame = lambda frame_id: 6
reader.frame_at = lambda _t: frames[8]
computer = DepthRecomputer(reader)
with patch("orisys.Sensor", _CountingSensor):
result = computer.depth_at(frames[8].t_rel_s)
sensor = computer._sensor._sensor
assert result.ok, result.error
assert sensor.resets == 1, "the reset boundary was not re-applied"
assert result.warnings == (), result.warnings
computer.close()
def test_a_dropped_frame_is_reported_not_substituted():
"""The index marks the hole; replay must say so instead of showing a neighbour."""
frames = [
+139
View File
@@ -0,0 +1,139 @@
"""Display unit selection and the per-axis drawing rule.
Two defects are covered here:
* the live charts chose their unit from the device-level ``force_calibrated``
flag, so a device with only its normal-force axis calibrated either showed
every axis in raw counts or showed raw counts inside newton panels;
* the drawing rule asked for **all three** axes to be finite, so a valid FN
disappeared from the chart whenever FX/FY had no calibrated value.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.record.sample import to_record_sample
NAN = float("nan")
def _packet(**overrides) -> SdkPacket:
frame = np.zeros((4, 4, 3), np.uint8)
kwargs = dict(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0,
fps=30.0,
process_ms=1.0,
normal_force=1.5,
shear_x=0.25,
shear_y=-0.25,
normal_force_raw=123.0,
shear_x_raw=2.0,
shear_y_raw=-2.0,
force_calibrated=True,
calibrated_axes=("fn", "fx", "fy"),
frame_id=0,
)
kwargs.update(overrides)
return SdkPacket(**kwargs)
def test_newton_mode_marks_uncalibrated_axes_unavailable():
sample = to_record_sample(_packet(calibrated_axes=("fn",)))
display = select_display_forces(units_pref="newton", sample=sample)
assert display.unit == "N"
assert display.values[0] == pytest.approx(1.5)
assert math.isnan(display.values[1]) and math.isnan(display.values[2])
assert display.available == (True, False, False)
assert display.unavailable_axes() == ("fx", "fy")
assert not display.using_raw_fallback
def test_raw_mode_shows_the_raw_counts_for_every_axis():
sample = to_record_sample(_packet(calibrated_axes=("fn",)))
display = select_display_forces(units_pref="raw", sample=sample)
assert display.unit == "raw"
assert display.values == (123.0, 2.0, -2.0)
assert display.available == (True, True, True)
assert not display.using_raw_fallback
def test_an_uncalibrated_device_falls_back_to_raw_and_says_so():
sample = to_record_sample(
_packet(force_calibrated=False, calibrated_axes=())
)
display = select_display_forces(units_pref="newton", sample=sample)
assert display.unit == "raw"
assert display.using_raw_fallback, "the fallback must be announceable"
assert display.requested_unit == "N"
assert display.values == (123.0, 2.0, -2.0)
def test_a_failed_readout_has_no_available_axis():
sample = to_record_sample(
_packet(valid=False, invalid_reason="compute failed", stale_forces=True)
)
for unit in ("N", "raw"):
display = select_display_forces(units_pref=unit, sample=sample)
assert display.available == (False, False, False)
assert all(math.isnan(value) for value in display.values)
def _store_with_one_calibrated_axis() -> SessionStore:
store = SessionStore()
for index in range(5):
store.append(
SessionSample(
index,
index / 30.0,
fn_raw=100.0 + index,
fx_raw=2.0,
fy_raw=-2.0,
fn_n=1.0 + index * 0.1,
fx_n=NAN,
fy_n=NAN,
valid=True,
calibrated_axes=("fn",),
)
)
return store
def test_a_valid_axis_is_drawn_even_when_the_others_have_no_value():
"""The all-axes rule hid a good FN because FX/FY were uncalibrated."""
slice_ = _store_with_one_calibrated_axis().data()
times, values, mask = slice_.plottable("N")
assert mask.shape == (5, 3)
assert mask[:, 0].all(), "the calibrated axis was hidden"
assert not mask[:, 1].any()
assert not mask[:, 2].any()
fn_times, fn_values = slice_.plottable_axis("N", 0)
assert fn_times.size == 5 == fn_values.size
assert float(fn_values.max()) == pytest.approx(1.4)
fx_times, _fx_values = slice_.plottable_axis("N", 1)
assert fx_times.size == 0
def test_the_raw_triplet_is_still_complete_for_the_same_samples():
slice_ = _store_with_one_calibrated_axis().data()
for index in range(3):
times, values = slice_.plottable_axis("raw", index)
assert times.size == 5
assert np.isfinite(values).all()
def test_an_invalid_sample_is_excluded_from_every_axis():
store = _store_with_one_calibrated_axis()
store.append(SessionSample(9, 9.0, fn_n=NAN, valid=False))
slice_ = store.data()
_times, _values, mask = slice_.plottable("N")
assert not mask[-1].any(), "an invalid sample reached a drawing path"
+218
View File
@@ -0,0 +1,218 @@
"""Long-history browsing: interval reads, envelopes and one curve interface.
The replay used to load every row of a recording before drawing anything, so a
multi-hour session could not be shown at all. These tests pin the two things that
make it possible — an interval read bounded by the interval, and a whole-session
envelope built while streaming the file once — and that the charts can read from
either a session store or a recording through the same interface.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.force.source import RecordingCurveSource, StoreCurveSource
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordingReader
from orisys_app_shared.record.index import RecordingIndex
SCRATCH = Path(__file__).resolve().parent / "_scratch_history"
ROWS = 12_000
SPIKE_ROW = 9_000
SPIKE_VALUE = 999.0
def _root() -> Path:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
return SCRATCH
def _packet(index: int) -> SdkPacket:
frame = np.zeros((4, 4, 3), np.uint8)
fx = SPIKE_VALUE if index == SPIKE_ROW else 0.5
return SdkPacket(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0 + index / 30.0,
mono_t=index / 30.0,
fps=30.0,
process_ms=1.0,
normal_force=1.0,
shear_x=fx,
shear_y=-0.5,
normal_force_raw=10.0 + (index % 7) * 0.01,
shear_x_raw=fx,
shear_y_raw=-0.5,
force_unit="N",
force_calibrated=True,
frame_id=index,
)
def _recording() -> Path:
"""A recording of ``ROWS`` rows with no drops: the writer is allowed to keep up."""
import time
root = _root()
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=root, session_id=1, metadata={})
for index in range(ROWS):
recorder.on_packet(_packet(index))
if index % 256 == 0:
# Feeding 12000 rows in a tight loop would (correctly) overflow the
# ordered stream and be recorded as gaps; this test is about history
# browsing, so let the writer drain.
deadline = time.monotonic() + 5.0
while recorder._queue.qsize() > 512 and time.monotonic() < deadline:
time.sleep(0.001)
report = recorder.stop()
assert report.stats.dropped_data == 0, report.problems
assert report.stats.frames == ROWS
return Path(directory)
def test_the_index_reads_only_the_interval_it_is_asked_for():
directory = _recording()
index = RecordingIndex(directory)
index.build()
assert index.rows == ROWS
assert index.duration_s() == pytest.approx((ROWS - 1) / 30.0, abs=1e-6)
# The whole range keeps every row, including the very first one.
whole = index.read_rows(0, ROWS)
assert len(whole) == ROWS
assert whole.frame_ids[0] == 0
assert whole.frame_ids[-1] == ROWS - 1
start, stop = index.row_range(300.0, 300.5)
# One row before the interval is included on purpose: a cursor exactly on a
# sample still wants the row it follows.
assert 15 <= stop - start <= 20, (start, stop)
window = index.read_rows(start, stop)
assert 15 <= len(window) <= 20
assert window.times[0] >= 300.0 - 1 / 30.0
assert window.times[-1] <= 300.5
def test_a_large_recording_is_loaded_as_a_marked_decimated_view():
directory = _recording()
reader = RecordingReader(directory, max_rows=500)
info = reader.info()
assert info.decimated, "a 12000-row recording was loaded in full"
assert info.total_rows == ROWS
assert len(info.times) <= 500
# The duration comes from the frame index, never from the decimated view.
assert info.duration_s == pytest.approx((ROWS - 1) / 30.0, abs=0.05)
assert len(info.frames) == ROWS
def test_a_small_recording_is_not_decimated():
directory = _recording()
reader = RecordingReader(directory, max_rows=50_000)
info = reader.info()
assert not info.decimated
assert len(info.times) == ROWS
assert info.total_rows == ROWS
def test_an_interval_read_sees_every_row_including_a_single_peak():
directory = _recording()
reader = RecordingReader(directory, max_rows=500)
spike_t = SPIKE_ROW / 30.0
window = reader.window(spike_t - 0.05, spike_t + 0.05)
assert window.times.size >= 3
assert float(np.nanmax(window.value_for_unit("N")[:, 1])) == pytest.approx(SPIKE_VALUE)
# ...and it is the *exact* interval, not the decimated whole-session view.
assert not window.decimated
assert window.times[0] >= spike_t - 0.1
def test_a_decimated_window_still_keeps_the_peak():
directory = _recording()
reader = RecordingReader(directory, max_rows=500)
window = reader.window(0.0, (ROWS - 1) / 30.0, max_points=300, unit="N")
assert window.decimated
assert len(window) <= 600
assert float(np.nanmax(window.value_for_unit("N")[:, 1])) == pytest.approx(SPIKE_VALUE)
def test_the_envelope_covers_the_whole_session_in_few_points():
directory = _recording()
reader = RecordingReader(directory, max_rows=500)
overview = reader.overview(unit="N", buckets=200)
assert 0 < len(overview) <= 200
assert not np.isnan(overview.highs[:, 1]).all()
# The spike is inside a bucket the strip would otherwise skip.
assert float(np.nanmax(overview.highs[:, 1])) == pytest.approx(SPIKE_VALUE)
assert float(overview.counts.sum()) > 0
# Cached: asking again does not re-stream the file.
assert reader.overview(unit="N", buckets=200) is overview
def test_the_two_sources_answer_the_same_questions():
directory = _recording()
reader = RecordingReader(directory, max_rows=ROWS + 1)
recording = RecordingCurveSource(reader)
store = SessionStore()
for index in range(ROWS):
sample = SessionSample(
index,
index / 30.0,
fn_raw=10.0 + (index % 7) * 0.01,
fx_raw=SPIKE_VALUE if index == SPIKE_ROW else 0.5,
fy_raw=-0.5,
fn_n=1.0,
fx_n=SPIKE_VALUE if index == SPIKE_ROW else 0.5,
fy_n=-0.5,
)
store.append(sample)
memory = StoreCurveSource(store)
for source in (recording, memory):
first, last = source.extent()
assert first == pytest.approx(0.0, abs=1e-6)
assert last == pytest.approx((ROWS - 1) / 30.0, abs=1e-6)
assert source.duration_s() == pytest.approx((ROWS - 1) / 30.0, abs=1e-6)
assert recording.kind == "disk" and memory.kind == "memory"
# The same interval, read from disk and from memory, agrees on the values.
# (The disk read also includes the row just before the interval.)
disk = recording.window(200.0, 210.0, unit="N")
cached = memory.window(200.0, 210.0, unit="N")
inside = (disk.times >= 200.0) & (disk.times <= 210.0)
assert inside.sum() == len(cached) > 0
assert np.allclose(disk.values[inside], cached.values, equal_nan=True)
# The CSV keeps six decimals; the in-memory store keeps the raw float.
assert np.allclose(disk.times[inside], cached.times, atol=1e-6)
# ...and so do the statistics of that interval (the disk read may include the
# one row that precedes it).
a = recording.statistics(200.0, 210.0, unit="N")
b = memory.statistics(200.0, 210.0, unit="N")
assert abs(a.samples - b.samples) <= 1
assert a.peaks[1] == pytest.approx(b.peaks[1])
assert a.means[1] == pytest.approx(b.means[1], abs=1e-6)
def test_the_whole_session_can_be_read_without_holding_it_all():
"""The point of the index: peak value with a bounded number of rows held."""
directory = _recording()
reader = RecordingReader(directory, max_rows=500)
overview = reader.overview(unit="N", buckets=100)
# What a whole-session strip needs is one band per bucket, not 12000 rows.
assert len(overview) <= 100
assert len(reader.info().times) <= 500
assert reader.info().total_rows == ROWS
# A single sample can still be read exactly, on demand.
spike_t = SPIKE_ROW / 30.0
exact = reader.window(spike_t, spike_t + 1 / 30.0)
assert 1 <= len(exact) <= 3, len(exact)
assert float(np.nanmax(exact.newtons[:, 1])) == pytest.approx(SPIKE_VALUE)
+105
View File
@@ -0,0 +1,105 @@
"""The module boundaries the responsibility split is supposed to create.
A pure move is only worth anything if the dependency directions actually change:
the finger worker must not need the box worker to describe a packet, the session
file format must not need the sampling state machine, and the models must not
import either. These assertions are cheap and they fail loudly if someone
re-introduces the cycle.
"""
from __future__ import annotations
import ast
from pathlib import Path
SHARED = Path(__file__).resolve().parents[1] / "src"
ACQUISITION = SHARED / "orisys_app_shared" / "acquisition"
FORCE = SHARED / "orisys_app_shared" / "force"
RECORD = SHARED / "orisys_app_shared" / "record"
def _imports(path: Path) -> set[str]:
"""Every module name imported by ``path`` (``from x import y`` counts as x)."""
tree = ast.parse(path.read_text(encoding="utf-8"))
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
found.add(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
found.add(node.module)
# ``from . import models`` also names the submodule.
for alias in node.names:
found.add(f"{node.module}.{alias.name}" if node.module else alias.name)
return found
def _imports_any(path: Path, needles) -> bool:
found = _imports(path)
for needle in needles:
if any(entry == needle or entry.endswith(f".{needle}") for entry in found):
return True
return False
def test_the_packet_model_does_not_import_a_worker():
assert not _imports_any(
ACQUISITION / "models.py", {"sdk_worker", "finger_worker"}
), "acquisition.models depends on a worker"
def test_the_finger_worker_takes_the_packet_from_models():
"""It used to import the box worker just to describe a frame."""
source = (ACQUISITION / "finger_worker.py").read_text(encoding="utf-8")
tree = ast.parse(source)
from_sdk_worker: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and (node.module or "").endswith("sdk_worker"):
from_sdk_worker.update(alias.name for alias in node.names)
assert "SdkPacket" not in from_sdk_worker, "the finger worker still borrows the packet"
assert "FixedCameraMode" not in from_sdk_worker
assert "load_fixed_mode" not in from_sdk_worker
# ...and the helpers it genuinely shares still come from there.
assert "process_chain" in from_sdk_worker
def test_the_calibration_models_do_not_import_the_sampler_or_the_session_file():
for path in (FORCE / "models.py",):
assert not _imports_any(
path, {"calibration_recorder", "calibration_session", "session"}
), f"{path.name} depends on a higher layer"
def test_the_session_format_does_not_import_the_sampling_state_machine():
assert not _imports_any(
FORCE / "calibration_session.py", {"calibration_recorder"}
), "the session file format still depends on the sampler"
def test_the_moved_models_are_the_same_objects_everywhere():
from orisys_app_shared.acquisition import finger_worker, models, sdk_worker
from orisys_app_shared.force import calibration_recorder, calibration_session, models as force_models
assert sdk_worker.SdkPacket is models.SdkPacket is finger_worker.SdkPacket
assert sdk_worker.FixedCameraMode is models.FixedCameraMode
assert calibration_recorder.CalibrationPoint is force_models.CalibrationPoint
assert calibration_recorder.AxisStats is force_models.AxisStats
assert calibration_session.SAMPLE_FIELDS is force_models.SAMPLE_FIELDS
# The recorder still offers the trace helpers it used to own.
assert callable(calibration_recorder.trace_from)
assert callable(calibration_recorder.trace_dt)
def test_the_recording_pieces_are_separate_modules():
from orisys_app_shared.record import depth, experiment
from orisys_app_shared.record.depth_backends import BoxReplay, FingerReplay
from orisys_app_shared.record.video_writer import VideoSegments
# The depth replay no longer defines its backends inline...
assert not hasattr(depth, "BoxReplay")
assert not hasattr(depth, "_BoxReplay")
# ...and the writer delegates video encoding to its own component.
assert not hasattr(experiment, "_open_segment")
assert callable(VideoSegments.write) and callable(VideoSegments.close)
assert BoxReplay.__name__ == "BoxReplay" and FingerReplay.__name__ == "FingerReplay"
+72
View File
@@ -137,6 +137,78 @@ def test_reader_window_and_streaming_agree(recording):
assert streamed == 61, "streaming must see every row"
def test_an_empty_recording_directory_is_readable():
"""A folder with no CSVs must open (empty), not raise an unpack error."""
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
try:
reader = RecordingReader(SCRATCH)
info = reader.info()
assert info.frames == []
assert info.times.size == 0
assert info.duration_s == 0.0
assert reader.frame_at(1.0) is None
assert reader.replay_warnings(sdk_version="0.5.1")
finally:
shutil.rmtree(SCRATCH, ignore_errors=True)
def test_a_new_recording_is_reported_as_time_ordered(recording):
info = RecordingReader(recording).info()
assert info.frames_out_of_order, "the writer no longer produces ordered rows"
assert info.forces_out_of_order
codes = RecordingReader(recording).replay_warnings(sdk_version="0.5.1")
assert "out_of_order" not in codes
def test_an_old_out_of_order_recording_is_re_sorted_and_reported():
"""Rows written by the pre-fix writer are not in time order.
``frame_at`` binary-searches these times, so an unsorted file would answer
with the wrong frame; the reader sorts and says so instead.
"""
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
(SCRATCH / "forces.csv").write_text(
"session_id,frame_id,t_rel_s,FN_raw,FX_raw,FY_raw,FN_N,FX_N,FY_N,valid,calibration_id\n"
"1,0,0.0,1.0,0.1,0.1,2.0,0.1,0.1,1,h\n"
"1,2,0.066,3.0,0.1,0.1,6.0,0.1,0.1,1,h\n" # written before frame 1
"1,1,0.033,2.0,0.1,0.1,4.0,0.1,0.1,1,h\n",
encoding="utf-8",
)
(SCRATCH / "frames.csv").write_text(
"frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped\n"
"0,0.0,2026-01-01T00:00:00.000Z,1,1,1,0\n"
"2,0.066,2026-01-01T00:00:00.066Z,1,3,1,0\n"
"1,0.033,2026-01-01T00:00:00.033Z,1,2,1,0\n",
encoding="utf-8",
)
(SCRATCH / "session.json").write_text(
json.dumps(
{
"format_version": "1.1",
"device": {"sensor_type": "box", "config_ref": "/cfg/sensor.json"},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False},
}
),
encoding="utf-8",
)
try:
reader = RecordingReader(SCRATCH)
info = reader.info()
assert not info.frames_out_of_order
assert not info.forces_out_of_order
assert [frame.frame_id for frame in info.frames] == [0, 1, 2]
assert info.times.tolist() == pytest.approx([0.0, 0.033, 0.066])
# The binary search is only valid because the rows were sorted.
assert reader.frame_at(0.05).frame_id == 1
assert reader.frame_at(0.07).frame_id == 2
assert "out_of_order" in reader.replay_warnings(sdk_version="0.5.1")
finally:
shutil.rmtree(SCRATCH, ignore_errors=True)
def test_depth_policy_says_recompute_and_warns_about_drift(recording):
reader = RecordingReader(recording)
+285 -8
View File
@@ -10,11 +10,9 @@ from __future__ import annotations
import csv
import json
import queue
import shutil
import threading
import time
from collections import deque
from pathlib import Path
from unittest.mock import patch
@@ -23,7 +21,11 @@ import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record.experiment import STATE_FAILED, STATE_STOPPED, _SessionWriter
from orisys_app_shared.record.experiment import (
STATE_FAILED,
STATE_STOPPED,
_SessionWriter,
)
SCRATCH = Path(__file__).resolve().parent / "_scratch"
@@ -189,23 +191,30 @@ def test_a_full_queue_keeps_the_rows_and_sheds_the_video():
def test_a_lost_row_is_recorded_as_an_index_gap():
"""A hole in the data must be visible, not silently bridged in replay."""
"""A hole in the data must be visible, not silently bridged in replay.
The row is only given up when the ordered stream itself is full; the gap
marker then carries the stream position, so it lands in the index where the
hole actually is instead of after newer frames.
"""
original = _SessionWriter.write_sample
calls = {"count": 0}
def slow(self, *args):
calls["count"] += 1
if calls["count"] <= 2:
# Stall just long enough for the queue and the tiny backlog to fill.
# Stall just long enough for the stream to pass the (tiny) cap.
time.sleep(0.3)
return original(self, *args)
recorder = ExperimentRecorder(RecordConfig(video=False, max_queue=16))
with patch.object(_SessionWriter, "write_sample", slow):
directory = recorder.start(root=_root(), session_id=1, metadata={})
recorder._row_backlog = deque(recorder._row_backlog, maxlen=2)
for index in range(40):
recorder.on_packet(_packet(index))
with patch(
"orisys_app_shared.record.experiment.ROW_BACKLOG", 2
):
for index in range(40):
recorder.on_packet(_packet(index))
report = recorder.stop()
with (Path(directory) / "frames.csv").open(encoding="utf-8", newline="") as handle:
@@ -218,6 +227,57 @@ def test_a_lost_row_is_recorded_as_an_index_gap():
assert report.stats.dropped_gaps > 0, "the loss was not marked in the index"
def test_the_frame_index_stays_in_time_order_when_rows_are_lost():
"""The old writer drained its backlog before the queue, so the index jumped."""
original = _SessionWriter.write_sample
calls = {"count": 0}
def slow(self, *args):
calls["count"] += 1
if calls["count"] <= 3:
time.sleep(0.2)
return original(self, *args)
recorder = ExperimentRecorder(RecordConfig(video=True, max_queue=4))
with patch.object(_SessionWriter, "write_sample", slow):
directory = recorder.start(root=_root(), session_id=1, metadata={})
with patch("orisys_app_shared.record.experiment.ROW_BACKLOG", 3):
for index in range(60):
recorder.on_packet(_packet(index))
recorder.stop()
with (Path(directory) / "frames.csv").open(encoding="utf-8", newline="") as handle:
frame_rows = list(csv.DictReader(handle))
times = [float(row["t_rel_s"]) for row in frame_rows]
assert times == sorted(times), "frames.csv is not in time order"
ids = [int(row["frame_id"]) for row in frame_rows]
assert ids == sorted(ids), "forces.csv/frames.csv disagree on order"
with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle:
force_rows = list(csv.DictReader(handle))
force_times = [float(row["t_rel_s"]) for row in force_rows]
assert force_times == sorted(force_times), "forces.csv is not in time order"
def test_the_video_byte_budget_is_released_when_a_frame_is_shed():
"""A shed frame must not leak its reservation for the rest of the session."""
recorder = ExperimentRecorder(
RecordConfig(video=True, max_queue=16, max_queue_bytes=4096)
)
directory = recorder.start(root=_root(), session_id=1, metadata={})
# Frames of 8x8x3 = 192 bytes, so the budget holds ~21 of them; without the
# release the counter would climb past the budget and stay there.
for index in range(200):
recorder.on_packet(_packet(index))
report = recorder.stop()
assert report.stats.frames == 200, "rows were lost to the video budget"
assert abs(recorder._queued_bytes) < 4096, (
f"the byte budget leaked: {recorder._queued_bytes} bytes still reserved"
)
with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
assert len(rows) == 200
def test_session_json_names_the_pre_roll_when_one_was_written():
frames = [np.full((8, 8, 3), index, np.uint8) for index in range(3)]
recorder = ExperimentRecorder(RecordConfig(video=True, fps=5.0))
@@ -241,6 +301,80 @@ def test_a_data_only_recording_says_why_it_has_no_pre_roll():
assert any("pre-roll" in problem for problem in report.problems)
def test_a_stalled_encoder_leaves_a_readable_recording():
"""Order, duration, positioning and the byte counter after a stall.
Stall the writer, push past the ordered stream's cap, let it recover, then
push again and read the directory back the way the replay does. The frames
acquired *after* the recovery must all be there: an overflow is a temporary
loss, not the end of the recording.
"""
from orisys_app_shared.record import RecordingReader
original = _SessionWriter.write_sample
started = threading.Event()
release = threading.Event()
calls = {"count": 0}
def slow(self, *args):
calls["count"] += 1
if calls["count"] == 2:
started.set()
release.wait(10.0)
return original(self, *args)
def drained(recorder, timeout: float = 10.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if recorder._queue.qsize() == 0:
return True
time.sleep(0.01)
return False
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, max_queue=4))
with patch.object(_SessionWriter, "write_sample", slow):
directory = recorder.start(root=_root(), session_id=1, metadata={})
with patch("orisys_app_shared.record.experiment.ROW_BACKLOG", 3):
for index in range(40):
recorder.on_packet(_packet(index))
assert started.wait(10.0), "the stall never happened"
release.set()
assert drained(recorder), "the writer never caught up"
before = recorder.stats().frames
for index in range(40, 100):
recorder.on_packet(_packet(index))
time.sleep(0.005)
assert drained(recorder), "the recovered writer never caught up"
report = recorder.stop()
assert not report.stats.failed, report.problems
assert recorder.stats().frames > before, "recording did not continue after the stall"
reader = RecordingReader(directory)
info = reader.info()
times = info.times.tolist()
ids = [int(value) for value in info.frame_ids]
assert times == sorted(times), "forces.csv is not in time order"
assert ids == sorted(ids)
frame_times = [frame.t_rel_s for frame in info.frames]
assert frame_times == sorted(frame_times), "frames.csv is not in time order"
# Everything acquired after the recovery is present.
assert set(range(40, 100)).issubset(set(ids)), (
f"frames lost after the encoder recovered: {sorted(set(range(40, 100)) - set(ids))}"
)
assert report.stats.duration_s == pytest.approx(99 / 30.0, abs=0.05)
assert info.duration_s == pytest.approx(99 / 30.0, abs=0.05)
# Positioning: the frame at 2 s is the frame acquired at 2 s.
frame = reader.frame_at(2.0)
assert frame is not None and frame.frame_id == 60, frame
assert frame.t_rel_s == pytest.approx(2.0, abs=0.05)
# The byte budget is back to zero once every frame has been consumed.
assert recorder._queued_bytes == 0, recorder._queued_bytes
def test_stopping_twice_returns_the_same_report():
recorder = ExperimentRecorder(RecordConfig(video=False))
recorder.start(root=_root(), session_id=1, metadata={})
@@ -249,3 +383,146 @@ def test_stopping_twice_returns_the_same_report():
second = recorder.stop()
assert first.directory == second.directory
assert second.ok
# ------------------------------------------------------------ failure handling
def test_a_directory_that_cannot_be_created_leaves_a_retryable_state():
"""A failed ``mkdir`` must not strand the recorder in ``starting``."""
root = _root()
blocker = root / "not-a-directory"
blocker.write_text("", encoding="utf-8")
recorder = ExperimentRecorder(RecordConfig(video=False))
with pytest.raises(Exception):
recorder.start(root=blocker, session_id=1, metadata={})
assert recorder.state == STATE_FAILED
assert not recorder.is_recording
assert recorder.directory is None
assert recorder.stats().last_error
# Retryable: the operator fixes the path and presses record again.
directory = recorder.start(root=root, session_id=2, metadata={})
recorder.on_packet(_packet(0))
report = recorder.stop()
assert report.ok, report.problems
assert Path(directory).is_dir()
def test_a_stop_during_initialization_never_resumes_recording():
"""``stop`` that lands while the files open must not be undone afterwards."""
recorder = ExperimentRecorder(RecordConfig(video=False))
original = ExperimentRecorder._write_session_json
writing = threading.Event()
release = threading.Event()
calls = {"count": 0}
def gated(self, *, final: bool) -> None:
calls["count"] += 1
if calls["count"] == 1:
# This is the writer thread's first metadata write.
writing.set()
release.wait(5.0)
return original(self, final=final)
with patch.object(ExperimentRecorder, "_write_session_json", gated):
directory = recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
assert writing.wait(5.0)
report = recorder.stop(wait=False)
release.set()
thread = recorder._thread
if thread is not None:
thread.join(5.0)
# The row accepted before the stop is still written...
with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
assert len(rows) == 1, "rows accepted before the stop were dropped"
# ...but the recording never went back to "recording", and it is finished.
assert not recorder.is_recording
assert recorder.state in (STATE_STOPPED, STATE_FAILED)
assert not report.completed or report.directory is not None
def test_stop_without_waiting_does_not_claim_completion():
started = threading.Event()
release = threading.Event()
original = _SessionWriter.write_sample
def slow(self, *args):
started.set()
release.wait(10.0)
return original(self, *args)
recorder = ExperimentRecorder(RecordConfig(video=False))
with patch.object(_SessionWriter, "write_sample", slow):
recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
assert started.wait(10.0)
report = recorder.stop(wait=False)
try:
assert not report.completed
assert not report.ok
assert recorder.report is None, (
"an unfinished stop was cached as the terminal report"
)
finally:
release.set()
recorder._thread.join(5.0)
assert recorder.report is not None and recorder.report.completed
def test_a_failing_flush_reaches_the_state_machine_at_once():
"""A flush failure used to live only in the writer's private counter."""
recorder = ExperimentRecorder(RecordConfig(video=False, flush_interval_s=0.0))
directory = recorder.start(root=_root(), session_id=1, metadata={})
class _BrokenHandle:
def flush(self):
raise OSError("input/output error")
def close(self):
return None
writer = _SessionWriter(
directory=Path(directory),
config=recorder.config,
session_id=1,
calibration_id="",
on_problem=recorder._record_failure,
)
writer._forces = (_BrokenHandle(), None)
writer.maybe_flush()
assert recorder.stats().write_errors == 1, "the flush failure was not reported"
assert "flush failed" in recorder.stats().last_error
for _ in range(4):
writer.maybe_flush()
assert recorder.stats().failed, "repeated flush failures never failed the record"
assert not recorder.is_recording
recorder.stop()
def test_metadata_write_failure_is_part_of_the_report():
"""A session.json that cannot be written is part of what happened."""
real_write_text = Path.write_text
def failing_write_text(self, *args, **kwargs):
if self.name.endswith(".tmp"):
raise OSError("read-only filesystem")
return real_write_text(self, *args, **kwargs)
recorder = ExperimentRecorder(RecordConfig(video=False))
with patch.object(Path, "write_text", failing_write_text):
recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
report = recorder.stop()
assert report.stats.write_errors >= 1
assert not report.ok, "a missing session.json was reported as a good recording"
assert any(
"read-only" in problem or "write error" in problem for problem in report.problems
), report.problems
assert report.stats.failed