Model/domain/sap10_calculator/tables/table_12.py
Khalim Conn-Kowlessar 68401c517a refactor: lift-and-shift packages/domain/src/domain/ml → domain/sap10_ml
Sibling migration to the sap10_calculator move — `domain.ml` now lives
at the root-level layout (`domain/sap10_ml/`) matching the pattern
already used by `domain.addresses`, `domain.tasks`, `domain.postcode`,
and `domain.sap10_calculator`.

Changes:

- `git mv packages/domain/src/domain/ml → domain/sap10_ml` (19 files;
  history preserved).
- Subpackage rename: `domain.ml` → `domain.sap10_ml`. 32 references
  rewritten across .py and .md files: 11 internal + 21 external
  (datatypes/epc/domain/mapper.py, 14 files in domain/sap10_calculator,
  2 backend tests, 2 ADRs, 1 README, 1 design doc).
- Path-string updates: `pytest.ini` testpath
  `packages/domain/src/domain/ml/tests` → `domain/sap10_ml/tests` so
  ML tests stay in the default auto-discovered sweep. `CONTEXT.md`
  also updated.

`packages/domain/src/domain/` is now empty — the workspace `domain/`
tree has been fully migrated. Together with the `domain/__init__.py`
deletions from the sap10_calculator commit (29ac35cc), `domain` is
now a single root-level namespace package with subpackages
{addresses, sap10_calculator, sap10_ml, tasks} + the standalone
`postcode.py` module.

Verified:

- Focused sweep (backend mapper-chain + sap10_calculator worksheet
  e2e + golden fixtures): 99 passed / 19 failed — identical baseline.
- Wider sweep (all sap10_calculator + sap10_ml): 1654 passed / 20
  failed (same pre-existing failures).
- domain/sap10_ml/tests: 210/210 PASSED at new path.
- Pyright net-zero: heat_transmission.py 13, cert_to_inputs.py 35,
  mapper.py 33, rdsap_uvalues.py 1 (all unchanged from baseline).

Note: `packages/domain/pyproject.toml` still declares
`packages = ["src/domain"]` for the hatchling wheel — that target
directory is now empty and the wheel build is effectively a no-op.
Retiring the workspace package or repointing the wheel is a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:01:35 +00:00

282 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
51: 0.210, 52: 0.241, 53: 0.298, 54: 0.375, 55: 0.269,
56: 0.298, 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