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>
234 lines
8.2 KiB
Python
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)
|