Files
orisys-app/shared/tests/test_calibration_model.py
T
ykcheungab 22cc59c402 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.
2026-09-13 14:55:53 +08:00

154 lines
5.4 KiB
Python

"""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)