From 318a859e881168979d2039b50ff963dd0d584eb0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 15:27:15 +0000 Subject: [PATCH] Audit check: lodged cylinder with unresolvable insulation drops storage loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portfolio 814 / scenario 1271 audit (2026-07-03): all 26 properties lodging has_hot_water_cylinder with insulation type 0 (none) or NULL over-rate their effective baseline by up to +18 SAP (avg +12.7) because _cylinder_storage_loss_override returns None for any insulation label it can't resolve and the worksheet keeps the zero-storage-loss combi default. Same-lodged neighbours split by 19 effective points (WD5 0DP pids 742215/742216, both lodged 54, effective 50 vs 69) and the inflated members distorted neighbour-divergence cohort medians (E17 6EB pid 742355 Δ+18). New HIGH check cylinder-storage-loss-dropped fires on the deterministic input pattern (cylinder present, insulation type ∉ {factory, loose jacket} or thickness missing) — 28/338 on the motivating portfolio (the 26 above plus 2 same-pattern rows with no lodged score). The calculator fix (uninsulated Table 2 loss, or RdSAP Table 29 age-band default) is separate follow-up work. Co-Authored-By: Claude Fable 5 --- scripts/audit/anomalies.py | 95 +++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 532108f7c..0ee0386d4 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -55,6 +55,26 @@ def _band_of(score: Optional[float]) -> Optional[str]: return Epc.from_sap_score(round(score)).value +def _int_or_none(value: object) -> Optional[int]: + """Coerce a jsonb-backed scalar (int / digit-string / None) to int.""" + if value is None: + return None + try: + return int(str(value)) + except ValueError: + return None + + +def _float_or_none(value: object) -> Optional[float]: + """Coerce a jsonb-backed scalar (number / numeric-string / None) to float.""" + if value is None: + return None + try: + return float(str(value)) + except ValueError: + return None + + class Severity(IntEnum): LOW = 1 MEDIUM = 2 @@ -89,6 +109,12 @@ class PropertyAudit: solar_sap_points: Optional[float] # max SAP a single solar_pv measure earns solar_bill_savings: Optional[float] # the solar_pv measure's £/yr bill saving n_measures: int + # Hot-water-cylinder fields from the latest lodged EPC row (None when the + # Property has no epc_property row). Insulation type codes are the RdSAP + # lodgement codes: 0 = none, 1 = factory foam, 2 = loose jacket. + has_hot_water_cylinder: Optional[bool] + cylinder_insulation_type: Optional[int] + cylinder_insulation_thickness_mm: Optional[float] @dataclass(frozen=True) @@ -342,6 +368,54 @@ def _negative_bill_savings(a: PropertyAudit) -> Optional[str]: return f"energy_bill_savings £{a.energy_bill_savings:.0f}/yr on £{a.cost_of_works:.0f} of works" +@check("cylinder-storage-loss-dropped", Severity.HIGH) +def _cylinder_storage_loss_dropped(a: PropertyAudit) -> Optional[str]: + """A lodged hot-water cylinder whose insulation fields cannot resolve a SAP + 10.2 Table 2 loss branch — insulation type is not factory foam (1) or loose + jacket (2), or the thickness is missing — so the calculator's + ``_cylinder_storage_loss_override`` (domain/sap10_calculator/rdsap/ + cert_to_inputs.py) returns None and the worksheet keeps the zero-storage-loss + default meant for combi / instantaneous systems. The baseline then + OVER-RATES: an UNINSULATED cylinder (type 0, the worst case SAP knows) scores + ~+13 SAP better than a properly insulated one instead of much worse. + + Provenance: portfolio 814 / scenario 1271 audit (2026-07-03). All 26 + properties with ``has_hot_water_cylinder`` and insulation type 0 (23) or NULL + (3) over-rate vs their lodged score — Δ+1..+18, avg +12.7 for type 0 — while + the type-1/2 population averages Δ−2. Same-lodged neighbours split by up to + 19 effective points (WD5 0DP: pid 742216 lodged 54 → effective 69 vs pid + 742215 lodged 54 → effective 50, identical building parts), and the inflated + members distorted neighbour-divergence cohort medians (E17 6EB pid 742355 + Δ+18; WD5 0DP pushed honest pid 742210 over the flag threshold). Fix is in + the calculator (apply the uninsulated Table 2 loss for type 0, or the RdSAP + Table 29 age-band default when unknown), not in the data. + + Fires on the input pattern (deterministic — the loss IS dropped for every + such row), not on a tuned delta threshold; the reason string reports the + effective-vs-lodged gap where a lodged score exists so triage can rank by + materiality. A full-SAP cert lodging a manufacturer's declared loss instead + of type/thickness would bypass the bug and false-positive here — none exist + on the motivating portfolio; revisit if one appears.""" + if a.has_hot_water_cylinder is not True: + return None + if ( + a.cylinder_insulation_type in (1, 2) + and a.cylinder_insulation_thickness_mm is not None + ): + return None + gap = ( + f", effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} " + f"(Δ{a.effective_sap - a.lodged_sap:+.0f})" + if a.effective_sap is not None and a.lodged_sap is not None + else "" + ) + return ( + f"cylinder lodged with insulation_type={a.cylinder_insulation_type} " + f"thickness={a.cylinder_insulation_thickness_mm} — storage loss dropped " + f"to zero (combi default), baseline over-rates{gap}" + ) + + # ─────────────────────── runner ─────────────────────── _QUERY = text( @@ -351,12 +425,24 @@ _QUERY = text( pbp.lodged_sap_score, pbp.lodged_epc_band, pbp.effective_sap_score, pbp.effective_epc_band, pbp.rebaseline_reason, pl.post_sap_points, pl.post_epc_rating, pl.cost_of_works, - pl.energy_bill_savings, pl.energy_consumption_savings + pl.energy_bill_savings, pl.energy_consumption_savings, + ep.has_hot_water_cylinder, ep.cylinder_insulation_type, + ep.cylinder_insulation_thickness_mm FROM property p LEFT JOIN property_baseline_performance pbp ON pbp.property_id = p.id LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = TRUE AND (:scenario_id IS NULL OR pl.scenario_id = :scenario_id) LEFT JOIN scenario s ON s.id = pl.scenario_id + LEFT JOIN LATERAL ( + SELECT e.has_hot_water_cylinder, + e.heating_cylinder_insulation_type AS cylinder_insulation_type, + e.heating_cylinder_insulation_thickness_mm + AS cylinder_insulation_thickness_mm + FROM epc_property e + WHERE e.property_id = p.id + ORDER BY e.id DESC + LIMIT 1 + ) ep ON TRUE WHERE (:portfolio_id IS NULL OR p.portfolio_id = :portfolio_id) AND (:property_id IS NULL OR p.id = :property_id) ORDER BY p.id @@ -479,6 +565,13 @@ def _load( solar_sap_points=solar_sap, solar_bill_savings=solar_bill, n_measures=n_measures, + has_hot_water_cylinder=m["has_hot_water_cylinder"], + cylinder_insulation_type=_int_or_none( + m["cylinder_insulation_type"] + ), + cylinder_insulation_thickness_mm=_float_or_none( + m["cylinder_insulation_thickness_mm"] + ), ) ) return out