mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(storage-heaters): Table 12a code-408 integrated-storage high-rate fraction
SAP 10.2 Table 12a Grid 1 (PDF p.191): electric storage heater SAP code 408 is an "Integrated (storage + direct-acting) system" with a 0.20 space-heating high-rate fraction on a 7-hour tariff — NOT the 0.00 of "other storage heaters". `_table_12a_system_for_main` returned None for all storage codes (an explicit TODO), so code 408 fell back to the 100%-low-rate path and billed space heating at the bare 7-hour low rate (5.50 p/kWh) — under-costing → over-rating. Mapped cat-7 storage: 408 -> INTEGRATED_STORAGE_DIRECT (0.20), others -> OTHER_STORAGE_HEATERS (0.00, unchanged behaviour). The enum + fraction rows already existed; this only wires the dispatch, so the split flows self-consistently to both the §10a cost and the Appendix-M1 D_PV high-rate fraction. Corpus: sap408 over-raters +14.6/+12.9/+12.7 -> +7.1/+5.1/+3.4 (two crossed into within-0.5). Gauge 65.9% -> 66.1%, MAE 1.160 -> 1.128. Floor 0.63 -> 0.64 / MAE ceiling 1.22 -> 1.18. Worksheet harness 47/47 0 diverge. The residual +3..+7 is the "all other uses" 0.90 high-rate fraction (lighting/pumps/HW still billed 100%-low on the off-peak legacy path) — the next slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dfcd7af57c
commit
bec62b9167
3 changed files with 91 additions and 6 deletions
|
|
@ -2408,7 +2408,8 @@ def _table_12a_system_for_main(
|
|||
|
||||
Coverage as fixtures land:
|
||||
- ASHP / GSHP (codes 211-224, 521-524, PCDB index) — wired
|
||||
- Storage heaters (401-409) — TODO
|
||||
- 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
|
||||
|
|
@ -2451,6 +2452,16 @@ def _table_12a_system_for_main(
|
|||
211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524
|
||||
):
|
||||
return Table12aSystem.ASHP_OTHER
|
||||
# Electric STORAGE heaters (RdSAP main_heating_category 7) — SAP 10.2
|
||||
# Table 12a Grid 1 (PDF p.191). Code 408 is an "Integrated (storage +
|
||||
# direct-acting) system" → 0.20 SH high-rate fraction at 7-hour; every
|
||||
# other storage code is "Other storage heaters" → 0.00 (charged wholly
|
||||
# off-peak, the same 100%-low-rate the None fallback already gave).
|
||||
# Gated on `_is_electric_main` belt-and-braces (all callers pre-gate).
|
||||
if main.main_heating_category == 7 and _is_electric_main(main):
|
||||
if code == 408:
|
||||
return Table12aSystem.INTEGRATED_STORAGE_DIRECT
|
||||
return Table12aSystem.OTHER_STORAGE_HEATERS
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -624,6 +624,78 @@ def test_cylinder_size_inaccessible_code_5_off_peak_dual_immersion_uses_210l() -
|
|||
assert volume_l is not None and abs(volume_l - 210.0) <= 1e-9
|
||||
|
||||
|
||||
def test_integrated_storage_heater_408_bills_table_12a_grid1_high_rate_fraction() -> None:
|
||||
# Arrange — electric storage heaters (cat 7), SAP code 408, on an
|
||||
# off-peak 7-hour (dual / Economy-7) meter. SAP 10.2 Table 12a Grid 1
|
||||
# (PDF p.191): code 408 is an "Integrated (storage + direct-acting)
|
||||
# system" with a 0.20 space-heating high-rate fraction at 7-hour (NOT
|
||||
# the 0.00 of "other storage heaters"). The scalar SH rate is therefore
|
||||
# the blend 0.20 × high (15.29) + 0.80 × low (5.50) = 7.458 p/kWh.
|
||||
from dataclasses import replace
|
||||
|
||||
main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=29, # electricity
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2401,
|
||||
main_heating_category=7, # electric storage heaters
|
||||
sap_main_heating_code=408,
|
||||
)
|
||||
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 scalar rate 0.20×15.29 + 0.80×5.50 = 7.458 p/kWh.
|
||||
assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.07458) <= 1e-9
|
||||
|
||||
|
||||
def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None:
|
||||
# Arrange — same off-peak storage cert but SAP code 401 ("other storage
|
||||
# heaters"): Table 12a Grid 1 gives a 0.00 high-rate fraction → the heat
|
||||
# is charged wholly at the 7-hour low rate (5.50 p/kWh). Guards that the
|
||||
# 408 integrated-system 0.20 fraction does NOT leak to other codes.
|
||||
from dataclasses import replace
|
||||
|
||||
main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=29,
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2401,
|
||||
main_heating_category=7,
|
||||
sap_main_heating_code=401,
|
||||
)
|
||||
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 — 100% low rate, 5.50 p/kWh.
|
||||
assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.0550) <= 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
|
||||
|
|
|
|||
|
|
@ -41,10 +41,12 @@ _CORPUS = Path(
|
|||
)
|
||||
|
||||
# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips).
|
||||
# Current: SAP within-0.5 = 65.9%, SAP MAE = 1.160 (heat-network Table 4c(3)
|
||||
# flat-rate charging factor, this slice: community cluster 38% -> 62% within-0.5).
|
||||
# Current: SAP within-0.5 = 66.1%, SAP MAE = 1.128 (Table 12a Grid 1
|
||||
# integrated-storage code-408 0.20 high-rate fraction, this slice: sap408
|
||||
# over-rate +14.6/+12.9/+12.7 -> +7.1/+5.1/+3.4; prior slice was the
|
||||
# heat-network Table 4c(3) flat-rate charging factor).
|
||||
# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below).
|
||||
# PE MAE = 14.7 kWh/m2/yr (signed +9.2).
|
||||
# PE MAE = 14.7 kWh/m2/yr (signed +9.1).
|
||||
#
|
||||
# The SAP (cost) gauge is the optimised target — its floor/ceiling are TIGHT.
|
||||
# CO2 and PE are reported + LOOSELY guarded: both run ~+5% high vs the lodged
|
||||
|
|
@ -63,8 +65,8 @@ _CORPUS = Path(
|
|||
# energy were 5% high; actual SAP bias is +0.145).
|
||||
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
|
||||
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
|
||||
_MIN_WITHIN_HALF_SAP = 0.63
|
||||
_MAX_SAP_MAE = 1.22
|
||||
_MIN_WITHIN_HALF_SAP = 0.64
|
||||
_MAX_SAP_MAE = 1.18
|
||||
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
|
||||
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue