From e7177a8bd4f318808ce8f0811d3a03b0790a221d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 14 Jun 2026 09:16:22 +0000 Subject: [PATCH] fix(electric-heaters): code-699 "electric heaters assumed" bills Table 12a direct-acting split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "No system present: electric heaters assumed" lodging carries SAP Table 4a code 699 (electric room heaters) but RdSAP main_heating_category 1, NOT 10. `_table_12a_system_for_main` keyed the direct-acting-electric routing on category==10 only, so the category-1 form fell through to None and `_space_heating_fuel_cost_gbp_per_kwh` billed space heating 100% at the off-peak LOW rate — as if direct-acting room heaters charged overnight like storage. Per RdSAP 10 §12 Rule 3 (PDF p.62) electric room heaters (691-694, 699) route to the 10-hour tariff, and SAP 10.2 Table 12a Grid 1 (PDF p.191) gives the "other direct-acting electric" row a 0.50 high-rate fraction at 10-hour (1.00 at 7-hour). Route those SAP codes — the same set §12 Rule 3 already uses — to OTHER_DIRECT_ACTING_ELECTRIC alongside the category-10 gate. Found via the PE/CO2-vs-cost split on the worst over-rater in the /tmp sample: cert 2958 PE +0% / CO2 -1% (energy correct) but SAP +32.2 — a pure cost-side bug. Space rate 7.50 -> 11.09 p/kWh; cert 2958 +32.2 -> +14.7. The committed corpus gauge is unchanged (its 3 non-category-10 code-699 certs are all on Single meters -> STANDARD tariff, so this split never applies to them); the win is on the unbiased /tmp population's single worst cert. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 38 +++++++++++++---- .../rdsap/test_cert_to_inputs.py | 41 +++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 12f5b17e..d60daefd 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2393,6 +2393,15 @@ _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12: Final[dict[Tariff, tuple[int, int]]] = { } +# SAP Table 4a electric room-heater codes (panel/convector/radiant 691, +# fan 692, portable 693, water-/oil-filled 694, "electric heaters assumed" +# 699) — the same set RdSAP 10 §12 Rule 3 (PDF p.62) routes to the 10-hour +# tariff. They are direct-acting electric for the Table 12a Grid 1 SH split. +_ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( + {691, 692, 693, 694, 699} +) + + def _table_12a_system_for_main( main: Optional[MainHeatingDetail], ) -> Optional[Table12aSystem]: @@ -2421,15 +2430,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 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): + # 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). + # Identified EITHER by RdSAP main_heating_category 10 OR by a Table 4a + # electric room-heater SAP code (691-694 panel/fan/portable/water- + # filled, 699 "electric heaters assumed" — the SAME set RdSAP 10 §12 + # Rule 3 (PDF p.62) routes to the 10-hour tariff). The "No system + # present: electric heaters assumed" lodging (code 699) carries + # 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 + # `_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 ( + main.main_heating_category == 10 + or (code is not None and code in _ELECTRIC_ROOM_HEATER_SAP_CODES) + ): return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC # A PCDB Table 362 record IS a heat pump by definition (the Appendix-N # efficiency cascade keys off it), whether or not a Table-4a SAP code 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 dbb817bb..4531c08d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -715,6 +715,47 @@ def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None: assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.0550) <= 1e-9 +def test_no_system_electric_heaters_assumed_code_699_bills_direct_acting_split() -> None: + # Arrange — "No system present: electric heaters assumed" lodges SAP + # Table 4a code 699 (electric room heaters) but RdSAP main_heating_ + # category 1, NOT 10, on a Dual (Economy-7) meter. RdSAP 10 §12 Rule 3 + # (PDF p.62) routes electric room heaters (691-694, 699) to the 10-hour + # tariff, and SAP 10.2 Table 12a Grid 1 (PDF p.191) gives the "other + # direct-acting electric" row a 0.50 high-rate fraction at 10-hour. + # The scalar SH rate is therefore the blend 0.50 × high (14.68) + 0.50 + # × low (7.50) = 11.09 p/kWh — NOT the 7.50 p/kWh of 100% off-peak that + # the category-10-only gate produced when it missed this category-1 + # lodging. + from dataclasses import replace + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2699, + main_heating_category=1, # NOT 10 — the "electric heaters assumed" form + sap_main_heating_code=699, + ) + base = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band="E")], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — blended 0.50×14.68 + 0.50×7.50 = 11.09 p/kWh. + assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.1109) <= 1e-9 + + 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