Model/domain/sap10_calculator/tables/table_32.py
Khalim Conn-Kowlessar 58a9547210 Slice S0380.168: Bio-liquid mapper extensions + Table 32 FAME price flip
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>
2026-06-02 10:14:10 +00:00

269 lines
11 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,
}
# 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