Model/domain/sap10_calculator/tables/table_12.py
Khalim Conn-Kowlessar 8e86de2257 S0380.182: community-heating CHP+boilers CO2/PE credit (§12b/13b) — closes CH2/CH4 CO2+PE
SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating
"CHP and boilers" (SAP code 302). Per unit of network heat fuel
H = (307)+(310) the effective generation factor is:

  chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel

  (363)/(463) CHP fuel      = chp_frac × 100/heat_eff × f_fuel
  (364)/(464) less credit   = −chp_frac × elec_eff/heat_eff × f_disp
  (368)/(468) boiler fuel   = (1−chp_frac) × 100/boiler_eff × f_fuel

f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up
boilers burn the same community fuel — verified vs CH2 gas / CH4 oil /
CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit
for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff
50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac
0.35 per-cert via community_heating_chp_fraction.

New `_heat_network_code_302_effective_factor` + Table 12f flexible
constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired
into all four factor helpers (main + HW, CO2 + PE) ahead of the existing
single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468)
boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the
(373)/(473)/(386)/(486) totals reconcile only with the boiler at the
full Table 12 factor — verified EXACT.

Two spec citations applied:
- Table 12f flexible-operation default for RdSAP community CHP is an
  Elmhurst engine choice (Table 12f notes make "standard" the default);
  mirrored per [[feedback-software-no-special-handling]] and documented
  in SAP_CALCULATOR.md §8.3.
- Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected
  0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302
  oil cascade (CH4) was the first to exercise it. PE 1.180 was already
  correct. No other variant uses these codes (no regression).

Closures (CO2 + PE only — the CHP credit does not touch cost/SAP):
  CH2 (CHP/Gas)  CO2 −1411.49→+0.0000, PE +1331.23→+0.0000  EXACT
  CH4 (CHP/Oil)  CO2 −4378.24→−0.0000, PE  +319.81→−0.0000  EXACT
  CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet
                 lodges a manual DLF=1.0 the Summary doesn't carry, so
                 cascade DLF=1.45 over-scales H; same root as the CH6
                 SAP −7.49 / cost +£172 (separate DLF front).

CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP
residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side,
untouched here). CH3 unchanged (code 304 community-HP COP front).

Corpus state: 37 variants EXACT on all four metrics (incl. CH1);
remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6
all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all
metrics per S0380.181); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:23:17 +00:00

288 lines
13 KiB
Python
Raw 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,
}
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