"""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