"""Unit tests for spc_service.py: Cp/Cpk (within, moving-range) vs Pp/Ppk (overall). Expected values are computed in each test from the formulas themselves (not copied from the implementation's output), so a regression in the formula would also break the test's own expectation derivation... except we compute them independently here with plain stdlib code, mirroring the spec in gitea:Adriano/TieMeasureFlow#19. """ import statistics as stats from datetime import datetime, timedelta from src.backend.services.spc_service import compute_capability, compute_control_chart def _expected_sigma_within(values: list[float]) -> float | None: """Independent re-implementation of MRbar/1.128 for test expectations.""" if len(values) < 2: return None mr = [abs(values[i] - values[i - 1]) for i in range(1, len(values))] mr_bar = stats.mean(mr) if mr_bar == 0: return None return mr_bar / 1.128 # 13 values around 10.00, then 12 values around 10.08: a level shift that # a global (overall) std dev smears, but that stays invisible to the # consecutive-point moving range within each stable block. _SHIFTED_SERIES = ( [10.00, 10.01, 9.99, 10.02, 9.98, 10.01, 9.99, 10.00, 10.02, 9.98, 10.01, 9.99, 10.00] + [10.08, 10.09, 10.07, 10.08, 10.09, 10.07, 10.08, 10.09, 10.07, 10.08, 10.09, 10.07] ) class TestComputeCapability: def test_cp_uses_sigma_within_pp_uses_overall_sample_stdev(self): utl, ltl = 10.2, 9.8 result = compute_capability(_SHIFTED_SERIES, utl, ltl, nominal=10.0) sample_std = stats.stdev(_SHIFTED_SERIES) sigma_within = _expected_sigma_within(_SHIFTED_SERIES) assert sigma_within is not None expected_cp = round((utl - ltl) / (6 * sigma_within), 4) expected_pp = round((utl - ltl) / (6 * sample_std), 4) assert result.cp == expected_cp assert result.pp == expected_pp # Cp (within, tight) must differ from Pp (overall, widened by the # level shift) — this is exactly the drift #19 says must surface. assert result.cp != result.pp assert result.cp > result.pp assert result.std_dev == round(sample_std, 6) assert result.std_dev_within == round(sigma_within, 6) def test_constant_series_returns_none_indices(self): values = [10.0] * 10 result = compute_capability(values, 10.2, 9.8, 10.0) assert result.cp is None assert result.cpk is None assert result.pp is None assert result.ppk is None assert result.std_dev_within is None def test_n_less_than_2_returns_none(self): result = compute_capability([10.0], 10.2, 9.8, 10.0) assert result.cp is None assert result.cpk is None assert result.pp is None assert result.ppk is None assert result.n == 1 def test_one_sided_tolerance_only_cpk(self): values = [10.0, 10.01, 9.99, 10.02, 9.98, 10.01, 9.99, 10.0] result = compute_capability(values, utl=10.2, ltl=None, nominal=10.0) assert result.cp is None # both UTL and LTL required assert result.pp is None assert result.cpk is not None assert result.ppk is not None class TestComputeControlChart: def test_ucl_lcl_use_sigma_within_not_overall_stdev(self): base = datetime(2026, 1, 1) timestamps = [base + timedelta(minutes=i) for i in range(len(_SHIFTED_SERIES))] result = compute_control_chart( _SHIFTED_SERIES, timestamps, 10.2, None, None, 9.8, 10.0 ) mean = stats.mean(_SHIFTED_SERIES) sigma_within = _expected_sigma_within(_SHIFTED_SERIES) expected_ucl = round(mean + 3 * sigma_within, 6) expected_lcl = round(mean - 3 * sigma_within, 6) assert result.ucl == expected_ucl assert result.lcl == expected_lcl # Points beyond the within-sigma limits must be flagged: the level # shift is detectable this way but was invisible with the overall # sample std dev (limits too wide to ever trip). assert result.out_of_control != [] overall_std = stats.stdev(_SHIFTED_SERIES) overall_ucl = mean + 3 * overall_std overall_lcl = mean - 3 * overall_std overall_ooc = [ i for i, v in enumerate(_SHIFTED_SERIES) if v > overall_ucl or v < overall_lcl ] assert overall_ooc == [] def test_constant_series_zero_limits(self): values = [10.0] * 6 timestamps = [datetime(2026, 1, 1) + timedelta(minutes=i) for i in range(6)] result = compute_control_chart(values, timestamps, 10.2, None, None, 9.8, 10.0) assert result.ucl == 10.0 assert result.lcl == 10.0 assert result.out_of_control == [] def test_single_value_zero_limits(self): timestamps = [datetime(2026, 1, 1)] result = compute_control_chart([10.0], timestamps, 10.2, None, None, 9.8, 10.0) assert result.ucl == 10.0 assert result.lcl == 10.0