§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 __future__ import annotations
from dataclasses import dataclass
from enum import Enum from enum import Enum
from math import cos, radians, sin from math import cos, radians, sin
from typing import Final from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from domain.sap.climate.appendix_u import ( from domain.sap.climate.appendix_u import (
horizontal_solar_irradiance_w_per_m2, horizontal_solar_irradiance_w_per_m2,
solar_declination_deg, 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. 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 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 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.internal_gains import OvershadingCategory
from domain.sap.worksheet.solar_gains import ( from domain.sap.worksheet.solar_gains import (
Orientation, Orientation,
RoofWindowInput,
RooflightInput,
solar_gains_from_cert,
surface_solar_flux_w_per_m2, surface_solar_flux_w_per_m2,
window_solar_gain_w, window_solar_gain_w,
z_solar_for_overshading, 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: def test_z_solar_for_overshading_returns_table_6d_first_column() -> None:
# Arrange — SAP 10.2 Table 6d "Winter solar access factor (for calculation # Arrange — SAP 10.2 Table 6d "Winter solar access factor (for calculation
# of solar gains for heating)" — first numeric column on p178. Used for # of solar gains for heating)" — first numeric column on p178. Used for