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]:
|
def _effective_lodged_divergence(a: PropertyAudit) -> Optional[str]:
|
||||||
"""The Effective baseline is far from the lodged accredited figure (≥15 SAP).
|
"""The Effective baseline is far from the lodged accredited figure (≥15 SAP).
|
||||||
Often legitimate (overrides / pre-SAP10 rebaseline), but worth a look — a big
|
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:
|
if a.effective_sap is None or a.lodged_sap is None:
|
||||||
return None
|
return None
|
||||||
|
if a.lodged_sap < 13:
|
||||||
|
return None
|
||||||
gap = a.effective_sap - a.lodged_sap
|
gap = a.effective_sap - a.lodged_sap
|
||||||
if abs(gap) < 15:
|
if abs(gap) < 15:
|
||||||
return None
|
return None
|
||||||
return f"effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} (Δ{gap:+.0f}, reason={a.rebaseline_reason})"
|
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)
|
@check("impossible-sap-over-100", Severity.HIGH)
|
||||||
def _impossible_sap_over_100(a: PropertyAudit) -> Optional[str]:
|
def _impossible_sap_over_100(a: PropertyAudit) -> Optional[str]:
|
||||||
"""A SAP score above 100 is impossible (SAP caps at 100) — a calculator /
|
"""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