Model/domain/sap10_calculator/worksheet/dimensions.py
Khalim Conn-Kowlessar d4d2b222fc feat(conservatory): §6.1 fabric cascade (27/27a/28a + TFA/volume)
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>
2026-06-16 15:59:26 +00:00

181 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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