mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
db27a6153f
2 changed files with 127 additions and 1 deletions
|
|
@ -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 /
|
||||
|
|
|
|||
94
tests/scripts/test_audit_anomalies.py
Normal file
94
tests/scripts/test_audit_anomalies.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue