From 67a4f92d53bf98398d2c49a727e9e88082830d2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 11:45:40 +0000 Subject: [PATCH] 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. --- .../domain/src/domain/ml/sap_efficiencies.py | 163 +++++++++++++++++ .../domain/ml/tests/test_sap_efficiencies.py | 167 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 packages/domain/src/domain/ml/sap_efficiencies.py create mode 100644 packages/domain/src/domain/ml/tests/test_sap_efficiencies.py diff --git a/packages/domain/src/domain/ml/sap_efficiencies.py b/packages/domain/src/domain/ml/sap_efficiencies.py new file mode 100644 index 00000000..caf2b30d --- /dev/null +++ b/packages/domain/src/domain/ml/sap_efficiencies.py @@ -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) diff --git a/packages/domain/src/domain/ml/tests/test_sap_efficiencies.py b/packages/domain/src/domain/ml/tests/test_sap_efficiencies.py new file mode 100644 index 00000000..80d76425 --- /dev/null +++ b/packages/domain/src/domain/ml/tests/test_sap_efficiencies.py @@ -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)