From e4bf4e70e8c23fe4aef9fde38f88df9b7798ff48 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 13:27:12 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.153:=20SAP=2010.2=20Table=203=20?= =?UTF-8?q?=E2=80=94=20not-separately-timed=20DHW=20for=20solid-fuel=20boi?= =?UTF-8?q?lers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off the DHW timing arrangement, the middle row giving winter h=5 / summer h=3 for "Cylinder thermostat, water heating NOT separately timed". 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. Per SAP 10.2 §9.2.4 (PDF p.27) these 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. Pre-slice `_separately_timed_dhw` returned True for any cylinder + non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel boilers through h=3 year-round (the third row, "Cylinder thermostat, water heating separately timed"). That under-counted winter (59)m by ~21 kWh/month × 8 winter months across the affected cohort, with the under-counted water-heating gain propagating into MIT / SH / SAP. New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES` (frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before the existing cylinder-present fallback. The post-S0380.140 electric- immersion / heat-pump / no-main branches are unchanged. Table 4b liquid-fuel boilers (101-141) keep the True default — modern gas/oil installations standardly include dual programmers and the worksheet confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at h=3 year-round. Worksheet evidence (heating-systems corpus property 001431): - solid fuel 3 (SAP code 160 range cooker boiler + WHC=901 cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0) and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30 → −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact). - solid fuel 2 (SAP code 158 closed room heater + back boiler): same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers (codes 156, 158) — the worksheet has summer (59)m = 0 because the Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes Jun-Sep HW through an electric immersion at η=100%. That's a bigger lift (monthly HW efficiency + fuel-split plumbing) and is a follow-up slice. Other corpus variants: no impact (verified via cohort sweep). The gate is narrow by SAP code so only the 2 affected variants move. Extended handover suite: 897 pass / 0 fail (+1 from new AAA test). Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData` import on the new test's `_cylinder_epc_for` return annotation). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 4 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 29 +++++ .../rdsap/tests/test_cert_to_inputs.py | 100 ++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) 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