mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
ccdaba5acd
commit
8e1d30c97d
4 changed files with 126 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue