"""SAP 10.2 §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. Room-in-roof contributes one additional storey per part where present (RdSAP §1.8 + §3.9). Reference: SAP 10.2 specification (14-03-2025), §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. """ 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 # Room-in-roof Simplified type 1 (true RR) storey height per RdSAP 10 # §3.9.1: assumed internal height 2.2 m (lower than 2.4 m to compensate # for sloping parts) + 0.25 m floor structure between RR and storey # below = 2.45 m. Simplified type 2 and Detailed assessment options are # not yet handled — see TODO at the RR sum below. _RR_SIMPLIFIED_STOREY_HEIGHT_M: Final[float] = 2.45 @dataclass(frozen=True) class Dimensions: """SAP 10.2 §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. §1 (Overall dwelling dimensions) mirrors the SAP10.2 worksheet form: each `SapFloorDimension` is one storey row (1x), (2x), (3x) where (3x) = (1x) × (2x). Line (4) Total floor area = Σ (1x), line (5) Dwelling volume = Σ (3x). When no storeys are present (site-notes baseline edge case), totals fall back to the certificate's top-level TFA × default height — defensive, not worksheet-faithful. """ parts = epc.sap_building_parts or [] # §1 worksheet accumulators — these directly map to lines (4) and (5). sum_per_storey_area_m2 = 0.0 # Σ (1x) sum_per_storey_volume_m3 = 0.0 # Σ (3x) = Σ (1x) × (2x) # §2/§3 inputs (gross/party wall, perimeter, ground/top floor) — kept # in this aggregate for now; carve-out is a follow-up. ground_area = 0.0 ground_perim = 0.0 top_area = 0.0 gross_wall = 0.0 party_wall = 0.0 # SAP §2 (9) "ns" is dwelling height (tallest part), NOT Σ across parts — # the (10) additional-infiltration adjustment otherwise inflates by 0.1 # per spurious storey. Track per-part counts and take the max below. part_storey_counts: list[int] = [] 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_floor_count = _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 # SAP §3 wall area: Σ (heat_loss_perimeter_i × height_i) across each # storey of the part. Pre-fix `ground_perim × avg_height × count` # over-counts upper storeys whenever they have a different # perimeter (e.g. set-back top floor, Elmhurst 000474 Main). for fd in part.sap_floor_dimensions: fa = fd.total_floor_area_m2 or 0.0 fh = fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M sum_per_storey_area_m2 += fa sum_per_storey_volume_m3 += fa * fh gross_wall += (fd.heat_loss_perimeter_m or 0.0) * fh party_wall += (fd.party_wall_length_m or 0.0) * fh # Room-in-roof: counts as one additional storey per RdSAP §1.8 + # §3.9. Both failing certs in the golden suite are Simplified # type 1 (gable lengths only), which RdSAP §3.9.1 says uses a # fixed 2.45 m storey height. TODO: handle Simplified type 2 # (RR with continuous common walls outside RR boundaries, # §3.9.2) and Detailed (actual measured dimensions, §3.10 + # Figure 4) — neither path appears in current corpus, but # downstream calcs will silently use 2.45 m if we hit one. rir = part.sap_room_in_roof rir_adds_storey = 0 if rir is not None and rir.floor_area > 0: sum_per_storey_area_m2 += rir.floor_area sum_per_storey_volume_m3 += ( rir.floor_area * _RR_SIMPLIFIED_STOREY_HEIGHT_M ) rir_adds_storey = 1 part_storey_counts.append(part_floor_count + rir_adds_storey) total_storey_count = max(part_storey_counts) if part_storey_counts else 0 has_storeys = sum_per_storey_area_m2 > 0 avg_height = ( sum_per_storey_volume_m3 / sum_per_storey_area_m2 if has_storeys else _DEFAULT_STOREY_HEIGHT_M ) return Dimensions( total_floor_area_m2=( sum_per_storey_area_m2 if has_storeys else epc.total_floor_area_m2 ), volume_m3=( sum_per_storey_volume_m3 if has_storeys else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M ), 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, )