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..0f20e7a39 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,92 @@ 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_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,