slice S-A5b: solar gains (SAP 10.3 §6 + Appendix U §U3.2)

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.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 22:59:25 +00:00
parent c317a72b71
commit 57bf7833a9
2 changed files with 293 additions and 0 deletions

View file

@ -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 certinputs 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

View file

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