slice S-A2: dimensions module (SAP 10.3 §1)

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.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 21:49:29 +00:00
parent 2661481625
commit fa5bdcc26f
4 changed files with 292 additions and 0 deletions

View file

@ -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,
)

View file

@ -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