diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 3317b9712..da334d564 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -175,15 +175,47 @@ def _zero_works_post_differs(a: PropertyAudit) -> Optional[str]: def _effective_lodged_divergence(a: PropertyAudit) -> Optional[str]: """The Effective baseline is far from the lodged accredited figure (≥15 SAP). Often legitimate (overrides / pre-SAP10 rebaseline), but worth a look — a big - gap can also mean a bad override or a calculator divergence.""" + gap can also mean a bad override or a calculator divergence. + + Lodged scores below 13 are handed off to ``implausible-lodged-score`` — they + indicate corrupt upstream EPC register data, not a modelling divergence.""" if a.effective_sap is None or a.lodged_sap is None: return None + if a.lodged_sap < 13: + return None gap = a.effective_sap - a.lodged_sap if abs(gap) < 15: return None return f"effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} (Δ{gap:+.0f}, reason={a.rebaseline_reason})" +@check("implausible-lodged-score", Severity.LOW) +def _implausible_lodged_score(a: PropertyAudit) -> Optional[str]: + """Lodged SAP score is below 13 with a large effective-vs-lodged gap — the + lodged figure is implausible upstream EPC register data, not a modelling bug. + + Floor of 13 is justified against the portfolio-796 distribution: every lodged + score below 13 either matches a known Class C exemplar (SAP 1: pids 727752, + 727008, 731205) or shares the same bad-data fingerprint — implausibly low + lodged paired with a reasonable effective in band D/E (pids 710449 SAP 5, + 722562 SAP 7, 725377 SAP 9, 727457 SAP 10, 723364 SAP 12). SAP 13 and above + are left in scope as ambiguous. + + The gap guard (≥15) mirrors ``effective-lodged-divergence`` so the two checks + partition the same population without overlap.""" + if a.lodged_sap is None or a.effective_sap is None: + return None + if a.lodged_sap >= 13: + return None + gap = a.effective_sap - a.lodged_sap + if abs(gap) < 15: + return None + return ( + f"lodged SAP {a.lodged_sap:.0f} is implausible (< 13) — upstream EPC data," + f" not a modelling bug (effective {a.effective_sap:.0f}, Δ{gap:+.0f})" + ) + + @check("impossible-sap-over-100", Severity.HIGH) def _impossible_sap_over_100(a: PropertyAudit) -> Optional[str]: """A SAP score above 100 is impossible (SAP caps at 100) — a calculator / diff --git a/tests/scripts/test_audit_anomalies.py b/tests/scripts/test_audit_anomalies.py new file mode 100644 index 000000000..280b7c3a9 --- /dev/null +++ b/tests/scripts/test_audit_anomalies.py @@ -0,0 +1,94 @@ +from typing import Optional + +import pytest + +from scripts.audit.anomalies import ( + PropertyAudit, + _effective_lodged_divergence, + _implausible_lodged_score, +) + + +def _make_audit( + *, + lodged_sap: Optional[float], + effective_sap: Optional[float], + rebaseline_reason: str = "both", +) -> PropertyAudit: + return PropertyAudit( + property_id=1, + uprn=None, + portfolio_id=796, + scenario_id=None, + scenario_goal_band=None, + lodged_sap=lodged_sap, + lodged_band=None, + effective_sap=effective_sap, + effective_band=None, + rebaseline_reason=rebaseline_reason, + post_sap=None, + post_band=None, + cost_of_works=None, + energy_bill_savings=None, + energy_consumption_savings=None, + solar_sap_points=None, + solar_bill_savings=None, + n_measures=0, + ) + + +class TestEffectiveLodgedDivergence: + def test_silent_when_lodged_sap_below_floor(self) -> None: + # Arrange — Class C exemplar: lodged=1, effective=76 (gap +75, but lodged is implausible upstream data) + audit = _make_audit(lodged_sap=1.0, effective_sap=76.0) + + # Act + result = _effective_lodged_divergence(audit) + + # Assert + assert result is None + + def test_fires_when_lodged_sap_at_or_above_floor_with_large_gap(self) -> None: + # Arrange — lodged=20 (band G, plausible), effective=65 (band D), gap +45 + audit = _make_audit(lodged_sap=20.0, effective_sap=65.0) + + # Act + result = _effective_lodged_divergence(audit) + + # Assert + assert result is not None + assert "65" in result + assert "20" in result + + +class TestImplausibleLodgedScore: + def test_fires_when_lodged_sap_below_floor_and_gap_large(self) -> None: + # Arrange — Class C exemplar: lodged=1, effective=76 (gap +75) + audit = _make_audit(lodged_sap=1.0, effective_sap=76.0) + + # Act + result = _implausible_lodged_score(audit) + + # Assert + assert result is not None + assert "1" in result + + def test_silent_when_gap_below_threshold(self) -> None: + # Arrange — lodged=9, effective=5 (gap -4, below ±15): both low, not a divergence anomaly + audit = _make_audit(lodged_sap=9.0, effective_sap=5.0) + + # Act + result = _implausible_lodged_score(audit) + + # Assert + assert result is None + + def test_silent_when_lodged_sap_at_or_above_floor(self) -> None: + # Arrange — lodged=13 is on the floor boundary; effective=70 gives large gap + audit = _make_audit(lodged_sap=13.0, effective_sap=70.0) + + # Act + result = _implausible_lodged_score(audit) + + # Assert + assert result is None