mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.
Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
tests.domain.sap10_calculator.worksheet (21 files incl. the external
importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
moved with the rdsap tests).
load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.
Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
528 lines
22 KiB
Python
528 lines
22 KiB
Python
"""Tests for SAP 10.3 §1 dwelling-dimensions module.
|
||
|
||
Builds the typed `Dimensions` aggregate from an `EpcPropertyData`. Geometry
|
||
is summed across every `sap_building_parts` entry (main dwelling + every
|
||
extension). Reuses the existing fixtures from the ML test pack so tests
|
||
match the shape `transform.py` already sees in production.
|
||
|
||
SAP 10.3 specification (13-01-2026), §1 reference at
|
||
domain/sap10_calculator/docs/specs/sap-10-3-full-specification-2026-01-13.pdf pages 11-12.
|
||
"""
|
||
|
||
import json
|
||
from dataclasses import replace
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from datatypes.epc.domain.epc_property_data import (
|
||
BuildingPartIdentifier,
|
||
EpcPropertyData,
|
||
SapRoomInRoof,
|
||
)
|
||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||
from domain.sap10_ml.tests._fixtures import (
|
||
make_building_part,
|
||
make_floor_dimension,
|
||
make_minimal_sap10_epc,
|
||
)
|
||
from domain.sap10_calculator.worksheet.dimensions import Dimensions, dimensions_from_cert
|
||
from tests.domain.sap10_calculator.worksheet._xlsx_loader import load_cells
|
||
|
||
_RIR_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "rir"
|
||
|
||
|
||
def test_single_storey_single_part_populates_every_dimension_field() -> None:
|
||
# Arrange — Single-storey detached house: TFA 100 m², heat-loss perimeter
|
||
# 40 m, storey height 2.5 m, party wall length 5 m. Single building part
|
||
# ("Main Dwelling") with one floor dimension. Top-level TFA matches the
|
||
# building-part TFA.
|
||
main = make_building_part(
|
||
identifier="Main Dwelling",
|
||
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,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=100.0, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert
|
||
assert isinstance(result, Dimensions)
|
||
assert result.total_floor_area_m2 == pytest.approx(100.0)
|
||
assert result.volume_m3 == pytest.approx(250.0) # 100 × 2.5
|
||
assert result.storey_count == 1
|
||
assert result.avg_storey_height_m == pytest.approx(2.5)
|
||
assert result.ground_floor_area_m2 == pytest.approx(100.0)
|
||
assert result.ground_floor_perimeter_m == pytest.approx(40.0)
|
||
assert result.top_floor_area_m2 == pytest.approx(100.0)
|
||
assert result.gross_wall_area_m2 == pytest.approx(100.0) # 40 × 2.5 × 1
|
||
assert result.party_wall_area_m2 == pytest.approx(12.5) # 5 × 2.5 × 1
|
||
|
||
|
||
def test_two_storey_doubles_wall_area_and_volume_but_not_roof_or_floor() -> None:
|
||
# Arrange — Same floor plan, but two storeys. Wall area and party area
|
||
# scale linearly with storey count; ground/top floor areas stay tied to
|
||
# the single ground/top floor only. Top-level TFA is the sum across both
|
||
# storeys = 200 m².
|
||
main = make_building_part(
|
||
identifier="Main Dwelling",
|
||
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,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=200.0, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert
|
||
assert result.total_floor_area_m2 == pytest.approx(200.0)
|
||
assert result.volume_m3 == pytest.approx(500.0) # 200 × 2.5
|
||
assert result.storey_count == 2
|
||
assert result.gross_wall_area_m2 == pytest.approx(200.0) # 40 × 2.5 × 2
|
||
assert result.party_wall_area_m2 == pytest.approx(25.0) # 5 × 2.5 × 2
|
||
assert result.ground_floor_area_m2 == pytest.approx(100.0)
|
||
assert result.top_floor_area_m2 == pytest.approx(100.0)
|
||
|
||
|
||
def test_main_plus_extension_sums_areas_perimeters_and_walls() -> None:
|
||
# Arrange — Main dwelling 100 m² + single-storey extension 15 m². Ground
|
||
# floor area, perimeter, wall area, party-wall area must all sum across
|
||
# parts. Top-level TFA matches the sum.
|
||
main = make_building_part(
|
||
identifier="Main Dwelling",
|
||
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",
|
||
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,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(
|
||
total_floor_area_m2=115.0,
|
||
sap_building_parts=[main, extension],
|
||
)
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert
|
||
assert result.total_floor_area_m2 == pytest.approx(115.0)
|
||
assert result.ground_floor_area_m2 == pytest.approx(115.0) # 100 + 15
|
||
assert result.ground_floor_perimeter_m == pytest.approx(56.0) # 40 + 16
|
||
assert result.top_floor_area_m2 == pytest.approx(115.0) # both parts are single-storey
|
||
# main: 40 × 2.5 × 1 = 100; extension: 16 × 2.4 × 1 = 38.4
|
||
assert result.gross_wall_area_m2 == pytest.approx(138.4, abs=1e-12)
|
||
# main party: 5 × 2.5 × 1 = 12.5; extension party: 0 × 2.4 × 1 = 0
|
||
assert result.party_wall_area_m2 == pytest.approx(12.5)
|
||
# SAP §2 (9) "ns": dwelling height (max across parts), NOT Σ across
|
||
# parts. Both parts here are single-storey, so the dwelling is one
|
||
# storey tall regardless of how many extensions stick out sideways.
|
||
assert result.storey_count == 1
|
||
|
||
|
||
def test_dwelling_storey_count_is_max_across_parts_not_sum() -> None:
|
||
# Arrange — SAP §2 (9) requires the **dwelling height** for the (10)
|
||
# additional-infiltration adjustment, which is the tallest part, not
|
||
# the sum of per-part floor counts. Surfaced by Elmhurst 000474: a
|
||
# 2-storey main + 1-storey side extension lodges as ns=2 in the
|
||
# worksheet, but our pre-fix code returned 2 + 1 = 3 and over-stated
|
||
# (10) by 0.1 ach.
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
extension = make_building_part(
|
||
identifier="Extension 1",
|
||
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,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=115.0, sap_building_parts=[main, extension])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert — dwelling is 2 storeys tall, not 3.
|
||
assert result.storey_count == 2
|
||
|
||
|
||
def test_gross_wall_area_sums_per_storey_perimeter_times_height_not_ground_perim_times_avg() -> None:
|
||
# Arrange — 2-storey terrace where the upper storey has a smaller
|
||
# heat-loss perimeter than the ground (e.g. set-back upper floor or a
|
||
# wider ground addition). Surfaced by Elmhurst 000474: Main has
|
||
# ground perim 7.07, first 5.27 — the worksheet sums each storey
|
||
# separately, but pre-fix code used `ground_perim × avg_height ×
|
||
# storey_count` which over-counts the upper storey's wall area.
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=10.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=6.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=100.0, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert — Σ (perim × height) = 10×2.5 + 6×2.5 = 40.
|
||
# Pre-fix would have given 10 × 2.5 × 2 = 50.
|
||
assert result.gross_wall_area_m2 == pytest.approx(40.0)
|
||
|
||
|
||
def test_party_wall_area_sums_per_storey_party_length_times_height_not_ground_party_times_avg() -> None:
|
||
# Arrange — Same per-storey-differs shape, but applied to the party
|
||
# wall. Two-storey main, ground party 5 m, upper party 3 m (e.g. the
|
||
# upper storey is set back from the party line). RdSAP §5.10 party
|
||
# area is also Σ (party_length_i × height_i), not
|
||
# ground_party × avg × count.
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=5.0, heat_loss_perimeter_m=0.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=3.0, heat_loss_perimeter_m=0.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=100.0, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert — Σ (party × height) = 5×2.5 + 3×2.5 = 20.
|
||
# Pre-fix would have given 5 × 2.5 × 2 = 25.
|
||
assert result.party_wall_area_m2 == pytest.approx(20.0)
|
||
|
||
|
||
def test_room_in_roof_on_main_adds_one_to_dwelling_storey_count_only_once() -> None:
|
||
# Arrange — Main 2-storey with RR + same-height 2-storey extension
|
||
# without RR. RR adds one storey to MAIN (giving 3), extension stays
|
||
# at 2 storeys. Dwelling height = max(3, 2) = 3. Pre-fix code summed
|
||
# main-with-rr (3) + extension (2) = 5.
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=1,
|
||
),
|
||
],
|
||
sap_room_in_roof=SapRoomInRoof(floor_area=20.0, construction_age_band="B"),
|
||
)
|
||
extension = make_building_part(
|
||
identifier="Extension 1",
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=15.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=15.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=140.0, sap_building_parts=[main, extension])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert — Main with RR is 3 storeys, extension is 2 — dwelling is 3.
|
||
assert result.storey_count == 3
|
||
|
||
|
||
def test_empty_sap_building_parts_uses_top_level_tfa_with_default_height() -> None:
|
||
# Arrange — Some cert flows arrive with sap_building_parts empty but
|
||
# total_floor_area_m2 populated (e.g. site-notes-only baseline). The
|
||
# calculator must not crash; geometric envelope fields fall back to zero
|
||
# and avg_storey_height_m defaults to 2.5 m so volume = TFA × 2.5.
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=80.0, sap_building_parts=[])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert
|
||
assert result.total_floor_area_m2 == pytest.approx(80.0)
|
||
assert result.volume_m3 == pytest.approx(200.0) # 80 × 2.5 default
|
||
assert result.storey_count == 0
|
||
assert result.avg_storey_height_m == pytest.approx(2.5)
|
||
assert result.ground_floor_area_m2 == 0.0
|
||
assert result.ground_floor_perimeter_m == 0.0
|
||
assert result.top_floor_area_m2 == 0.0
|
||
assert result.gross_wall_area_m2 == 0.0
|
||
assert result.party_wall_area_m2 == 0.0
|
||
|
||
|
||
def test_party_wall_area_scales_with_room_height_and_storey_count() -> None:
|
||
# Arrange — Two-storey terrace with non-default room height 2.7 m and a
|
||
# 10 m party wall on each floor. Expected party_wall_area = 10 × 2.7 × 2 = 54.
|
||
main = make_building_part(
|
||
identifier="Main Dwelling",
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=80.0, room_height_m=2.7,
|
||
party_wall_length_m=10.0, heat_loss_perimeter_m=30.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=80.0, room_height_m=2.7,
|
||
party_wall_length_m=10.0, heat_loss_perimeter_m=30.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=160.0, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert
|
||
assert result.avg_storey_height_m == pytest.approx(2.7)
|
||
assert result.party_wall_area_m2 == pytest.approx(54.0) # 10 × 2.7 × 2
|
||
assert result.gross_wall_area_m2 == pytest.approx(162.0) # 30 × 2.7 × 2
|
||
assert result.volume_m3 == pytest.approx(432.0) # 160 × 2.7
|
||
|
||
|
||
def test_section_1_matches_excel_worksheet_conformance() -> None:
|
||
"""Mirror the worked example in `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
|
||
sheet `NonRegionalWeather`, §1 (Overall dwelling dimensions).
|
||
|
||
Excel cells:
|
||
Q7 = (1a) Basement area = 84.44 m²
|
||
S7 = (2a) Basement height = 2.92 m
|
||
U7 = (3a) Basement volume = 246.5648 m³
|
||
Q9 = (1b) Ground area = 74.55 m²
|
||
S9 = (2b) Ground height = 3.56 m
|
||
U9 = (3b) Ground volume = 265.398 m³
|
||
Q23 = (4) Total floor area = Σ (1x) = 158.99 m²
|
||
U25 = (5) Dwelling volume = Σ (3x) = 511.9628 m³
|
||
|
||
`SapFloorDimension` has no basement representation (API never sets
|
||
it), so Excel's Basement+Ground are mapped to floor=0 and floor=1 —
|
||
§1 is a pure sum so storey labels don't affect the result.
|
||
"""
|
||
# Arrange
|
||
excel = load_cells(
|
||
"NonRegionalWeather", ["Q7", "S7", "U7", "Q9", "S9", "U9", "Q23", "U25"]
|
||
)
|
||
# Sanity-check the Excel arithmetic itself before testing against our code.
|
||
assert excel["Q7"] * excel["S7"] == pytest.approx(excel["U7"])
|
||
assert excel["Q9"] * excel["S9"] == pytest.approx(excel["U9"])
|
||
assert excel["Q7"] + excel["Q9"] == pytest.approx(excel["Q23"])
|
||
assert excel["U7"] + excel["U9"] == pytest.approx(excel["U25"])
|
||
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=excel["Q7"], room_height_m=excel["S7"],
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=excel["Q9"], room_height_m=excel["S9"],
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(
|
||
total_floor_area_m2=excel["Q23"], sap_building_parts=[main]
|
||
)
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert — line (4) and line (5) match the worksheet.
|
||
assert result.total_floor_area_m2 == pytest.approx(excel["Q23"])
|
||
assert result.volume_m3 == pytest.approx(excel["U25"])
|
||
|
||
|
||
def test_section_1_uses_per_storey_sums_even_when_cert_top_level_disagrees() -> None:
|
||
"""§1 lines (4)/(5) are Σ of per-storey (1x)/(3x), not the cert's
|
||
top-level TFA. Catches a regression where Dimensions reads
|
||
`epc.total_floor_area_m2` directly instead of summing per-storey."""
|
||
# Arrange — Two storeys totalling 100 m² + 50 m² = 150 m². Set the
|
||
# cert's top-level TFA to a deliberately wrong 999.0 so a per-storey
|
||
# sum gives 150 m² but a cert-level read gives 999.
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=100.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=0,
|
||
),
|
||
make_floor_dimension(
|
||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=1,
|
||
),
|
||
],
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=999.0, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert
|
||
assert result.total_floor_area_m2 == pytest.approx(150.0) # Σ (1x), not 999
|
||
assert result.volume_m3 == pytest.approx(375.0) # Σ (3x) = 150 × 2.5
|
||
|
||
|
||
def test_room_in_roof_adds_one_storey_with_simplified_2_45m_height() -> None:
|
||
"""RdSAP §1.8 + §3.9: a room-in-roof counts as a separate storey for
|
||
§1. For Simplified type 1 (true RR, the common API shape — only
|
||
gable-wall lengths populated) RdSAP §3.9.1 fixes the storey height
|
||
at 2.45 m (= 2.2 m internal + 0.25 m floor structure between RR and
|
||
storey below). Modelled after golden cert 0240: ground floor 97.72 m²
|
||
+ room-in-roof 83.2 m² should sum to TFA 180.92 (matches cert TFA
|
||
202 to within the ~10 m² rounding the cert applies elsewhere)."""
|
||
# Arrange — single part with one ground floor + a room-in-roof block.
|
||
main = make_building_part(
|
||
identifier=BuildingPartIdentifier.MAIN,
|
||
floor_dimensions=[
|
||
make_floor_dimension(
|
||
total_floor_area_m2=97.72, room_height_m=2.28,
|
||
party_wall_length_m=0.0, heat_loss_perimeter_m=36.45, floor=0,
|
||
),
|
||
],
|
||
sap_room_in_roof=SapRoomInRoof(
|
||
floor_area=83.2, construction_age_band="J",
|
||
),
|
||
)
|
||
epc = make_minimal_sap10_epc(total_floor_area_m2=180.92, sap_building_parts=[main])
|
||
|
||
# Act
|
||
result = dimensions_from_cert(epc)
|
||
|
||
# Assert — TFA = ground + RR; volume = ground×ht + RR×2.45.
|
||
assert result.total_floor_area_m2 == pytest.approx(97.72 + 83.2)
|
||
assert result.volume_m3 == pytest.approx(97.72 * 2.28 + 83.2 * 2.45)
|
||
assert result.storey_count == 2 # ground floor + room-in-roof storey
|
||
|
||
|
||
def _strip_room_in_roof(epc: EpcPropertyData) -> EpcPropertyData:
|
||
"""Return a copy of `epc` with every building part's `sap_room_in_roof`
|
||
set to None. Used to isolate the RR contribution to §1 outputs by
|
||
diffing against the with-RR result."""
|
||
parts_no_rr = [
|
||
replace(p, sap_room_in_roof=None) for p in (epc.sap_building_parts or [])
|
||
]
|
||
return replace(epc, sap_building_parts=parts_no_rr)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"cert_filename, rr_shape_label",
|
||
[
|
||
("0782-3058-6209-9186-1200.json", "room_in_roof_type_2 (Detailed type 2 — gable + common wall heights)"),
|
||
("0636-8125-6600-0416-2202.json", "room_in_roof_details with stud_walls (Detailed type 1)"),
|
||
("0636-6824-0100-0500-6222.json", "room_in_roof_details with common_walls (Detailed type 2)"),
|
||
],
|
||
)
|
||
def test_all_rir_shapes_apply_section_1_2_45m_convention_uniformly(
|
||
cert_filename: str, rr_shape_label: str,
|
||
) -> None:
|
||
"""RdSAP §3.9.2 wall-area formulas and §3.10 detailed measurements
|
||
are for §3 heat-loss U-value calculation, **not** §1 dimensions —
|
||
confirmed at `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`
|
||
pages 22-24. The §1 storey-height convention of 2.45 m from §3.9.1
|
||
extends uniformly to every RR shape: each contributes exactly
|
||
`floor_area` to TFA, `floor_area × 2.45` to volume, and +1 storey.
|
||
|
||
Real-corpus fixtures (from /workspaces/model/data/ml_training/bulk/
|
||
certificates-2026.json.zip) exercise the three non-Simplified-type-1
|
||
shapes; the dynamic-delta assertion catches any future code path
|
||
that special-cases by shape."""
|
||
# Arrange — load a real cert with a non-Simplified-type-1 RR block
|
||
doc = json.loads((_RIR_FIXTURES_DIR / cert_filename).read_text())
|
||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||
parts_with_rr = [
|
||
p for p in (epc.sap_building_parts or []) if p.sap_room_in_roof is not None
|
||
]
|
||
rir_floor_area_total = sum(p.sap_room_in_roof.floor_area for p in parts_with_rr)
|
||
assert rir_floor_area_total > 0, f"Fixture {cert_filename} should carry RR floor_area"
|
||
|
||
# Act — compute §1 outputs with and without the RR block to isolate its delta
|
||
result_with_rr = dimensions_from_cert(epc)
|
||
result_without_rr = dimensions_from_cert(_strip_room_in_roof(epc))
|
||
|
||
# Assert — TFA and volume DO sum across every RR-bearing part (genuine
|
||
# sums per RdSAP §3.9.1). Storey count, by contrast, is the dwelling
|
||
# height (max across parts) per SAP §2 (9), so RR contributes at most
|
||
# one extra storey to the dwelling — the storey it adds to the part
|
||
# that ends up tallest. All three fixtures here have single-storey
|
||
# parts, so any part gaining RR becomes the new tallest stack and
|
||
# storey_delta is exactly 1.
|
||
tfa_delta = result_with_rr.total_floor_area_m2 - result_without_rr.total_floor_area_m2
|
||
volume_delta = result_with_rr.volume_m3 - result_without_rr.volume_m3
|
||
storey_delta = result_with_rr.storey_count - result_without_rr.storey_count
|
||
|
||
assert tfa_delta == pytest.approx(rir_floor_area_total)
|
||
assert volume_delta == pytest.approx(rir_floor_area_total * 2.45)
|
||
assert storey_delta == 1
|
||
|
||
|
||
from types import ModuleType # noqa: E402 (kept near the Elmhurst tests)
|
||
from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( # noqa: E402
|
||
ALL_FIXTURES as _ELMHURST_FIXTURES,
|
||
fixture_id as _elmhurst_fixture_id,
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
|
||
def test_section_1_matches_elmhurst_worksheet(fixture: ModuleType) -> None:
|
||
"""Real Elmhurst SAP10.2 worksheets — asserts §1 lines (4) Total Floor
|
||
Area and (5) Dwelling Volume against the canonical Elmhurst output for
|
||
each registered fixture. Pytest id = the worksheet reference number."""
|
||
# Arrange / Act
|
||
result = dimensions_from_cert(fixture.build_epc())
|
||
|
||
# Assert
|
||
# PDF 4-d.p. display floor per [[feedback-e2e-validation-philosophy]].
|
||
# Actual cohort diffs are 1e-14 (essentially exact) for these scalars.
|
||
assert result.total_floor_area_m2 == pytest.approx(fixture.LINE_4_TFA_M2, abs=1e-4)
|
||
assert result.volume_m3 == pytest.approx(fixture.LINE_5_VOLUME_M3, abs=1e-4)
|