Model/domain/sap10_calculator/tables/table_32.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

313 lines
14 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.

"""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.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,
}
# 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).
# DEFERRED (not included): API 9 = dual fuel (mineral + wood) is also a
# collision (Table-32 9 = LPG SC11F 3.48 p vs dual fuel 3.99 p) but the
# 0.45 p delta nets neutral-to-negative on the (outlier-dominated)
# dual-fuel certs and shifts them in a direction not yet understood —
# investigate separately.
#
# 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, 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`. Unknown →
mains gas (3.48 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_32.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 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