From aa2c7a9171ac6e0edf1b11ce7777002ac0da8bfa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 14:54:24 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-B7:=20per-end-use=20fuel=20cost=20?= =?UTF-8?q?=E2=80=94=20HW=20uses=20water-fuel,=20lighting=20always=20elect?= =?UTF-8?q?ric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.3 §12 charges fuel costs by end-use, not by main heating fuel. For a gas-heated dwelling with an electric immersion hot-water cylinder, HW bills at the electric rate (13.19 p/kWh) not the gas main-heating rate (3.48 p/kWh) — a 3.8× cost difference for HW that propagates straight to ECF. Lighting, central-heating pumps, and fans always electric regardless of main fuel. Discovered by hand-tracing cert 8035-9023 (Detached bungalow, actual SAP 43, predicted 63). Trace showed our hot-water + lighting + pumps lines were charging mains-gas rates throughout, under-counting cost by ~£290/yr. 100-cert parity probe (biggest single Session-B slice so far): MAE 5.70 → 4.90 (-0.80, -14%) RMSE 7.48 → 6.68 (-11%) within ±1: 20% → 24% within ±3: 37% → 46% within ±5: 54% → 67% bias +1.50 → -1.44 (over-corrected by ~3 SAP points) The over-correction (bias now slightly negative) means we're now under-predicting on average. Next slice tackles where we're charging too much electricity — probably HW on dwellings with combi boilers (no immersion, water still on main fuel) and the water_heating_code 901 ("from main system") inheritance path. Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 31 +++++++++++++++++-- .../sap/rdsap/tests/test_cert_to_inputs.py | 29 +++++++++++------ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 09d28179..1e6dd84e 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -160,6 +160,12 @@ _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0 +# SAP 10.3 §12: lighting + central-heating pumps + fans always bill at +# the standard-electricity rate regardless of the main heating fuel — +# Table 32 code 30 (standard electricity), 13.19 p/kWh. +_STANDARD_ELECTRICITY_P_PER_KWH: Final[float] = 13.19 + + # SAP 10.3 Table 9 main_heating_control codes → control type (1/2/3). # Type 1: no time + temp control, or one but not both. # Type 2: programmer + room thermostat (+/− TRVs). @@ -374,6 +380,25 @@ def _space_heating_fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> f return _fuel_cost_gbp_per_kwh(main) +def _hot_water_fuel_cost_gbp_per_kwh( + main: Optional[MainHeatingDetail], water_heating_fuel: Optional[int] +) -> float: + """Hot water bills at the *water-heating* fuel's rate — distinct from + the main heating fuel for gas-heated dwellings whose DHW runs off an + electric immersion. Falls back to the main fuel when the cert + doesn't lodge a separate water fuel.""" + if water_heating_fuel is not None: + return fuel_unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP + return _fuel_cost_gbp_per_kwh(main) + + +def _other_fuel_cost_gbp_per_kwh() -> float: + """Pumps, fans, and lighting always bill at the standard-electricity + rate (SAP 10.3 §12; Table 32 code 30) regardless of the main heating + fuel — these end uses are electric in every UK dwelling.""" + return _STANDARD_ELECTRICITY_P_PER_KWH * _PENCE_TO_GBP + + def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: @@ -495,7 +520,9 @@ def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs: pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR, lighting_kwh_per_yr=lighting_kwh, space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(main), - hot_water_fuel_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main), - other_fuel_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main), + hot_water_fuel_cost_gbp_per_kwh=_hot_water_fuel_cost_gbp_per_kwh( + main, epc.sap_heating.water_heating_fuel + ), + other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(), co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), ) diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 38e3e458..c06ec41b 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -203,19 +203,27 @@ def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: assert inputs_lo.main_heating_efficiency == 0.70 -def test_mains_gas_fuel_cost_in_gbp_per_kwh() -> None: - # Arrange — Table 12 mains-gas unit price is 3.48 p/kWh; mapper must - # report this as £0.0348/kWh (decimal-pound, not pence). For mains- - # gas dwellings all three end-use fuel costs collapse to the same value. - epc = _typical_semi_detached_epc() +def test_gas_heating_with_electric_immersion_charges_hw_at_electricity_rate() -> None: + # Arrange — Default test fixture: mains-gas main heating but the + # SapHeating fixture uses water_heating_fuel=26 (also mains gas) so + # all three lines collapse to gas. Override water_heating_fuel to 29 + # (electricity) to verify the mapper picks the water fuel rate. + gas_only = _typical_semi_detached_epc() + electric_hw = _typical_semi_detached_epc() + electric_hw.sap_heating.water_heating_fuel = 29 # electricity # Act - inputs = cert_to_inputs(epc) + inputs_gas = cert_to_inputs(gas_only) + inputs_hw = cert_to_inputs(electric_hw) - # Assert - assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.0348 - assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.0348 - assert inputs.other_fuel_cost_gbp_per_kwh == 0.0348 + # Assert — gas main → space heating at gas rate; HW switches to electric + # rate when water_heating_fuel is electric; lighting/pumps always electric. + assert inputs_gas.space_heating_fuel_cost_gbp_per_kwh == 0.0348 + assert inputs_gas.hot_water_fuel_cost_gbp_per_kwh == 0.0348 + assert inputs_gas.other_fuel_cost_gbp_per_kwh == 0.1319 + assert inputs_hw.space_heating_fuel_cost_gbp_per_kwh == 0.0348 + assert inputs_hw.hot_water_fuel_cost_gbp_per_kwh == 0.1319 + assert inputs_hw.other_fuel_cost_gbp_per_kwh == 0.1319 def test_main_heating_control_code_maps_to_sap_control_type() -> None: @@ -309,6 +317,7 @@ def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None: ), ], sap_heating=make_sap_heating( + water_heating_fuel=29, # all-electric house: water is also electric main_heating_details=[ MainHeatingDetail( has_fghrs=False,