diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 45aa4096..9127ebf0 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1835,6 +1835,33 @@ def pcdb_combi_loss_override( return None +# SAP 10.2 §4 line 7702 gates the Table 3a keep-hot combi loss default +# to combi boilers ("enter '0' if not a combi boiler"). The Open EPC API +# typically lodges `sap_main_heating_code = None` so we cannot key off the +# precise SAP code; the next best signal is `main_heating_category`. +# Categories 1 and 2 enumerate the gas / oil / solid-fuel boiler family +# (which contains all combi boilers); categories 3 and 6 are community +# heat networks (treated as boiler-like by the cascade and the existing +# DLF-scaling regression test). Categories 4 (heat pump), 5 (warm air), +# 7 (electric storage), 10 (room heaters) etc. are never combis and must +# zero (61)m per the spec. +_TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset( + {1, 2, 3, 6} +) + + +def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool: + """Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2 + §4 line 7702. Returns True only when the main heating system is in the + boiler family or a community heat network — outside that set the spec's + "enter '0' if not a combi boiler" rule fires and the cascade must zero + (61)m. + """ + if main is None: + return False + return main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES + + def _water_heating_worksheet_and_gains( *, epc: EpcPropertyData, @@ -1869,6 +1896,14 @@ def _water_heating_worksheet_and_gains( energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh, daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly, ) + # SAP 10.2 §4 line 7702: non-combi main heating → (61)m = 0. Without + # this gate the cascade falls through to `combi_loss_monthly_kwh_table_ + # 3a_keep_hot_time_clock()` (600 kWh/yr) on every cert lacking a PCDB + # Table 105 boiler record — including all heat pump certs. + if combi_loss_override is None and not _table_3a_combi_loss_default_applies( + _first_main_heating(epc) + ): + combi_loss_override = zero_monthly wh_result = water_heating_from_cert( epc=epc, mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index f7680043..009ed92b 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -29,6 +29,7 @@ from domain.sap10_ml.tests._fixtures import ( ) from domain.sap10_calculator.calculator import Sap10Calculator, SapResult from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _water_heating_worksheet_and_gains, cert_to_demand_inputs, cert_to_inputs, pcdb_combi_loss_override, @@ -1013,3 +1014,56 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis() ) is None ) + + +def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None: + """SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for + each month from Table 3a, 3b or 3c (enter "0" if not a combi + boiler)'. Air Source Heat Pump main heating (main_heating_category=4) + is NOT a combi boiler — the Table 3a keep-hot 600 kWh/yr fall-through + in `water_heating_from_cert` must be gated by a main-heating-category + check so non-combi certs receive (61)m = 0 per the spec parenthetical. + + Without this gate the cascade silently inflates HW fuel by ~260 kWh/yr + (~1.6 SAP points) on every HP cert that lacks a PCDB Table 105 record + (i.e. every HP cert — Table 105 only contains gas/oil boilers). + """ + # Arrange — synthetic semi-detached, ASHP main heating + # (main_heating_category=4, fuel=29 electricity), hot water from + # cylinder via main heating (water_heating_code=901). + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, # heat pump + sap_main_heating_code=None, # Open EPC API typically lodges None + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, # from main heating + ), + ) + + # Act — run the §4 worksheet cascade as the orchestrator does. + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, # arbitrary; combi loss is upstream of η + is_instantaneous=False, + primary_age="D", + pcdb_record=None, # no PCDB Table 105 boiler record for an HP + ) + + # Assert — (61)m = (0,)*12 for a non-combi main. + assert wh_result is not None + for month_idx, value in enumerate(wh_result.combi_loss_monthly_kwh): + assert value == 0.0, ( + f"month {month_idx}: combi loss {value!r} should be 0 for " + f"non-combi main heating per SAP 10.2 §4 line 7702" + )