From 068088bc2fa0be3f52cb30af225a8ac6cab15778 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 19:03:58 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.140:=20=C2=A74=20cylinder=20stora?= =?UTF-8?q?ge=20loss=20=E2=80=94=20extractor=20picks=20up=20=C2=A716=20the?= =?UTF-8?q?rmostat=20lodging=20+=20Table=202b=20note=20b=20restricts=20?= =?UTF-8?q?=C3=970.9=20to=20boiler/warm-air/HP=20systems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding bugs were over-counting the SAP 10.2 §4 (56)m cylinder storage loss by ~76 kWh/yr across all 17 cylinder-with-immersion corpus variants (cascade HW kWh 2460.40 vs worksheet 2384.12): (1) **Extractor gap.** Elmhurst Summary §15.1 "Hot Water Cylinder" block lodges `Cylinder Size` / `Insulation Thickness` but NOT `Cylinder Thermostat`. The thermostat is lodged separately in §16 "Recommendations" as `Cylinder thermostat (Already installed)`. The extractor only searched §15.1, so `cylinder_thermostat` resolved to None for every variant on property 001431. The cascade then defaulted `has_cylinder_thermostat=False`, applying SAP 10.2 Table 2b's ×1.3 "no thermostat" multiplier. (2) **Cascade spec gap.** `_separately_timed_dhw` returned True for any cylinder-lodged cert regardless of HW fuel. Per SAP 10.2 Table 2b note b) (PDF p.159): > "Multiply Temperature Factor by 0.9 if there is separate time > control of domestic hot water (boiler systems, warm air systems > and heat pump systems)" Electric immersion is NOT in the bracketed list — the ×0.9 reduction is restricted to boiler / warm-air / HP systems. Pre- slice the cascade over-applied ×0.9 on electric-immersion certs. Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs the worksheet's TF = 0.60 (base — thermostat present, immersion exempt). After both fixes the cascade HW kWh matches the worksheet's (64) at 1e-3 precision (2384.116 vs 2384.12). Corpus impact (16 cylinder-with-immersion variants on 18-hour meter): | variant | SAP_c shift | Cost shift | |--------------|------------:|-----------:| | electric 1 | -0.20 → -0.06 | -£3.34 | | electric 2 | -1.27 → +0.47 | -£4.44 | | electric 3 | +2.42 → +2.55 | -£2.91 | | electric 5 | -0.06 → +0.07 | -£3.06 | | electric 6 | +1.19 → +1.33 | -£3.20 | | electric 7 | +1.14 → +1.29 | -£3.35 | | electric 8 | -0.41 → -0.26 | -£3.50 | | electric 9 | -0.24 → -0.12 | -£2.91 | | solid fuel 4-11 | -0.45..-0.09 → -0.29..+0.10 | -£3 to -£4 | The HW kWh line closes cleanly; some SAP residuals sign-flip slightly because the cascade's now-correct HW kWh exposes the SH+Sec demand mismatch for storage heaters (electric 3/6/7 — open driver is the Table 11 `main_heating_category=None` default for codes 401/402, queued for a mapper-side slice). Tests: - new AAA test `test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b` - 16 corpus pins re-tightened (8 electric + 8 solid fuel) Extended handover suite: 883 pass (was 882; +1 new test), 0 fail. Pyright net-zero on touched files (43 → 43 errors, all pre-existing). Per [[feedback-spec-citation-in-commits]] + [[feedback-spec-floor-skepticism]] (the "HW +76 kWh uniform overcount" across 17 variants traced to TWO spec-citable defaults the cascade was getting wrong, not a precision floor). Co-Authored-By: Claude Opus 4.7 --- .../documents_parser/elmhurst_extractor.py | 13 ++++ .../tests/test_heating_systems_corpus.py | 54 ++++++++++----- .../sap10_calculator/rdsap/cert_to_inputs.py | 22 ++++-- .../rdsap/tests/test_cert_to_inputs.py | 68 +++++++++++++++++++ 4 files changed, 137 insertions(+), 20 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 666980d2..4a3dc895 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1344,6 +1344,19 @@ class ElmhurstSiteNotesExtractor: if cylinder_thermostat_raw is not None else None ) + # Fallback: Elmhurst Summary §16 "Recommendations" block carries + # existing fittings as ` (Already installed)` lines. + # When §15.1 doesn't lodge "Cylinder Thermostat" directly, treat + # the "Cylinder thermostat (Already installed)" recommendation + # line as confirmation that the thermostat is present (per + # S0380.140 corpus probe — all 41 variants on property 001431 + # lodge this in §16 but none in §15.1, so the §15.1-only lookup + # returned None and the cascade defaulted `has_cylinder_thermostat + # = False`, mis-applying SAP 10.2 Table 2b's ×1.3 "no thermostat" + # multiplier). + if cylinder_thermostat is None: + if "Cylinder thermostat (Already installed)" in self._lines: + cylinder_thermostat = True return WaterHeating( water_heating_code=self._str_val("Water Heating Code"), water_heating_sap_code=self._int_val("Water Heating SapCode"), diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 437dba5f..28ada210 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -196,16 +196,38 @@ class _CorpusExpectation: # = Table 11 Cat 7). Total absolute SAP residual across the cluster # went from 10.10 to 5.46. _RDSAP_DEFINITELY_OFF_PEAK frozenset was # deleted (dead code; canonical dispatch covers it). +# +# Slice S0380.140 fixed the §4 worksheet (56)m cylinder storage loss +# cascade. Two compounding bugs were over-counting (56)m by ~76 kWh/yr +# across all 17 cylinder-with-immersion corpus variants: +# (1) the Elmhurst Summary §16 "Recommendations" block lodges the +# cylinder thermostat as "Cylinder thermostat (Already +# installed)" — but the extractor only looked in §15.1 for the +# label "Cylinder Thermostat", so the field was None for every +# variant on property 001431. The cascade defaulted +# `has_cylinder_thermostat=False`, mis-applying SAP 10.2 Table +# 2b's ×1.3 "no thermostat" multiplier; +# (2) `_separately_timed_dhw` returned True for any cylinder cert, +# but Table 2b note b restricts the ×0.9 separately-timed +# multiplier to "boiler systems, warm air systems and heat +# pump systems" — electric immersion is not in the list. +# Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs +# the worksheet's TF = 0.60 (base — thermostat present, immersion +# exempt from ×0.9). After both fixes the cascade HW kWh matches the +# worksheet's (64) at 1e-3 (2384.116 vs 2384.12). Cost shifts -£3..-£6 +# per affected variant, SAP residuals shift ±0.15 across 16 variants; +# the SH+Sec demand mismatch for electric 3/6/7 (Table 11 fraction +# for codes 401/402) remains the open driver of those SAP residuals. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017), - _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.2021, expected_cost_resid_gbp=+4.6562, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+164.9052), - _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-1.2714, expected_cost_resid_gbp=+29.2944, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+970.7570), - _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.4189, expected_cost_resid_gbp=-55.7339, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0579, expected_cost_resid_gbp=+1.3337, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.1888, expected_cost_resid_gbp=-27.3926, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.1449, expected_cost_resid_gbp=-26.3805, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932), - _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.4086, expected_cost_resid_gbp=+9.4133, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233), - _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.2444, expected_cost_resid_gbp=+5.6333, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936), + _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789), + _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.4737, expected_cost_resid_gbp=-10.9153, expected_co2_resid_kg=+10.9544, expected_pe_resid_kwh=+100.9401), + _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-117.8401, expected_pe_resid_kwh=-1121.9666), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-11.0752, expected_pe_resid_kwh=-161.0345), + _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.3278, expected_cost_resid_gbp=-30.5954, expected_co2_resid_kg=-56.1047, expected_pe_resid_kwh=-562.5298), + _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-37.8591, expected_pe_resid_kwh=-498.4709), + _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896), + _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), @@ -221,14 +243,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # control-type gaps — separate slices. _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624), _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), - _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.4528, expected_cost_resid_gbp=+10.4331, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685), - _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.3350, expected_cost_resid_gbp=+7.7205, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-0.0902, expected_cost_resid_gbp=+2.0800, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778), - _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0025, expected_cost_resid_gbp=-0.0583, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084), - _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.2280, expected_cost_resid_gbp=+5.2530, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830), - _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.3344, expected_cost_resid_gbp=+7.7031, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.2932, expected_cost_resid_gbp=+6.7559, expected_co2_resid_kg=+25.7581, expected_pe_resid_kwh=+119.8372), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.4180, expected_cost_resid_gbp=+9.6325, expected_co2_resid_kg=+32.7399, expected_pe_resid_kwh=+170.5611), + _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.2919, expected_cost_resid_gbp=+6.7262, expected_co2_resid_kg=-68.4116, expected_pe_resid_kwh=+89.7782), + _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.1655, expected_cost_resid_gbp=+3.8136, expected_co2_resid_kg=-44.3197, expected_pe_resid_kwh=+92.8384), + _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0281, expected_cost_resid_gbp=-0.6473, expected_co2_resid_kg=+0.6642, expected_pe_resid_kwh=+44.7851), + _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0994, expected_cost_resid_gbp=-2.3310, expected_co2_resid_kg=-75.1034, expected_pe_resid_kwh=+16.7917), + _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.0804, expected_cost_resid_gbp=+1.8511, expected_co2_resid_kg=+18.0444, expected_pe_resid_kwh=+45.1812), + _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.1956, expected_cost_resid_gbp=+4.5065, expected_co2_resid_kg=+19.6820, expected_pe_resid_kwh=+92.8981), + _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.1605, expected_cost_resid_gbp=+3.6988, expected_co2_resid_kg=+17.7916, expected_pe_resid_kwh=+66.5227), + _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.2633, expected_cost_resid_gbp=+6.0671, expected_co2_resid_kg=+23.5398, expected_pe_resid_kwh=+104.1723), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0b58f8bc..48bb053a 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3432,21 +3432,35 @@ def _separately_timed_dhw( """SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature Factor by 0.9 if there is separate time control of domestic hot water (boiler systems, warm air systems and heat pump systems)". - RdSAP §3 default: when a hot-water cylinder is lodged, DHW timing - is separate from space heat — the cylinder is heated on its own - programmer / overnight boost regardless of which heat generator - (boiler, HP, or combi-acting-as-boiler) feeds it. + The spec restricts the ×0.9 reduction to those three system types + — electric immersion DHW is NOT in the list, so the ×0.9 multiplier + must NOT apply when the water-heating fuel is electric (whether + on a standard meter or off-peak immersion timer). + + 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. 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 always-True default for the HP-without-cylinder edge case the earlier cohort calibration was sized around. + + Pre-S0380.140 this returned True for any cylinder-lodged cert + regardless of HW fuel, which over-applied the ×0.9 multiplier on + electric-immersion certs. Combined with the cascade's + `cylinder_thermostat is None → False` fallback (over-applying ×1.3), + these compounded to TF=0.702 vs the worksheet's TF=0.60, over- + counting (56)m storage loss by ~76 kWh/yr × 17 corpus variants. """ if main is None: return False if main.main_heating_category == 4: return True + if _is_electric_water(epc.sap_heating.water_heating_fuel): + 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 0f58d2aa..59e59440 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -49,6 +49,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] + _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] _space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] @@ -1430,6 +1431,73 @@ def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None: assert _is_off_peak_meter("Unknown", fuel_is_electric=False) is False +def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() -> None: + # Arrange — SAP 10.2 Table 2b note b) (PDF p.159) restricts the + # `×0.9 separately-timed` temperature-factor multiplier to "boiler + # systems, warm air systems and heat pump systems". Electric + # immersion DHW is NOT in this list — its `separately-timed` schedule + # is implicit in the off-peak tariff and doesn't earn the ×0.9 + # storage-loss reduction. Pre-S0380.140 `_separately_timed_dhw` + # returned True for every cylinder-lodged cert regardless of HW fuel, + # which over-applied the ×0.9 multiplier on electric-immersion certs. + # Combined with the cascade's `cylinder_thermostat is None → False` + # fallback that simultaneously over-applied ×1.3, these compounded + # to TF=0.702 (≈0.60×1.3×0.9) when the worksheet uses TF=0.60 (base + # — has thermostat, not separately timed for immersion). The + # cylinder-storage-loss (56)m line over-counted by ~76 kWh/yr across + # the 17 cylinder-with-immersion variants of the corpus. + main_gas_boiler = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, # mains gas + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, # gas boiler + sap_main_heating_code=102, + ) + cylinder_with_gas_boiler_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=[main_gas_boiler], + water_heating_fuel=26, # gas → boiler-fed HW + water_heating_code=901, + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + ), + ) + cylinder_with_immersion_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=[main_gas_boiler], + water_heating_fuel=30, # standard electricity → immersion + water_heating_code=903, + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + ), + ) + + # Act + sep_boiler_fed = _separately_timed_dhw( + cylinder_with_gas_boiler_epc, main_gas_boiler, + ) + sep_immersion = _separately_timed_dhw( + cylinder_with_immersion_epc, main_gas_boiler, + ) + + # Assert — gas-boiler-fed cylinder keeps the ×0.9 multiplier (boiler + # system per Table 2b note b); electric-immersion cylinder does not. + assert sep_boiler_fed is True + assert sep_immersion is False + + 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