§6 slice 2: solar_gains_from_cert orchestrator (000490 line (83) ≤5e-3 W)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 20:28:46 +00:00
parent da5909de3d
commit 4b83e7023f
2 changed files with 301 additions and 0 deletions

View file

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

View file

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