diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index b725555a8..22d379c32 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5879,6 +5879,10 @@ _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 # which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor # L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4). _CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2 +# RdSAP 10 field 7-11 code 0 = NO insulation. SAP 10.2 Table 2 has no +# separate uninsulated row — the loose-jacket formula at t = 0 IS the +# uninsulated factor (L = 0.005 + 1.76 / 12.8 = 0.1425 kWh/L/day). +_CYLINDER_INSULATION_TYPE_NONE: Final[int] = 0 def _cylinder_storage_loss_insulation_label( @@ -7106,9 +7110,38 @@ def _cylinder_storage_loss_override( insulation_label = _cylinder_storage_loss_insulation_label( sh.cylinder_insulation_type ) + thickness_mm: Optional[float] = ( + float(sh.cylinder_insulation_thickness_mm) + if sh.cylinder_insulation_thickness_mm is not None + else None + ) + # SAP 10.2 Table 2: an uninsulated cylinder takes the loose-jacket + # formula at t = 0 — the WORST loss SAP knows, never the zero-loss + # combi default (which over-rates the dwelling). Two lodgement shapes + # describe it: the EPB API's insulation type 0, and the Elmhurst + # site-notes path's type None + explicit 0 mm ("No Insulation" maps to + # no material; the lodged thickness carries the signal — see + # `_ELMHURST_CYLINDER_NO_INSULATION_LABELS` in the mapper). + if _int_or_none(sh.cylinder_insulation_type) == _CYLINDER_INSULATION_TYPE_NONE: + insulation_label, thickness_mm = "loose_jacket", 0.0 + if insulation_label is None and thickness_mm == 0.0: + insulation_label = "loose_jacket" + # RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not + # accessible": a lodged cylinder with NO insulation data at all takes + # the age-band default — the same row the §10.7 no-water-heating + # default uses. An ABSENT thickness means not accessible (Table 29); + # a lodged 0 mm means uninsulated (handled above). When the age band + # itself is unresolvable, fall through to the legacy None (zero loss) + # rather than raising mid-rebaseline. + if insulation_label is None and thickness_mm is None: + band = (_dwelling_age_band(epc) or "")[:1].upper() + table_29_default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) + if table_29_default is not None: + default_code, default_mm = table_29_default + insulation_label = _cylinder_storage_loss_insulation_label(default_code) + thickness_mm = float(default_mm) if insulation_label is None: return None - thickness_mm = sh.cylinder_insulation_thickness_mm if thickness_mm is None: return None storage_56m = cylinder_storage_loss_monthly_kwh( 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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index ec770e72b..c8836a92b 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -6365,6 +6365,237 @@ def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_bra assert got_jan_kwh > 36.9530 # loose jacket loses more than factory +def test_uninsulated_cylinder_computes_storage_loss_via_table2_jacket_t0() -> None: + """SAP 10.2 Table 2 (PDF p.158): an UNINSULATED cylinder takes the + loose-jacket loss factor at t = 0 — L = 0.005 + 1.76 / 12.8 = 0.1425 + kWh/L/day, the worst storage loss SAP knows. The EPB API lodges + cylinder_insulation_type=0 = no insulation (1 = factory, 2 = loose + jacket) with no thickness field. Before this fix + `_cylinder_storage_loss_override` returned None for type 0, so the + worksheet kept the zero-storage-loss combi default and the dwelling + OVER-RATED — the opposite of what "no insulation" means. Portfolio + 814's audit (2026-07-03) found all 26 such properties over-rated + +1..+18 SAP vs lodged (avg +12.7 for type 0); same-lodged WD5 0DP + neighbours (pids 742215/742216, both lodged 54, identical building + parts) split effective 50 vs 69 because one lodged a 12 mm jacket and + the other lodged no insulation. + """ + # Arrange — identical to the loose-jacket storage-loss test but + # cylinder_insulation_type=0 (none) and no lodged thickness. + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=0, # no insulation + cylinder_insulation_thickness_mm=None, + cylinder_thermostat="Y", + ), + ) + # Expected (56)m Jan from the Table 2 loose-jacket branch at t=0 — + # the SAP uninsulated-cylinder factor (same V / VF / TF as the + # loose-jacket test; only the thickness differs). + loss_factor = cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=0.0 + ) + vol_factor = cylinder_volume_factor_table_2a(160.0) + temp_factor = cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31 + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — non-None (was the zero-loss combi default) and equal to + # the loose-jacket branch at t=0; an uninsulated cylinder must lose + # MORE than the 50 mm loose-jacket case, never less. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + jacket_50mm_jan_kwh = ( + 160.0 + * cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=50.0 + ) + * vol_factor + * temp_factor + * 31 + ) + assert got_jan_kwh > jacket_50mm_jan_kwh + + +def test_elmhurst_no_insulation_cylinder_thickness_zero_takes_uninsulated_loss() -> None: + """The Elmhurst site-notes mapper lodges a "No Insulation" cylinder as + `cylinder_insulation_type=None` + `cylinder_insulation_thickness_mm=0` + — per its own comment "the lodged §15.1 Insulation Thickness (0 mm) + carries the storage-loss signal the cascade's SAP 10.2 Table 2 + dispatch needs" (`_ELMHURST_CYLINDER_NO_INSULATION_LABELS`, + datatypes/epc/domain/mapper.py). The cascade must honour that signal: + a lodged 0 mm with no insulation type is the uninsulated cylinder — + Table 2 loose-jacket branch at t = 0 — not the zero-loss combi + default it previously fell to (the type-None guard returned None + before the thickness was ever read). + """ + # Arrange — same dwelling as the type-0 test; only the lodgement + # shape differs (type None + explicit 0 mm, the Elmhurst path). + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=None, # Elmhurst "No Insulation" + cylinder_insulation_thickness_mm=0, + cylinder_thermostat="Y", + ), + ) + expected_jan_kwh = ( + 160.0 + * cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=0.0 + ) + * cylinder_volume_factor_table_2a(160.0) + * cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + * 31 + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — identical series to the gov-API type-0 lodgement: the two + # front-ends describe the same uninsulated cylinder. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + + +def test_cylinder_with_unknown_insulation_defaults_per_table_29_age_band() -> None: + """RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not + accessible": when a cylinder is lodged but its insulation is unknown + (type absent AND no thickness — ADR-0028 records ~308/1000 17.0 certs + omitting `cylinder_insulation_type`), the age-band default applies: + bands A-F → 12 mm loose jacket, G/H → 25 mm factory, I-M → 38 mm + factory. This is the same Table 29 row the §10.7 no-water-heating + default already uses. Previously the cascade returned None (zero + storage loss), silently treating an unknown cylinder as a combi. + Distinct from the uninsulated case: a lodged 0 mm means NO insulation + (t = 0), an absent thickness means NOT ACCESSIBLE (Table 29). + """ + # Arrange — same dwelling, insulation fields both absent; the + # fixture's building part lodges construction_age_band "B" → the + # Table 29 A-F row (12 mm loose jacket). + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], # age band B + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=None, # not lodged + cylinder_insulation_thickness_mm=None, # not lodged + cylinder_thermostat="Y", + ), + ) + expected_jan_kwh = ( + 160.0 + * cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=12.0 + ) + * cylinder_volume_factor_table_2a(160.0) + * cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + * 31 + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — the Table 29 band-B default (12 mm loose jacket), not the + # zero-loss combi default and not the harsher uninsulated t=0. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + + def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None: """RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating system is lodged, the default cylinder takes the age-band insulation, diff --git a/tests/scripts/test_audit_anomalies.py b/tests/scripts/test_audit_anomalies.py index 2cc510bba..dd8771500 100644 --- a/tests/scripts/test_audit_anomalies.py +++ b/tests/scripts/test_audit_anomalies.py @@ -20,6 +20,9 @@ def _make_audit( post_band: Optional[str] = None, post_sap: Optional[float] = None, cost_of_works: Optional[float] = None, + has_hot_water_cylinder: Optional[bool] = None, + cylinder_insulation_type: Optional[int] = None, + cylinder_insulation_thickness_mm: Optional[float] = None, ) -> PropertyAudit: return PropertyAudit( property_id=1, @@ -41,6 +44,9 @@ def _make_audit( solar_sap_points=None, solar_bill_savings=None, n_measures=0, + has_hot_water_cylinder=has_hot_water_cylinder, + cylinder_insulation_type=cylinder_insulation_type, + cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, )