Model/domain/sap10_calculator/tables/table_12.py
Khalim Conn-Kowlessar a7761ea83f fix(fuel): map gov-API community fuels 30/31/32 (waste/biomass/biogas) to Table-12 community rows, gated on heat-network context
The gov-API `main_fuel_type`/`water_heating_fuel` enum (epc_codes.csv)
codes 30="waste combustion (community)", 31="biomass (community)",
32="biogas (community)" collide in VALUE with the Table-32 electricity
codes 30 (standard rate), 31 (7-hour low) and 32 (7-hour high). All three
sit in `_ELECTRIC_FUEL_CODES`, so `is_electric_fuel_code` flagged a
community-scheme main as electric and `_is_electric_main` routed its cost
through the off-peak electricity branch — BYPASSING the heat-network rate
in `_heat_network_factor_fuel_code`. Cert 8536 (biomass community, SAP
code 301) was billing at 5.5 p/kWh grid electricity instead of the 4.24
p/kWh heat-network rate → -17.2 SAP.

Per RdSAP 10 §C / SAP 10.2 Table 12 (PDF p.191) the community
waste/biomass/biogas rows are codes 42/43/44 (the same rows the
backwards-compat enum codes 11/12/13 already map to). Add 30->42, 31->43,
32->44 to both API fuel-translation tables.

The remap CANNOT be global (`canonical_fuel_code`): the cascade uses the
bare Table-32 code 30 internally as `_STANDARD_ELECTRICITY_FUEL_CODE`
(the RdSAP no-water-heating immersion default writes
`water_heating_fuel=30`), so a blanket remap mis-prices genuine grid
electricity as community waste (cert 2211 regressed +16 SAP in a
prototype). Instead `_heat_network_community_fuel_code` translates only
when `_is_heat_network_main` is true, at the `_main_fuel_code` /
`_water_heating_fuel_code` fuel-TYPE boundary, where the community
meaning is unambiguous.

Per the strict-raise principle ([[reference-unmapped-sap-code]]), a
heat-network main lodging a colliding community fuel the table doesn't
cover raises `UnmappedSapCode` rather than silently falling through to
the same-numbered electricity code.

Eval (API SAP vs lodged): cert 8536 -17.25 -> -6.51, cert 5036 -6.29 ->
+1.36; mean|err| 1.329 -> 1.312, within-1.0 67.88% -> 67.99%,
within-2.0 81.74% -> 81.85%, within-0.5 held at 53.14%, 909 computed /
0 raises. No golden / calculator regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:55:48 +00:00

288 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SAP 10.2 (14-03-2025 amendment) 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.2* (14-03-2025), page
189 (Table 12). Keys are the SAP 10.2/10.3 fuel code numbers — they
remained stable across the 10.2 → 10.3 jump.
The calculator targets SAP 10.2 per ADR-0010 because no SAP-10.3-lodged
certs exist in the corpus to validate against. SAP 10.3 differs from
SAP 10.2 mainly on CO2 factors (grid electricity 0.136 → 0.086 kg/kWh,
37%; mains gas 0.210 → 0.214 kg/kWh, +2%); prices and primary energy
factors are largely unchanged. When the corpus migrates to SAP 10.3
this module re-points to those values.
The Energy Cost Deflator stays at 0.36 (used in ECF — see
`domain.sap10_calculator.worksheet.rating`).
"""
from __future__ import annotations
from typing import Final, Optional
# 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.2 Table 12 — annual-average CO2 emission factor in kg CO2-
# equivalent per kWh of delivered energy. For ELECTRICITY end-uses,
# Table 12d (above) overrides this annual factor with monthly values per
# the spec text on p.194; the value here is the legacy fallback when
# monthly distribution isn't available.
# SAP 10.2 Table 12d (p.194) — monthly variation in CO2 emission factors
# for electricity. The spec text: "Where electricity is the fuel used, the
# relevant set of factors in the table below should be used to calculate
# the monthly CO2 emissions INSTEAD of the annual average factor given in
# Table 12." So for ratings, electricity end-uses use Σ(kWh_m × CO2_m)
# rather than annual_kwh × annual_factor.
CO2_KG_PER_KWH_MONTHLY: Final[dict[int, tuple[float, ...]]] = {
# Standard tariff (default electricity)
30: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# 7-hour tariff
32: (0.171, 0.168, 0.161, 0.150, 0.138, 0.125, 0.117, 0.118, 0.128, 0.143, 0.158, 0.171),
31: (0.143, 0.141, 0.135, 0.126, 0.116, 0.105, 0.098, 0.099, 0.107, 0.120, 0.133, 0.144),
# 10-hour tariff
34: (0.168, 0.165, 0.159, 0.148, 0.136, 0.124, 0.115, 0.116, 0.126, 0.141, 0.156, 0.168),
33: (0.155, 0.153, 0.146, 0.137, 0.126, 0.114, 0.106, 0.107, 0.116, 0.130, 0.144, 0.155),
# 18-hour tariff (matches standard tariff)
38: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
40: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# 24-hour heating tariff
35: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# Electricity sold to grid (PV)
60: (0.196, 0.190, 0.175, 0.153, 0.129, 0.106, 0.092, 0.093, 0.110, 0.138, 0.169, 0.197),
# Electricity sold to grid, other
36: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# Electricity, any tariff
39: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# Heat from electric heat pump
41: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# Low-grade heat recovered from process
49: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
# Electricity for pumping in distribution network
50: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
}
def co2_monthly_factors_kg_per_kwh(fuel_code: int | None) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Table 12d (p.194) monthly CO2 factors for electricity. Returns
None for non-electricity fuels (use the annual `co2_factor_kg_per_kwh`)."""
if fuel_code is None:
return None
if fuel_code in CO2_KG_PER_KWH_MONTHLY:
return CO2_KG_PER_KWH_MONTHLY[fuel_code]
return None
# SAP 10.2 Table 12e (p.195) — monthly variation in PE (primary energy)
# emission factors for electricity. Spec text: "Where electricity is the
# fuel used, the relevant set of factors in the table below should be
# used to calculate the monthly primary energy instead the annual average
# factor given in Table 12." Same shape as Table 12d (CO2): electricity
# end-uses use Σ(kWh_m × PE_m); gas/non-electricity fuels keep the
# annual Table 12 PE factor.
PE_FACTOR_MONTHLY: Final[dict[int, tuple[float, ...]]] = {
# Standard tariff
30: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# 7-hour tariff
32: (1.635, 1.626, 1.600, 1.562, 1.518, 1.471, 1.440, 1.443, 1.479, 1.535, 1.591, 1.637),
31: (1.521, 1.512, 1.488, 1.453, 1.411, 1.368, 1.339, 1.342, 1.376, 1.428, 1.480, 1.522),
# 10-hour tariff
34: (1.625, 1.615, 1.590, 1.552, 1.507, 1.462, 1.430, 1.433, 1.470, 1.525, 1.580, 1.626),
33: (1.571, 1.561, 1.537, 1.500, 1.457, 1.413, 1.382, 1.386, 1.421, 1.474, 1.528, 1.572),
# 18-hour tariff (matches standard tariff)
38: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
40: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# 24-hour heating tariff
35: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Electricity sold to grid (PV) — note (i): deducted, low PE factor
60: (0.715, 0.697, 0.645, 0.567, 0.478, 0.389, 0.330, 0.336, 0.405, 0.513, 0.623, 0.718),
# Electricity sold to grid, other
36: (0.602, 0.593, 0.568, 0.530, 0.487, 0.441, 0.410, 0.413, 0.449, 0.504, 0.558, 0.604),
# Electricity, any tariff
39: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Heat from electric heat pump
41: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Low-grade heat recovered from process
49: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Electricity for pumping in distribution network
50: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
}
def pe_monthly_factors_kwh_per_kwh(
fuel_code: int | None,
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Table 12e (p.195) monthly PE factors for electricity. Returns
None for non-electricity fuels (use the annual `primary_energy_factor`)."""
if fuel_code is None:
return None
if fuel_code in PE_FACTOR_MONTHLY:
return PE_FACTOR_MONTHLY[fuel_code]
return None
CO2_KG_PER_KWH: Final[dict[int, float]] = {
# Gas fuels
1: 0.210,
2: 0.241, 3: 0.241, 5: 0.241, 9: 0.241,
7: 0.024,
# Liquid fuels
4: 0.298,
71: 0.036, 73: 0.018,
75: 0.214, 76: 0.105,
# Solid fuels
11: 0.395, 15: 0.395, 12: 0.366,
20: 0.028, 22: 0.053, 23: 0.053, 21: 0.023,
10: 0.087,
# Electricity — all grid tariffs use the same annual-average CO2 factor.
30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136,
38: 0.136, 40: 0.136, 39: 0.136, 60: 0.136, 36: 0.136,
# Heat networks
# Heat-network oil (code 53 "assumes 'gas oil'") and mineral-oil/
# biodiesel boilers (code 56) carry 0.335 kg CO2/kWh per SAP 10.2
# Table 12 (p.189) — NOT the individual-appliance heating-oil factor
# (code 4 = 0.298). (Fixed in S0380.182 when the code-302 CHP CO2
# cascade first exercised heat-network oil; PE 1.180 was already
# correct.)
51: 0.210, 52: 0.241, 53: 0.335, 54: 0.375, 55: 0.269,
56: 0.335, 57: 0.036, 58: 0.018,
41: 0.136, 42: 0.015, 43: 0.029, 44: 0.024,
45: 0.015, 46: 0.011, 47: 0.011, 48: 0.136, 49: 0.136,
50: 0.0,
}
_DEFAULT_CO2_KG_PER_KWH: Final[float] = 0.210 # mains gas baseline
# Gov EPC API main_fuel_type → SAP 10.3 Table 12 fuel code. Lifted from
# the SAP 10.2 mapper (`domain.sap10_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, 30: 42, 31: 43, 32: 44, 33: 11,
}
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
# SAP 10.2 Table 12 "Primary energy factor" column. The cert's
# `energy_consumption_current` field (PEUI) is delivered energy times
# this factor per fuel, summed across end-uses, divided by TFA.
PRIMARY_ENERGY_FACTOR: Final[dict[int, float]] = {
# Gas
1: 1.130,
2: 1.141, 3: 1.141, 5: 1.133, 9: 1.163,
7: 1.286,
# Liquid
4: 1.180,
71: 1.180, 73: 1.180, 75: 1.136, 76: 1.472,
# Solid
11: 1.064, 15: 1.064, 12: 1.261, 20: 1.046,
22: 1.325, 23: 1.325, 21: 1.046, 10: 1.049,
# Electricity — all grid tariffs same PEF.
30: 1.501, 31: 1.501, 32: 1.501, 33: 1.501, 34: 1.501, 35: 1.501,
38: 1.501, 40: 1.501, 39: 1.501, 60: 0.501, 36: 0.501,
# Heat networks (sample — main values; less common)
51: 1.130, 52: 1.141, 53: 1.180, 54: 1.064, 55: 1.180,
56: 1.180, 57: 1.180, 58: 1.180,
41: 1.501, 42: 0.063, 43: 1.037, 44: 1.286,
45: 0.051, 46: 0.051, 47: 0.063, 48: 1.501, 49: 1.501,
50: 0.0,
}
_DEFAULT_PEF: Final[float] = 1.130 # mains gas baseline
def primary_energy_factor(fuel_code: int | None) -> float:
"""Primary energy factor for the given fuel code, accepting either
Table 12 code or gov API enum (translated). Unknown → mains gas
(1.13)."""
if fuel_code is None:
return _DEFAULT_PEF
if fuel_code in PRIMARY_ENERGY_FACTOR:
return PRIMARY_ENERGY_FACTOR[fuel_code]
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
if translated is not None and translated in PRIMARY_ENERGY_FACTOR:
return PRIMARY_ENERGY_FACTOR[translated]
return _DEFAULT_PEF
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