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