diff --git a/docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf b/docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf new file mode 100644 index 00000000..fa5a32e9 Binary files /dev/null and b/docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf differ diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 38df234e..6915583c 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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]: diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 87e6c68a..708fd4b6 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -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: diff --git a/packages/domain/src/domain/sap/tables/__init__.py b/packages/domain/src/domain/sap/tables/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/tables/table_12.py b/packages/domain/src/domain/sap/tables/table_12.py new file mode 100644 index 00000000..398b1a79 --- /dev/null +++ b/packages/domain/src/domain/sap/tables/table_12.py @@ -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