Model/domain/sap10_ml/tests/test_ecf.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

158 lines
5.6 KiB
Python

"""Tests for the predicted_total_cost / predicted_ecf / predicted_log10_ecf
features (slice 16e, ADR-0008)."""
from math import log10
import pytest
from domain.sap10_ml.ecf import (
predicted_ecf,
predicted_log10_ecf,
predicted_pv_generation_kwh,
predicted_total_fuel_cost_gbp,
)
def test_predicted_pv_generation_kwh_scales_linearly_with_peak_power() -> None:
# Arrange — UK average yield ~ 850 kWh/kWp/yr; 4 kWp -> ~3400 kWh.
# Act
a = predicted_pv_generation_kwh(pv_total_peak_power_kw=4.0, region_code="1")
b = predicted_pv_generation_kwh(pv_total_peak_power_kw=8.0, region_code="1")
# Assert
assert b == pytest.approx(2.0 * a, abs=0.01)
def test_predicted_pv_generation_kwh_returns_zero_for_no_pv() -> None:
# Arrange / Act / Assert
assert predicted_pv_generation_kwh(pv_total_peak_power_kw=0.0, region_code="1") == 0.0
assert predicted_pv_generation_kwh(pv_total_peak_power_kw=None, region_code="1") == 0.0
def test_predicted_total_fuel_cost_subtracts_pv_credit_at_electricity_price() -> None:
# Arrange — gas heat + DHW + lighting, with 3000 kWh PV generation.
# Base cost: (10000*3.48 + 2500*3.48 + 600*13.19) / 100 = 514.14
# PV credit: 3000 * 13.19 / 100 = 395.70
# Net: 514.14 - 395.70 = 118.44
# Act
with_pv = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=1, water_heating_fuel_code=1,
predicted_pv_kwh=3000.0,
)
no_pv = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=1, water_heating_fuel_code=1,
predicted_pv_kwh=0.0,
)
# Assert
assert no_pv == pytest.approx(514.14, abs=0.05)
assert with_pv == pytest.approx(118.44, abs=0.05)
def test_predicted_total_fuel_cost_pv_kwh_defaults_to_zero_for_backwards_compatibility() -> None:
# Arrange / Act — existing callers pre-17a omit predicted_pv_kwh entirely.
# Act
result = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=1, water_heating_fuel_code=1,
)
# Assert — same as predicted_pv_kwh=0.
assert result == pytest.approx(514.14, abs=0.05)
def test_predicted_pv_generation_kwh_scotland_lower_than_southern_england() -> None:
# Arrange — Thames (region 1) vs Highland (region 17); same kWp.
# Act
thames = predicted_pv_generation_kwh(pv_total_peak_power_kw=4.0, region_code="1")
highland = predicted_pv_generation_kwh(pv_total_peak_power_kw=4.0, region_code="17")
# Assert
assert highland < thames
def test_predicted_total_fuel_cost_gas_heated_returns_expected_gbp() -> None:
# Arrange — 12,000 kWh gas heat, 3,000 kWh gas DHW, 800 kWh lighting.
# Gas (code 1) 3.48 p/kWh, electricity (30) 13.19 p/kWh.
# Expected total: (12000*3.48 + 3000*3.48 + 800*13.19) / 100 = (41760 + 10440 + 10552) / 100 = 627.52
# Act
result = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=12000.0,
predicted_hot_water_kwh=3000.0,
predicted_lighting_kwh=800.0,
main_fuel_code=1,
water_heating_fuel_code=1,
)
# Assert
assert result == pytest.approx(627.52, abs=0.05)
def test_predicted_total_fuel_cost_electric_heated_higher_than_gas() -> None:
# Arrange — same kWh demand on electricity vs gas.
# Act
gas = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=1, water_heating_fuel_code=1,
)
elec = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=30, water_heating_fuel_code=30,
)
# Assert
assert elec > gas * 2.0
def test_predicted_ecf_uses_sap_deflator_and_tfa_plus_45() -> None:
# Arrange — total cost 627.52, TFA 100.
# ECF = 0.42 * 627.52 / (100 + 45) = 263.56 / 145 = 1.817
# Act
result = predicted_ecf(predicted_total_cost_gbp=627.52, total_floor_area_m2=100.0)
# Assert
assert result == pytest.approx(1.817, abs=0.005)
def test_predicted_ecf_returns_zero_for_unspecified_floor_area() -> None:
# Arrange / Act / Assert
assert predicted_ecf(predicted_total_cost_gbp=627.52, total_floor_area_m2=None) == 0.0
def test_predicted_log10_ecf_matches_log10_for_positive_input() -> None:
# Arrange / Act / Assert
assert predicted_log10_ecf(1.817) == pytest.approx(log10(1.817), abs=0.0001)
def test_predicted_log10_ecf_returns_zero_for_nonpositive_input() -> None:
# Arrange / Act / Assert
assert predicted_log10_ecf(0.0) == 0.0
assert predicted_log10_ecf(-1.5) == 0.0
def test_predicted_ecf_grows_when_more_expensive_fuel() -> None:
# Arrange — same kWh, different fuel; electricity ECF >> gas ECF.
# Act
gas_cost = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=1, water_heating_fuel_code=1,
)
elec_cost = predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh=10000.0, predicted_hot_water_kwh=2500.0,
predicted_lighting_kwh=600.0, main_fuel_code=30, water_heating_fuel_code=30,
)
gas_ecf = predicted_ecf(gas_cost, total_floor_area_m2=100.0)
elec_ecf = predicted_ecf(elec_cost, total_floor_area_m2=100.0)
# Assert — higher ECF -> worse SAP, matches intuition for resistive-electric heating.
assert elec_ecf > gas_ecf