mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§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:
parent
da5909de3d
commit
4b83e7023f
2 changed files with 301 additions and 0 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue