diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 7b3ca948..f2a94124 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -241,8 +241,8 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cost / CO2 / PE all route via the correct Table 32 fuel code. # Remaining residuals are likely heating-system efficiency or # control-type gaps — separate slices. - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.0649, expected_cost_resid_gbp=-47.5795, expected_co2_resid_kg=+295.4889, expected_pe_resid_kwh=-754.0879), - _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+0.2968, expected_cost_resid_gbp=-6.8392, expected_co2_resid_kg=-74.2162, expected_pe_resid_kwh=-214.2510), + _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+1.8594, expected_cost_resid_gbp=-42.8447, expected_co2_resid_kg=+346.8694, expected_pe_resid_kwh=-605.7603), + _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762), _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9a377fad..837b8401 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3762,6 +3762,17 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 +# SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent +# boilers (151, 153, 155, 159), open-fire + back boiler (156), closed +# room heater + back boiler (158), range cooker boiler (160, 161). +# Per the structure described in §9.2.4 these systems do not ship with +# dual programmers; DHW timing follows the appliance burn schedule, NOT +# a separate cylinder programmer. +_TABLE_4A_SOLID_FUEL_BOILER_CODES: Final[frozenset[int]] = frozenset( + {151, 153, 155, 156, 158, 159, 160, 161} +) + + def _separately_timed_dhw( epc: EpcPropertyData, main: Optional[MainHeatingDetail], ) -> bool: @@ -3773,11 +3784,27 @@ def _separately_timed_dhw( must NOT apply when the water-heating fuel is electric (whether on a standard meter or off-peak immersion timer). + Same flag drives SAP 10.2 Table 3 (PDF p.160) primary-loss row + selection: "Cylinder thermostat, water heating separately timed" + gives winter h=3 / summer h=3; "not separately timed" gives winter + h=5 / summer h=3. + RdSAP §3 default: when a hot-water cylinder is lodged AND the cylinder is fed by a boiler / warm-air / HP, DHW timing is separate from space heat — the cylinder is heated on its own programmer / overnight boost regardless of which heat generator feeds it. + Solid-fuel boilers (Table 4a codes 151-161) are the exception. Per + SAP 10.2 §9.2.4 these systems are "independent solid fuel boilers, + open fires with a back boiler and room heaters with a boiler" — + the appliance itself is the timer. DHW timing follows the burn + schedule, NOT a separate cylinder programmer, so the middle Table + 3 row applies (winter h=5 / summer h=3). Worksheet evidence from + the heating-systems corpus property 001431: solid fuel 3 (code + 160 + WHC=901 + cylinder thermostat) lodges (59)m winter = 64.58 + (h=5, p=0) and (59)m summer = 41.92 / 43.31 (h=3, p=0). Pre-slice + the cascade returned True here, routing through h=3 year-round. + Combi-only dwellings (no cylinder) skip the multiplier — DHW is instantaneous and shares the boiler's space-heating cycle, so there's no separate timer. Heat pumps (cat 4) keep their existing @@ -3797,6 +3824,8 @@ def _separately_timed_dhw( return True if _is_electric_water(epc.sap_heating.water_heating_fuel): return False + if main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES: + return False return bool(epc.has_hot_water_cylinder) 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 892fe543..e76dadaa 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -19,6 +19,7 @@ from typing import Final import pytest from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, MainHeatingDetail, PhotovoltaicArray, SapFloorDimension, @@ -1627,6 +1628,105 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() assert sep_immersion is False +def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() -> None: + # Arrange — SAP 10.2 Table 3 (PDF p.160) gives three primary-loss + # rows keyed off the DHW timing arrangement: + # + # Hot water controls Winter Summer + # No cylinder thermostat 11 3 + # Cylinder thermostat, water heating NOT separately timed 5 3 + # Cylinder thermostat, water heating separately timed 3 3 + # + # Solid-fuel boiler systems (Table 4a codes 151-161 — independent + # boilers, open-fire + back boilers, closed room heaters with + # boilers, range cooker boilers, stoves with boilers) do not ship + # with dual programmers — the appliance itself is the timer (the + # fire/cooker burns or it doesn't). DHW timing is therefore tied to + # the main heating burn schedule, NOT separately timed. The + # worksheet bears this out for the heating-systems corpus: solid + # fuel 3 (code 160 + WHC=901 + cylinder thermostat) lodges + # winter (59)m = 64.58 (h=5, p=0) and summer (59)m = 41.92 / 43.31 + # (h=3, p=0) — exactly the middle row above. + # + # The pre-slice cascade returned True from `_separately_timed_dhw` + # for any cylinder + non-electric HW fuel (the post-S0380.140 + # gate), which routed solid-fuel-boiler certs through the h=3 + # year-round bottom row. That under-counted winter (59) by ~22 + # kWh/month × 8 winter months ≈ 170 kWh/yr per affected cert, and + # the under-counted water-heating gain propagated through to MIT / + # SH / SAP. Cohort impact (heating-systems corpus, property 001431): + # solid fuel 3 closes to ΔSAP ±1e-4 (was +0.30); solid fuel 2 + # narrows from +2.06 to +1.86 (the remaining residual is the + # §12.4.4 immersion-in-summer rule for back-boilers, a follow-up). + # + # Discriminator: SAP code in Table 4a solid-fuel-boiler range + # 151-161. Liquid-fuel / gas Table 4b boilers (codes 101-141) are + # NOT covered — modern gas/oil installations standardly include a + # cylinder thermostat + separate DHW programmer; the + # `_separately_timed_dhw=True` default is correct for them. + + def _solid_fuel_boiler_main(sap_code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=15, # Table 32 code 15 = anthracite + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2103, + sap_main_heating_code=sap_code, + ) + + def _cylinder_epc_for(main: MainHeatingDetail) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_fuel=15, + water_heating_code=901, # HW from main heating + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + ), + ) + + # Act / Assert — every Table 4a solid-fuel boiler code 151..161 + # routes through the not-separately-timed branch (False). + for code in (151, 153, 155, 156, 158, 159, 160, 161): + main = _solid_fuel_boiler_main(code) + epc = _cylinder_epc_for(main) + assert _separately_timed_dhw(epc, main) is False, ( + f"SAP code {code}: solid-fuel boiler should NOT be separately " + f"timed (Table 3 middle row, winter h=5 / summer h=3)" + ) + + # Liquid-fuel Table 4b boiler (code 102 = gas combi) stays on the + # `separately_timed_dhw=True` default — modern gas installations + # ship with dual programmers and the post-S0380.140 logic is + # correct here. + gas_main = 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, + ) + gas_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_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_fuel=26, + water_heating_code=901, + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + ), + ) + assert _separately_timed_dhw(gas_epc, gas_main) is True + + def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None: # Arrange — an electric storage heater (SAP code 401) on an 18-hour # tariff. `_table_12a_system_for_main` returns None for storage