mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +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