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>
158 lines
5.6 KiB
Python
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
|