Model/domain/sap10_ml/tests/test_sap_efficiencies.py
Khalim Conn-Kowlessar 68401c517a refactor: lift-and-shift packages/domain/src/domain/ml → domain/sap10_ml
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>
2026-05-26 13:01:35 +00:00

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)