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