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

234 lines
8.2 KiB
Python

"""Tests for crude annual demand approximations (slice 16d)."""
import pytest
from domain.sap10_ml.demand import (
predicted_hot_water_kwh,
predicted_lighting_kwh,
predicted_space_heating_kwh,
)
def test_predicted_space_heating_scales_with_envelope_w_per_k() -> None:
# Arrange — same region, same efficiency, double the HLC -> double the kWh.
# Act
low = predicted_space_heating_kwh(envelope_heat_loss_w_per_k=100.0, region_code="1", seasonal_efficiency_main=0.84)
high = predicted_space_heating_kwh(envelope_heat_loss_w_per_k=200.0, region_code="1", seasonal_efficiency_main=0.84)
# Assert
assert high == pytest.approx(2.0 * low, abs=0.01)
def test_predicted_space_heating_returns_zero_when_efficiency_zero() -> None:
# Arrange / Act / Assert
assert predicted_space_heating_kwh(envelope_heat_loss_w_per_k=200.0, region_code="1", seasonal_efficiency_main=0.0) == 0.0
def test_predicted_space_heating_falls_back_to_uk_average_when_region_unknown() -> None:
# Arrange — region None should still produce a finite positive kWh.
# Act
result = predicted_space_heating_kwh(envelope_heat_loss_w_per_k=200.0, region_code=None, seasonal_efficiency_main=0.84)
# Assert
assert result > 0.0
def test_predicted_space_heating_adds_ventilation_to_envelope_hlc() -> None:
# Arrange — SAP10.2 HLC = envelope (conduction) + ventilation (infiltration);
# demand scales with HLC, so adding 50 W/K of ventilation to a 200 W/K
# envelope should give the same kWh as a 250 W/K envelope alone.
# Act
combined = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=200.0,
region_code="1",
seasonal_efficiency_main=0.84,
ventilation_heat_loss_w_per_k=50.0,
)
equivalent = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=250.0,
region_code="1",
seasonal_efficiency_main=0.84,
)
# Assert
assert combined == pytest.approx(equivalent, rel=0.001)
def test_predicted_space_heating_default_ventilation_zero_preserves_envelope_only_behaviour() -> None:
# Arrange — back-compat: callers that don't pass ventilation get the
# original envelope-only result.
# Act
envelope_only = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=200.0,
region_code="1",
seasonal_efficiency_main=0.84,
)
explicit_zero = predicted_space_heating_kwh(
envelope_heat_loss_w_per_k=200.0,
region_code="1",
seasonal_efficiency_main=0.84,
ventilation_heat_loss_w_per_k=0.0,
)
# Assert
assert envelope_only == pytest.approx(explicit_zero, rel=0.001)
def test_predicted_space_heating_scotland_higher_than_thames() -> None:
# Arrange — same HLC, same efficiency; Scotland's HDH > Thames's.
# Act
thames = predicted_space_heating_kwh(envelope_heat_loss_w_per_k=200.0, region_code="1", seasonal_efficiency_main=0.84)
scotland = predicted_space_heating_kwh(envelope_heat_loss_w_per_k=200.0, region_code="14", seasonal_efficiency_main=0.84)
# Assert
assert scotland > thames
def test_predicted_hot_water_scales_with_floor_area() -> None:
# Arrange — same efficiency, larger TFA -> more occupants -> more kWh.
# Act
small = predicted_hot_water_kwh(total_floor_area_m2=50.0, seasonal_efficiency_water=0.84)
large = predicted_hot_water_kwh(total_floor_area_m2=150.0, seasonal_efficiency_water=0.84)
# Assert
assert large > small
def test_predicted_hot_water_returns_zero_for_unspecified_floor_area() -> None:
# Arrange / Act / Assert
assert predicted_hot_water_kwh(total_floor_area_m2=None, seasonal_efficiency_water=0.84) == 0.0
def test_predicted_hot_water_kwh_adds_storage_loss_when_cylinder_described() -> None:
# Arrange — SAP10.2 Appendix J / Table 2: cylinder storage loss adds to
# the delivered DHW load. For a 110L cylinder with 38 mm foam (typical
# post-1992) the loss factor is 0.0056 kWh/L/day; annual loss in heated
# space = 110 * 0.0056 * 365 * 0.6 = 135 kWh useful -> delivered loss
# /efficiency. Same home without cylinder description gets the simple
# formula (no storage term).
# Act
with_cylinder = predicted_hot_water_kwh(
total_floor_area_m2=80.0,
seasonal_efficiency_water=0.84,
cylinder_size=1,
cylinder_insulation_thickness_mm=38,
cylinder_insulation_type=2, # foam
)
without_cylinder = predicted_hot_water_kwh(
total_floor_area_m2=80.0,
seasonal_efficiency_water=0.84,
)
# Assert
assert with_cylinder > without_cylinder
# storage_loss = 110 * 0.0056 * 365 * 0.6 / 0.84 ≈ 161 kWh delivered.
assert (with_cylinder - without_cylinder) == pytest.approx(161.0, abs=15.0)
def test_predicted_hot_water_kwh_lower_storage_loss_for_thicker_insulation() -> None:
# Arrange — same cylinder size, 12mm jacket vs 100mm foam.
# Act
jacket = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84,
cylinder_size=1, cylinder_insulation_thickness_mm=12, cylinder_insulation_type=1,
)
foam_100mm = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84,
cylinder_size=1, cylinder_insulation_thickness_mm=100, cylinder_insulation_type=2,
)
# Assert
assert jacket > foam_100mm
def test_predicted_hot_water_kwh_drops_with_wwhrs() -> None:
# Arrange — WWHRS recovers ~15% of bath energy.
# Act
no_wwhrs = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_wwhrs=False,
)
with_wwhrs = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_wwhrs=True,
)
# Assert
assert with_wwhrs < no_wwhrs
def test_predicted_hot_water_kwh_drops_with_solar_water_heating() -> None:
# Arrange — solar HW saves ~250 kWh/yr (SAP10.2 Appendix G simplified).
# Act
no_solar = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_solar_water_heating=False,
)
with_solar = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_solar_water_heating=True,
)
# Assert
assert with_solar < no_solar
def test_predicted_hot_water_kwh_uses_age_band_default_when_insulation_unspecified() -> None:
# Arrange — RdSAP10 Table 29: A-F -> 12mm jacket; G-H -> 25mm foam; I-M -> 38mm foam.
# Age G cylinder with no explicit insulation should default to 25mm foam,
# giving a lower loss than age A (12mm jacket).
# Act
age_a = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84,
cylinder_size=1, age_band="A",
)
age_g = predicted_hot_water_kwh(
total_floor_area_m2=80.0, seasonal_efficiency_water=0.84,
cylinder_size=1, age_band="G",
)
# Assert
assert age_g < age_a
def test_predicted_hot_water_typical_uk_home_falls_in_sensible_range() -> None:
# Arrange — 80 m^2 home, gas-combi efficiency.
# Act
result = predicted_hot_water_kwh(total_floor_area_m2=80.0, seasonal_efficiency_water=0.84)
# Assert — typical UK home DHW is 2000-3500 kWh/yr.
assert 1500.0 < result < 4500.0
def test_predicted_lighting_drops_with_led_bulbs() -> None:
# Arrange — same TFA, all-incandescent vs all-LED.
# Act
incandescent = predicted_lighting_kwh(total_floor_area_m2=100.0, cfl_count=0, led_count=0, incandescent_count=10)
all_led = predicted_lighting_kwh(total_floor_area_m2=100.0, cfl_count=0, led_count=10, incandescent_count=0)
# Assert
assert all_led < incandescent
def test_predicted_lighting_returns_zero_for_unspecified_floor_area() -> None:
# Arrange / Act / Assert
assert predicted_lighting_kwh(total_floor_area_m2=None, cfl_count=0, led_count=0, incandescent_count=10) == 0.0
def test_predicted_lighting_with_no_bulb_data_uses_base_demand() -> None:
# Arrange — TFA known but no bulb counts (all zero -> treat as full incandescent).
# Act
result = predicted_lighting_kwh(total_floor_area_m2=100.0, cfl_count=0, led_count=0, incandescent_count=0)
# Assert — base demand 9.3 * 100 = 930 kWh.
assert result == pytest.approx(930.0, abs=10.0)