mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Wire the non-separated conservatory into the §3 heat-transmission +
§1 dimensions cascade per RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51):
"The floor area and volume of a non-separated conservatory are added to
the total floor area and volume of the dwelling. Its roof area is taken
as its floor area divided by cos(20°), and wall area is taken as the
product of its exposed perimeter and its height. ... The conservatory
walls and roof are taken as fully glazed ... Glazed walls are taken as
windows, glazed roof as rooflight."
New `worksheet/conservatory.py` derives the geometry:
- height from the equivalent storey count (§6.1: 1 storey → ground-floor
room height; 1½ → ground + 0.25 + 0.5×first; etc.);
- glazed WALL → window (27) at Table 25 U (double 3.1 / single 4.8) with
the §3.2 curtain resistance (R=0.04) → U_eff 2.758;
- glazed ROOF → rooflight (27a) at Table 25 roof U (double 3.4 / single
5.3) + curtain → U_eff 2.993;
- FLOOR → (28a) via BS EN ISO 13370 as an uninsulated SOLID ground floor
with 300 mm walls (§5.12, spec p.43), exposed perimeter = glazed
perimeter → U 0.89;
- glazed wall + roof + floor areas join (31)/(36); the fully-glazed
structure walls/roof add nothing (the glazing IS the window/rooflight).
`dimensions_from_cert` adds the conservatory floor area to TFA (4) and
floor area × height to volume (5) (feeds ventilation (8)), without making
it a storey (avg storey height for §2 infiltration is unchanged).
Pinned against the simulated case-44 P960 §3 at abs=1e-4 — every line ref
EXACT: (4) 95.3800, (5) 257.1630, (27) 96.1169, (27a) 38.2201, (28a)
21.4164, (29a) 35.5852, (30) 7.4688, (31) 294.2900, (33) 207.3274,
(36) 23.5432. The remaining whole-dwelling SAP/CO2 gap is the §6 solar
gains, closed in the next slice. Worksheet harness stays 47/47 0-raised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
7.3 KiB
Python
181 lines
7.3 KiB
Python
"""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
|
||
from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry
|
||
|
||
_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` (used by §2 (9) dwelling height → infiltration) is a
|
||
# property of the dwelling's storeys, so the conservatory is excluded
|
||
# from it. The conservatory IS added to TFA (4) and volume (5) per
|
||
# RdSAP 10 §6.1 ("The floor area and volume of a non-separated
|
||
# conservatory are added to the total floor area and volume of the
|
||
# dwelling") — it just doesn't form a storey.
|
||
avg_height = (
|
||
sum_per_storey_volume_m3 / sum_per_storey_area_m2
|
||
if has_storeys
|
||
else _DEFAULT_STOREY_HEIGHT_M
|
||
)
|
||
cons = conservatory_geometry(epc)
|
||
cons_floor_area_m2 = cons.floor_area_m2 if cons is not None else 0.0
|
||
cons_volume_m3 = cons.volume_m3 if cons is not None else 0.0
|
||
return Dimensions(
|
||
total_floor_area_m2=(
|
||
sum_per_storey_area_m2 + cons_floor_area_m2
|
||
if has_storeys
|
||
else epc.total_floor_area_m2
|
||
),
|
||
volume_m3=(
|
||
sum_per_storey_volume_m3 + cons_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,
|
||
)
|