73592f085f
What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
small things — one time, one validity flag, one frame id per row plus a byte
offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
read_rows() seeks to the nearest checkpoint and scans forward, and overview()
folds every row into its bucket during the same pass to build a per-axis
min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
the extreme row of every axis in the requested unit, so a long interval never
has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
itself decimated, with total_rows and a duration taken from the frame index
rather than from the view; window()/window_all()/overview()/statistics() read
the interval being asked about. list/window semantics unchanged for small
recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
interval + cached envelope) implement one interface — extent, window,
envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
being shown (bounded by CHART_MAX_POINTS), and says so when the view is
decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
be overwritten by the writer's own transition to "recording" (found by a flaky
test; the state check now covers STATE_FAILED).
Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.
Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
the index reports its rows and reads a bounded interval; a large recording is
marked decimated with its true duration and total rows; a small one is not; an
interval read sees the exact rows including a single peak; a decimated window
keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
spike and is cached; the disk and memory sources agree on extent, values and
statistics; the whole session can be read without holding it all. Shared
suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
count, that an interval read spans only the interval, the envelope size and
total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
the unified source, and lists what is still hardware work.
Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
board (needs the hardware), and range CSV export.
247 lines
9.0 KiB
Python
247 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()
|