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>
149 lines
6.2 KiB
Python
149 lines
6.2 KiB
Python
"""RdSAP 10 §6.1 — non-separated (heated) conservatory geometry.
|
||
|
||
A non-separated conservatory is treated as part of the dwelling
|
||
(RdSAP 10 Specification, 9th June 2025, §6.1 + Table 25, pages 49-51):
|
||
|
||
- its floor area and volume are added to TFA (4) and volume (5);
|
||
- its fully-glazed walls bill as a window — line (27) — at the Table 25
|
||
"U-value of window"; its glazed roof bills as a rooflight — line (27a)
|
||
— at the Table 25 "U-value of roof window"; both U-values already
|
||
include the §3.2 curtain resistance (R=0.04 m²K/W);
|
||
- its floor adds a ground-loss term — line (28a) — via BS EN ISO 13370,
|
||
taken as an uninsulated solid floor with 300 mm walls (§5.12 note,
|
||
spec p.43), exposed perimeter = glazed perimeter;
|
||
- its glazed wall + glazed roof + floor areas count toward the total
|
||
exposed area (31) and hence thermal bridging (36); the fully-glazed
|
||
"structure" walls/roof themselves add nothing (the glazing IS the
|
||
window/rooflight).
|
||
|
||
Its roof area is the floor area / cos(20°) and its wall area is the
|
||
exposed perimeter × height; the height is translated from the lodged
|
||
equivalent storey count (§6.1): 1 storey → ground-floor room height;
|
||
1½ → ground + 0.25 + 0.5×first; 2 → ground + 0.25 + first; etc.
|
||
|
||
A SEPARATED conservatory (§6.2) is disregarded entirely — the mapper
|
||
maps it to None, so it never reaches this module.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from math import cos, radians
|
||
from typing import Final, Optional
|
||
|
||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||
|
||
# RdSAP 10 §6.1 — conservatory roof area = floor area / cos(20°); §6.1
|
||
# also fixes the rooflight solar pitch at 20°.
|
||
CONSERVATORY_ROOF_PITCH_DEG: Final[float] = 20.0
|
||
_COS_ROOF_PITCH: Final[float] = cos(radians(CONSERVATORY_ROOF_PITCH_DEG))
|
||
|
||
# RdSAP 10 Table 25 (PDF p.51) — default conservatory glazing U-values
|
||
# (W/m²K, INCLUSIVE of the §3.2 curtain resistance) and g-values. The
|
||
# Summary lodges only double vs single (no triple), so a bool selects the
|
||
# row: True → double (6 mm gap), False → single.
|
||
_TABLE_25_WALL_U: Final[dict[bool, float]] = {True: 3.1, False: 4.8}
|
||
_TABLE_25_ROOF_U: Final[dict[bool, float]] = {True: 3.4, False: 5.3}
|
||
_TABLE_25_G_VALUE: Final[dict[bool, float]] = {True: 0.76, False: 0.85}
|
||
_TABLE_25_FRAME_FACTOR: Final[float] = 0.70 # Table 25 — wood/PVC frame
|
||
|
||
# SAP 10.2 §3.2 formula (2) curtain/blind resistance. Table 25 U-values
|
||
# are "adjusted for curtains" already, so the EFFECTIVE conduction U is
|
||
# 1 / (1/U_table25 + 0.04) — the same transform `heat_transmission`
|
||
# applies to regular windows/rooflights.
|
||
_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04
|
||
|
||
# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an
|
||
# uninsulated solid ground floor with 300 mm walls.
|
||
_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300
|
||
_AREA_ROUND_DP: Final[int] = 2
|
||
|
||
|
||
def _round2(value: float) -> float:
|
||
"""RdSAP 10 §15 (p.66): element areas + conservatory height → 2 d.p."""
|
||
return float(
|
||
Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ConservatoryGeometry:
|
||
"""Derived §6.1 geometry for one non-separated conservatory. Areas and
|
||
height are rounded to 2 d.p. per RdSAP 10 §15."""
|
||
|
||
height_m: float
|
||
floor_area_m2: float
|
||
glazed_wall_area_m2: float
|
||
glazed_roof_area_m2: float
|
||
glazed_perimeter_m: float
|
||
wall_u_raw: float # Table 25 window U, pre-curtain
|
||
roof_u_raw: float # Table 25 roof-window U, pre-curtain
|
||
wall_u_eff: float # post-curtain conduction U for line (27)
|
||
roof_u_eff: float # post-curtain conduction U for line (27a)
|
||
g_value: float
|
||
frame_factor: float
|
||
volume_m3: float
|
||
|
||
|
||
def _conservatory_height_m(epc: EpcPropertyData, storeys: float) -> float:
|
||
"""Translate the equivalent storey count into a metre height per
|
||
RdSAP 10 §6.1 using the dwelling's per-storey room heights:
|
||
|
||
1 storey → ground-floor room height
|
||
1½ storey → ground + 0.25 + 0.5 × first-floor room height
|
||
2 storey → ground + 0.25 + first-floor room height
|
||
etc.
|
||
|
||
Room heights are taken from the Main building part's floor
|
||
dimensions (floor 0 = ground, 1 = first, ...). Returns 0.0 when no
|
||
storeys are lodged (defensive; the conservatory then bills no walls)."""
|
||
parts = epc.sap_building_parts or []
|
||
heights: list[float] = []
|
||
if parts:
|
||
fds = sorted(
|
||
parts[0].sap_floor_dimensions,
|
||
key=lambda fd: fd.floor if fd.floor is not None else 0,
|
||
)
|
||
heights = [fd.room_height_m for fd in fds if fd.room_height_m]
|
||
if not heights:
|
||
return 0.0
|
||
n_full = int(storeys)
|
||
height = heights[0]
|
||
for s in range(1, n_full):
|
||
height += 0.25 + heights[min(s, len(heights) - 1)]
|
||
if storeys - n_full >= 0.5:
|
||
height += 0.25 + 0.5 * heights[min(n_full, len(heights) - 1)]
|
||
return _round2(height)
|
||
|
||
|
||
def conservatory_geometry(
|
||
epc: EpcPropertyData,
|
||
) -> Optional[ConservatoryGeometry]:
|
||
"""Build the §6.1 conservatory geometry, or None when there is no
|
||
(non-separated) conservatory."""
|
||
cons = epc.sap_conservatory
|
||
if cons is None or cons.thermally_separated:
|
||
return None
|
||
height = _conservatory_height_m(epc, cons.room_height_storeys)
|
||
floor_area = cons.floor_area_m2
|
||
glazed_perimeter = cons.glazed_perimeter_m
|
||
glazed_wall = _round2(glazed_perimeter * height)
|
||
glazed_roof = _round2(floor_area / _COS_ROOF_PITCH)
|
||
dg = cons.double_glazed
|
||
wall_u_raw = _TABLE_25_WALL_U[dg]
|
||
roof_u_raw = _TABLE_25_ROOF_U[dg]
|
||
return ConservatoryGeometry(
|
||
height_m=height,
|
||
floor_area_m2=floor_area,
|
||
glazed_wall_area_m2=glazed_wall,
|
||
glazed_roof_area_m2=glazed_roof,
|
||
glazed_perimeter_m=glazed_perimeter,
|
||
wall_u_raw=wall_u_raw,
|
||
roof_u_raw=roof_u_raw,
|
||
wall_u_eff=1.0 / (1.0 / wall_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W),
|
||
roof_u_eff=1.0 / (1.0 / roof_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W),
|
||
g_value=_TABLE_25_G_VALUE[dg],
|
||
frame_factor=_TABLE_25_FRAME_FACTOR,
|
||
volume_m3=floor_area * height,
|
||
)
|