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