Fix the phase-1/2 review findings in recording, calibration and depth replay

What
- record: one packet->row conversion (record/sample.py). A failed compute chain
  records no force value at all, a newton value is written only for an axis that
  really has a calibration curve, and mono_t (not the wall clock) orders history
  with a per-row calibration hash.
- record lifecycle: explicit starting/recording/stopping/stopped/failed states
  owned by the writer thread; stop() reports completed=False on timeout and does
  not write ended_at; consecutive write failures stop acceptance immediately.
- record capacity: byte budget for the video queue (512 MiB default); a full
  queue sheds the *frame* and parks the tiny data row, so a video overflow no
  longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted
  as dropped_gaps / unrecorded_gaps.
- calibration identity: sha256 content hash instead of a path, minted from the
  calibration the sensor actually loaded (reported before the first frame) and
  confirmed by the acquisition thread; events are timestamped when they happen.
- calibration diagnostics: the fit summary, residual and validation error now
  come from the model that export/apply would use (piecewise_linear); validation
  points are held out of the build; repeated reference loads are merged with an
  n-weighted mean and pooled std.
- calibration record: every point keeps its full three-axis sample series
  (frame id, time, raw, newtons, valid) plus diagnostics, written to an
  independent calibration_session_*.json that can be reopened for inspection;
  save record / export coefficients / apply to device are three separate actions.
- depth replay: the cursor's frame is bound to the depth result by request
  sequence and frame id, so a moved cursor never shows another frame's map; the
  SDK is stepped from its current position instead of re-reading from the start;
  the live chain (box deformation+contact, finger unroll+tactile) is replayed
  with the exact resolved config path; algorithm state is rebuilt from a reset
  marker or from the recorded pre-roll, and says so when it cannot be.

Why
The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw
counts into the newton columns, recording a stale value after a failed compute,
losing CSV rows when the video queue filled, reporting success while a writer was
still running, diagnosing a model nobody applies, applying a calibration before
the device confirmed it, dropping the samples behind a calibration point, showing
a stale depth map under a moved cursor and recomputing depth of the wrong frame.

Verification
- D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped
  (log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept
  with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points.
- apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice).
- shared suites: 91 tests, 0 failures (new: test_record_semantics,
  test_record_lifecycle, test_calibration_model, test_calibration_record,
  test_depth_replay).
- apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness
  artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the
  shim cannot drive), unchanged from before this change.
- compileall clean; locale parity zh/en + t() key coverage: 0 failures.

Not in this change
- P2#10 disk-based browsing of a long recording and range CSV export (next
  round, per your choice).
- Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL
  three-dimensional view.
- Hardware verification: no camera attached, so the SDK's real processing chain
  and the pre-roll's effect on a live tracker were exercised with fakes only.
This commit is contained in:
ykcheungab
2026-09-13 14:55:53 +08:00
parent 9354d7be1a
commit 22cc59c402
26 changed files with 3387 additions and 290 deletions
+153
View File
@@ -0,0 +1,153 @@
"""Calibration diagnostics must describe the model that is exported.
The review found the panel reporting a private least-squares fit while the
exported file carried a piecewise-linear curve, validation points leaking into
the build, and repeated loads being overwritten so only the last one reached the
model.
"""
from __future__ import annotations
import pytest
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
build_export_model,
build_force_calibration_v2,
merge_samples,
predict_channel,
)
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample
def _point(recorder, load: float, raw: float, *, channel: str = "fn"):
recorder.start(channel=channel, reference_load_n=load, duration_s=0.05)
for index in range(8):
recorder.feed(
SessionSample(
index,
index * 0.01,
fn_raw=raw,
fx_raw=raw,
fy_raw=raw,
fn_n=raw / 10.0,
fx_n=raw / 10.0,
fy_n=raw / 10.0,
)
)
return recorder.finish_now()
def test_the_diagnosed_model_is_the_exported_model():
recorder = CalibrationRecorder()
for load, raw in ((1.0, 100.0), (2.0, 400.0), (3.0, 300.6), (5.0, 500.9)):
_point(recorder, load, raw)
fit = recorder.fit("fn")
assert fit.usable
assert fit.method == "piecewise_linear"
assert fit.breakpoints == 4
# A piecewise curve passes through the points it was built from.
assert fit.max_residual_n == pytest.approx(0.0, abs=1e-9)
def test_validation_points_are_held_out_and_measured():
recorder = CalibrationRecorder()
points = [
_point(recorder, 1.0, 100.0),
_point(recorder, 2.0, 400.0),
_point(recorder, 3.0, 300.6),
_point(recorder, 5.0, 500.9),
]
recorder.set_validation(points[1], True)
fit = recorder.fit("fn")
assert fit.n_points == 3, "a validation point entered the build"
assert fit.validation_points == 1
assert fit.validation_error_n > 0.05, "the off-trend point was not measured"
assert fit.breakpoints == 3
assert points[1].residual_n != points[1].residual_n # NaN: not in the fit
def test_excluded_points_leave_the_build():
recorder = CalibrationRecorder()
points = [_point(recorder, 1.0, 100.0), _point(recorder, 5.0, 500.0)]
outlier = _point(recorder, 3.0, 900.0)
recorder.set_excluded(outlier, True)
fit = recorder.fit("fn")
assert fit.n_points == 2
store = CalSessionStore()
for point in recorder.all_points():
if point.excluded or point.is_validation:
continue
store.aggregate(
point.channel,
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(point.loaded_stats().mean),
n=int(point.loaded_stats().n),
),
)
doc, _warnings = build_force_calibration_v2(store)
points_in_doc = doc["channels"]["normal"]["points"]
assert [point[0] for point in points_in_doc] == [100.0, 500.0], (
"the excluded point reached the exported coefficients"
)
assert points # silence linters about the unused list
def test_validation_points_never_reach_the_exported_coefficients():
recorder = CalibrationRecorder()
_point(recorder, 1.0, 100.0)
_point(recorder, 5.0, 500.0)
held_out = _point(recorder, 3.0, 300.0)
recorder.set_validation(held_out, True)
store = CalSessionStore()
for point in recorder.all_points():
if point.excluded or point.is_validation:
continue
store.aggregate(
point.channel,
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(point.loaded_stats().mean),
n=int(point.loaded_stats().n),
),
)
doc, _ = build_force_calibration_v2(store)
raws = [point[0] for point in doc["channels"]["normal"]["points"]]
assert 300.0 not in raws, "a validation point was used to build the model"
def test_repeated_loads_are_merged_not_overwritten():
"""Two samples of one load carry scatter the model should see."""
first = CalSample(weight_n=2.0, raw_mean=100.0, n=10, std=1.0)
second = CalSample(weight_n=2.0, raw_mean=110.0, n=30, std=2.0)
merged = merge_samples(first, second)
assert merged.n == 40
assert merged.raw_mean == pytest.approx((100.0 * 10 + 110.0 * 30) / 40)
assert merged.std > 2.0, "the scatter between the two samples was lost"
store = CalSessionStore()
store.aggregate("fn", first)
store.aggregate("fn", second)
assert len(store.channel("fn")) == 1
assert store.channel("fn")[0].n == 40
def test_predict_channel_applies_the_exported_curve():
store = CalSessionStore()
store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10))
store.aggregate("fn", CalSample(weight_n=10.0, raw_mean=100.0, n=10))
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
assert predict_channel(model, "fn", 50.0) == pytest.approx(5.0, abs=1e-6)
def test_a_channel_with_one_point_is_not_exported():
store = CalSessionStore()
store.aggregate("fn", CalSample(weight_n=1.0, raw_mean=10.0, n=10))
with pytest.raises(ValueError):
build_force_calibration_v2(store)
+143
View File
@@ -0,0 +1,143 @@
"""A calibration session must survive as evidence, not just as coefficients.
The review's P1-8: the recorder threw away the raw sample buffer and kept only
three-axis statistics plus a 400-point trace, so the full series behind a point
could never be re-examined.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.calibration_session import (
SAMPLE_FIELDS,
build_session_document,
read_calibration_session,
write_calibration_session,
)
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 _recorded_point(index_offset: float = 0.0):
recorder = CalibrationRecorder()
recorder.start(channel="fn", reference_load_n=2.0, duration_s=0.05)
for index in range(6):
recorder.feed(
SessionSample(
index,
index * 1.0 / 30.0,
fn_raw=float(index) + index_offset,
fx_raw=1.0,
fy_raw=2.0,
fn_n=float(index) * 10.0,
fx_n=1.0,
fy_n=2.0,
valid=index != 5,
)
)
return recorder.finish_now()
def test_a_point_keeps_every_sample_including_invalid_ones():
point = _recorded_point()
assert len(point.samples) == 6
assert point.n_total == 6
assert point.n_valid == 5
# frame id, relative time, three raw axes, three newton axes, validity.
first = point.samples[0]
assert first[0] == 0, "the frame id of the sample was not kept"
assert len(first) == len(SAMPLE_FIELDS)
assert point.samples[-1][-1] is False
def test_the_serialized_point_carries_the_series():
point = _recorded_point()
payload = point.as_dict()
assert payload["sample_fields"] == list(SAMPLE_FIELDS)
assert payload["n_samples"] == 6
assert len(payload["samples"]) == 6
assert payload["duration_s"] == pytest.approx(5.0 / 30.0, abs=1e-6)
def test_a_session_round_trips_through_disk():
point = _recorded_point()
path = write_calibration_session(
_root() / "session.json", [point], meta={"sensor_type": "box", "sn": "SN1"}
)
record = read_calibration_session(path)
assert record.ok
assert record.meta["sn"] == "SN1"
assert len(record.points) == 1
restored = record.points[0]
assert restored.channel == point.channel
assert restored.reference_load_n == point.reference_load_n
assert restored.duration_s == pytest.approx(point.duration_s)
assert restored.n_total == point.n_total
assert restored.n_valid == point.n_valid
assert restored.raw["fn"] == point.raw["fn"]
assert len(restored.samples) == len(point.samples)
assert restored.samples[0][0] == 0
def test_diagnostics_survive_a_round_trip():
point = _recorded_point()
point.excluded = False
point.is_validation = True
point.note = "operator note"
point.direction = "unload"
path = write_calibration_session(_root() / "session.json", [point])
restored = read_calibration_session(path).points[0]
assert restored.is_validation
assert not restored.excluded
assert restored.note == "operator note"
assert restored.direction == "unload"
def test_a_foreign_document_is_refused_rather_than_guessed():
target = _root() / "not-a-session.json"
target.write_text(json.dumps({"hello": "world"}), encoding="utf-8")
with pytest.raises(ValueError):
read_calibration_session(target)
def test_a_newer_format_is_loaded_with_a_warning():
"""The layout may have changed; the operator is told instead of guessing."""
document = build_session_document([_recorded_point()])
document["format_version"] = "99.0"
path = _root() / "future.json"
path.write_text(json.dumps(document), encoding="utf-8")
record = read_calibration_session(path)
assert record.points, "a readable point should still be returned"
assert record.warnings and "99.0" in record.warnings[0]
def test_the_recorder_can_reopen_a_saved_session():
points = [
_recorded_point(),
_recorded_point(5.0),
_recorded_point(9.0),
]
path = write_calibration_session(_root() / "session.json", points)
restored = read_calibration_session(path).points
recorder = CalibrationRecorder()
assert recorder.load_points(restored, replace=True) == 3
assert [item.channel for item in recorder.all_points()] == ["fn", "fn", "fn"]
# Diagnostics travel with the points.
recorder.set_validation(recorder.all_points()[1], True)
fit = recorder.fit("fn")
assert fit.validation_points == 1
assert fit.n_points == 2
+248
View File
@@ -0,0 +1,248 @@
"""Offline depth replay: where to start, how far to step, which chain to run.
P1-3 and P1-4: the pane kept showing the previous frame's depth after the cursor
moved, the recomputer re-read ``target`` frames on every call (so requesting
frames 2 and 3 landed on 5), it skipped the live processing chain, and it
reduced the recorded config path to a name that could load a different file.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from orisys_app_shared.record.depth import DepthRecomputer
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 _CountingSensor:
"""A sensor whose reported depth is the number of frames it has read."""
opened: list[dict] = []
def __init__(self, *args, **kwargs):
_CountingSensor.opened.append({"args": args, "kwargs": kwargs})
self.n = 0
self.chain_calls = 0
self.info = SimpleNamespace(DEPTH=1)
def get_img(self):
self.n += 1
return np.ones((2, 2))
def compute_deformation(self, **_kwargs):
self.chain_calls += 1
def compute_contact(self):
self.chain_calls += 1
def read_info(self, _channel):
return np.array([self.n])
def disconnect(self):
return None
def _reader(session: dict, *, segments=("raw_0001.mp4",), frames=()):
directory = _root()
for name in segments:
(directory / name).write_bytes(b"")
return SimpleNamespace(
session=lambda: session,
segment_path=lambda segment: directory / f"raw_{segment:04d}.mp4",
segments=lambda: [directory / name for name in segments],
info=lambda: SimpleNamespace(frames=list(frames)),
frame_of=lambda frame_id: next(
(frame for frame in frames if frame.frame_id == frame_id), None
),
frame_at=lambda t: None,
last_reset_frame=lambda frame_id: None,
segment_start_frame=lambda segment: next(
(frame for frame in frames if frame.segment == segment), None
),
preroll_frames=lambda: 0,
preroll_segment=lambda: None,
directory=directory,
)
def _session(config_ref: str) -> dict:
return {"device": {"config_ref": config_ref, "sensor_type": "box"}}
def _config_file() -> str:
"""A config that exists, so ``preflight`` is not what a test measures."""
path = _root() / "sensor.json"
path.write_text("{}", encoding="utf-8")
return str(path)
def _reader_with_config(frames):
"""A reader whose recorded config really exists on disk.
``_reader`` recreates the scratch directory, so the config file is written
*after* the reader is built.
"""
directory = _root()
config = directory / "sensor.json"
reader = _reader(_session(str(config)), frames=frames)
config.write_text("{}", encoding="utf-8")
return reader
def test_stepping_continues_from_the_current_position():
"""The old code re-read ``target`` frames every call: 2 then 3 landed on 5."""
_CountingSensor.opened.clear()
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
with patch("orisys.Sensor", _CountingSensor):
first = computer._depth_from_segment(Path("raw_0001.mp4"), 2)
second = computer._depth_from_segment(Path("raw_0001.mp4"), 3)
assert first.tolist() == [2]
assert second.tolist() == [3]
# One sensor for the whole replay: reopening would reset the algorithm state.
assert len(_CountingSensor.opened) == 1
computer.close()
def test_stepping_backwards_reopens_the_segment():
_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]
assert computer._depth_from_segment(Path("raw_0001.mp4"), 2).tolist() == [2]
assert len(_CountingSensor.opened) == 2, "seeking backwards needs a fresh source"
computer.close()
def test_the_live_processing_chain_runs_for_every_replayed_frame():
"""Without it the replay is a different algorithm than the recording."""
_CountingSensor.opened.clear()
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
with patch("orisys.Sensor", _CountingSensor):
computer._depth_from_segment(Path("raw_0001.mp4"), 3)
sensor = computer._sensor._sensor
assert sensor.chain_calls == 6, "one deformation + one contact per frame"
computer.close()
def test_the_exact_config_path_is_opened(tmp_path=None):
"""A reduced name could load a different file with the same name."""
_CountingSensor.opened.clear()
config = Path("D:/custom/box/sensor.json")
computer = DepthRecomputer(_reader(_session(str(config))))
with patch("orisys.Sensor", _CountingSensor):
computer._depth_from_segment(Path("raw_0001.mp4"), 1)
assert _CountingSensor.opened[0]["kwargs"]["config_name"] == str(config)
computer.close()
def test_the_plan_starts_at_the_last_reset_in_the_same_segment():
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(_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.stop_at == 9
assert plan.preroll is None
assert plan.warnings == ()
computer.close()
def test_the_plan_uses_the_pre_roll_for_the_start_of_a_recording():
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(5)
]
def reader_factory():
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
reader.preroll_frames = lambda: 30
reader.preroll_segment = lambda: reader.directory / "preroll_0001.mp4"
return reader
reader = reader_factory()
(reader.directory / "preroll_0001.mp4").write_bytes(b"")
computer = DepthRecomputer(reader)
plan = computer.plan_for(frames[3])
assert plan.preroll is not None
assert plan.preroll_frames == 30
assert plan.warnings == ()
computer.close()
def test_a_segment_without_history_is_flagged_approximate():
"""Starting mid-recording with no reset and no pre-roll cannot be exact."""
frames = [
SimpleNamespace(
frame_id=index,
t_rel_s=index / 30.0,
segment=2,
segment_frame=index + 1,
in_video=True,
dropped=False,
)
for index in range(5)
]
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
computer = DepthRecomputer(reader)
plan = computer.plan_for(frames[2])
assert "state_approximate" in plan.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 = [
SimpleNamespace(
frame_id=0,
t_rel_s=0.0,
segment=1,
segment_frame=1,
in_video=True,
dropped=False,
),
SimpleNamespace(
frame_id=1,
t_rel_s=1 / 30.0,
segment=1,
segment_frame=0,
in_video=False,
dropped=True,
),
]
reader = _reader_with_config(frames)
reader.frame_at = lambda _t: frames[1]
computer = DepthRecomputer(reader)
result = computer.depth_at(1 / 30.0)
assert not result.ok
assert result.error == "frame_not_recorded"
computer.close()
+251
View File
@@ -0,0 +1,251 @@
"""Recording lifecycle: terminal states, refusal to reuse a live writer, gaps.
The review's P1-5 and P1-6 are both about a recording that looks alive while it
is not: five write errors left ``is_recording`` true, a stop timeout reported
success and wrote ``ended_at`` while the files were still open, and a full queue
threw the CSV row away while counting only the video loss.
"""
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
import numpy as np
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
SCRATCH = Path(__file__).resolve().parent / "_scratch"
def _packet(index: int = 0) -> SdkPacket:
frame = np.zeros((8, 8, 3), np.uint8)
return SdkPacket(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0 + index / 30.0,
fps=30.0,
process_ms=1.0,
frame_id=index,
)
def _root() -> Path:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
return SCRATCH
def _wait_for(predicate, timeout: float = 5.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return False
def test_repeated_write_failures_end_the_recording_state():
recorder = ExperimentRecorder(RecordConfig(video=False))
with patch.object(_SessionWriter, "write_sample", side_effect=OSError("disk full")):
recorder.start(root=_root(), session_id=1, metadata={})
for index in range(5):
recorder.on_packet(_packet(index))
assert _wait_for(lambda: recorder.stats().write_errors >= 5)
assert recorder.stats().failed
assert not recorder.is_recording, "a dead writer still claimed to be recording"
assert recorder.state == STATE_FAILED
report = recorder.stop()
assert not report.ok
def test_packets_after_a_fatal_error_are_refused_not_silently_eaten():
recorder = ExperimentRecorder(RecordConfig(video=False))
with patch.object(_SessionWriter, "write_sample", side_effect=OSError("disk full")):
recorder.start(root=_root(), session_id=1, metadata={})
for index in range(5):
recorder.on_packet(_packet(index))
assert _wait_for(lambda: recorder.stats().write_errors >= 5)
for index in range(10):
recorder.on_packet(_packet(index))
assert recorder.stats().rejected_after_failure > 0
recorder.stop()
def test_a_stopped_writer_is_in_a_terminal_state():
recorder = ExperimentRecorder(RecordConfig(video=False))
recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
report = recorder.stop()
assert report.completed and report.ok
assert recorder.state == STATE_STOPPED
assert not recorder.is_recording
def test_a_writer_that_misses_the_deadline_is_not_reported_as_finished():
"""A stop timeout must not claim the files are closed."""
started = threading.Event()
release = threading.Event()
original = _SessionWriter.write_sample
def slow(self, *args):
started.set()
release.wait(5.0)
return original(self, *args)
recorder = ExperimentRecorder(RecordConfig(video=False))
with patch.object(_SessionWriter, "write_sample", slow):
directory = recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
assert started.wait(5.0)
thread = recorder._thread
report = recorder.stop(timeout=0.01)
try:
assert not report.ok
assert not report.completed
assert thread.is_alive(), "the report claimed the writer had stopped"
meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8"))
assert not meta["ended_at"], "ended_at was written before the files closed"
finally:
release.set()
thread.join(5.0)
# A late stop observes the writer's own report, not the timeout placeholder.
assert recorder.state == STATE_STOPPED
def test_a_live_writer_cannot_be_reused_for_a_new_session():
started = threading.Event()
release = threading.Event()
original = _SessionWriter.write_sample
def slow(self, *args):
started.set()
release.wait(5.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(5.0)
with pytest.raises(RuntimeError):
recorder.start(root=_root(), session_id=2, metadata={})
release.set()
recorder.stop()
def test_events_are_accepted_as_soon_as_start_returns():
"""The writer may still be opening its files; "started" must survive that."""
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=_root(), session_id=1, metadata={})
recorder.log_event("record_started", "mode=video_data")
recorder.on_packet(_packet(0))
recorder.stop()
with (Path(directory) / "events.csv").open(encoding="utf-8", newline="") as handle:
kinds = [row["kind"] for row in csv.DictReader(handle)]
assert kinds == ["record_started"]
def test_a_full_queue_keeps_the_rows_and_sheds_the_video():
"""P1-6: losing the CSV row because the video could not keep up."""
started = threading.Event()
release = threading.Event()
original = _SessionWriter.write_sample
def slow(self, *args):
started.set()
release.wait(5.0)
return original(self, *args)
recorder = ExperimentRecorder(RecordConfig(video=True, max_queue=16))
with patch.object(_SessionWriter, "write_sample", slow):
directory = recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
assert started.wait(5.0)
for index in range(1, 25):
recorder.on_packet(_packet(index))
release.set()
report = recorder.stop()
with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
assert len(rows) == 25, "data rows were lost because the video queue filled up"
assert report.stats.dropped_data == 0
assert report.stats.dropped_video > 0, "the video loss was not reported"
with (Path(directory) / "frames.csv").open(encoding="utf-8", newline="") as handle:
frame_rows = list(csv.DictReader(handle))
assert len(frame_rows) == len(rows)
assert [row["frame_id"] for row in frame_rows] == [row["frame_id"] for row in rows]
def test_a_lost_row_is_recorded_as_an_index_gap():
"""A hole in the data must be visible, not silently bridged in replay."""
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.
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))
report = recorder.stop()
with (Path(directory) / "frames.csv").open(encoding="utf-8", newline="") as handle:
frame_rows = list(csv.DictReader(handle))
assert frame_rows, "the frame index is empty"
assert any(row["dropped"] == "1" for row in frame_rows), (
"a frame whose row was lost left no trace in the index"
)
assert report.stats.dropped_data > 0, "the loss was not counted"
assert report.stats.dropped_gaps > 0, "the loss was not marked in the index"
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))
directory = recorder.start(root=_root(), session_id=1, metadata={}, preroll=frames)
recorder.on_packet(_packet(0))
recorder.stop()
meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8"))
assert meta["preroll_frames"] == 3
assert (Path(directory) / meta["preroll_segment"]).is_file()
def test_a_data_only_recording_says_why_it_has_no_pre_roll():
"""The first frames cannot be replayed faithfully; that must be stated."""
frames = [np.full((8, 8, 3), index, np.uint8) for index in range(3)]
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=_root(), session_id=1, metadata={}, preroll=frames)
recorder.on_packet(_packet(0))
report = recorder.stop()
meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8"))
assert meta["preroll_frames"] == 0
assert any("pre-roll" in problem for problem in report.problems)
def test_stopping_twice_returns_the_same_report():
recorder = ExperimentRecorder(RecordConfig(video=False))
recorder.start(root=_root(), session_id=1, metadata={})
recorder.on_packet(_packet(0))
first = recorder.stop()
second = recorder.stop()
assert first.directory == second.directory
assert second.ok
+152
View File
@@ -0,0 +1,152 @@
"""Recording semantics: what a row means, and what a missing value means.
Every case here comes from a reproducible defect in the phase-1/2 review: an
uncalibrated sensor writing its raw counts into the newton columns, a failed
compute step recording the previous frame's number, and a wall clock standing in
for the acquisition clock.
"""
from __future__ import annotations
import csv
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.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record.sample import STALE_REASON, to_record_sample
SCRATCH = Path(__file__).resolve().parent / "_scratch"
_NUMERIC = ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
def _packet(index: int = 0, **overrides) -> SdkPacket:
frame = np.zeros((8, 8, 3), np.uint8)
kwargs = dict(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0 + index / 30.0,
fps=30.0,
process_ms=1.0,
normal_force=123.0,
shear_x=2.0,
shear_y=3.0,
normal_force_raw=123.0,
shear_x_raw=2.0,
shear_y_raw=3.0,
force_unit="raw",
force_calibrated=False,
frame_id=index,
)
kwargs.update(overrides)
return SdkPacket(**kwargs)
def _root() -> Path:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
return SCRATCH
def _record(packets) -> list[dict]:
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=_root(), session_id=1, metadata={})
for packet in packets:
recorder.on_packet(packet)
recorder.stop()
with (directory / "forces.csv").open(encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def test_uncalibrated_sensor_writes_no_newton_values():
"""The SDK reports raw counts in the ordinary force fields when uncalibrated.
Copying those into ``FN_N`` claimed a newton measurement that does not exist.
"""
rows = _record([_packet(0)])
assert rows[0]["FN_raw"] == "123.0"
assert rows[0]["FN_N"] == ""
assert rows[0]["FX_N"] == ""
assert rows[0]["FY_N"] == ""
def test_partially_calibrated_sensor_fills_only_the_calibrated_axes():
rows = _record(
[
_packet(
0,
force_calibrated=True,
calibrated_axes=("fn",),
normal_force=1.5,
shear_x=2.0,
shear_y=3.0,
)
]
)
assert float(rows[0]["FN_N"]) == pytest.approx(1.5)
assert rows[0]["FX_N"] == "", "an uncalibrated axis must not claim a newton value"
assert rows[0]["FY_N"] == ""
def test_failed_compute_records_nothing_numeric():
"""A failed chain leaves the SDK's previous value in place; it is not this frame's."""
rows = _record([_packet(0, valid=False, invalid_reason="compute failed", stale_forces=True)])
for column in _NUMERIC:
assert rows[0][column] == "", f"{column} recorded a value for a failed frame"
def test_stale_forces_are_named_even_without_a_reason():
sample = to_record_sample(_packet(0, stale_forces=True))
assert not sample.valid
assert sample.invalid_reason == STALE_REASON
def test_invalid_frames_keep_their_row_and_frame_index():
rows = _record([_packet(0), _packet(1, valid=False, invalid_reason="read failed")])
assert len(rows) == 2, "an invalid frame still belongs in the recording"
assert rows[1]["valid"] == "0"
def test_acquisition_time_is_monotonic_and_ignores_the_wall_clock():
"""Ordering must come from the acquisition clock, not from ``time.time()``.
A packet without an explicit ``mono_t`` used to record ``0.0`` for every
frame, which made the whole recording simultaneous.
"""
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=_root(), session_id=1, metadata={})
for index in range(5):
recorder.on_packet(_packet(index, timestamp=1000.0 + index / 30.0))
recorder.stop()
with (directory / "forces.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
times = [float(row["t_rel_s"]) for row in rows]
assert times == sorted(times)
assert times[0] == pytest.approx(0.0)
assert times[-1] > times[0], "every frame was recorded at the same instant"
def test_a_wall_clock_that_steps_backwards_cannot_reorder_history():
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(root=_root(), session_id=1, metadata={})
# mono_t increases while the wall clock jumps back an hour.
for index in range(4):
recorder.on_packet(
_packet(index, mono_t=index / 30.0, timestamp=1000.0 - index * 3600.0)
)
recorder.stop()
with (directory / "forces.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
times = [float(row["t_rel_s"]) for row in rows]
assert times == sorted(times)
def test_the_recorded_calibration_version_comes_from_the_packet():
"""A session can outlive the coefficients it started with."""
packet = _packet(0, calibration_version="hash-after-apply")
sample = to_record_sample(packet, calibration_version="hash-at-start")
assert sample.calibration_version == "hash-after-apply"