mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
9694650abe
commit
eea5d3a5a8
2 changed files with 79 additions and 2 deletions
|
|
@ -2520,6 +2520,25 @@ _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset(
|
||||||
{691, 692, 693, 694, 699}
|
{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(
|
def _table_12a_system_for_main(
|
||||||
main: Optional[MainHeatingDetail],
|
main: Optional[MainHeatingDetail],
|
||||||
|
|
@ -2539,8 +2558,9 @@ def _table_12a_system_for_main(
|
||||||
- Storage heaters (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20),
|
- Storage heaters (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20),
|
||||||
all others → OTHER_STORAGE_HEATERS (0.00) — wired
|
all others → OTHER_STORAGE_HEATERS (0.00) — wired
|
||||||
- Underfloor heating (421-422) — TODO
|
- Underfloor heating (421-422) — TODO
|
||||||
- Direct-acting electric (191) / CPSU (192) / electric storage
|
- Direct-acting electric boiler (191) → 0.90/0.50; electric storage
|
||||||
boiler (193, 195) — TODO
|
boilers (193/194/195/196) → 0.00 — wired
|
||||||
|
- Electric CPSU (192) — Appendix F high-rate cascade still TODO
|
||||||
"""
|
"""
|
||||||
if main is None:
|
if main is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -2601,6 +2621,19 @@ def _table_12a_system_for_main(
|
||||||
if code == 408:
|
if code == 408:
|
||||||
return Table12aSystem.INTEGRATED_STORAGE_DIRECT
|
return Table12aSystem.INTEGRATED_STORAGE_DIRECT
|
||||||
return Table12aSystem.OTHER_STORAGE_HEATERS
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage]
|
||||||
dimensions_from_cert,
|
dimensions_from_cert,
|
||||||
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
|
||||||
|
_table_12a_system_for_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
_is_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
|
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
|
||||||
_is_electric_water, # 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
|
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:
|
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,
|
# Arrange — heat-network main (Table 4a code 301 = community heating,
|
||||||
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution
|
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue