Merge pull request #1374 from Hestia-Homes/audit/bad-lodged-source-data

fix(audit): Class C — silence effective-lodged-divergence for implausible lodged scores (#1361)
This commit is contained in:
Daniel Roth 2026-07-01 09:15:45 +01:00 committed by GitHub
commit db27a6153f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 127 additions and 1 deletions

View file

@ -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 /

View file

@ -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