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

344 lines
13 KiB
Python

"""Tests for envelope_heat_loss_w_per_k.
Uses the existing `make_building_part` / `make_floor_dimension` fixtures so
test cases stay close to the shape transform.py sees on a real cert.
"""
import pytest
from domain.sap10_ml.envelope import envelope_heat_loss_w_per_k
from domain.sap10_ml.tests._fixtures import make_building_part, make_floor_dimension
def test_envelope_single_storey_no_windows_no_doors_age_g_cavity_returns_expected_w_per_k() -> None:
# Arrange — Mid-terrace, age G cavity-as-built, 100 m^2, 40 m perimeter, 5 m party wall,
# 2.5 m room height, single storey, no windows, no doors.
# Expected (RdSAP10 Tables 6,15,18,21):
# U_wall = 0.60, U_roof = 0.40, U_floor ~= 0.61 (ISO 13370 with A=100, P=40),
# U_party = 0.0 (solid masonry default), y = 0.15.
# Wall area = 40 * 2.5 - 0 - 0 = 100 m^2; party = 5 * 2.5 = 12.5 m^2.
# Heat loss ~= 0.60*100 + 0.40*100 + 0.61*100 + 0.0*12.5 + 0.15*(100+100+100+12.5)
# = 60 + 40 + 61 + 0 + 46.875 ~= 208 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4, # cavity
wall_insulation_type=4, # none
party_wall_construction=1, # solid masonry
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0,
room_height_m=2.5,
party_wall_length_m=5.0,
heat_loss_perimeter_m=40.0,
floor=0,
)
],
)
# Act
result = envelope_heat_loss_w_per_k(
sap_building_parts=[main],
country_code="ENG",
window_total_area_m2=0.0,
window_avg_u_value=None,
door_count=0,
insulated_door_count=0,
insulated_door_u_value=None,
)
# Assert
assert result == pytest.approx(208.0, abs=8.0)
def test_envelope_doubles_for_two_storey_dwelling() -> None:
# Arrange — same floor plan but 2 storeys (wall area + party area double; roof+floor stay).
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=1,
),
],
)
# Act
result = envelope_heat_loss_w_per_k(
sap_building_parts=[main],
country_code="ENG",
window_total_area_m2=0.0,
window_avg_u_value=None,
door_count=0,
insulated_door_count=0,
insulated_door_u_value=None,
)
# Assert — 0.60*200 + 0.40*100 + 0.61*100 + 0 + 0.15*(200+100+100+25) ~= 285 W/K.
assert result == pytest.approx(285.0, abs=12.0)
def test_envelope_drops_with_better_insulation() -> None:
# Arrange — same geometry, age band M (post-2012, well insulated).
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="M",
wall_construction=4,
wall_insulation_type=2, # filled cavity
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
age_g_main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
age_m = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
age_g = envelope_heat_loss_w_per_k(
sap_building_parts=[age_g_main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
# Assert
assert age_m < age_g
def test_envelope_returns_zero_when_no_building_parts() -> None:
# Arrange / Act / Assert
assert envelope_heat_loss_w_per_k(
sap_building_parts=[], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
) == 0.0
def test_envelope_sums_main_and_extension_contributions() -> None:
# Arrange — main + one extension; combined > main alone.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
extension = make_building_part(
identifier="Extension 1",
construction_age_band="L",
wall_construction=4,
wall_insulation_type=2,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=15.0, room_height_m=2.4,
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0,
)
],
)
# Act
main_only = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
with_extension = envelope_heat_loss_w_per_k(
sap_building_parts=[main, extension], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
# Assert
assert with_extension > main_only
def test_envelope_increases_with_windows_and_doors() -> None:
# Arrange — same base, but with 15 m^2 of windows + 1 door.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
no_openings = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
with_openings = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=15.0,
window_avg_u_value=2.8, door_count=1, insulated_door_count=0, insulated_door_u_value=None,
)
# Assert — openings add net heat loss because U_window (2.8) > U_wall (0.60).
assert with_openings > no_openings
def test_envelope_uninsulated_roof_description_raises_heat_loss() -> None:
# Arrange — Catastrophic heritage roof: top-level roofs[i].description says
# "no insulation". Without the flag the Table 18 age-G default of 0.40 W/m^2K
# under-states heat loss; with it u_roof returns 2.30 W/m^2K so the envelope
# rises by roughly (2.30-0.40)*100 = 190 W/K for a 100 m^2 roof.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
default_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
uninsulated_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
roof_description="Pitched, no insulation (assumed)",
)
# Assert — heat loss jumps by ~190 W/K (1.90 W/m^2K * 100 m^2 roof area).
assert uninsulated_roof - default_roof == pytest.approx(190.0, abs=15.0)
def test_envelope_limited_roof_insulation_description_raises_heat_loss() -> None:
# Arrange — "limited insulation" maps to u_roof=1.50; delta vs Table 18
# age-G default of 0.40 is 1.10 W/m^2K * 100 m^2 = 110 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
default_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
limited_roof = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
roof_description="Roof room(s), limited insulation",
)
# Assert
assert limited_roof - default_roof == pytest.approx(110.0, abs=15.0)
def test_envelope_stone_wall_description_raises_heat_loss_vs_cavity_default() -> None:
# Arrange — construction integer missing on a Victorian (age D) cert.
# _DEFAULT_WALL_BY_AGE picks CAVITY (1.5 W/m^2K uninsulated for D) by
# default; a walls[i].description naming "Sandstone" should resolve to
# stone instead (1.7 W/m^2K). Delta on net wall area ~100 m^2 -> +20 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="D",
wall_construction=10, # WALL_UNKNOWN -> envelope passes None to u_wall
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
default_wall = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
stone_wall = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
wall_description="Sandstone or limestone, as built, no insulation (assumed)",
)
# Assert — heat loss rises by ~20 W/K (0.2 W/m^2K * 100 m^2 net wall area).
assert stone_wall - default_wall == pytest.approx(20.0, abs=5.0)
def test_envelope_never_null_even_with_missing_fields() -> None:
# Arrange — minimal building part with most fields unspecified.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="NR", # unrecorded
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=36.0, floor=0,
)
],
)
# Act
result = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code=None,
window_total_area_m2=0.0, window_avg_u_value=None,
door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
# Assert — finite, positive.
assert result > 0.0