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