mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Mapper extensions (`_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`):
"BFD": 71, # HVO — corpus variant oil 2 (SAP 127)
"BXE": 73, # FAME — corpus variant oil 3 (SAP 128)
"BXF": 73, # FAME alt — corpus variant oil 4 (SAP 129)
"BZC": 76, # Bioethanol — corpus variant oil 5 (SAP 126)
"B3C": 75, # B30K — corpus variant oil 6 (SAP 126)
`_ELMHURST_MAIN_FUEL_TO_SAP10` water-side labels:
"Bio-liquid HVO from used cooking oil": 71,
"Bio-liquid FAME from animal/vegetable oils": 73,
"Bioethanol": 76,
"B30K": 75,
Values are direct Table 32 codes (the bio-liquid codes 71/73/75/76
don't collide with any API enum value so they pass through
`unit_price_p_per_kwh` etc. unchanged). Spec: SAP 10.2 Table 12
(PDF p.189) notes (d)/(e)/(f).
Pre-slice all 5 oil 2-6 variants raised `MissingMainFuelType` per
S0380.132. Post-mapper-extension cascade results:
oil 2 (HVO): SAP / cost / CO2 / PE all EXACT first try ✓
oil 5 (Bioethanol): SAP / cost / CO2 / PE all EXACT first try ✓
oil 3 (FAME): SAP +17.34, cost −£398
oil 4 (FAME alt): SAP +16.06, cost −£367
oil 6 (B30K): SAP +3.05, cost −£70
Slice S0380.131 had left a deferred TODO in `table_32.py` for FAME
code 73 ("worksheet 7.64 vs spec 5.44 — flipping has no measurable
cascade effect today, deferred until a cert that exercises it
surfaces"). Now exercised — flipping `73: 5.44 → 7.64` closes 85 %
of the oil 3/4 cost gap:
oil 3 (FAME): SAP +17.34 → +2.59, cost −£398 → −£62
oil 4 (FAME alt): SAP +16.06 → +2.56, cost −£367 → −£57
The Elmhurst-engine canonical 7.64 ↔ spec PDF 5.44 divergence is the
same pattern S0380.131 applied to heating oil (code 4: 7.64 → 5.44)
per [[feedback-software-no-special-handling]].
Remaining residuals on oil 3 / oil 4 / oil 6 are cascade-side
(HW kWh under by ~250-900, SH demand small diff, CO2/PE blend
artifacts) — pinned at observed values as forcing functions for
follow-up slices. Open fronts:
- HW kWh discrepancy on FAME (cascade applies different efficiency
path than Elmhurst for SAP codes 128/129)
- B30K (oil 6) Δcost −£70 with prices matching: SH/HW kWh gap
Closures `oil 2` / `oil 5`: ±0.0000 on all 4 metrics. Moves all 5
oil variants out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into
`_EXPECTATIONS`.
Blocked tier now: 6 variants (community heating × 5, no system).
Cascade-OK tier: 32 variants (up from 30), 30 EXACT + 3 (oil 3/4/6)
pinned with non-zero residuals + 1 (pcdb 1 SH residual closed in
S0380.165).
Tests:
- test_elmhurst_main_heating_ees_maps_bio_liquid_codes_to_table_32_fuel_codes
- test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels
- corpus pins: oil 2/3/4/5/6 expected residuals
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
269 lines
11 KiB
Python
269 lines
11 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.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,
|
||
}
|
||
|
||
|
||
# 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
|