diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index a7ff845e1..70c33142c 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -59,6 +59,14 @@ _HEAT_PUMP_CATEGORY = 4 _HHR_STORAGE_OVERLAY = HeatingOverlay( main_fuel_type=_ELECTRICITY_FUEL, sap_main_heating_code=_HHR_STORAGE_SAP_CODE, + # RdSAP electric-storage-heater category (7). Set so the overlaid EPC stays + # internally consistent: without it the pre-existing system's category (e.g. + # 10 = electric room heaters) survives alongside the new storage code 409, + # and the SAP 10.2 Table 12a resolver — which classifies by category — + # misreads the off-peak storage heating as direct-acting electric and prices + # 100% at the peak rate (verified: UPRN 100020942571 HHRSH SAP 48 vs + # accredited Elmhurst 71; category 7 -> 73). See ADR-0024. + main_heating_category=7, main_heating_control=2404, water_heating_code=903, water_heating_fuel=_ELECTRICITY_FUEL, diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 275c841f1..b725555a8 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2574,6 +2574,17 @@ _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( {691, 692, 693, 694, 699} ) +# SAP 10.2 Table 4a electric STORAGE-heater codes (PDF p.169, codes 401-409: +# old-large-volume / slimline / fan / integrated-storage-direct 408 / high-heat- +# retention 409, etc.). Mirrors the storage subset of `table_12a._RULE_2_STORAGE_ +# CODES` (heaters only — excludes underfloor 421/422 and storage boilers 193/195, +# which have their own Table 12a rows). Used to classify storage heaters by SAP +# code as well as RdSAP `main_heating_category == 7`, so a storage-coded system +# is priced off-peak even when its category is stale or inconsistent. +_ELECTRIC_STORAGE_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( + range(401, 410) +) + # SAP 10.2 Table 4a electric boilers (PDF p.170, codes 191-196) → their # Table 12a Grid 1 SH rows (PDF p.191). NOTE the boiler families do NOT all # share a row — read the spec exactly: @@ -2623,6 +2634,26 @@ def _table_12a_system_for_main( main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ) + # Electric STORAGE heaters — SAP 10.2 Table 12a Grid 1 (PDF p.191). + # Detected by RdSAP main_heating_category 7 OR, authoritatively, by a Table + # 4a electric-storage-heater SAP code (401-409). Keyed on the CODE as well as + # the category, and checked BEFORE the room-heater branch below, so a storage + # system whose category is stale or inconsistent — e.g. a recommendation + # `HeatingOverlay` that sets `sap_main_heating_code=409` (HHR storage) but + # leaves the pre-existing electric-room-heater `main_heating_category=10` — is + # still priced off-peak here instead of being misclassified as direct-acting + # (1.00 high-rate → 100% peak) by the category-10 gate. A specific Table 4a + # code is more authoritative than the coarser category. Code 408 is an + # "Integrated (storage + direct-acting) system" → 0.20 SH high-rate fraction + # at 7-hour; every other storage code → "Other storage heaters" → 0.00 + # (charged wholly off-peak). Gated on `_is_electric_main` belt-and-braces. + if _is_electric_main(main) and ( + main.main_heating_category == 7 + or (code is not None and code in _ELECTRIC_STORAGE_HEATER_SAP_CODES) + ): + if code == 408: + return Table12aSystem.INTEGRATED_STORAGE_DIRECT + return Table12aSystem.OTHER_STORAGE_HEATERS # Electric room heaters are direct-acting electric → SAP 10.2 Table # 12a Grid 1 (PDF p.191) "Other systems including direct-acting # electric" row (7-hour high-rate fraction 1.00, 10-hour 0.50). @@ -2634,9 +2665,9 @@ def _table_12a_system_for_main( # main_heating_category 1, NOT 10, so the category-only gate missed it # and it fell through to None → 100% off-peak LOW rate, billing # direct-acting heaters as if they charged overnight like storage - # (cert 2958 +32.2 SAP, the worst over-rate in the sample). Distinct - # from electric STORAGE heaters (category 7), which DO charge off-peak - # and correctly fall through to None here (→ 100% low rate). Gated on + # (cert 2958 +32.2 SAP, the worst over-rate in the sample). Electric + # STORAGE heaters (category 7 / codes 401-409) are handled by the + # storage-heater branch above, so they never reach here. Gated on # `_is_electric_main` so a non-electric room heater (gas / solid-fuel # cat 10) is excluded; all callers already pre-gate on electric. if _is_electric_main(main) and ( @@ -2665,16 +2696,6 @@ def _table_12a_system_for_main( 211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524 ): return Table12aSystem.ASHP_OTHER - # Electric STORAGE heaters (RdSAP main_heating_category 7) — SAP 10.2 - # Table 12a Grid 1 (PDF p.191). Code 408 is an "Integrated (storage + - # direct-acting) system" → 0.20 SH high-rate fraction at 7-hour; every - # other storage code is "Other storage heaters" → 0.00 (charged wholly - # off-peak, the same 100%-low-rate the None fallback already gave). - # Gated on `_is_electric_main` belt-and-braces (all callers pre-gate). - if main.main_heating_category == 7 and _is_electric_main(main): - if code == 408: - return Table12aSystem.INTEGRATED_STORAGE_DIRECT - return Table12aSystem.OTHER_STORAGE_HEATERS # Electric boilers (Table 4a codes 191-196) — resolve the misleading TODO # that lumped them as one "direct-acting" family. They split across THREE # distinct Table 12a Grid 1 rows (see `_DIRECT_ACTING_ELECTRIC_BOILER_CODES` diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 02eb928db..366c51869 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -76,6 +76,12 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: ].overlay.heating == HeatingOverlay( main_fuel_type=30, sap_main_heating_code=409, + # Storage category (7) pins the fix for the off-peak-pricing bug: the + # overlay must set it so the Table 12a resolver classifies the off-peak + # storage heating as storage (0.00 high-rate) and not direct-acting + # electric (1.00) via a stale category. See test_cert_to_inputs + # `test_storage_heater_classified_by_sap_code_over_stale_category`. + main_heating_category=7, main_heating_control=2404, water_heating_code=903, water_heating_fuel=30, 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 6b9daf5ad..ec770e72b 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1054,6 +1054,55 @@ def test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows() -> None assert space_heating_high_rate_fraction(storage, Tariff.SEVEN_HOUR) == 0.00 +def test_storage_heater_classified_by_sap_code_over_stale_category() -> None: + # Regression: electric STORAGE heaters must resolve to a storage Table 12a + # Grid 1 row from their Table 4a SAP code (401-409) even when the RdSAP + # `main_heating_category` is stale/inconsistent (== 10, electric room + # heaters). This is exactly the state the HHRSH recommendation overlay + # produces — it sets `sap_main_heating_code=409` on a dwelling that was + # electric room heaters (category 10) — and the category-10 room-heater + # branch used to win, mispricing the off-peak storage heating as + # direct-acting electric (1.00 high-rate → 100% peak). Verified real-cert + # impact: UPRN 100020942571 HHRSH SAP 48 (peak-priced) vs accredited + # Elmhurst 71; code-authoritative classification restores it to 73. + from domain.sap10_calculator.tables.table_12a import ( + Table12aSystem, + Tariff, + space_heating_high_rate_fraction, + ) + + def _storage_main(code: int, category: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2404, # HHRSH controls + main_heating_category=category, + sap_main_heating_code=code, + ) + + # HHR storage (409) with a stale room-heater category (10) — the overlay case. + hhr_stale = _table_12a_system_for_main(_storage_main(409, category=10)) + assert hhr_stale is Table12aSystem.OTHER_STORAGE_HEATERS + assert space_heating_high_rate_fraction(hhr_stale, Tariff.SEVEN_HOUR) == 0.00 + + # Integrated storage/direct-acting (408) is still its own row, code-driven. + integrated = _table_12a_system_for_main(_storage_main(408, category=10)) + assert integrated is Table12aSystem.INTEGRATED_STORAGE_DIRECT + assert space_heating_high_rate_fraction(integrated, Tariff.SEVEN_HOUR) == 0.20 + + # The canonical consistent case (category 7) is unchanged. + hhr_consistent = _table_12a_system_for_main(_storage_main(409, category=7)) + assert hhr_consistent is Table12aSystem.OTHER_STORAGE_HEATERS + + # A genuine electric room heater (code 691, category 10) is NOT swept into + # the storage branch — it stays direct-acting (1.00 high-rate at 7-hour). + room_heater = _table_12a_system_for_main(_storage_main(691, category=10)) + assert room_heater is Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC + assert space_heating_high_rate_fraction(room_heater, Tariff.SEVEN_HOUR) == 1.00 + + 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