From cf097afc00fa565962e7047827277a6c15948f46 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 30 Jun 2026 15:24:22 +0000 Subject: [PATCH 1/4] =?UTF-8?q?effective-lodged-divergence=20is=20silent?= =?UTF-8?q?=20for=20implausible=20lodged=20scores=20(<=2013)=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/scripts/test_audit_anomalies.py | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/scripts/test_audit_anomalies.py diff --git a/tests/scripts/test_audit_anomalies.py b/tests/scripts/test_audit_anomalies.py new file mode 100644 index 000000000..b6acf3fde --- /dev/null +++ b/tests/scripts/test_audit_anomalies.py @@ -0,0 +1,48 @@ +from typing import Optional + +import pytest + +from scripts.audit.anomalies import ( + PropertyAudit, + _effective_lodged_divergence, +) + + +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 From fe1d04983b0c28f561ac5c5c252cb07f3d2dab28 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 30 Jun 2026 15:25:02 +0000 Subject: [PATCH 2/4] =?UTF-8?q?effective-lodged-divergence=20is=20silent?= =?UTF-8?q?=20for=20implausible=20lodged=20scores=20(<=2013)=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- scripts/audit/anomalies.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 7b1ac62e2..c856a5d17 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -175,9 +175,14 @@ 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 From e70df63ee6f2cbc375b386a151d402c5ddb4a38c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 30 Jun 2026 15:26:29 +0000 Subject: [PATCH 3/4] =?UTF-8?q?implausible-lodged-score=20check=20fires=20?= =?UTF-8?q?for=20bad=20upstream=20EPC=20data=20(lodged=20<=2013,=20gap=20>?= =?UTF-8?q?=3D=2015)=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- scripts/audit/anomalies.py | 5 +++++ tests/scripts/test_audit_anomalies.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index c856a5d17..762fc30a5 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -189,6 +189,11 @@ def _effective_lodged_divergence(a: PropertyAudit) -> Optional[str]: 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]: + raise NotImplementedError + + @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 index b6acf3fde..69cd25080 100644 --- a/tests/scripts/test_audit_anomalies.py +++ b/tests/scripts/test_audit_anomalies.py @@ -5,6 +5,7 @@ import pytest from scripts.audit.anomalies import ( PropertyAudit, _effective_lodged_divergence, + _implausible_lodged_score, ) @@ -46,3 +47,28 @@ class TestEffectiveLodgedDivergence: # 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 From afe1801273dc5d382e50160e0de3460ccb05ed82 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 30 Jun 2026 15:27:19 +0000 Subject: [PATCH 4/4] =?UTF-8?q?implausible-lodged-score=20check=20fires=20?= =?UTF-8?q?for=20bad=20upstream=20EPC=20data=20(lodged=20<=2013,=20gap=20>?= =?UTF-8?q?=3D=2015)=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- scripts/audit/anomalies.py | 24 +++++++++++++++++++++++- tests/scripts/test_audit_anomalies.py | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 762fc30a5..b48b74015 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -191,7 +191,29 @@ def _effective_lodged_divergence(a: PropertyAudit) -> Optional[str]: @check("implausible-lodged-score", Severity.LOW) def _implausible_lodged_score(a: PropertyAudit) -> Optional[str]: - raise NotImplementedError + """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) diff --git a/tests/scripts/test_audit_anomalies.py b/tests/scripts/test_audit_anomalies.py index 69cd25080..280b7c3a9 100644 --- a/tests/scripts/test_audit_anomalies.py +++ b/tests/scripts/test_audit_anomalies.py @@ -72,3 +72,23 @@ class TestImplausibleLodgedScore: # 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