slice S-B7: per-end-use fuel cost — HW uses water-fuel, lighting always electric

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 14:54:24 +00:00
parent 29c776bb23
commit aa2c7a9171
2 changed files with 48 additions and 12 deletions

View file

@ -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),
)

View file

@ -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,