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