From 0476b4b235cb649134ebdac195967ca3434ce6f9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 20:39:15 +0000 Subject: [PATCH] S0380.230: electric room heaters (cat 10) on off-peak bill at Table 12a direct-acting high rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 12a Grid 1 (PDF p.191): an electric room heater (RdSAP main_heating_category 10, e.g. SAP code 691) is direct-acting electric, so it sits on the "Other systems including direct-acting electric" row — 7-hour high-rate fraction 1.00, 10-hour 0.50. It runs on demand, mostly at the HIGH rate; it does NOT earn the 100%-low-rate of overnight storage charging (which is category 7). `_table_12a_system_for_main` only mapped ASHP, so an electric room heater fell through to the "100% low-rate" fallback (5.50 p, £0.0550), under- charging space heating by ~9.79 p/kWh and systematically OVER-rating the cluster. Now maps electric cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC (gated on `_is_electric_main`, so gas/solid-fuel cat-10 room heaters are excluded). The same Table 12a fraction flows through cost, CO2 (Table 12d) and PE (Table 12e) — all three callers already pre-gate on electric. Mirror of S0380.228 (same fallback bug for electric SECONDARY heating). 1,000-cert 2026 API sample (no worksheet for this cluster — ±0.5-vs-lodged fallback bar): cat-10 mean |err| 9.49 → 7.11, %<0.5 10.4% → 16.7%; headline %<0.5 42.5% → 42.9%, overall mean |err| 2.29 → 2.16. cat-7 (storage) and cat-2 (gas) unchanged. Full §4 suite green (2405 passed). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 10 ++++ .../rdsap/test_cert_to_inputs.py | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 58a08efd..93af44e6 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2138,6 +2138,16 @@ 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 room heaters (RdSAP main_heating_category 10) 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). Distinct from electric STORAGE + # heaters (category 7), which charge off-peak and correctly fall + # through to None here (→ 100% low rate). 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, this is belt-and-braces. + if main.main_heating_category == 10 and _is_electric_main(main): + return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC # ASHP — Table 4a rows 211-217 (earlier generations) + 221-227 # (2013+) cover the air-source space. Warm-air ASHPs are 521-524. if code is not None and ( 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 fc95112c..c0d9d685 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2938,6 +2938,55 @@ def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> assert abs(cost_eighteen_hour - 0.0741) <= 1e-6 +def test_space_heating_electric_room_heater_off_peak_bills_at_direct_acting_high_rate() -> None: + # Arrange — an ELECTRIC room heater (RdSAP main_heating_category 10, + # e.g. SAP code 691) is direct-acting electric, so SAP 10.2 Table 12a + # Grid 1 (PDF p.191) puts it on the "Other systems including direct- + # acting electric" row: 7-hour high-rate fraction 1.00, 10-hour 0.50. + # Unlike STORAGE heaters (category 7), which charge off-peak and so + # correctly bill 100% at the low rate, a room heater runs on demand — + # mostly at the HIGH rate. `_table_12a_system_for_main` only mapped + # ASHP, so a room heater fell through to the "100% low-rate" fallback + # (5.50 p, £0.0550), under-charging space heating by ~9.79 p/kWh and + # systematically OVER-rating the cat-10 cluster (1,000-cert API sample: + # 48 certs, mean |err| 9.49, signed +5.08). The fix maps electric + # cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC. Mirror of S0380.228 + # (which fixed the same fallback for electric SECONDARY heating). + from domain.sap10_calculator.tables.table_12a import Tariff + electric_room_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # standard electricity + heat_emitter_type=2, + emitter_temperature=1, + main_heating_control=2602, + main_heating_category=10, # electric room heaters + sap_main_heating_code=691, + ) + gas_room_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=1, # mains gas — NOT electric + heat_emitter_type=2, + emitter_temperature=1, + main_heating_control=2602, + main_heating_category=10, # gas room heater (also cat 10) + sap_main_heating_code=631, + ) + + # Act — 7-hour off-peak tariff. + electric_rate = _space_heating_fuel_cost_gbp_per_kwh( + electric_room_heater_main, Tariff.SEVEN_HOUR, prices=SAP_10_2_SPEC_PRICES, + ) + gas_rate = _space_heating_fuel_cost_gbp_per_kwh( + gas_room_heater_main, Tariff.SEVEN_HOUR, prices=SAP_10_2_SPEC_PRICES, + ) + + # Assert — electric room heater: 1.00 × 15.29 p = £0.1529 (high rate). + # Gas room heater is unaffected (non-electric → single Table 32 rate, + # not the off-peak electric split). + assert abs(electric_rate - 0.1529) <= 1e-6 + assert abs(gas_rate - 0.0550) > 1e-6 + + def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None: # Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution # Loss Factor by dwelling age band A..M. None → K-or-newer