fix(electric-heaters): code-699 "electric heaters assumed" bills Table 12a direct-acting split

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 09:16:22 +00:00
parent 9ee3821138
commit e7177a8bd4
2 changed files with 70 additions and 9 deletions

View file

@ -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

View file

@ -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