diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0851e92b..d35be43b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 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 326034d0..9d7971cf 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 27bdcd52..e6c3be1e 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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