slice S-B4: per-end-use fuel cost (Economy-7 for electric storage)

Splits the single CalculatorInputs.fuel_unit_cost_gbp_per_kwh into three
end-use lines — space_heating, hot_water, other — to match SAP 10.3 §12
which charges different tariffs per end-use on Economy-7 dwellings.

cert→inputs rule: when sap_main_heating_code is in the electric-storage
(401-409), high-heat-retention storage (421-425), or direct-electric
(191-196) ranges, space heating bills at the 7h-low rate (5.5p/kWh)
while hot water + lighting + pumps stay on standard electricity
(13.19p/kWh). All other fuels use a single rate across all three end-
uses.

100-cert parity probe impact:
  MAE   7.53 → 5.72   (-1.81, -24%)
  RMSE 11.60 → 7.58   (-4.02, -35%)
  worst residual -56 → -25 (Semi-detached bungalow)
  within ±10:  85% → 91%

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 14:18:56 +00:00
parent ccdaba5acd
commit 8e1d30c97d
4 changed files with 126 additions and 8 deletions

View file

@ -78,7 +78,14 @@ class WindowInput:
@dataclass(frozen=True)
class CalculatorInputs:
"""Synthetic SAP 10.3 calculator inputs. The cert→inputs mapper
(S-A7b) produces one of these from an `EpcPropertyData`."""
(S-A7b) produces one of these from an `EpcPropertyData`.
Fuel-cost fields are per-end-use because SAP §12 / Table 32 charges
different tariffs for space heating vs hot water vs lighting/pumps
depending on the dwelling's tariff (e.g. Economy-7 charges space
heating at the off-peak rate but lighting at standard). For single-
tariff dwellings the three fields are equal.
"""
dimensions: Dimensions
heat_transmission: HeatTransmission
@ -94,7 +101,9 @@ class CalculatorInputs:
hot_water_kwh_per_yr: float
pumps_fans_kwh_per_yr: float
lighting_kwh_per_yr: float
fuel_unit_cost_gbp_per_kwh: float
space_heating_fuel_cost_gbp_per_kwh: float
hot_water_fuel_cost_gbp_per_kwh: float
other_fuel_cost_gbp_per_kwh: float
co2_factor_kg_per_kwh: float
@ -256,7 +265,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ inputs.pumps_fans_kwh_per_yr
+ inputs.lighting_kwh_per_yr
)
total_cost = delivered_fuel_kwh * inputs.fuel_unit_cost_gbp_per_kwh
total_cost = (
main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
+ inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
+ (inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr)
* inputs.other_fuel_cost_gbp_per_kwh
)
ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa)
sap_int = sap_rating_integer(ecf=ecf)
sap_cont = sap_rating(ecf=ecf)

View file

@ -125,6 +125,18 @@ _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
# SAP 10.2 Table 4a "electric heating" range that picks up an Economy-7
# off-peak tariff for the space-heating fuel cost: electric storage
# heaters (401-409), high-heat-retention storage heaters (421-425), and
# direct-electric room/boiler heating (191-196). Hot water and lighting
# on these dwellings still bill at the on-peak standard rate.
_E7_SPACE_HEATING_CODES: Final[frozenset[int]] = frozenset(
list(range(191, 197)) + list(range(401, 410)) + list(range(421, 426))
)
# Table 32 code 31 — Economy-7 "7h low" off-peak rate.
_E7_LOW_RATE_P_PER_KWH: Final[float] = 5.50
def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
"""Map `EpcPropertyData.dwelling_type` to which envelope surfaces are
party (not heat-loss). Mid-floor flats/maisonettes lose both floor +
@ -272,6 +284,24 @@ def _fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> float:
return fuel_unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
def _is_electric_storage_or_direct(main: Optional[MainHeatingDetail]) -> bool:
"""RdSAP convention: electric storage heaters + direct-electric main
systems bill space heating at the off-peak rate while hot water +
lighting + pumps stay on the on-peak/standard rate."""
if main is None:
return False
code = main.sap_main_heating_code
return code is not None and code in _E7_SPACE_HEATING_CODES
def _space_heating_fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> float:
"""Off-peak rate when the main heating is electric-storage / direct-
electric, else the standard main-fuel rate."""
if _is_electric_storage_or_direct(main):
return _E7_LOW_RATE_P_PER_KWH * _PENCE_TO_GBP
return _fuel_cost_gbp_per_kwh(main)
def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float:
"""SAP 10.3 Table 12 CO2 emission factor by Table 32 fuel code."""
code = _main_fuel_code(main)
@ -390,6 +420,8 @@ def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs:
hot_water_kwh_per_yr=hw_kwh,
pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR,
lighting_kwh_per_yr=lighting_kwh,
fuel_unit_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main),
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),
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
)

View file

@ -205,14 +205,60 @@ def test_main_heating_efficiency_reads_sap_main_heating_code() -> None:
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).
# 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()
# Act
inputs = cert_to_inputs(epc)
# Assert
assert inputs.fuel_unit_cost_gbp_per_kwh == 0.0348
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
def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None:
# Arrange — RdSAP convention: when the main heating is electric-
# storage (code 401-409) or direct-electric (191-196), space heating
# is charged at the 7h-low off-peak rate (Table 32 code 31, 5.5p/kWh)
# while hot water + lighting + pumps remain on standard electricity
# (code 30, 13.19p/kWh). Critical fix for the 5/7 worst residuals on
# storage-heated dwellings.
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=3,
region_code="1",
dwelling_type="Detached bungalow",
sap_building_parts=[
make_building_part(
floor_dimensions=[
make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0),
],
),
],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=7,
sap_main_heating_code=402,
),
],
),
)
# Act
inputs = cert_to_inputs(epc)
# Assert
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.055
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.1319
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1319
def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() -> None:

View file

@ -89,7 +89,9 @@ def _baseline_inputs() -> CalculatorInputs:
hot_water_kwh_per_yr=2400.0,
pumps_fans_kwh_per_yr=100.0,
lighting_kwh_per_yr=600.0,
fuel_unit_cost_gbp_per_kwh=0.07,
space_heating_fuel_cost_gbp_per_kwh=0.07,
hot_water_fuel_cost_gbp_per_kwh=0.07,
other_fuel_cost_gbp_per_kwh=0.07,
co2_factor_kg_per_kwh=0.21,
)
@ -118,7 +120,7 @@ def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() ->
+ result.lighting_kwh_per_yr
)
assert result.total_fuel_cost_gbp == pytest.approx(
expected_fuel * inputs.fuel_unit_cost_gbp_per_kwh, rel=1e-6
expected_fuel * inputs.space_heating_fuel_cost_gbp_per_kwh, rel=1e-6
)
@ -194,3 +196,27 @@ def test_ecf_uses_table_12_energy_cost_deflator() -> None:
/ (inputs.dimensions.total_floor_area_m2 + 45.0)
)
assert result.ecf == pytest.approx(expected_ecf, rel=1e-6)
def test_split_tariff_charges_space_heating_at_off_peak_rate() -> None:
# Arrange — Economy-7 dwelling: storage-heater space heating at the
# 7h-low rate (~5.5 p/kWh), everything else on standard (13.19 p/kWh).
# Verifies the split-tariff cost line aggregates correctly per SAP §12.
base = _baseline_inputs()
e7 = replace(
base,
space_heating_fuel_cost_gbp_per_kwh=0.055,
hot_water_fuel_cost_gbp_per_kwh=0.1319,
other_fuel_cost_gbp_per_kwh=0.1319,
)
# Act
r_e7 = calculate_sap_from_inputs(e7)
# Assert
expected_cost = (
r_e7.main_heating_fuel_kwh_per_yr * 0.055
+ r_e7.hot_water_kwh_per_yr * 0.1319
+ (r_e7.pumps_fans_kwh_per_yr + r_e7.lighting_kwh_per_yr) * 0.1319
)
assert r_e7.total_fuel_cost_gbp == pytest.approx(expected_cost, rel=1e-6)