From fa5bdcc26f6c30204bd76fe0805a68bd740e573e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 21:49:29 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A2:=20dimensions=20module=20(SAP=2010?= =?UTF-8?q?.3=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the SAP10 Calculator Session A (ADR-0009). Ships a frozen Dimensions dataclass + dimensions_from_cert(epc) pure function under domain/sap/worksheet/. Aggregates geometry across every sap_building_parts entry (main dwelling + each extension): total floor area, volume, storey count, area-weighted average storey height, ground/top floor area, ground-floor heat-loss perimeter, gross wall area, party wall area. Top-level epc.total_floor_area_m2 is the authoritative TFA; per-storey sums drive the wall-area calculations. Volume = TFA × avg_storey_height. 5 AAA cycles cover: single-storey single-part, two-storey scaling, main+extension aggregation, empty-cert fallback to default 2.5 m height, and a non-default-height terrace exercising party-wall scaling. Edge cases (porches, conservatories, integral garages, RIR storey treatment) deferred to later slices per ADR-0009 Session A scope. --- .../src/domain/sap/worksheet/__init__.py | 0 .../src/domain/sap/worksheet/dimensions.py | 116 ++++++++++++ .../domain/sap/worksheet/tests/__init__.py | 0 .../sap/worksheet/tests/test_dimensions.py | 176 ++++++++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/__init__.py create mode 100644 packages/domain/src/domain/sap/worksheet/dimensions.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/__init__.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py diff --git a/packages/domain/src/domain/sap/worksheet/__init__.py b/packages/domain/src/domain/sap/worksheet/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/worksheet/dimensions.py b/packages/domain/src/domain/sap/worksheet/dimensions.py new file mode 100644 index 00000000..8294d272 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/dimensions.py @@ -0,0 +1,116 @@ +"""SAP 10.3 §1 — dwelling dimensions. + +Builds the typed `Dimensions` aggregate that the rest of the worksheet +reads: total floor area, volume, gross/party wall areas, ground and top +floor areas, perimeter. Geometry is summed across every entry in +`epc.sap_building_parts` (main dwelling + every extension), so a cert with +N parts produces totals over all N. + +Reference: SAP 10.3 specification (13-01-2026), §1 (pages 10-12); for +existing dwellings see RdSAP 10 §3 (areas and dimensions). + +Edge cases explicitly out of scope for the first slice (see ADR-0009 +Session A scope): porches, conservatories, integral garages, basements +with non-fixed staircases, room-in-roof storey treatment. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart + + +_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 + + +@dataclass(frozen=True) +class Dimensions: + """SAP 10.3 §1 geometric inputs to the monthly heat-balance loop.""" + + total_floor_area_m2: float + volume_m3: float + storey_count: int + avg_storey_height_m: float + ground_floor_area_m2: float + ground_floor_perimeter_m: float + top_floor_area_m2: float + gross_wall_area_m2: float + party_wall_area_m2: float + + +def _part_storey_count(part: SapBuildingPart) -> int: + return len(part.sap_floor_dimensions) + + +def _part_avg_storey_height_m(part: SapBuildingPart) -> float: + weighted = 0.0 + area = 0.0 + for fd in part.sap_floor_dimensions: + fa = fd.total_floor_area_m2 or 0.0 + weighted += fa * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) + area += fa + return weighted / area if area > 0 else _DEFAULT_STOREY_HEIGHT_M + + +def _part_ground_floor(part: SapBuildingPart): + fds = part.sap_floor_dimensions + if not fds: + return None + return next((fd for fd in fds if fd.floor == 0), fds[0]) + + +def _part_top_floor(part: SapBuildingPart): + fds = part.sap_floor_dimensions + if not fds: + return None + return max(fds, key=lambda fd: fd.floor if fd.floor is not None else 0) + + +def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions: + """Build the `Dimensions` aggregate from an EpcPropertyData.""" + parts = epc.sap_building_parts or [] + + ground_area = 0.0 + ground_perim = 0.0 + top_area = 0.0 + gross_wall = 0.0 + party_wall = 0.0 + total_storey_count = 0 + weighted_height = 0.0 + weighted_height_area = 0.0 + for part in parts: + ground = _part_ground_floor(part) + top = _part_top_floor(part) + if ground is None or top is None: + continue + part_height = _part_avg_storey_height_m(part) + part_storeys = _part_storey_count(part) + ground_area += ground.total_floor_area_m2 or 0.0 + ground_perim += ground.heat_loss_perimeter_m or 0.0 + top_area += top.total_floor_area_m2 or 0.0 + gross_wall += (ground.heat_loss_perimeter_m or 0.0) * part_height * part_storeys + party_wall += (ground.party_wall_length_m or 0.0) * part_height * part_storeys + total_storey_count += part_storeys + for fd in part.sap_floor_dimensions: + fa = fd.total_floor_area_m2 or 0.0 + weighted_height += fa * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) + weighted_height_area += fa + + avg_height = ( + weighted_height / weighted_height_area + if weighted_height_area > 0 + else _DEFAULT_STOREY_HEIGHT_M + ) + return Dimensions( + total_floor_area_m2=epc.total_floor_area_m2, + volume_m3=epc.total_floor_area_m2 * avg_height, + storey_count=total_storey_count, + avg_storey_height_m=avg_height, + ground_floor_area_m2=ground_area, + ground_floor_perimeter_m=ground_perim, + top_floor_area_m2=top_area, + gross_wall_area_m2=gross_wall, + party_wall_area_m2=party_wall, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/__init__.py b/packages/domain/src/domain/sap/worksheet/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py b/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py new file mode 100644 index 00000000..beac4b04 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py @@ -0,0 +1,176 @@ +"""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 +docs/sap-spec/sap-10-3-full-specification-2026-01-13.pdf pages 11-12. +""" + +import pytest + +from domain.ml.tests._fixtures import ( + make_building_part, + make_floor_dimension, + make_minimal_sap10_epc, +) +from domain.sap.worksheet.dimensions import Dimensions, dimensions_from_cert + + +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=0.05) + # 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) + assert result.storey_count == 2 # one storey per part, two parts + + +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