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:
Khalim Conn-Kowlessar 2026-05-18 15:14:11 +00:00
parent 6d256ab2bc
commit c74857ac14
5 changed files with 177 additions and 67 deletions

View file

@ -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]:

View file

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

View 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