mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
c317a72b71
commit
57bf7833a9
2 changed files with 293 additions and 0 deletions
138
packages/domain/src/domain/sap/worksheet/solar_gains.py
Normal file
138
packages/domain/src/domain/sap/worksheet/solar_gains.py
Normal 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 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue