mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
8bd8f8a622
commit
67a4f92d53
2 changed files with 330 additions and 0 deletions
163
packages/domain/src/domain/ml/sap_efficiencies.py
Normal file
163
packages/domain/src/domain/ml/sap_efficiencies.py
Normal 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)
|
||||
167
packages/domain/src/domain/ml/tests/test_sap_efficiencies.py
Normal file
167
packages/domain/src/domain/ml/tests/test_sap_efficiencies.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue