Model/domain/sap10_calculator/tables/table_32.py
Jun-te Kim 2498192b72 docs(fuel): TODO to price code-39 electricity by actual tariff (Khalim)
Note that code 39 "any tariff" currently collapses to the standard electricity
rate; future work should resolve it to the dwelling's actual tariff (off-peak vs
standard) so off-peak electric heating prices correctly. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:35:31 +00:00

338 lines
15 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.

"""RdSAP10 Table 32 — fuel prices, standing charges, PV export credit.
Sourced verbatim from `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`,
page 95 (Table 32). RdSAP10 §19.1: SAP rating for RdSAP10 is calculated
using Table 32 prices (not Table 12) for §10a and §10b. The calculator
targets RdSAP10 cost per ADR-0010 amendment.
CO2 emission factors and primary energy factors are unchanged from
SAP10.2 Table 12 (RdSAP10 §19.2), so they continue to live in
`domain.sap10_calculator.tables.table_12` rather than being duplicated here.
Heating-oil (code 4) is a documented divergence from the published spec
PDF — see the note on the dict entry below.
"""
from __future__ import annotations
from typing import Final, Optional
from domain.sap10_calculator.exceptions import UnpricedFuelCode
from domain.sap10_calculator.tables.table_12a import Tariff
_DEFAULT_P_PER_KWH: Final[float] = 3.48 # fall back to mains gas
# RdSAP10 Table 32 — unit price in pence per kWh, sourced verbatim from
# PDF page 95.
UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = {
# Gas fuels
1: 3.48, # mains gas
2: 7.60, # bulk LPG
3: 10.30, # bottled LPG (main heating)
5: 12.19, # bottled LPG (secondary)
9: 3.48, # LPG SC11F
7: 7.60, # biogas (including anaerobic digestion)
# Liquid fuels
#
# Slice S0380.131 — heating oil (code 4): operationally-canonical
# 5.44 p/kWh, not the 7.64 published in the RdSAP 10 Specification
# 10-06-2025 PDF Table 32 (p.95). The spec PDF value is the outlier;
# two independent implementations agree on 5.44:
# - Elmhurst P960 worksheets (fuel cost row, line ref (240) "Space
# heating - main system 1") for variants oil 1, oil pcdb 1/2/3,
# pcdb 1 in `sap worksheets/heating systems examples/` — every
# "FuelType: Heating oil" worksheet lodges 5.4400 p/kWh.
# - The gov.uk EPC register's lodging software back-solves to
# ~5.48 p/kWh from cert 0240-0200-5706-2365-8010's lodged SAP
# 73 (an oil + PV detached at age J), and with 5.44 in the
# cascade this cert closes to ΔSAP = 0 exactly against its
# lodged value.
# BRE technical papers (`docs/specs/sap10 technical papers/`) carry
# no Table 32 errata or fuel-price update, so the change is grounded
# in empirical cross-source evidence rather than a spec citation.
# FAME (code 73) shows the inverse pattern on oil 3/4 worksheets:
# the RdSAP 10 Spec PDF Table 32 lists 5.44 p/kWh but worksheet
# (240) "Space heating - main system 1" for variants oil 3 (EES
# BXE, SAP 128) + oil 4 (EES BXF, SAP 129) lodges 7.64. Slice
# S0380.168 flipped 5.44 → 7.64 to match the worksheet — same
# empirical-divergence justification as the .131 heating-oil flip;
# the Elmhurst engine is the canonical reference per
# [[feedback-software-no-special-handling]].
4: 5.44, # heating oil — see comment above (Slice S0380.131)
71: 7.64, # bio-liquid HVO
73: 7.64, # bio-liquid FAME — Slice S0380.168 flip (5.44 → 7.64)
75: 6.10, # B30K
76: 47.0, # bioethanol
# Solid fuels
11: 3.67, # house coal
15: 3.64, # anthracite
12: 4.61, # manufactured smokeless fuel
20: 4.23, # wood logs
22: 5.81, # wood pellets (secondary)
23: 5.26, # wood pellets (main)
21: 3.07, # wood chips
10: 3.99, # dual fuel
# Electricity
30: 13.19, # standard tariff
32: 15.29, # 7-hour tariff (high rate)
31: 5.50, # 7-hour tariff (low rate / off-peak)
34: 14.68, # 10-hour tariff (high rate)
33: 7.50, # 10-hour tariff (low rate)
38: 13.67, # 18-hour tariff (high rate)
40: 7.41, # 18-hour tariff (low rate)
35: 6.61, # 24-hour heating tariff
60: 13.19, # electricity sold to grid, PV
# Heat networks
51: 4.24, 52: 4.24, 53: 4.24, 54: 4.24,
55: 4.24, 56: 4.24, 57: 4.24, 58: 4.24,
41: 4.24, # heat from electric heat pump
42: 4.24, # heat recovered from waste combustion
43: 4.24, # heat from boilers - biomass
44: 4.24, # heat from boilers - biogas
45: 2.97, # heat recovered from power station
46: 2.97, # low grade heat recovered from process
47: 2.97, # heat recovered from geothermal / natural
48: 2.97, # heat from CHP
49: 2.97, # high grade heat recovered from process
}
# Gov EPC API main_fuel_type / water_heating_fuel → RdSAP10 Table 32 fuel
# code. Same shape as `table_12.API_FUEL_TO_TABLE_12` — the API enum is
# unchanged across SAP10.2 ↔ RdSAP10.
API_FUEL_TO_TABLE_32: 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,
# SAP Table 12 code 39 = "electricity, any tariff" (epc_codes.csv main_fuel
# 39 = "electricity, unspecified tariff"; spec footnote (j): defines an
# electric system, cost/CO2/PE = standard electricity). Resolve to standard
# electricity (30) so it classifies as electric and prices at the standard
# rate — otherwise it now raises UnpricedFuelCode (was: mis-rated non-electric).
# TODO (Khalim): "any tariff" collapses to the STANDARD electricity rate. In
# future, resolve 39 to the dwelling's actual tariff (meter_type → off-peak
# 7h/10h/18h/24h vs standard) so off-peak electric heating is priced at the
# off-peak rate rather than always standard.
39: 30,
}
# Gov-API `main_fuel_type` enum codes whose value COLLIDES with a
# same-valued Table 32 code of a DIFFERENT fuel. The gov EPC register
# always lodges the API enum, so for these the API translation is
# authoritative and must win over the direct same-value Table-code
# lookup (which otherwise mis-prices solid fuel at the colliding code's
# rate). Confirmed by the description-vs-code audit on
# `main_heating[].description`:
# 5 = anthracite — Table-32 code 5 is bulk LPG (secondary), 12.19 p
# vs anthracite 3.64 p. Drove the cohort's worst cert (2100,
# -61 SAP at the LPG rate).
# 33 = coal — Table-32 code 33 is the electricity 10-hour low rate
# 7.5 p vs house coal 3.67 p (and `is_electric_fuel_code(33)`
# wrongly classified the coal main as electric).
# 9 = dual fuel (mineral + wood) — Table-32 code 9 is LPG SC11F
# 3.48 p vs dual fuel 3.99 p. The gov-API lodges API enum 9 for a
# dual-fuel appliance (description "Room heaters, dual fuel
# (mineral and wood)"), but the same-value Table-32 lookup returns
# LPG 3.48 p, under-pricing the (mostly secondary) dual-fuel heat.
# A prior session deferred this as "direction not understood"
# while the EPC PE/CO2 lens was confounded by the climate-cascade
# bug (fixed in fc7c4d2d); on the corrected lens the dual-fuel
# secondary cohort over-rates (SAP too high = cost too low) by
# +0.55 signed, and pricing UP to the dual-fuel 3.99 p row reduces
# that over-rate — the correct direction.
#
# COMMUNITY FUELS (handled elsewhere, NOT here): API 30 (waste
# combustion), 31 (biomass) and 32 (biogas) — all "(community)" in the
# enum — collide in VALUE with the Table-32 electricity codes 30 (standard
# rate), 31 (7-hour low) and 32 (7-hour high). They must NOT be
# canonicalised globally: the cascade uses the bare Table-32 code 30
# internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (e.g. the RdSAP
# no-water-heating immersion default writes `water_heating_fuel=30`), so a
# blanket remap would mis-price genuine grid electricity as community
# waste. The translation is therefore done at the fuel-TYPE boundary
# GATED on heat-network context (`_heat_network_community_fuel_code` in
# cert_to_inputs), where the community meaning is unambiguous. Community
# fuels 20/25 do not collide with an electricity code, so they resolve
# correctly through the heat-network path without any special handling.
_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 9, 33})
def canonical_fuel_code(fuel_code: Optional[int]) -> Optional[int]:
"""Normalise a colliding gov-API fuel enum (see
`_GOV_API_COLLISION_FUELS`) to its canonical Table 32 code so the
same-value collision can't mis-resolve it. Non-colliding codes and
already-canonical Table codes pass through unchanged."""
if fuel_code in _GOV_API_COLLISION_FUELS:
return API_FUEL_TO_TABLE_32.get(fuel_code, fuel_code)
return fuel_code
# RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel
# code. Only fuels with a published standing charge appear here;
# unlisted codes default to £0/yr. Application of these charges to
# (251) is gated by Table 12 note (a).
STANDING_CHARGE_GBP_PER_YR: Final[dict[int, float]] = {
# Gas fuels
1: 120.0, # mains gas
2: 70.0, # bulk LPG
9: 120.0, # LPG SC11F
7: 70.0, # biogas
# Electricity (high-rate codes carry the off-peak meter standing)
30: 54.0, # standard tariff
32: 24.0, # 7-hour high rate
34: 23.0, # 10-hour high rate
38: 40.0, # 18-hour high rate
35: 70.0, # 24-hour heating tariff
# Heat networks — Table 32 note (l): include half (£60/yr) if only
# DHW provided by heat network. Raw row carries £120/yr.
51: 120.0,
}
def unit_price_p_per_kwh(fuel_code: Optional[int]) -> float:
"""Unit price (p/kWh) for the given fuel code. Accepts either a
Table 32 code or a gov API `main_fuel_type` / `water_heating_fuel`
enum; translates the latter via `API_FUEL_TO_TABLE_32`.
`None` (no fuel lodged) → mains-gas default; callers resolve a
"no system" before pricing. A concrete but UNRECOGNISED code raises
`UnpricedFuelCode` rather than silently defaulting to the gas price —
an unhandled fuel billed at 3.48 p/kWh mis-costs the dwelling (the
same failure mode as the dual-main wood-vs-electric over-cost). The
strict-raise surfaces the gap at the price boundary; 0 corpus certs
hit it today (every lodged fuel resolves), so the raise is a guard
against future / unmapped fuels, mirroring `MissingMainFuelType`."""
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_32.get(fuel_code)
if translated is not None and translated in UNIT_PRICE_P_PER_KWH:
return UNIT_PRICE_P_PER_KWH[translated]
raise UnpricedFuelCode(fuel_code)
def standing_charge_gbp(fuel_code: Optional[int]) -> float:
"""Annual standing charge (£/yr) for the given Table 32 fuel code.
Fuels without a published standing charge return 0.0. Application
to (251) is gated by `additional_standing_charges_gbp` per Table 12
note (a)."""
if fuel_code is None:
return 0.0
if fuel_code in STANDING_CHARGE_GBP_PER_YR:
return STANDING_CHARGE_GBP_PER_YR[fuel_code]
# Only translate via API enum when fuel_code isn't already a known
# Table 32 code — wood logs (Table 32 code 20) collides with the API
# enum value 20 (heat networks) and must not be translated.
if fuel_code in UNIT_PRICE_P_PER_KWH:
return 0.0
translated = API_FUEL_TO_TABLE_32.get(fuel_code)
if translated is not None and translated in STANDING_CHARGE_GBP_PER_YR:
return STANDING_CHARGE_GBP_PER_YR[translated]
return 0.0
# Gas Table 32 codes (after API enum translation).
_GAS_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 2, 3, 5, 9, 7})
# Electricity Table 32 codes (after API enum translation).
_ELECTRIC_FUEL_CODES: Final[frozenset[int]] = frozenset(
{30, 31, 32, 33, 34, 35, 38, 40, 60}
)
# Liquid fuel Table 32 codes (oil + bioliquids) after API enum
# translation. Drawn from Table 32 PDF p.95 rows:
# 4 heating oil
# 71 bio-liquid HVO
# 73 bio-liquid FAME
# 75 B30K
# 76 bioethanol
# LPG is treated as GAS (its own rows 2/3/5/9) and is NOT in this set.
_LIQUID_FUEL_CODES: Final[frozenset[int]] = frozenset({4, 71, 73, 75, 76})
# Off-peak tariff → high-rate Table 32 code (the row carrying the
# off-peak meter standing per Table 32 PDF page 95).
_OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = {
Tariff.SEVEN_HOUR: 32,
Tariff.TEN_HOUR: 34,
Tariff.EIGHTEEN_HOUR: 38,
Tariff.TWENTY_FOUR_HOUR: 35,
}
def to_table_32_code(fuel_code: Optional[int]) -> Optional[int]:
"""Normalise a fuel code (Table 32 or API enum) to its Table 32 form."""
if fuel_code is None:
return None
if fuel_code in UNIT_PRICE_P_PER_KWH:
return fuel_code
return API_FUEL_TO_TABLE_32.get(fuel_code)
def _is_gas_code(fuel_code: Optional[int]) -> bool:
code = to_table_32_code(fuel_code)
return code is not None and code in _GAS_FUEL_CODES
def is_electric_fuel_code(fuel_code: Optional[int]) -> bool:
"""Whether the fuel code maps to a Table 32 electricity row, after
normalising via T32-first then API-translate fallback.
Use this in preference to ad-hoc literal-set checks like
`code in {10, 25, 29}`: those mix API enum codes (where 10 is
"electricity backwards-compat") and Table 32 codes (where 10 is
"dual fuel mineral+wood"), so a Table-32-code-10 dual-fuel main
silently mis-classifies as electric. The S0380.135 EES-code →
Table 32 mapper lookups set `main_fuel_type` to Table 32 codes
(BDI → 10 = dual fuel), so the literal-set checks fail loudly here
unless normalised through `to_table_32_code` first.
"""
code = to_table_32_code(fuel_code)
return code is not None and code in _ELECTRIC_FUEL_CODES
def is_liquid_fuel_code(fuel_code: Optional[int]) -> bool:
"""Whether the fuel code maps to a Table 32 liquid fuel row
(heating oil + bioliquids), after T32-first / API-translate
normalisation. Mirrors `is_electric_fuel_code`. Used by SAP 10.2
Table 4f (PDF p.174) "Liquid fuel boiler flue fan and fuel
pump" (100 kWh/yr) gate.
LPG is treated as GAS by Table 4f (separate "Gas boiler" row,
45 kWh/yr) — `is_liquid_fuel_code` returns False for LPG codes.
"""
code = to_table_32_code(fuel_code)
return code is not None and code in _LIQUID_FUEL_CODES
def additional_standing_charges_gbp(
*,
main_fuel_code: Optional[int],
water_heating_fuel_code: Optional[int],
tariff: Tariff,
) -> float:
"""SAP rating (regulated) standing-charge total for (251), gated per
Table 12 note (a):
- Std electricity standing → omitted
- Off-peak electricity standing → added if either main heating or
hot water uses off-peak electricity. Standing lives on the high-
rate Table 32 code for the tariff in use.
- Gas standing → added if gas is used for space (main or secondary)
or water heating.
"""
total = 0.0
if _is_gas_code(main_fuel_code) or _is_gas_code(water_heating_fuel_code):
# Pick whichever gas code is in use, preferring main heating.
gas_code = main_fuel_code if _is_gas_code(main_fuel_code) else water_heating_fuel_code
total += standing_charge_gbp(gas_code)
if tariff is not Tariff.STANDARD and (
is_electric_fuel_code(main_fuel_code) or is_electric_fuel_code(water_heating_fuel_code)
):
off_peak_code = _OFF_PEAK_STANDING_CODE.get(tariff)
if off_peak_code is not None:
total += standing_charge_gbp(off_peak_code)
return total