mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
2661481625
commit
fa5bdcc26f
4 changed files with 292 additions and 0 deletions
0
packages/domain/src/domain/sap/worksheet/__init__.py
Normal file
0
packages/domain/src/domain/sap/worksheet/__init__.py
Normal file
116
packages/domain/src/domain/sap/worksheet/dimensions.py
Normal file
116
packages/domain/src/domain/sap/worksheet/dimensions.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue