Files
orisys-app/shared/tests/test_calibration_model.py
2026-09-27 19:20:43 +08:00

246 lines
9.0 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 shutil
from pathlib import Path
import pytest
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
build_export_model,
build_force_calibration_v2,
merge_samples,
predict_channel,
write_force_calibration,
)
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample
SCRATCH = Path(__file__).resolve().parent / "_scratch"
def _root() -> Path:
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
return SCRATCH
def _point(recorder, load: float, raw: float, *, channel: str = "fn"):
recorder.start(channel=channel, reference_load_n=load, duration_s=0.05)
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)
# ---------------------------------------------------------------- one preparation
def _session_with_duplicates_and_validation():
recorder = CalibrationRecorder()
points = [
_point(recorder, 1.0, 100.0),
_point(recorder, 2.0, 200.0),
# A deliberate repeat of load 2.0 at a different raw level.
_point(recorder, 2.0, 240.0),
_point(recorder, 4.0, 400.0),
_point(recorder, 3.0, 290.0),
]
recorder.set_validation(points[4], True)
return recorder, points
def test_duplicate_loads_are_merged_before_the_model_is_built():
_recorder, _points = _session_with_duplicates_and_validation()
prepared = _recorder.prepared()
assert prepared.samples["fn"], "no training samples"
loads = [sample.weight_n for sample in prepared.samples["fn"]]
assert loads == sorted(set(loads)), f"duplicate loads were not merged: {loads}"
assert len(loads) == 3
merged = next(sample for sample in prepared.samples["fn"] if sample.weight_n == 2.0)
assert merged.n == 16, "the repeat's samples were not combined"
assert merged.raw_mean == pytest.approx((200.0 + 240.0) / 2.0)
assert prepared.points_in_fit("fn") == 3
def test_the_exported_file_predicts_exactly_what_the_panel_shows():
"""Read the written document back and compare it with the diagnostics.
The panel's numbers must describe the file that is actually written, so the
test loads the file with the SDK (not with the prepared object) and predicts
every training and validation point from it.
"""
import orisys.calibration as sdk_calibration
recorder, points = _session_with_duplicates_and_validation()
prepared = recorder.prepared(meta={"sensor_type": "box", "sn": "SN9"})
assert prepared.ok
document = prepared.to_document()
path = _root() / "force_calibration.json"
write_force_calibration(document, path)
from_file = sdk_calibration.load(path, strict=True)
for point in points:
raw = float(point.loaded_stats().mean)
predicted = float(from_file.get("fn").convert(raw))
value = prepared.predict("fn", raw)
assert predicted == pytest.approx(value, abs=1e-9)
if point.is_validation:
assert prepared.validation_errors[id(point)] == pytest.approx(
abs(predicted - point.reference_load_n), abs=1e-9
)
else:
assert prepared.residuals[id(point)] == pytest.approx(
predicted - point.reference_load_n, abs=1e-9
)
# The validation point is measured, never built in.
assert prepared.validation_points("fn") == 1
assert prepared.max_validation_error("fn") > 0.0
exported_raws = sorted(point[0] for point in document["channels"]["normal"]["points"])
assert exported_raws == [100.0, 220.0, 400.0], exported_raws
def test_the_recorder_fit_and_the_preparation_agree():
recorder, _points = _session_with_duplicates_and_validation()
prepared = recorder.prepared()
fit = recorder.fit("fn", prepared=prepared)
assert fit.n_points == prepared.points_in_fit("fn")
assert fit.breakpoints == prepared.breakpoints("fn")
assert fit.max_residual_n == pytest.approx(prepared.max_residual("fn"))
assert fit.validation_error_n == pytest.approx(prepared.max_validation_error("fn"))
# ...and a fit that prepares on its own gets the same numbers.
assert recorder.fit("fn").as_dict() == fit.as_dict()