fix(tariff): map electric boilers 191/193-196 to their Table 12a Grid 1 rows

SAP 10.2 Table 4a electric boilers (PDF p.170) split across three distinct
Table 12a Grid 1 SH rows (PDF p.191), not one "direct-acting" family as the
stale TODO in `_table_12a_system_for_main` implied:

  - 191 Direct-acting electric boiler   -> "Direct-acting electric boiler (a)"
    row: 7-hour 0.90, 10-hour 0.50 (NOT the 1.00/0.50 "Other direct-acting
    electric heating" room-heater row).
  - 193/194/195/196 Electric dry core / water storage boiler -> "Electric dry
    core or water storage boiler" row: 7-hour 0.00 (charged wholly off-peak =
    100% low rate, identical to the None fallback).
  - 192 Electric CPSU -> Appendix F; left falling through to None (off-peak
    low) until the Appendix-F high-rate cascade is implemented.

The enum + fractions already existed in table_12a.py; only the code->enum
mapping was missing. Resolves the TODO and pins the spec-correct 0.00 for the
storage boilers so 195 can't be mis-"fixed" up to a direct-acting fraction.

Forward guard, 0 corpus impact: storage boilers already billed 100% low via
the None fallback, and all corpus 191 certs are on standard tariff (Table 12a
off-peak split never fires). Corpus gauge unchanged 73.3% / MAE 0.774.

Pin: test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows.
pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 21:48:59 +00:00
parent 9694650abe
commit eea5d3a5a8
2 changed files with 79 additions and 2 deletions

View file

@ -2520,6 +2520,25 @@ _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset(
{691, 692, 693, 694, 699}
)
# 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:
# 191 Direct-acting electric boiler → "Direct-acting electric boiler
# (a)" row: 7-hour 0.90, 10-hour 0.50 (NOT the "Other direct-acting
# electric heating" 1.00/0.50 room-heater row).
# 192 Electric CPSU → "Electric CPSU" row: Appendix F
# (no flat Table 12a fraction — left as a documented gap, see below).
# 193/194 Electric dry core storage boiler → "Electric dry core or water
# 195/196 Electric water storage boiler storage boiler" row: 7-hour
# 0.00 (charged wholly off-peak — identical to the 100%-low-rate the
# None fallback already gave; mapped EXPLICITLY so the spec-correct
# 0.00 is pinned and can't be "fixed" up to a wrong direct-acting 1.00).
_DIRECT_ACTING_ELECTRIC_BOILER_CODES: Final[frozenset[int]] = frozenset({191})
_ELECTRIC_STORAGE_BOILER_CODES: Final[frozenset[int]] = frozenset(
{193, 194, 195, 196}
)
_ELECTRIC_CPSU_CODES: Final[frozenset[int]] = frozenset({192})
def _table_12a_system_for_main(
main: Optional[MainHeatingDetail],
@ -2539,8 +2558,9 @@ def _table_12a_system_for_main(
- Storage heaters (cat 7): 408 INTEGRATED_STORAGE_DIRECT (0.20),
all others OTHER_STORAGE_HEATERS (0.00) wired
- Underfloor heating (421-422) TODO
- Direct-acting electric (191) / CPSU (192) / electric storage
boiler (193, 195) TODO
- Direct-acting electric boiler (191) 0.90/0.50; electric storage
boilers (193/194/195/196) 0.00 wired
- Electric CPSU (192) Appendix F high-rate cascade still TODO
"""
if main is None:
return None
@ -2601,6 +2621,19 @@ def _table_12a_system_for_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`
# et al). 191 alone is direct-acting (0.90/0.50); 193-196 are storage
# boilers (0.00 = 100% low, the spec-correct value the None fallback gave —
# so this is a forward guard, not a corpus mover); 192 CPSU needs Appendix F
# and is left to fall through to None (the off-peak-low fallback) until the
# Appendix-F high-rate cascade is implemented.
if code is not None and _is_electric_main(main):
if code in _DIRECT_ACTING_ELECTRIC_BOILER_CODES:
return Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER
if code in _ELECTRIC_STORAGE_BOILER_CODES:
return Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE
return None

View file

@ -65,6 +65,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage]
dimensions_from_cert,
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_table_12a_system_for_main, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
@ -911,6 +912,49 @@ def test_no_system_electric_heaters_assumed_code_699_bills_direct_acting_split()
assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.1109) <= 1e-9
def test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows() -> None:
# Arrange — SAP 10.2 Table 4a electric boilers (PDF p.170) do NOT share
# one Table 12a Grid 1 row (PDF p.191). Read exactly: code 191
# "Direct-acting electric boiler (a)" → 7-hour 0.90 / 10-hour 0.50 (its
# OWN row, NOT the 1.00/0.50 "Other direct-acting electric heating"
# room-heater row); code 195 "Electric water storage boiler" → the
# "Electric dry core or water storage boiler" row → 7-hour 0.00 (charged
# wholly off-peak = 100% low rate, identical to the None fallback). This
# pins the spec-correct 0.00 so 195 can't be mis-"fixed" up to a wrong
# direct-acting fraction.
from domain.sap10_calculator.tables.table_12a import (
Table12aSystem,
Tariff,
space_heating_high_rate_fraction,
)
def _electric_boiler_main(code: int) -> MainHeatingDetail:
return MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=0,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2, # boiler
sap_main_heating_code=code,
)
# Act
direct_acting = _table_12a_system_for_main(_electric_boiler_main(191))
storage = _table_12a_system_for_main(_electric_boiler_main(195))
# Assert — distinct rows with their published fractions.
assert direct_acting is Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER
assert storage is Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE
assert (
space_heating_high_rate_fraction(direct_acting, Tariff.SEVEN_HOUR) == 0.90
)
assert (
space_heating_high_rate_fraction(direct_acting, Tariff.TEN_HOUR) == 0.50
)
assert space_heating_high_rate_fraction(storage, Tariff.SEVEN_HOUR) == 0.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