slice 16b: sap_efficiencies.py with Table 4a/4b/32 lookups

Encodes SAP10.2 Table 4a (heating-system code -> space-eff %), Table 4b
(gas/oil boiler winter eff %), and Table 32 (fuel-code -> p/kWh).

Helpers:
- seasonal_efficiency(code) -> decimal; unknown -> 0.80 (gas-boiler typical)
- water_heating_efficiency(water_code, main_code) -> decimal; codes
  901/914 inherit the main code's efficiency
- fuel_unit_price_p_per_kwh(fuel_code) -> p/kWh; unknown -> 3.48 (mains gas)

All returns are total. Provides the seasonal-efficiency input to slice 16d
and the price multipliers for slice 16e's cost reconstruction.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 11:45:40 +00:00
parent 8bd8f8a622
commit 67a4f92d53
2 changed files with 330 additions and 0 deletions

View file

@ -0,0 +1,163 @@
"""SAP10.2 seasonal-efficiency + fuel-price lookups for ML feature engineering.
Source: BRE, *SAP 10.2* (14-03-2025) Tables 4a, 4b, and Table 32 (the
RdSAP10 fuel-price replica of SAP10.2 Table 12).
Helpers return:
- seasonal_efficiency(code) -> decimal (0.84 not 84)
- water_heating_efficiency(water_code, main_code) -> decimal
- fuel_unit_price_p_per_kwh(fuel_code) -> pence per kWh
All helpers are total: unknown codes cascade to typical-fuel defaults so the
predicted_total_fuel_cost feature is never null.
"""
from __future__ import annotations
from typing import Final, Optional
# ---------------------------------------------------------------------------
# Table 4a + Table 4b — space-heating seasonal efficiency by code
# Decimal, not percent. Codes 101-141 use Table 4b winter eff. Codes 151+
# use Table 4a "Efficiency %" column. Heat pumps: column "space".
# ---------------------------------------------------------------------------
_SPACE_EFF_BY_CODE: Final[dict[int, float]] = {
# Table 4b gas/oil boilers — winter efficiency.
101: 0.74, 102: 0.84, 103: 0.74, 104: 0.84, 105: 0.70, 106: 0.80,
107: 0.70, 108: 0.80, 109: 0.66,
110: 0.73, 111: 0.69, 112: 0.71, 113: 0.84, 114: 0.84,
115: 0.66, 116: 0.56, 117: 0.66, 118: 0.66, 119: 0.66,
120: 0.74, 121: 0.83, 122: 0.70, 123: 0.79,
124: 0.66, 125: 0.71, 126: 0.80, 127: 0.84,
128: 0.71, 129: 0.77, 130: 0.82, 131: 0.66, 132: 0.71,
133: 0.47, 134: 0.51, 135: 0.61, 136: 0.66, 137: 0.66, 138: 0.71,
139: 0.61, 140: 0.71, 141: 0.76,
# Table 4a solid-fuel boilers.
151: 0.60, 153: 0.65, 155: 0.70, 156: 0.55, 158: 0.65, 159: 0.70,
160: 0.45, 161: 0.55,
# Electric boilers (Table 4a).
191: 1.00, 192: 1.00, 193: 1.00, 194: 0.85, 195: 1.00, 196: 0.85,
# Heat pumps (Table 4a, space column).
211: 2.30, 213: 2.30, 214: 1.70, 215: 1.20, 216: 1.20, 217: 1.10,
221: 1.70, 223: 1.70, 224: 1.70, 225: 0.84, 226: 0.84, 227: 0.77,
# Heat networks (Table 4a).
301: 0.80, 302: 0.75, 304: 3.00,
# Storage / electric.
401: 1.00, 402: 1.00, 403: 1.00, 404: 1.00, 405: 1.00, 406: 1.00,
407: 1.00, 408: 1.00, 409: 1.00,
# Electric underfloor.
421: 1.00, 422: 1.00, 423: 1.00, 424: 1.00, 425: 1.00,
# Warm air.
501: 0.70, 502: 0.76, 503: 0.72, 504: 0.78, 505: 0.69,
506: 0.70, 507: 0.76, 508: 0.72, 509: 0.78, 510: 0.85, 511: 0.81,
512: 0.70, 513: 0.72, 514: 0.70, 515: 1.00, 520: 0.81,
# Warm-air heat pumps.
521: 2.30, 523: 2.30, 524: 1.70, 525: 1.20, 526: 1.20, 527: 1.10,
# Room heaters — gas mains/biogas (column A).
601: 0.50, 602: 0.50, 603: 0.63, 604: 0.63, 605: 0.40, 606: 0.40,
607: 0.45, 609: 0.58, 610: 0.72, 611: 0.85, 612: 0.20, 613: 0.90,
# Room heaters — liquid.
621: 0.55, 622: 0.65, 623: 0.60, 624: 0.70, 625: 0.94,
# Room heaters — solid (column B non-HETAS).
631: 0.32, 632: 0.50, 633: 0.60, 634: 0.65, 635: 0.65, 636: 0.70,
# Room heaters — electric.
691: 1.00, 692: 1.00, 693: 1.00, 694: 1.00,
# Other.
699: 1.00, 701: 1.00,
}
# Table 4a hot-water section — DHW seasonal efficiency for DHW-only codes.
_WATER_EFF_BY_CODE: Final[dict[int, float]] = {
999: 1.00, # No HW system present, electric immersion assumed
901: 0.0, # From main heating — sentinel: use main code
902: 0.0, # From secondary — sentinel
903: 1.00, # Electric immersion
907: 0.70, # Single-point gas at point of use
908: 0.65, # Multi-point gas
909: 1.00, # Electric instantaneous
911: 0.65, # Gas boiler/circulator for water only
912: 0.70, # Liquid fuel boiler/circulator
913: 0.55, # Solid fuel boiler for water only
914: 0.0, # From second main system — sentinel
921: 0.46, 922: 0.50, 923: 0.60, 924: 0.65, 925: 0.65, 926: 0.70,
927: 0.60, 928: 0.70, 929: 0.75, 930: 0.45, 931: 0.55,
941: 1.70, # Electric heat pump for water only
950: 0.80, 951: 0.75, 952: 3.00, # Hot-water heat networks
}
def seasonal_efficiency(sap_main_heating_code: Optional[int]) -> float:
"""Space-heating seasonal efficiency as a decimal (0.84 = 84%).
Falls back to 0.80 (typical gas-boiler) when the code is missing or
not in Table 4a/4b.
"""
if sap_main_heating_code is None:
return 0.80
return _SPACE_EFF_BY_CODE.get(sap_main_heating_code, 0.80)
def water_heating_efficiency(
water_heating_code: Optional[int],
main_heating_code: Optional[int],
) -> float:
"""Water-heating seasonal efficiency as a decimal.
Codes 901/914 ("from main / from second main") inherit the main code's
seasonal efficiency. Code 902 ("from secondary") falls back to typical.
Unknown -> 0.78 (gas-combi typical).
"""
if water_heating_code is None:
return 0.78
eff = _WATER_EFF_BY_CODE.get(water_heating_code)
if eff is None:
return 0.78
if eff == 0.0: # sentinel for "inherit"
return seasonal_efficiency(main_heating_code)
return eff
# ---------------------------------------------------------------------------
# Table 32 — fuel prices in pence per kWh
# ---------------------------------------------------------------------------
_FUEL_UNIT_PRICE: Final[dict[int, float]] = {
# Gas fuels
1: 3.48, # mains gas
2: 7.60, # bulk LPG
3: 10.30, # bottled LPG (main heating)
5: 3.48, # bottled LPG (secondary) — RdSAP10 ascribes mains-gas price; LPG bottle code
9: 7.60, # LPG SC11F
7: 0.0, # biogas — note: SAP10.2 cost not given for some biofuel codes
# Liquid fuels
4: 5.44, # heating oil
71: 7.64, 73: 7.64, 75: 6.10, 76: 47.0,
# Solid fuels
11: 3.67, 15: 3.64, 12: 4.61, 20: 4.23, 22: 5.81, 23: 5.26, 21: 3.07, 10: 3.99,
# Electricity
30: 13.19, # standard
32: 15.29, # 7h high
31: 5.50, # 7h low
34: 14.68, # 10h high
33: 7.50, # 10h low
38: 13.67, # 18h high
40: 7.41, # 18h low
35: 6.61, # 24h heating
39: 13.19, # any tariff (default to standard)
60: 13.19, # PV export (cost-neutral here)
36: 13.19, # other export
# Heat networks (cost per unit of heat)
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, 42: 4.24, 43: 4.24, 44: 4.24, 45: 2.97, 46: 2.97, 48: 2.97, 50: 0.0,
}
def fuel_unit_price_p_per_kwh(fuel_code: Optional[int]) -> float:
"""Table 32 unit price (p/kWh). Unknown -> mains gas (3.48 p/kWh),
the dominant UK heating fuel."""
if fuel_code is None:
return 3.48
return _FUEL_UNIT_PRICE.get(fuel_code, 3.48)

View file

@ -0,0 +1,167 @@
"""Tests for SAP10.2 efficiency + fuel-price lookups.
Reference values:
- Table 4a (Heating systems space and water) in SAP10.2 (14-03-2025)
- Table 4b (Seasonal efficiency for gas and liquid fuel boilers)
- Table 32 (RdSAP10-specific fuel prices, emission factors, primary energy)
Returns decimal efficiencies (0.80, not 80) and pence-per-kWh prices.
Helpers never raise on missing codes; they fall back to typical-fuel values.
"""
import pytest
from domain.ml.sap_efficiencies import (
fuel_unit_price_p_per_kwh,
seasonal_efficiency,
water_heating_efficiency,
)
# ----- Space-heating seasonal efficiency (Table 4a / 4b) -----
def test_seasonal_efficiency_condensing_gas_combi_returns_table4b_winter_value() -> None:
# Arrange — Table 4b, code 104 condensing combi with automatic ignition -> 84% winter.
# Act
result = seasonal_efficiency(sap_main_heating_code=104)
# Assert
assert result == pytest.approx(0.84, abs=0.005)
def test_seasonal_efficiency_air_source_heat_pump_returns_table4a_value() -> None:
# Arrange — Table 4a, code 214 ASHP <=35C -> 170% space.
# Act
result = seasonal_efficiency(sap_main_heating_code=214)
# Assert
assert result == pytest.approx(1.70, abs=0.005)
def test_seasonal_efficiency_ground_source_heat_pump_returns_table4a_value() -> None:
# Arrange — Table 4a, code 211 GSHP <=35C -> 230% space.
# Act
result = seasonal_efficiency(sap_main_heating_code=211)
# Assert
assert result == pytest.approx(2.30, abs=0.005)
def test_seasonal_efficiency_oil_boiler_returns_table4b_value() -> None:
# Arrange — Table 4b, code 126 standard oil 1998+ -> 80% winter.
# Act
result = seasonal_efficiency(sap_main_heating_code=126)
# Assert
assert result == pytest.approx(0.80, abs=0.005)
def test_seasonal_efficiency_electric_storage_heater_returns_unity() -> None:
# Arrange — Table 4a, code 401 storage heater -> 100%.
# Act
result = seasonal_efficiency(sap_main_heating_code=401)
# Assert
assert result == pytest.approx(1.0, abs=0.005)
def test_seasonal_efficiency_unknown_code_falls_back_to_mid_range() -> None:
# Arrange — code not in table.
# Act
result = seasonal_efficiency(sap_main_heating_code=None)
# Assert — gas-boiler typical ~0.80 W/W.
assert result == pytest.approx(0.80, abs=0.01)
# ----- Water-heating efficiency (Table 4a hot-water section) -----
def test_water_heating_efficiency_immersion_returns_unity() -> None:
# Arrange — Table 4a, code 903 electric immersion -> 100%.
# Act
result = water_heating_efficiency(water_heating_code=903, main_heating_code=None)
# Assert
assert result == pytest.approx(1.0, abs=0.005)
def test_water_heating_efficiency_from_main_system_inherits_main_efficiency() -> None:
# Arrange — Table 4a, code 901 "from main heating system".
# Act
result = water_heating_efficiency(water_heating_code=901, main_heating_code=104)
# Assert — inherits main code 104 (condensing gas combi) -> 0.84.
assert result == pytest.approx(0.84, abs=0.005)
def test_water_heating_efficiency_unknown_falls_back_to_typical() -> None:
# Arrange — no signal.
# Act
result = water_heating_efficiency(water_heating_code=None, main_heating_code=None)
# Assert — gas-combi typical 0.78.
assert result == pytest.approx(0.78, abs=0.05)
# ----- Fuel prices (Table 32) -----
def test_fuel_unit_price_mains_gas_returns_table32_value() -> None:
# Arrange — Table 32, code 1 mains gas -> 3.48 p/kWh.
# Act
result = fuel_unit_price_p_per_kwh(fuel_code=1)
# Assert
assert result == pytest.approx(3.48, abs=0.01)
def test_fuel_unit_price_oil_returns_table32_value() -> None:
# Arrange — Table 32, code 4 heating oil -> 5.44 p/kWh.
# Act
result = fuel_unit_price_p_per_kwh(fuel_code=4)
# Assert
assert result == pytest.approx(5.44, abs=0.01)
def test_fuel_unit_price_standard_electricity_returns_table32_value() -> None:
# Arrange — Table 32, code 30 standard tariff -> 13.19 p/kWh.
# Act
result = fuel_unit_price_p_per_kwh(fuel_code=30)
# Assert
assert result == pytest.approx(13.19, abs=0.01)
def test_fuel_unit_price_off_peak_low_rate_returns_table32_value() -> None:
# Arrange — Table 32, code 31 7-hour low rate -> 5.50 p/kWh.
# Act
result = fuel_unit_price_p_per_kwh(fuel_code=31)
# Assert
assert result == pytest.approx(5.50, abs=0.01)
def test_fuel_unit_price_unknown_falls_back_to_mains_gas() -> None:
# Arrange — unknown code.
# Act
result = fuel_unit_price_p_per_kwh(fuel_code=None)
# Assert — mains gas typical (most common UK heating fuel).
assert result == pytest.approx(3.48, abs=0.01)