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>
344 lines
13 KiB
Python
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
|