mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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 __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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue