"""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)