mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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>
338 lines
15 KiB
Python
338 lines
15 KiB
Python
"""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
|