mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Sibling migration to the sap10_calculator move — `domain.ml` now lives
at the root-level layout (`domain/sap10_ml/`) matching the pattern
already used by `domain.addresses`, `domain.tasks`, `domain.postcode`,
and `domain.sap10_calculator`.
Changes:
- `git mv packages/domain/src/domain/ml → domain/sap10_ml` (19 files;
history preserved).
- Subpackage rename: `domain.ml` → `domain.sap10_ml`. 32 references
rewritten across .py and .md files: 11 internal + 21 external
(datatypes/epc/domain/mapper.py, 14 files in domain/sap10_calculator,
2 backend tests, 2 ADRs, 1 README, 1 design doc).
- Path-string updates: `pytest.ini` testpath
`packages/domain/src/domain/ml/tests` → `domain/sap10_ml/tests` so
ML tests stay in the default auto-discovered sweep. `CONTEXT.md`
also updated.
`packages/domain/src/domain/` is now empty — the workspace `domain/`
tree has been fully migrated. Together with the `domain/__init__.py`
deletions from the sap10_calculator commit (29ac35cc), `domain` is
now a single root-level namespace package with subpackages
{addresses, sap10_calculator, sap10_ml, tasks} + the standalone
`postcode.py` module.
Verified:
- Focused sweep (backend mapper-chain + sap10_calculator worksheet
e2e + golden fixtures): 99 passed / 19 failed — identical baseline.
- Wider sweep (all sap10_calculator + sap10_ml): 1654 passed / 20
failed (same pre-existing failures).
- domain/sap10_ml/tests: 210/210 PASSED at new path.
- Pyright net-zero: heat_transmission.py 13, cert_to_inputs.py 35,
mapper.py 33, rdsap_uvalues.py 1 (all unchanged from baseline).
Note: `packages/domain/pyproject.toml` still declares
`packages = ["src/domain"]` for the hatchling wheel — that target
directory is now empty and the wheel build is effectively a no-op.
Retiring the workspace package or repointing the wheel is a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
277 lines
8.6 KiB
Python
277 lines
8.6 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_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)
|