Fix repeat ownership and close out capture evidence
Start each repeat after its own manifest entry, refuse packets from a restarted or stopped camera, keep a simulated repeat from reading as recorded evidence, and decide completeness from the writer report. Add the operator summary (four files plus an atomic pointer) and read-only video review of a hold window. Verified with the full Autocal suite (567 passed), the shared recorder/playback files (57 passed), the lint gate and the documentation gate; the two pre-existing panel_check layout failures also fail on a clean HEAD worktree.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""The summary fit is a forward regression of raw on the declared reference.
|
||||
|
||||
Hand-computed lines, one nonlinear set, and the proof that its slope is not the
|
||||
reciprocal of the review's inverse fit (``N = gain * raw + offset``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.summary import fit_axis
|
||||
|
||||
|
||||
def _window(load: float, fn: float | None, *, branch: str = "load", verified: bool = False,
|
||||
eligible: bool = True, site: str = "P1", trial: str = "t1") -> dict:
|
||||
return {
|
||||
"session_id": "session-1", "site_id": site, "trial_id": trial,
|
||||
"repeat_index": 1, "step_index": 0, "branch": branch,
|
||||
"reference_load_n": load, "eligible": eligible,
|
||||
"stability_verified": verified,
|
||||
"FN_mean": fn, "FX_mean": 0.0, "FY_mean": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_a_straight_line_is_recovered_exactly():
|
||||
fit = fit_axis([(0.0, 100.0), (2.0, 180.0), (4.0, 260.0)])
|
||||
|
||||
assert fit["status"] == "available"
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert fit["intercept_raw"] == pytest.approx(100.0)
|
||||
assert fit["R2"] == pytest.approx(1.0)
|
||||
assert fit["rmse_raw"] == pytest.approx(0.0)
|
||||
assert fit["reference_min_N"] == 0.0 and fit["reference_max_N"] == 4.0
|
||||
assert fit["window_count"] == 3
|
||||
|
||||
|
||||
def test_a_nonlinear_set_matches_the_hand_computation():
|
||||
# x mean 1, y mean 166.667; slope 80; intercept 86.667; SSE 1066.667;
|
||||
# SST 13866.667 -> R2 1 - 1066.667 / 13866.667 = 0.923077.
|
||||
fit = fit_axis([(0.0, 100.0), (1.0, 140.0), (2.0, 260.0)])
|
||||
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(80.0)
|
||||
assert fit["intercept_raw"] == pytest.approx(86.6666666667, rel=1e-9)
|
||||
assert fit["R2"] == pytest.approx(0.9230769231, rel=1e-9)
|
||||
assert fit["rmse_raw"] == pytest.approx(math.sqrt(1066.6666667 / 3), rel=1e-9)
|
||||
|
||||
|
||||
def test_a_constant_signal_has_no_coefficient_of_determination():
|
||||
fit = fit_axis([(0.0, 5.0), (1.0, 5.0), (2.0, 5.0)])
|
||||
|
||||
assert fit["status"] == "flat_signal"
|
||||
assert fit["R2"] is None
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(0.0)
|
||||
assert fit["intercept_raw"] == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_one_reference_level_cannot_be_fitted():
|
||||
fit = fit_axis([(5.0, 300.0), (5.0, 310.0)])
|
||||
assert fit["status"] == "insufficient_reference_levels"
|
||||
assert "slope_raw_per_N" not in fit
|
||||
|
||||
|
||||
def test_no_finite_sample_is_reported_as_missing_data():
|
||||
fit = fit_axis([])
|
||||
assert fit["status"] == "no_data"
|
||||
|
||||
|
||||
def test_repeated_reference_levels_are_weighted_but_counted():
|
||||
fit = fit_axis([(0.0, 100.0), (5.0, 300.0), (5.0, 320.0), (10.0, 520.0)])
|
||||
|
||||
assert fit["window_count"] == 4
|
||||
assert fit["reference_min_N"] == 0.0 and fit["reference_max_N"] == 10.0
|
||||
assert fit["slope_raw_per_N"] == pytest.approx(42.0)
|
||||
|
||||
|
||||
def test_the_forward_slope_is_not_the_reciprocal_of_the_inverse_fit():
|
||||
"""``N = gain*raw + offset`` and ``raw = a*N + b`` answer different questions."""
|
||||
import numpy as np
|
||||
|
||||
from orisys_autocal.session_review import _fit
|
||||
|
||||
raw = [100.0, 300.0, 520.0]
|
||||
loads = [0.0, 5.0, 10.0]
|
||||
forward = fit_axis(list(zip(loads, raw)))
|
||||
inverse = _fit([
|
||||
{"raw_mean": value, "reference_n": load, "status": "candidate", "trial_id": "t1"}
|
||||
for value, load in zip(raw, loads)
|
||||
])
|
||||
gain, _offset = np.polyfit(np.array(raw), np.array(loads), 1)
|
||||
|
||||
assert forward["slope_raw_per_N"] == pytest.approx(42.0)
|
||||
assert inverse["sensitivity_raw_per_n"] == pytest.approx(1.0 / gain)
|
||||
assert abs(forward["slope_raw_per_N"] - 1.0 / gain) > 1e-3
|
||||
|
||||
|
||||
def test_unloading_is_shown_but_never_fitted():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
windows = [
|
||||
_window(0.0, 100.0, branch="baseline"),
|
||||
_window(5.0, 300.0),
|
||||
_window(5.0, 340.0, branch="unload"),
|
||||
_window(0.0, 120.0, branch="unload"),
|
||||
]
|
||||
|
||||
rows = build_sensitivity(windows)
|
||||
|
||||
assert {row["component"] for row in rows} == {"FN", "FX", "FY"}
|
||||
fn = next(row for row in rows if row["component"] == "FN")
|
||||
assert fn["window_count"] == 2
|
||||
assert fn["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
# The unloading values are far higher; a fit that included them would not be 40.
|
||||
assert fn["endpoint_raw_per_N"] == pytest.approx(40.0)
|
||||
|
||||
|
||||
def test_endpoint_and_initial_step_use_the_declared_loads():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
windows = [
|
||||
_window(0.0, 100.0, branch="baseline"),
|
||||
_window(2.0, 180.0),
|
||||
_window(5.0, 300.0),
|
||||
]
|
||||
|
||||
fn = next(row for row in build_sensitivity(windows) if row["component"] == "FN")
|
||||
|
||||
# endpoint: (raw at the highest load - raw at zero) / highest load.
|
||||
assert fn["endpoint_raw_per_N"] == pytest.approx((300.0 - 100.0) / 5.0)
|
||||
# initial step: the lowest positive load minus zero.
|
||||
assert fn["initial_step_raw"] == pytest.approx(80.0)
|
||||
|
||||
|
||||
def test_a_missing_zero_load_leaves_the_derived_numbers_empty():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
fn = next(row for row in build_sensitivity([_window(5.0, 300.0), _window(10.0, 500.0)])
|
||||
if row["component"] == "FN")
|
||||
|
||||
assert fn["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert fn["endpoint_raw_per_N"] is None
|
||||
assert fn["initial_step_raw"] is None
|
||||
|
||||
|
||||
def test_confirmed_and_candidate_windows_are_never_fitted_together():
|
||||
from orisys_autocal.summary import build_sensitivity
|
||||
|
||||
windows = [
|
||||
_window(0.0, 100.0, branch="baseline"),
|
||||
_window(5.0, 300.0),
|
||||
_window(0.0, 100.0, branch="baseline", verified=True),
|
||||
_window(10.0, 500.0, verified=True),
|
||||
]
|
||||
|
||||
rows = build_sensitivity(windows)
|
||||
by_review = {}
|
||||
for row in rows:
|
||||
if row["component"] == "FN":
|
||||
by_review[row["review_status"]] = row
|
||||
|
||||
assert set(by_review) == {"candidate", "confirmed"}
|
||||
assert by_review["candidate"]["window_count"] == 2
|
||||
assert by_review["candidate"]["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
assert by_review["confirmed"]["window_count"] == 2
|
||||
assert by_review["confirmed"]["slope_raw_per_N"] == pytest.approx(40.0)
|
||||
Reference in New Issue
Block a user