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