mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
An API audit flagged the solid-fuel room-heater space efficiencies (_SPACE_EFF_BY_CODE 631-636) as reading the "Water" column of SAP 10.2 Table 4a. That was a misread: the two room-heater columns are (A) minimum-for-HETAS-approved and (B) other appliances — BOTH are space efficiency, not space/water. RdSAP defaults to column (B) when HETAS approval is not lodged, which is what these values already hold and what the reference software produces (Elmhurst worksheet "solid fuel 9", SAP code 636 → (206) space efficiency = 70 = column B; flipping to column A 75 broke that pin and three sibling solid-fuel corpus pins). No value change — add a pin test + spec-cited comment so the column-(A)/ (B) distinction is explicit and this misread can't recur. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
338 lines
11 KiB
Python
338 lines
11 KiB
Python
"""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.sap10_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_solid_fuel_room_heaters_use_table4a_column_b() -> None:
|
|
# Arrange — SAP 10.2 Table 4a (p.167) solid-fuel room heaters give
|
|
# column (A) for HETAS-approved appliances and column (B) for other
|
|
# appliances. RdSAP defaults to column (B) when HETAS approval is not
|
|
# lodged (Elmhurst worksheet "solid fuel 9" code 636 → (206)=70 = B):
|
|
# 631 open fire 32, 633 closed room heater 60, 634 with boiler 65,
|
|
# 635 pellet stove 65, 636 with boiler 70.
|
|
|
|
# Act / Assert
|
|
assert seasonal_efficiency(sap_main_heating_code=631) == 0.32
|
|
assert seasonal_efficiency(sap_main_heating_code=633) == 0.60
|
|
assert seasonal_efficiency(sap_main_heating_code=634) == 0.65
|
|
assert seasonal_efficiency(sap_main_heating_code=635) == 0.65
|
|
assert seasonal_efficiency(sap_main_heating_code=636) == 0.70
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_seasonal_efficiency_null_code_uses_heat_pump_category_fallback() -> None:
|
|
# Arrange — many real certs have sap_main_heating_code=None but the gov
|
|
# API still gives main_heating_category=4 (heat pump). Without the
|
|
# category fallback `seasonal_efficiency` returns 0.80 (gas boiler),
|
|
# under-counting a heat pump's COP by ~3x and driving sap_score down.
|
|
|
|
# Act
|
|
result = seasonal_efficiency(
|
|
sap_main_heating_code=None,
|
|
main_heating_category=4,
|
|
)
|
|
|
|
# Assert — SAP10.2 Table 4a heat-pump space COP ~2.30 (code 211 typical).
|
|
assert result == pytest.approx(2.30, abs=0.01)
|
|
|
|
|
|
def test_seasonal_efficiency_null_code_uses_storage_heater_category_fallback() -> None:
|
|
# Arrange — cat=7 (high-heat-retention electric storage) with null code.
|
|
|
|
# Act
|
|
result = seasonal_efficiency(
|
|
sap_main_heating_code=None,
|
|
main_heating_category=7,
|
|
)
|
|
|
|
# Assert — Table 4a electric storage = 1.00.
|
|
assert result == pytest.approx(1.00, abs=0.01)
|
|
|
|
|
|
def test_seasonal_efficiency_null_code_room_heaters_gas_fuel_fallback() -> None:
|
|
# Arrange — cat=10 (room heaters) + fuel=26 (mains gas, gov API code).
|
|
# Without the fuel-aware fallback, gas room heaters get the 0.80 default
|
|
# (gas boiler) when they should be ~0.55 (Table 4a 605-606 gas decorative).
|
|
|
|
# Act
|
|
result = seasonal_efficiency(
|
|
sap_main_heating_code=None,
|
|
main_heating_category=10,
|
|
main_fuel_type=26,
|
|
)
|
|
|
|
# Assert
|
|
assert result == pytest.approx(0.55, abs=0.05)
|
|
|
|
|
|
def test_seasonal_efficiency_null_code_room_heaters_electric_fuel_fallback() -> None:
|
|
# Arrange — cat=10 + fuel=29 (electricity not community).
|
|
|
|
# Act
|
|
result = seasonal_efficiency(
|
|
sap_main_heating_code=None,
|
|
main_heating_category=10,
|
|
main_fuel_type=29,
|
|
)
|
|
|
|
# Assert — electric room heater = 1.00.
|
|
assert result == pytest.approx(1.00, abs=0.01)
|
|
|
|
|
|
def test_seasonal_efficiency_explicit_code_beats_category_fallback() -> None:
|
|
# Arrange — when both are present, the SAP code is authoritative.
|
|
# Code 211 GSHP -> 2.30; category=2 (boilers) would otherwise return 0.80.
|
|
|
|
# Act
|
|
result = seasonal_efficiency(
|
|
sap_main_heating_code=211,
|
|
main_heating_category=2,
|
|
)
|
|
|
|
# Assert
|
|
assert result == pytest.approx(2.30, abs=0.01)
|
|
|
|
|
|
def test_seasonal_efficiency_null_code_central_heating_category_keeps_default() -> None:
|
|
# Arrange — cat=2 (central heating with separate HW) -> keep gas-boiler default.
|
|
|
|
# Act
|
|
result = seasonal_efficiency(
|
|
sap_main_heating_code=None,
|
|
main_heating_category=2,
|
|
)
|
|
|
|
# Assert
|
|
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)
|
|
|
|
|
|
# Gov EPC API uses a different fuel enum from SAP10.2 Table 32. The mapper
|
|
# stores the API codes in primary_main_fuel_type / water_heating_fuel so we
|
|
# must translate (e.g. API 29 = electricity not community -> Table 32 30).
|
|
|
|
def test_fuel_unit_price_recognises_api_code_26_mains_gas_not_community() -> None:
|
|
# Arrange / Act — gov API code 26 ("mains gas (not community)") -> Table 32 code 1 (3.48 p/kWh).
|
|
assert fuel_unit_price_p_per_kwh(fuel_code=26) == pytest.approx(3.48, abs=0.01)
|
|
|
|
|
|
def test_fuel_unit_price_recognises_api_code_28_oil_not_community() -> None:
|
|
# Arrange / Act — gov API code 28 = oil; should map to Table 32 oil (5.44 p/kWh).
|
|
assert fuel_unit_price_p_per_kwh(fuel_code=28) == pytest.approx(5.44, abs=0.01)
|
|
|
|
|
|
def test_fuel_unit_price_recognises_api_code_29_electricity_not_community() -> None:
|
|
# Arrange / Act — gov API code 29 = electricity; standard tariff 13.19 p/kWh.
|
|
assert fuel_unit_price_p_per_kwh(fuel_code=29) == pytest.approx(13.19, abs=0.01)
|
|
|
|
|
|
def test_fuel_unit_price_recognises_api_code_27_lpg_not_community() -> None:
|
|
# Arrange / Act — gov API code 27 = LPG not community -> bulk LPG 7.60 p/kWh.
|
|
assert fuel_unit_price_p_per_kwh(fuel_code=27) == pytest.approx(7.60, abs=0.01)
|
|
|
|
|
|
# ----- Robustness: strict-raise on a lodged-but-unmapped code -----
|
|
|
|
|
|
def test_seasonal_efficiency_raises_on_present_unmapped_code_and_category() -> None:
|
|
# Arrange — a main-heating SAP code AND category that resolve in NEITHER
|
|
# _SPACE_EFF_BY_CODE nor the category/room-heater fallbacks. The blind
|
|
# 0.80 gas-boiler default "catastrophically misrates heat pumps and
|
|
# storage" (per the table comment), so a lodged-but-unmapped pairing must
|
|
# surface as UnmappedSapCode, not silently rate as a gas boiler.
|
|
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
|
|
|
# Act / Assert
|
|
with pytest.raises(UnmappedSapCode):
|
|
seasonal_efficiency(sap_main_heating_code=8888, main_heating_category=99)
|
|
|
|
|
|
def test_seasonal_efficiency_no_data_still_defaults_to_gas_boiler() -> None:
|
|
# Arrange — when NOTHING is lodged (code and category both absent), the
|
|
# 0.80 typical-gas default is the correct "no data" fallback, not a raise.
|
|
# Act
|
|
result = seasonal_efficiency(sap_main_heating_code=None, main_heating_category=None)
|
|
|
|
# Assert
|
|
assert result == 0.80
|
|
|
|
|
|
def test_water_heating_efficiency_raises_on_present_unmapped_code() -> None:
|
|
# Arrange — a water-heating code that exists in NO SAP 10.2 Table 4a HW
|
|
# row must surface rather than silently take the 0.78 gas-combi default.
|
|
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
|
|
|
# Act / Assert
|
|
with pytest.raises(UnmappedSapCode):
|
|
water_heating_efficiency(water_heating_code=9999, main_heating_code=None)
|
|
|
|
|
|
def test_water_heating_efficiency_absent_code_still_defaults() -> None:
|
|
# Arrange — no water-heating code lodged (None) keeps the typical default.
|
|
# Act
|
|
result = water_heating_efficiency(water_heating_code=None, main_heating_code=None)
|
|
|
|
# Assert
|
|
assert result == 0.78
|