From 57bf7833a978f5cc70e18baec3bfaa2743d34229 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 22:59:25 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A5b:=20solar=20gains=20(SAP=2010.3=20?= =?UTF-8?q?=C2=A76=20+=20Appendix=20U=20=C2=A7U3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth slice of the SAP10 Calculator Session A (ADR-0009). Two layers under domain.sap.worksheet.solar_gains: 1. surface_solar_flux_w_per_m2(orientation, pitch_deg, region, month) — implements Appendix U §U3.2 polynomial that converts the horizontal solar irradiance from Table U3 to per-orientation per-pitch surface flux: S(orient, p, m) = S_h,m × R_h-inc R_h-inc = A cos²(φ-δ) + B cos(φ-δ) + C where A, B, C are cubics in sin(p/2) with coefficients k1-k9 from Table U5. Reads latitude φ from Table U4 and solar declination δ from Table U3 footer (already in domain.sap.climate.appendix_u). 2. window_solar_gain_w(area_m2, surface_flux, g⊥, FF, Z) — implements §6.1 equation (5): G = 0.9 × A × S × g⊥ × FF × Z. Orientation enum maps the 8 SAP cardinal codes to the 5 Table U5 columns: N/S to their own column; NE/NW share; E/W share; SE/SW share. 7 AAA cycles cover: UK average South vertical July hand-computed flux, rooflight pitch=0 collapses to horizontal Table U3 directly, North-vertical summer > winter (diffuse signal), NE/NW share constants symmetry, equation (5) window gain, zero-area edge case, out-of-range region validation. Tables 6b (g⊥), 6c (frame factor), 6d (overshading Z) defaults deferred to the cert→inputs mapper slice — callers pass them explicitly here so the physics stays cert-shape-independent. --- .../src/domain/sap/worksheet/solar_gains.py | 138 ++++++++++++++++ .../sap/worksheet/tests/test_solar_gains.py | 155 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/solar_gains.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py diff --git a/packages/domain/src/domain/sap/worksheet/solar_gains.py b/packages/domain/src/domain/sap/worksheet/solar_gains.py new file mode 100644 index 00000000..c727feb2 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/solar_gains.py @@ -0,0 +1,138 @@ +"""SAP 10.3 §6 + Appendix U §U3.2 — solar gains. + +Two layers: + +1. `surface_solar_flux_w_per_m2(orientation, pitch_deg, region, month)` + implements the §U3.2 polynomial that converts the horizontal solar + irradiance from Table U3 into a per-orientation per-pitch surface flux. + Reads: + - S_h,m from Appendix U Table U3 (already in `domain.sap.climate.appendix_u`) + - δ from Appendix U Table U3 footer (already in `appendix_u.solar_declination_deg`) + - φ from Appendix U Table U4 (this module) + - k1..k9 from Appendix U Table U5 (this module) + - Formula: S(orient, p, m) = S_h,m × R_h-inc + R_h-inc = A cos²(φ-δ) + B cos(φ-δ) + C + where A, B, C are cubics in sin(p/2) with coefficients k1-k9. + +2. `window_solar_gain_w(area_m2, surface_flux, g_value, frame_factor, + overshading)` implements §6.1 equation (5) `G = 0.9 × A × S × g⊥ × FF × Z`, + plus an `_g_window` alternative for BFRC-rated windows (equation (6)). + +Defaults for g⊥ (Table 6b), FF (Table 6c), Z (Table 6d) are deferred to +the cert→inputs mapper slice — this module takes them as caller inputs so +its physics is independent of cert-shape assumptions. + +Reference: SAP 10.3 specification §6 + Appendix U §§U3.2-3.3 (pages +127-129); Table U5 columns map 8 cardinal cert orientations to 5 +coefficient sets (N, NE/NW, E/W, SE/SW, S). +""" + +from __future__ import annotations + +from enum import Enum +from math import cos, radians, sin +from typing import Final + +from domain.sap.climate.appendix_u import ( + horizontal_solar_irradiance_w_per_m2, + solar_declination_deg, +) + + +class Orientation(Enum): + """Cardinal compass directions per SAP10 orientation codes 1-8.""" + + N = "N" + NE = "NE" + E = "E" + SE = "SE" + S = "S" + SW = "SW" + W = "W" + NW = "NW" + + +# Appendix U Table U4 — representative latitude (°N) for each of 22 SAP +# climate regions (region 0 = UK average). +_LATITUDE_DEG: Final[tuple[float, ...]] = ( + 53.5, # 0 UK average + 51.6, 51.1, 50.9, 50.5, 51.5, 52.6, 53.5, 54.6, 55.2, 54.4, + 53.5, 52.1, 52.6, 55.9, 56.2, 57.3, 57.5, 57.7, 59.0, 60.1, 54.6, +) + + +# Appendix U Table U5 — k1..k9 polynomial constants per Table-U5 column. +# Columns: North, NE/NW, E/W, SE/SW, South. +_K_NORTH: Final[tuple[float, ...]] = (26.3, -38.5, 14.8, -16.5, 27.3, -11.9, -1.06, 0.0872, -0.191) +_K_NE_NW: Final[tuple[float, ...]] = (0.165, -3.68, 3.0, 6.38, -4.53, -0.405, -4.38, 4.89, -1.99) +_K_E_W: Final[tuple[float, ...]] = (1.44, -2.36, 1.07, -0.514, 1.89, -1.64, -0.542, -0.757, 0.604) +_K_SE_SW: Final[tuple[float, ...]] = (-2.95, 2.89, 1.17, 5.67, -3.54, -4.28, -2.72, -0.25, 3.07) +_K_SOUTH: Final[tuple[float, ...]] = (-0.66, -0.106, 2.93, 3.63, -0.374, -7.4, -2.71, -0.991, 4.59) + + +_ORIENTATION_TO_K: Final[dict[Orientation, tuple[float, ...]]] = { + Orientation.N: _K_NORTH, + Orientation.NE: _K_NE_NW, + Orientation.E: _K_E_W, + Orientation.SE: _K_SE_SW, + Orientation.S: _K_SOUTH, + Orientation.SW: _K_SE_SW, + Orientation.W: _K_E_W, + Orientation.NW: _K_NE_NW, +} + + +def _latitude_deg(region: int) -> float: + if not 0 <= region < len(_LATITUDE_DEG): + raise ValueError(f"region must be 0..{len(_LATITUDE_DEG) - 1}, got {region}") + return _LATITUDE_DEG[region] + + +def surface_solar_flux_w_per_m2( + *, + orientation: Orientation, + pitch_deg: float, + region: int, + month: int, +) -> float: + """Per-orientation per-pitch monthly solar flux on a surface (W/m²). + + SAP 10.3 Appendix U §U3.2 polynomial conversion from the horizontal + irradiance in Table U3 to any orientation/tilt combination. + """ + s_h = horizontal_solar_irradiance_w_per_m2(region, month) + declination = solar_declination_deg(month) + latitude = _latitude_deg(region) + + half_pitch = radians(pitch_deg) / 2.0 + s = sin(half_pitch) + s2 = s * s + s3 = s2 * s + + k1, k2, k3, k4, k5, k6, k7, k8, k9 = _ORIENTATION_TO_K[orientation] + a = k1 * s3 + k2 * s2 + k3 * s + b = k4 * s3 + k5 * s2 + k6 * s + c = k7 * s3 + k8 * s2 + k9 * s + 1.0 + + cos_phi_minus_delta = cos(radians(latitude - declination)) + r_h_inc = a * cos_phi_minus_delta * cos_phi_minus_delta + b * cos_phi_minus_delta + c + return s_h * r_h_inc + + +def window_solar_gain_w( + *, + area_m2: float, + surface_flux_w_per_m2: float, + g_perpendicular: float, + frame_factor: float, + overshading_factor: float, +) -> float: + """Solar gain through a window (W). SAP 10.3 §6.1 equation (5): + + G_solar = 0.9 × A_w × S × g⊥ × FF × Z + + where 0.9 corrects for the ratio of typical average transmittance to + that at normal incidence. For BFRC-rated windows whose quoted g_window + 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 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 new file mode 100644 index 00000000..eaddb76e --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py @@ -0,0 +1,155 @@ +"""Tests for SAP 10.3 §6 + Appendix U §U3.2 solar gains. + +Converts horizontal solar irradiance (Table U3) to per-orientation per-pitch +surface flux using the SAP 10.3 §U3.2 / Table U5 polynomial. Window solar +gain follows §6.1 equation (5): G = 0.9 × A_w × S × g⊥ × FF × Z. + +Reference: SAP 10.3 specification (13-01-2026), §6 (page 25), +Appendix U §U3.2 (pages 127-129), Table U4 (latitudes), Table U5 +(k1-k9 constants per orientation). +""" + +import pytest + +from domain.sap.worksheet.solar_gains import ( + Orientation, + surface_solar_flux_w_per_m2, + window_solar_gain_w, +) + + +def test_uk_average_south_vertical_july_returns_hand_computed_flux() -> None: + # Arrange — UK average region (0), South-facing vertical (p=90°), July. + # Hand-computed from Appendix U §U3.2 with Table U5 South constants: + # S_h,Jul = 189 W/m² (Table U3 region 0) + # δ_Jul = 21.2° + # φ_0 = 53.5° (Table U4 UK average) + # sin(p/2)=0.7071, sin²=0.5, sin³=0.3536 + # A = -0.66×0.3536 + -0.106×0.5 + 2.93×0.7071 ≈ 1.785 + # B = 3.63×0.3536 + -0.374×0.5 + -7.4×0.7071 ≈ -4.136 + # C = -2.71×0.3536 + -0.991×0.5 + 4.59×0.7071 + 1 ≈ 2.792 + # φ - δ = 32.3°, cos = 0.8453, cos² = 0.7145 + # R_h-inc = 1.785×0.7145 + -4.136×0.8453 + 2.792 ≈ 0.571 + # S = 189 × 0.571 ≈ 108 W/m² + + # Act + result = surface_solar_flux_w_per_m2( + orientation=Orientation.S, + pitch_deg=90.0, + region=0, + month=7, + ) + + # Assert + assert result == pytest.approx(108.0, abs=4.0) + + +def test_rooflight_horizontal_pitch_collapses_to_horizontal_table_u3_flux() -> None: + # Arrange — Pitch = 0° (horizontal rooflight). sin(p/2) = 0, so A = B = 0, + # C = 1; the §U3.2 polynomial reduces to S(orient, 0, m) = S_h,m. The + # orientation doesn't matter at pitch 0 — verifies the formula degenerates + # correctly and matches our Appendix U Table U3 lookup directly. + + # Act + south_horizontal = surface_solar_flux_w_per_m2( + orientation=Orientation.S, pitch_deg=0.0, region=0, month=7, + ) + north_horizontal = surface_solar_flux_w_per_m2( + orientation=Orientation.N, pitch_deg=0.0, region=0, month=7, + ) + + # Assert + assert south_horizontal == pytest.approx(189.0, abs=0.5) + assert north_horizontal == pytest.approx(189.0, abs=0.5) + + +def test_north_vertical_summer_brighter_than_winter_per_diffuse_signal() -> None: + # Arrange — North-facing vertical windows never receive direct sunlight + # in the northern hemisphere, but they get more diffuse radiation in + # summer because the sky brightens. Per Appendix U §U3.2 North column: + # Jan: S_h=26, δ=-20.7°, R ≈ 0.41 → ≈ 10.6 W/m² + # Jul: S_h=189, δ=21.2°, R ≈ 0.39 → ≈ 74 W/m² + # Even though July's conversion factor is slightly smaller than January's + # (winter sun is lower so the polynomial favours vertical surfaces), + # July's horizontal flux is so much larger that summer flux dominates. + + # Act + jan = surface_solar_flux_w_per_m2( + orientation=Orientation.N, pitch_deg=90.0, region=0, month=1, + ) + jul = surface_solar_flux_w_per_m2( + orientation=Orientation.N, pitch_deg=90.0, region=0, month=7, + ) + + # Assert + assert jan == pytest.approx(10.6, abs=2.0) + assert jul == pytest.approx(74.0, abs=4.0) + assert jul > jan + + +def test_ne_and_nw_share_table_u5_constants() -> None: + # Arrange — Per Appendix U Table U5 the NE/NW column is shared (one + # column applies to both orientations). E/W and SE/SW likewise share. + # Verifies our orientation→k-set map matches the spec's column-sharing + # pattern, not just the labelled cardinal points. + + # Act + ne = surface_solar_flux_w_per_m2(orientation=Orientation.NE, pitch_deg=90.0, region=0, month=4) + nw = surface_solar_flux_w_per_m2(orientation=Orientation.NW, pitch_deg=90.0, region=0, month=4) + e = surface_solar_flux_w_per_m2(orientation=Orientation.E, pitch_deg=90.0, region=0, month=4) + w = surface_solar_flux_w_per_m2(orientation=Orientation.W, pitch_deg=90.0, region=0, month=4) + se = surface_solar_flux_w_per_m2(orientation=Orientation.SE, pitch_deg=90.0, region=0, month=4) + sw = surface_solar_flux_w_per_m2(orientation=Orientation.SW, pitch_deg=90.0, region=0, month=4) + + # Assert + assert ne == pytest.approx(nw, abs=0.01) + assert e == pytest.approx(w, abs=0.01) + assert se == pytest.approx(sw, abs=0.01) + + +def test_window_solar_gain_applies_equation_5() -> None: + # Arrange — SAP 10.3 §6.1 equation (5): + # G_solar = 0.9 × A_w × S × g⊥ × FF × Z + # For A_w=2 m², S=108 W/m², g⊥=0.76, FF=0.7, Z=0.77: + # G = 0.9 × 2 × 108 × 0.76 × 0.7 × 0.77 ≈ 79.7 W + + # Act + result = window_solar_gain_w( + area_m2=2.0, + surface_flux_w_per_m2=108.0, + g_perpendicular=0.76, + frame_factor=0.7, + overshading_factor=0.77, + ) + + # Assert + assert result == pytest.approx(79.7, abs=1.0) + + +def test_window_solar_gain_zero_area_returns_zero() -> None: + # Arrange — A zero-area window contributes no solar gain regardless of + # other inputs. Edge case the orchestrator triggers when a building part + # has no windows in a given orientation. + + # Act + result = window_solar_gain_w( + area_m2=0.0, + surface_flux_w_per_m2=200.0, + g_perpendicular=0.85, + frame_factor=0.7, + overshading_factor=1.0, + ) + + # Assert + assert result == 0.0 + + +def test_out_of_range_region_raises_value_error() -> None: + # Arrange — Table U4 has 22 entries (regions 0..21). 22 is the first + # invalid index; the lookup must fail fast on out-of-range input. + + # Act / Assert + with pytest.raises(ValueError, match="region"): + surface_solar_flux_w_per_m2( + orientation=Orientation.S, pitch_deg=90.0, region=22, month=7, + )