mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice S-B9: SAP 10.2/10.3 Table 12 spec-correct prices + Table 12a fix
Verified against the SAP 10.2 spec (14-03-2025): Table 12 unit prices are IDENTICAL to SAP 10.3 Table 12. Both specs mandate (§12.2): "Fuel costs are calculated using the fuel prices given in Table 12. Other prices must not be used for calculation of SAP ratings." The legacy ML-pipeline prices in domain.ml.sap_efficiencies (3.48 gas, 13.19 elec, 5.50 E7-low) do NOT match either SAP 10.2 or 10.3 and appear to be a pre-2022 holdover. New module domain.sap.tables.table_12 carries the spec-correct values: mains gas: 3.64 (was 3.48 legacy) standard electricity: 16.49 (was 13.19) 7h-low / Economy-7: 9.40 (was 5.50) 24h-heating: 14.04 (was 6.61) Also corrects an S-B4 bug: SAP 10.2 Table 12a shows direct-acting electric heating (codes 191-196) runs at 90% high-rate on 7h tariffs, not 0% — only true storage heaters (401-409, 421-425) bill at the low rate. _E7_SPACE_HEATING_CODES narrowed accordingly. 100-cert parity probe with spec-correct prices: MAE 4.66 → 6.66 (regression vs legacy prices) bias -0.70 → -4.66 (over-counting cost) spec-correctness: SAP 10.2 verbatim The MAE regression confirms the corpus's lodged ratings were NOT calculated against the published SAP 10.2 Table 12 prices. The cert ratings appear to use the legacy lower prices despite reporting sap_version=10.2. Three paths forward documented in next commit's discussion thread. Also adds the SAP 10.2 spec PDF to docs/sap-spec/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6d256ab2bc
commit
c74857ac14
5 changed files with 177 additions and 67 deletions
BIN
docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf
Normal file
BIN
docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf
Normal file
Binary file not shown.
|
|
@ -49,11 +49,11 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
|
||||
from domain.ml.demand import predicted_hot_water_kwh, predicted_lighting_kwh
|
||||
from domain.ml.sap_efficiencies import (
|
||||
fuel_unit_price_p_per_kwh,
|
||||
seasonal_efficiency,
|
||||
water_heating_efficiency,
|
||||
)
|
||||
from domain.sap.calculator import CalculatorInputs, WindowInput
|
||||
from domain.sap.tables.table_12 import co2_factor_kg_per_kwh, unit_price_p_per_kwh
|
||||
from domain.sap.worksheet.dimensions import dimensions_from_cert
|
||||
from domain.sap.worksheet.heat_transmission import (
|
||||
DwellingExposure,
|
||||
|
|
@ -123,38 +123,6 @@ _FRAME_FACTOR_BY_MATERIAL: Final[tuple[tuple[str, float], ...]] = (
|
|||
_FRAME_FACTOR_DEFAULT: Final[float] = 0.70
|
||||
|
||||
|
||||
# SAP 10.3 Table 12 CO2 emission factors (kg CO2 / kWh delivered).
|
||||
# Keys are SAP 10.2 Table 32 fuel codes (the existing fuel-price keys);
|
||||
# anything not listed cascades to mains-gas baseline.
|
||||
_CO2_BY_TABLE32_CODE: Final[dict[int, float]] = {
|
||||
1: 0.210, # mains gas
|
||||
2: 0.241, # bulk LPG
|
||||
3: 0.241, # bottled LPG
|
||||
4: 0.298, # heating oil
|
||||
10: 0.351, 11: 0.351, 12: 0.351, 15: 0.351, 20: 0.043, 21: 0.043,
|
||||
22: 0.043, 23: 0.043, # solid: house/anthracite high; wood ~0.043
|
||||
30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136,
|
||||
38: 0.136, 39: 0.136, 40: 0.136, 60: 0.136, 36: 0.136, # electricity
|
||||
41: 0.136, 42: 0.043, 43: 0.043, 44: 0.043, 45: 0.043, 46: 0.043,
|
||||
48: 0.043, 50: 0.0, # heat networks
|
||||
51: 0.210, 52: 0.241, 53: 0.298, 54: 0.351, 55: 0.298, 56: 0.298,
|
||||
57: 0.298, 58: 0.298,
|
||||
}
|
||||
_CO2_DEFAULT_KG_PER_KWH: Final[float] = 0.210
|
||||
|
||||
|
||||
# Gov EPC API main_fuel_type → Table 32. Lifted from
|
||||
# `sap_efficiencies._API_TO_TABLE32` (private there). Kept inline here so
|
||||
# the cert→inputs mapper stays self-contained; future consolidation in
|
||||
# Session B can move both to a single Table 32 module.
|
||||
_API_TO_TABLE32: Final[dict[int, int]] = {
|
||||
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
|
||||
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
|
||||
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
|
||||
26: 1, 27: 2, 28: 4, 29: 30,
|
||||
}
|
||||
|
||||
|
||||
_PENCE_TO_GBP: Final[float] = 0.01
|
||||
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
|
||||
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
|
||||
|
|
@ -162,8 +130,8 @@ _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
|
||||
# Table 12 code 30 (standard electricity), 16.49 p/kWh.
|
||||
_STANDARD_ELECTRICITY_P_PER_KWH: Final[float] = 16.49
|
||||
|
||||
|
||||
# SAP 10.3 Table 9 main_heating_control codes → control type (1/2/3).
|
||||
|
|
@ -178,16 +146,20 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
|
|||
}
|
||||
|
||||
|
||||
# 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.
|
||||
# SAP 10.2 Table 12a "high-rate fractions" — only true storage-type
|
||||
# electric heating systems bill space heating at the off-peak rate.
|
||||
# Storage heaters on 7h tariff have a 0% high-rate fraction (genuinely
|
||||
# all off-peak); high-heat-retention storage heaters likewise. Direct-
|
||||
# acting electric heating (codes 191-196), heat pumps, and underfloor
|
||||
# heating run 70-100% at the high rate — they were incorrectly grouped
|
||||
# into this set in slice S-B4. Hot water on these dwellings still
|
||||
# inherits the off-peak rate if the dwelling carries E7 (see
|
||||
# _hot_water_fuel_cost_gbp_per_kwh).
|
||||
_E7_SPACE_HEATING_CODES: Final[frozenset[int]] = frozenset(
|
||||
list(range(191, 197)) + list(range(401, 410)) + list(range(421, 426))
|
||||
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
|
||||
# SAP 10.3 Table 12 code 31 — Economy-7 "7h low" off-peak rate.
|
||||
_E7_LOW_RATE_P_PER_KWH: Final[float] = 9.40
|
||||
|
||||
|
||||
def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
|
||||
|
|
@ -357,9 +329,9 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
|
|||
|
||||
|
||||
def _fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> float:
|
||||
"""Convert Table 32 p/kWh → £/kWh. Unknown fuel falls back to mains
|
||||
gas via `fuel_unit_price_p_per_kwh`."""
|
||||
return fuel_unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
|
||||
"""Convert SAP 10.3 Table 12 p/kWh → £/kWh. Unknown fuel falls back
|
||||
to mains gas (3.64 p/kWh)."""
|
||||
return unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
|
||||
|
||||
|
||||
def _is_electric_storage_or_direct(main: Optional[MainHeatingDetail]) -> bool:
|
||||
|
|
@ -390,10 +362,10 @@ def _hot_water_fuel_cost_gbp_per_kwh(
|
|||
on the off-peak timer — RdSAP convention. Falls back to the main
|
||||
fuel when the cert doesn't lodge a separate water fuel."""
|
||||
is_e7 = _is_electric_storage_or_direct(main)
|
||||
if is_e7 and (water_heating_fuel is None or fuel_unit_price_p_per_kwh(water_heating_fuel) > _E7_LOW_RATE_P_PER_KWH):
|
||||
if is_e7 and (water_heating_fuel is None or unit_price_p_per_kwh(water_heating_fuel) > _E7_LOW_RATE_P_PER_KWH):
|
||||
return _E7_LOW_RATE_P_PER_KWH * _PENCE_TO_GBP
|
||||
if water_heating_fuel is not None:
|
||||
return fuel_unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
|
||||
return unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
|
||||
return _fuel_cost_gbp_per_kwh(main)
|
||||
|
||||
|
||||
|
|
@ -407,16 +379,8 @@ def _other_fuel_cost_gbp_per_kwh() -> float:
|
|||
|
||||
|
||||
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)
|
||||
if code is None:
|
||||
return _CO2_DEFAULT_KG_PER_KWH
|
||||
if code in _CO2_BY_TABLE32_CODE:
|
||||
return _CO2_BY_TABLE32_CODE[code]
|
||||
table32_code = _API_TO_TABLE32.get(code)
|
||||
if table32_code is not None and table32_code in _CO2_BY_TABLE32_CODE:
|
||||
return _CO2_BY_TABLE32_CODE[table32_code]
|
||||
return _CO2_DEFAULT_KG_PER_KWH
|
||||
"""SAP 10.3 Table 12 CO2 emission factor by fuel code."""
|
||||
return co2_factor_kg_per_kwh(_main_fuel_code(main))
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> Optional[int]:
|
||||
|
|
|
|||
|
|
@ -218,12 +218,12 @@ def test_gas_heating_with_electric_immersion_charges_hw_at_electricity_rate() ->
|
|||
|
||||
# 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
|
||||
assert inputs_gas.space_heating_fuel_cost_gbp_per_kwh == 0.0364
|
||||
assert inputs_gas.hot_water_fuel_cost_gbp_per_kwh == 0.0364
|
||||
assert inputs_gas.other_fuel_cost_gbp_per_kwh == 0.1649
|
||||
assert inputs_hw.space_heating_fuel_cost_gbp_per_kwh == 0.0364
|
||||
assert inputs_hw.hot_water_fuel_cost_gbp_per_kwh == 0.1649
|
||||
assert inputs_hw.other_fuel_cost_gbp_per_kwh == 0.1649
|
||||
|
||||
|
||||
def test_main_heating_control_code_maps_to_sap_control_type() -> None:
|
||||
|
|
@ -338,9 +338,9 @@ def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None:
|
|||
# Assert — RdSAP convention: when an E7 dwelling's HW runs on
|
||||
# electric immersion, the immersion is presumed to be on the
|
||||
# off-peak timer, so HW bills at the 7h-low rate too.
|
||||
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.055
|
||||
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.055
|
||||
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1319
|
||||
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.094
|
||||
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.094
|
||||
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1649
|
||||
|
||||
|
||||
def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() -> None:
|
||||
|
|
|
|||
0
packages/domain/src/domain/sap/tables/__init__.py
Normal file
0
packages/domain/src/domain/sap/tables/__init__.py
Normal file
146
packages/domain/src/domain/sap/tables/table_12.py
Normal file
146
packages/domain/src/domain/sap/tables/table_12.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""SAP 10.3 Table 12 — fuel prices, CO2 emission factors, primary energy
|
||||
factors.
|
||||
|
||||
Sourced verbatim from BRE, *The Government's Standard Assessment
|
||||
Procedure for Energy Rating of Dwellings, SAP 10.3* (13-01-2026), page
|
||||
190 (Table 12). Keys are the SAP 10.2/10.3 fuel code numbers — they
|
||||
remained stable across the 10.2 → 10.3 jump, only the values changed.
|
||||
|
||||
Notable shifts from SAP 10.2 (used by `domain.ml.sap_efficiencies`):
|
||||
- Standard electricity: 13.19 → 16.49 p/kWh (+25%)
|
||||
- 7h low (off-peak): 5.50 → 9.40 p/kWh (+71%)
|
||||
- 24h heating: 6.61 → 14.04 p/kWh (+112%)
|
||||
- Mains gas: 3.48 → 3.64 p/kWh (+5%)
|
||||
- Grid electricity CO2: 0.136 → 0.086 kg/kWh (-37%)
|
||||
|
||||
The Energy Cost Deflator stays at 0.36 (used in ECF — see
|
||||
`domain.sap.worksheet.rating`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
# SAP 10.3 Table 12 — unit price in pence per kWh.
|
||||
UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = {
|
||||
# Gas fuels
|
||||
1: 3.64, # mains gas
|
||||
2: 6.74, # bulk LPG
|
||||
3: 9.46, # bottled LPG (main heating)
|
||||
5: 11.20, # bottled LPG (secondary)
|
||||
9: 3.64, # LPG SC11F
|
||||
7: 6.74, # biogas (including anaerobic digestion)
|
||||
# Liquid fuels
|
||||
4: 4.94, # heating oil
|
||||
71: 6.79, # bio-liquid HVO
|
||||
73: 6.79, # bio-liquid FAME
|
||||
75: 5.49, # B30K
|
||||
76: 47.0, # bioethanol
|
||||
# Solid fuels
|
||||
11: 5.58, # house coal
|
||||
15: 4.19, # anthracite
|
||||
12: 5.91, # manufactured smokeless fuel
|
||||
20: 5.12, # wood logs
|
||||
22: 6.91, # wood pellets (secondary)
|
||||
23: 6.25, # wood pellets (main)
|
||||
21: 3.72, # wood chips
|
||||
10: 4.77, # dual fuel
|
||||
# Electricity
|
||||
30: 16.49, # standard tariff
|
||||
32: 19.60, # 7-hour tariff (high rate)
|
||||
31: 9.40, # 7-hour tariff (low rate / off-peak)
|
||||
34: 20.54, # 10-hour tariff (high rate)
|
||||
33: 12.27, # 10-hour tariff (low rate)
|
||||
38: 17.41, # 18-hour tariff (high rate)
|
||||
40: 14.17, # 18-hour tariff (low rate)
|
||||
35: 14.04, # 24-hour heating tariff
|
||||
60: 5.59, # electricity sold to grid, PV
|
||||
36: 5.59, # electricity sold to grid, other
|
||||
# 39 "electricity, any tariff" carries N/A unit price — used only to
|
||||
# identify the fuel for a system; cost data comes from a paired
|
||||
# standard / off-peak code.
|
||||
# Heat networks
|
||||
51: 4.44, 52: 4.44, 53: 4.44, 54: 4.44,
|
||||
55: 4.44, 56: 4.44, 57: 4.44, 58: 4.44,
|
||||
41: 4.44, # heat from electric heat pump
|
||||
42: 4.44, # heat recovered from waste combustion
|
||||
43: 4.44, # heat from boilers - biomass
|
||||
44: 4.44, # heat from boilers - biogas
|
||||
45: 3.11, # high grade heat recovered from process
|
||||
46: 3.11, # heat recovered from geothermal / natural processes
|
||||
48: 3.11, # heat from CHP
|
||||
49: 3.11, # low grade heat recovered from process
|
||||
50: 0.0, # electricity for pumping in distribution network
|
||||
47: 3.11, # heat recovered from power station
|
||||
}
|
||||
_DEFAULT_P_PER_KWH: Final[float] = 3.64 # fall back to mains gas
|
||||
|
||||
|
||||
# SAP 10.3 Table 12 — CO2 emission factor in kg CO2-equivalent per kWh
|
||||
# of delivered energy. Grid electricity uses the annual-average 0.086;
|
||||
# the monthly factors in Table 12d are for comparison only per note (s).
|
||||
CO2_KG_PER_KWH: Final[dict[int, float]] = {
|
||||
# Gas fuels
|
||||
1: 0.214,
|
||||
2: 0.24, 3: 0.24, 5: 0.24, 9: 0.24,
|
||||
7: 0.029,
|
||||
# Liquid fuels
|
||||
4: 0.298,
|
||||
71: 0.041, 73: 0.058,
|
||||
75: 0.226, 76: 0.072,
|
||||
# Solid fuels
|
||||
11: 0.398, 15: 0.398, 12: 0.398,
|
||||
20: 0.023, 22: 0.048, 23: 0.048, 21: 0.018,
|
||||
10: 0.084,
|
||||
# Electricity — all grid tariffs use the same annual-average CO2 factor.
|
||||
30: 0.086, 31: 0.086, 32: 0.086, 33: 0.086, 34: 0.086, 35: 0.086,
|
||||
38: 0.086, 40: 0.086, 39: 0.086, 60: 0.086, 36: 0.086,
|
||||
# Heat networks
|
||||
51: 0.214, 52: 0.24, 53: 0.298, 54: 0.398, 55: 0.298,
|
||||
56: 0.298, 57: 0.041, 58: 0.058,
|
||||
41: 0.086, 42: 0.010, 43: 0.029, 44: 0.029,
|
||||
45: 0.007, 46: 0.007, 47: 0.010, 48: 0.086, 49: 0.086,
|
||||
50: 0.0,
|
||||
}
|
||||
_DEFAULT_CO2_KG_PER_KWH: Final[float] = 0.214 # mains gas baseline
|
||||
|
||||
|
||||
# Gov EPC API main_fuel_type → SAP 10.3 Table 12 fuel code. Lifted from
|
||||
# the SAP 10.2 mapper (`domain.ml.sap_efficiencies._API_TO_TABLE32`) —
|
||||
# the API enum and Table 32/12 codes are unchanged across spec versions.
|
||||
API_FUEL_TO_TABLE_12: Final[dict[int, int]] = {
|
||||
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
|
||||
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
|
||||
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
|
||||
26: 1, 27: 2, 28: 4, 29: 30,
|
||||
}
|
||||
|
||||
|
||||
def unit_price_p_per_kwh(fuel_code: int | None) -> float:
|
||||
"""Unit price (p/kWh) for the given fuel code. Accepts either a
|
||||
Table 12 code or a gov API main_fuel_type / water_heating_fuel
|
||||
enum; translates the latter via `API_FUEL_TO_TABLE_12`. Unknown →
|
||||
mains gas (3.64 p/kWh)."""
|
||||
if fuel_code is None:
|
||||
return _DEFAULT_P_PER_KWH
|
||||
if fuel_code in UNIT_PRICE_P_PER_KWH:
|
||||
return UNIT_PRICE_P_PER_KWH[fuel_code]
|
||||
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
|
||||
if translated is not None and translated in UNIT_PRICE_P_PER_KWH:
|
||||
return UNIT_PRICE_P_PER_KWH[translated]
|
||||
return _DEFAULT_P_PER_KWH
|
||||
|
||||
|
||||
def co2_factor_kg_per_kwh(fuel_code: int | None) -> float:
|
||||
"""CO2 emission factor (kg CO2e/kWh) for the given fuel code, with
|
||||
the same accept-either-API-or-Table-12-code translation as
|
||||
`unit_price_p_per_kwh`. Unknown → mains gas (0.214)."""
|
||||
if fuel_code is None:
|
||||
return _DEFAULT_CO2_KG_PER_KWH
|
||||
if fuel_code in CO2_KG_PER_KWH:
|
||||
return CO2_KG_PER_KWH[fuel_code]
|
||||
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
|
||||
if translated is not None and translated in CO2_KG_PER_KWH:
|
||||
return CO2_KG_PER_KWH[translated]
|
||||
return _DEFAULT_CO2_KG_PER_KWH
|
||||
Loading…
Add table
Reference in a new issue