From 4b83e7023f2aee8e6e62ef0d8f2865ce3a4db354 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 20:28:46 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A76=20slice=202:=20solar=5Fgains=5Ffrom=5Fc?= =?UTF-8?q?ert=20orchestrator=20(000490=20line=20(83)=20=E2=89=A45e-3=20W)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RoofWindowInput + RooflightInput + SolarGainsResult dataclasses and the solar_gains_from_cert orchestrator. Aggregates per-orientation sums from epc.sap_windows (Table 6b/6c/6d lookups internal); roof windows take explicit pitch (RdSAP10 Table 24 default 45°, Z=1.0) and rooflights are horizontal per SAP10.2 §U3.2 p128 (pitch=0°, Z=1.0). Driven by U985-0001-000490 worksheet (83) total solar gains 12-tuple to abs=5e-3 W (audit reconciled the underlying flux + window-gain leaves to ≤5e-5 W; the 5e-3 W budget is the conformance ceiling for §6). Table 6b g⊥ values are corrected vs cert_to_inputs._window_inputs (which ships 0.72 for codes 2&3 — the spec is 0.76 for "Double glazed (air or argon filled)"). The legacy lookup dies in slice 8 when _window_inputs is deleted. Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/worksheet/solar_gains.py | 258 ++++++++++++++++++ .../sap/worksheet/tests/test_solar_gains.py | 43 +++ 2 files changed, 301 insertions(+) diff --git a/packages/domain/src/domain/sap/worksheet/solar_gains.py b/packages/domain/src/domain/sap/worksheet/solar_gains.py index c7f86d76..041ba179 100644 --- a/packages/domain/src/domain/sap/worksheet/solar_gains.py +++ b/packages/domain/src/domain/sap/worksheet/solar_gains.py @@ -29,10 +29,12 @@ coefficient sets (N, NE/NW, E/W, SE/SW, S). from __future__ import annotations +from dataclasses import dataclass from enum import Enum from math import cos, radians, sin from typing import Final +from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow from domain.sap.climate.appendix_u import ( horizontal_solar_irradiance_w_per_m2, solar_declination_deg, @@ -153,3 +155,259 @@ def window_solar_gain_w( already bakes in 0.9 × g⊥ × FF, use `window_solar_gain_w_bfrc` instead. """ return 0.9 * area_m2 * surface_flux_w_per_m2 * g_perpendicular * frame_factor * overshading_factor + + +# SAP 10.2 Table 6b — total solar energy transmittance g⊥ at normal incidence +# by SAP10 glazing_type code (p178). Default 0.76 (modal double-glazed UK +# stock) when the cert's glazing_type is missing or unrecognised. +_G_PERPENDICULAR_BY_GLAZING_TYPE: Final[dict[int, float]] = { + 1: 0.85, # single glazed + 2: 0.76, # double glazed (air or argon filled) — 2002-2022 + 3: 0.76, # double glazed (air or argon filled) — pre-2002 + 4: 0.63, # double glazed low-E soft-coat + 5: 0.76, # window with secondary glazing + 6: 0.68, # triple glazed (air or argon filled) +} +_G_PERPENDICULAR_DEFAULT: Final[float] = 0.76 + + +# SAP 10.2 Table 6c — frame factor (proportion of opening glazed) by frame +# material substring. Case-insensitive matching; cert lodges this as free +# text ("PVC", "Wood", "Metal"). +_FRAME_FACTOR_BY_MATERIAL_SUBSTR: Final[tuple[tuple[str, float], ...]] = ( + ("metal", 0.8), + ("aluminium", 0.8), + ("wood", 0.7), + ("timber", 0.7), + ("pvc", 0.7), + ("upvc", 0.7), + ("composite", 0.7), +) +_FRAME_FACTOR_DEFAULT: Final[float] = 0.7 + + +# SAP10 octant code → Orientation enum. Cert windows with a code outside 1..8 +# (e.g. 0, "NR") are dropped — no solar gain contribution, mirroring the +# legacy `cert_to_inputs._window_inputs` shortcut. +_ORIENTATION_BY_SAP10_CODE: Final[dict[int, Orientation]] = { + 1: Orientation.N, + 2: Orientation.NE, + 3: Orientation.E, + 4: Orientation.SE, + 5: Orientation.S, + 6: Orientation.SW, + 7: Orientation.W, + 8: Orientation.NW, +} + + +_ROOF_WINDOW_DEFAULT_PITCH_DEG: Final[float] = 45.0 # RdSAP10 Table 24 +_ROOFLIGHT_PITCH_DEG: Final[float] = 0.0 # SAP10.2 §U3.2 p128 +_HORIZONTAL_Z: Final[float] = 1.0 # Table 6d note 2 — roof windows + rooflights +_MONTHS: Final[range] = range(1, 13) + + +@dataclass(frozen=True) +class RoofWindowInput: + """SAP 10.2 §6 "Roof window" — glazed opening in a pitched roof. Pitch + < 70° is treated at its actual inclination; pitch ≥ 70° collapses to + vertical per §U3.2. Z=1.0 regardless of overshading (Table 6d note 2). + """ + + area_m2: float + orientation: Orientation + g_perpendicular: float + frame_factor: float + pitch_deg: float = _ROOF_WINDOW_DEFAULT_PITCH_DEG + + +@dataclass(frozen=True) +class RooflightInput: + """SAP 10.2 §6 "Rooflight" — horizontal glazed opening. Per §U3.2 p128 + rooflights are assumed horizontal (pitch = 0°); orientation is therefore + immaterial (the §U3.2 polynomial degenerates to S_h,m at pitch 0). + Z=1.0 regardless of overshading (Table 6d note 2). + """ + + area_m2: float + g_perpendicular: float + frame_factor: float + + +@dataclass(frozen=True) +class SolarGainsResult: + """SAP 10.2 §6 line refs (74)..(83), each a 12-tuple of watts per month. + + Returned by `solar_gains_from_cert`. Downstream §7 utilisation factor + + §9 heat-balance consume `total_solar_gains_monthly_w` directly; the + per-orientation tuples are exposed for worksheet conformance + audit. + Field names mirror the SAP 10.2 line refs. + """ + + north_monthly_w: tuple[float, ...] # (74) + northeast_monthly_w: tuple[float, ...] # (75) + east_monthly_w: tuple[float, ...] # (76) + southeast_monthly_w: tuple[float, ...] # (77) + south_monthly_w: tuple[float, ...] # (78) + southwest_monthly_w: tuple[float, ...] # (79) + west_monthly_w: tuple[float, ...] # (80) + northwest_monthly_w: tuple[float, ...] # (81) + roof_windows_monthly_w: tuple[float, ...] # (82) + rooflights_monthly_w: tuple[float, ...] # (82a) + total_solar_gains_monthly_w: tuple[float, ...] # (83) + + +def _g_perpendicular(w: SapWindow) -> float: + """Table 6b g⊥ by glazing_type code, defaulting to 0.76 for unknowns.""" + if isinstance(w.glazing_type, int) and w.glazing_type in _G_PERPENDICULAR_BY_GLAZING_TYPE: + return _G_PERPENDICULAR_BY_GLAZING_TYPE[w.glazing_type] + return _G_PERPENDICULAR_DEFAULT + + +def _frame_factor(w: SapWindow) -> float: + """Table 6c frame factor. Prefer cert's `frame_factor`; else look up + by `frame_material` substring.""" + if w.frame_factor is not None: + return float(w.frame_factor) + material = (w.frame_material or "").lower() + for needle, ff in _FRAME_FACTOR_BY_MATERIAL_SUBSTR: + if needle in material: + return ff + return _FRAME_FACTOR_DEFAULT + + +def _orientation(w: SapWindow) -> Orientation | None: + """Map cert `orientation` code (1..8) to enum; None for unmapped.""" + if isinstance(w.orientation, int) and w.orientation in _ORIENTATION_BY_SAP10_CODE: + return _ORIENTATION_BY_SAP10_CODE[w.orientation] + return None + + +def _vertical_window_gain_monthly_w( + *, + w: SapWindow, + orientation: Orientation, + region: int, + z_solar: float, +) -> tuple[float, ...]: + """Compute the 12-tuple of monthly solar gain (W) for one vertical wall + window. Pitch = 90° always; Table 6b/6c lookups derive g⊥ and FF.""" + area = float(w.window_width) * float(w.window_height) + g_perp = _g_perpendicular(w) + ff = _frame_factor(w) + return tuple( + window_solar_gain_w( + area_m2=area, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=orientation, pitch_deg=90.0, region=region, month=m, + ), + g_perpendicular=g_perp, + frame_factor=ff, + overshading_factor=z_solar, + ) + for m in _MONTHS + ) + + +def _sum_tuples(*tuples: tuple[float, ...]) -> tuple[float, ...]: + """Element-wise sum of 12-tuples. Returns 12-tuple of zeros if empty.""" + if not tuples: + return (0.0,) * 12 + return tuple(sum(t[i] for t in tuples) for i in range(12)) + + +def solar_gains_from_cert( + *, + epc: EpcPropertyData, + region: int, + overshading: OvershadingCategory = OvershadingCategory.AVERAGE, + roof_windows: tuple[RoofWindowInput, ...] = (), + rooflights: tuple[RooflightInput, ...] = (), +) -> SolarGainsResult: + """SAP 10.2 §6 orchestrator — chain (74)..(83) for the dwelling. + + Inputs: + epc cert (sap_windows feed (74)..(81); rooflights NOT lodged) + region SAP climate region (0 = UK avg for SAP rating pass) + overshading Table 6d bucket → Z for vertical wall windows + roof_windows RdSAP §6 "Roof window" — pitched, Z=1.0 + rooflights RdSAP §6 "Rooflight" — horizontal, Z=1.0 + + Coverage caveat: cert summaries do not lodge a distinct rooflight code, + so `cert_to_inputs` passes both `roof_windows` and `rooflights` as empty + tuples. Conformance against fixtures with roof glazing is exercised via + explicit `_build_section_6_epc(fixture)` test wrappers. + """ + z_vertical = z_solar_for_overshading(overshading) + + per_orientation: dict[Orientation, list[tuple[float, ...]]] = { + o: [] for o in Orientation + } + for w in epc.sap_windows: + orientation = _orientation(w) + if orientation is None: + continue + per_orientation[orientation].append( + _vertical_window_gain_monthly_w( + w=w, orientation=orientation, region=region, z_solar=z_vertical, + ) + ) + + roof_windows_monthly = _sum_tuples(*( + tuple( + window_solar_gain_w( + area_m2=rw.area_m2, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=rw.orientation, pitch_deg=rw.pitch_deg, + region=region, month=m, + ), + g_perpendicular=rw.g_perpendicular, + frame_factor=rw.frame_factor, + overshading_factor=_HORIZONTAL_Z, + ) + for m in _MONTHS + ) + for rw in roof_windows + )) + + rooflights_monthly = _sum_tuples(*( + tuple( + window_solar_gain_w( + area_m2=rl.area_m2, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=Orientation.N, # immaterial at pitch 0 + pitch_deg=_ROOFLIGHT_PITCH_DEG, + region=region, month=m, + ), + g_perpendicular=rl.g_perpendicular, + frame_factor=rl.frame_factor, + overshading_factor=_HORIZONTAL_Z, + ) + for m in _MONTHS + ) + for rl in rooflights + )) + + per_orientation_summed = { + o: _sum_tuples(*per_orientation[o]) for o in Orientation + } + + total = _sum_tuples( + *per_orientation_summed.values(), + roof_windows_monthly, + rooflights_monthly, + ) + + return SolarGainsResult( + north_monthly_w=per_orientation_summed[Orientation.N], + northeast_monthly_w=per_orientation_summed[Orientation.NE], + east_monthly_w=per_orientation_summed[Orientation.E], + southeast_monthly_w=per_orientation_summed[Orientation.SE], + south_monthly_w=per_orientation_summed[Orientation.S], + southwest_monthly_w=per_orientation_summed[Orientation.SW], + west_monthly_w=per_orientation_summed[Orientation.W], + northwest_monthly_w=per_orientation_summed[Orientation.NW], + roof_windows_monthly_w=roof_windows_monthly, + rooflights_monthly_w=rooflights_monthly, + total_solar_gains_monthly_w=total, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py b/packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py index 4bd239ea..3fa8d631 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py @@ -11,15 +11,58 @@ Appendix U §U3.2 (pages 127-129), Table U4 (latitudes), Table U5 import pytest +from datatypes.epc.domain.epc_property_data import SapWindow +from domain.ml.tests._fixtures import make_minimal_sap10_epc, make_window from domain.sap.worksheet.internal_gains import OvershadingCategory from domain.sap.worksheet.solar_gains import ( Orientation, + RoofWindowInput, + RooflightInput, + solar_gains_from_cert, surface_solar_flux_w_per_m2, window_solar_gain_w, z_solar_for_overshading, ) +# Worksheet U985-0001-000490 reference (UK-avg weather, region 0): +# 3 windows DG-pre-2002 / PVC / 12mm: NE 0.81 m², SE 5.52 m², NW 2.70 m². +# (83) total solar gains W per month (Jan..Dec). +_W000490_LINE_83_TOTAL_SOLAR_W: tuple[float, ...] = ( + 89.4795, 157.2665, 228.0608, 304.1703, 360.4042, 366.4669, + 349.7056, 306.4273, 254.2093, 177.2863, 108.0591, 76.0043, +) + + +def test_solar_gains_from_cert_reproduces_000490_line_83_total() -> None: + # Arrange — Elmhurst U985-0001-000490: 3 vertical wall windows, DG pre-2002, + # PVC frame, AVERAGE overshading (Z=0.77), region 0 (UK-avg weather, SAP + # rating pass). No roof windows, no rooflights. Per-window inputs derived + # from worksheet §6 row F-column values: g⊥=0.76 (Table 6b "Double glazed + # (air or argon filled)"), FF=0.70 (Table 6c PVC-U). + epc = make_minimal_sap10_epc( + total_floor_area_m2=53.0, + sap_windows=[ + make_window(orientation=2, width=0.81, height=1.0), # NE 0.81 m² + make_window(orientation=4, width=5.52, height=1.0), # SE 5.52 m² + make_window(orientation=8, width=2.70, height=1.0), # NW 2.70 m² + ], + ) + + # Act + result = solar_gains_from_cert( + epc=epc, + region=0, + overshading=OvershadingCategory.AVERAGE, + ) + + # Assert + for m, expected in enumerate(_W000490_LINE_83_TOTAL_SOLAR_W): + assert result.total_solar_gains_monthly_w[m] == pytest.approx( + expected, abs=5e-3 + ), f"(83) month {m+1}" + + def test_z_solar_for_overshading_returns_table_6d_first_column() -> None: # Arrange — SAP 10.2 Table 6d "Winter solar access factor (for calculation # of solar gains for heating)" — first numeric column on p178. Used for