From 3cb2711418fdf753f133164b65c7d5be53d037e5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 10 Jun 2026 21:01:05 +0000 Subject: [PATCH] fix(water-heating): assume cylinder thermostat present for electric/immersion/heat-network DHW (SAP 9.4.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A cylinder thermostat should be assumed to be present when the domestic hot water is obtained from a heat network, an immersion heater, a thermal store, a combi boiler or a CPSU." RdSAP 10 Table 29 (p.56) points the no-access default at this rule. The storage-loss Table 2b temperature factor previously read only the lodged `cylinder_thermostat` ("Y") — so an unlodged thermostat always took the ×1.3 absent-penalty, over-stating storage loss by 30%. New `_cylinder_thermostat_present` assumes it present when DHW is from a heat network, WHC 903 (immersion), or a direct-acting electric boiler (SAP code 191 — electric-resistance, immersion-equivalent). Found via the worksheet-folder harness: cert 2474-3059-4202-4496-3200 (Summary path: WHC 901, main SAP 191, electric, no lodged cylinder stat) diverged −1.86 from its dr87 worksheet. The worksheet lodges (53) temperature factor 0.6000 (present) and "add cylinder thermostat (SAP increase too small)" — already assumed present. Fix lands HW output (64) 2701.99 → 2323.88, EXACT to the worksheet; 2474 −1.86 → −0.87 (residual is a separate space-demand fabric thread). No other worksheet in the 47-cert harness moved. API eval within-0.5 56.9% → 57.6%; mean|err| 1.197 → 1.185; signed −0.202 → −0.165. Regression green (only pre-existing fails); goldens + heating corpus unaffected. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 38 ++++++++++++- .../rdsap/test_cert_to_inputs.py | 55 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0f8bdbde..af3448b9 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6174,6 +6174,42 @@ def _primary_loss_override( return base +def _cylinder_thermostat_present( + epc: EpcPropertyData, main: Optional[MainHeatingDetail], +) -> bool: + """Whether a cylinder thermostat is present for the Table 2b temperature + factor (absent → ×1.3 penalty on the storage loss). + + A lodged "Y" wins. Otherwise SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A + cylinder thermostat should be assumed to be present when the domestic + hot water is obtained from a heat network, an immersion heater, a + thermal store, a combi boiler or a CPSU." RdSAP 10 Table 29 (PDF p.56) + points the no-access default at this rule. So a cylinder heated by an + immersion (WHC 903), a direct-acting electric boiler (SAP code 191 — + electric-resistance, immersion-equivalent), or a heat network gets the + base Table 2b factor (no absent-thermostat ×1.3). + + Cert 2474 (Summary path: WHC 901, main SAP 191, electric, no lodged + cylinder thermostat) is the case: the dr87 worksheet lodges (53) + temperature factor 0.6000 (thermostat present), and the "add cylinder + thermostat" recommendation reads "SAP increase too small" because it + is already assumed present. Without this the cascade applied ×1.3 and + over-stated storage loss by ~378 kWh/yr (SAP −1.86).""" + if epc.sap_heating.cylinder_thermostat == "Y": + return True + dhw_main = _water_heating_main(epc) + if _is_heat_network_main(dhw_main): + return True + if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: + return True + if ( + dhw_main is not None + and dhw_main.sap_main_heating_code == _DIRECT_ACTING_ELECTRIC_BOILER_CODE + ): + return True + return False + + def _cylinder_storage_loss_override( epc: EpcPropertyData, main: Optional[MainHeatingDetail], @@ -6217,7 +6253,7 @@ def _cylinder_storage_loss_override( volume_l=volume_l, insulation_type=insulation_label, thickness_mm=float(thickness_mm), - has_cylinder_thermostat=sh.cylinder_thermostat == "Y", + has_cylinder_thermostat=_cylinder_thermostat_present(epc, main), # SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the # ×0.9 multiplier to boiler / warm-air / heat-pump systems — # community heating excluded. Gate via the dedicated helper so 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 330fe4ce..b5497ec8 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -49,6 +49,7 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage] + _cylinder_thermostat_present, # pyright: ignore[reportPrivateUsage] _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage] @@ -365,6 +366,60 @@ def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None: assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6 +def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None: + # Arrange — SAP 10.2 §9.4.9 (PDF p.32): "A cylinder thermostat should be + # assumed to be present when the domestic hot water is obtained from a + # heat network, an immersion heater, a thermal store, a combi boiler or + # a CPSU." A direct-acting electric boiler (SAP code 191) heats the + # cylinder by electric resistance (immersion-equivalent) → assumed + # present even with no lodged cylinder thermostat. Reproduces cert + # 2474-3059-4202-4496-3200 (Summary path: WHC 901, main SAP 191). + direct_electric = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # standard electricity + heat_emitter_type=0, + emitter_temperature="NA", + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=191, + ) + part = make_building_part(construction_age_band="D") + epc_191 = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[direct_electric], + water_heating_code=901, # from main, no lodged cylinder stat + ), + ) + # A gas boiler is NOT in the §9.4.9 list — its cylinder keeps the + # absent-thermostat default (Table 2b ×1.3) when none is lodged. + gas_boiler = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, + ) + epc_gas = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_boiler], water_heating_code=901, + ), + ) + + # Act / Assert — 191 electric DHW assumes present; gas boiler does not. + assert _cylinder_thermostat_present(epc_191, direct_electric) is True + assert _cylinder_thermostat_present(epc_gas, gas_boiler) is False + + def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: # Arrange — heat-network main (Table 4a code 301 = community heating, # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution