mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice S-A5a: internal gains (SAP 10.3 §5 + Appendix L)
Fifth slice of the SAP10 Calculator Session A (ADR-0009). Ships
internal_gains_w(*, total_floor_area_m2, month, occupancy=None) returning
an InternalGainsBreakdown over four named SAP 10.3 components:
metabolic_w — 60 W × N (SAP convention; constant year-round)
cooking_w — 35 + 7N per Appendix L equation (L18)
appliances_w — Appendix L (L13) E_A = 207.8 × (TFA × N)^0.4714
with the (L14) monthly cosine variation, converted
to watts via (L16a)
lighting_w — Appendix L existing-dwelling fallback chain
(L5b, L8c, L9c-d, L10, L12). Default efficacy 21.3
lm/W, no daylight bonus, 85% internal fraction.
Occupancy defaults via Appendix J Table 1b when not supplied:
N = 1 + 1.76 × (1 - exp(-0.000349 × (TFA - 13.9)²)) + 0.0013 × (TFA - 13.9)
for TFA > 13.9 m², else N = 1.
Daylight-factor + occupancy override remain caller's responsibility for
later slices (solar_gains will populate G_L; cert-to-inputs mapper will
choose between RdSAP default and explicit assessor input).
8 AAA cycles cover: cooking constant, metabolic 60W/N, Appendix J
occupancy default for typical and tiny TFA, appliances monthly variation,
lighting existing-dwelling fallback, total = sum, month-range validation.
This commit is contained in:
parent
732eef6adb
commit
c317a72b71
2 changed files with 239 additions and 0 deletions
95
packages/domain/src/domain/sap/worksheet/internal_gains.py
Normal file
95
packages/domain/src/domain/sap/worksheet/internal_gains.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""SAP 10.3 §5 + Appendix L — internal gains.
|
||||
|
||||
Internal gains in watts per month, broken down into:
|
||||
|
||||
- Metabolic: 60 × N (SAP convention; constant year-round)
|
||||
- Cooking (L18): G_C = 35 + 7N (constant)
|
||||
- Appliances (L13, L14, L16a): E_A monthly fraction × 1000 / (24 × n_m)
|
||||
- Lighting (L1-L12): E_L monthly fraction × 0.85 × 1000 / (24 × n_m)
|
||||
|
||||
Occupancy N defaults via Appendix J Table 1b when not supplied. Lighting
|
||||
defaults to the SAP "existing dwelling, no fixed lighting" fallback
|
||||
(efficacy 21.3 lm/W; no daylighting bonus, C_daylight = 1.433).
|
||||
|
||||
Reference: SAP 10.3 specification (13-01-2026), §5 + Appendix L
|
||||
(pages 88-91), Appendix J (occupancy from TFA).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import cos, exp, pi
|
||||
from typing import Final, Optional
|
||||
|
||||
|
||||
_METABOLIC_W_PER_OCCUPANT: Final[float] = 60.0
|
||||
|
||||
|
||||
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
# Appendix L existing-dwelling lighting fallback constants.
|
||||
_FIXED_LIGHTING_EFFICACY_LM_PER_W: Final[float] = 21.3
|
||||
_FIXED_LIGHTING_LUMENS_PER_M2: Final[float] = 185.0
|
||||
_REFERENCE_LIGHTING_LUMENS_PER_M2: Final[float] = 330.0
|
||||
_DAYLIGHT_FACTOR_NO_BONUS: Final[float] = 1.433
|
||||
_LIGHTING_INTERNAL_FRACTION: Final[float] = 0.85
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InternalGainsBreakdown:
|
||||
"""SAP 10.3 §5 internal-gain components in watts for a given month."""
|
||||
|
||||
metabolic_w: float
|
||||
cooking_w: float
|
||||
appliances_w: float
|
||||
lighting_w: float
|
||||
total_w: float
|
||||
|
||||
|
||||
def _default_occupancy_sap_j(total_floor_area_m2: float) -> float:
|
||||
"""SAP 10.3 Appendix J Table 1b occupancy default from TFA."""
|
||||
if total_floor_area_m2 <= 13.9:
|
||||
return 1.0
|
||||
tfa_offset = total_floor_area_m2 - 13.9
|
||||
return 1.0 + 1.76 * (1 - exp(-0.000349 * tfa_offset * tfa_offset)) + 0.0013 * tfa_offset
|
||||
|
||||
|
||||
def internal_gains_w(
|
||||
*,
|
||||
total_floor_area_m2: float,
|
||||
month: int,
|
||||
occupancy: Optional[float] = None,
|
||||
) -> InternalGainsBreakdown:
|
||||
"""SAP 10.3 §5 internal gains in watts for a given month."""
|
||||
n = occupancy if occupancy is not None else _default_occupancy_sap_j(total_floor_area_m2)
|
||||
if not 1 <= month <= 12:
|
||||
raise ValueError(f"month must be 1..12, got {month}")
|
||||
n_m = _DAYS_IN_MONTH[month - 1]
|
||||
metabolic = _METABOLIC_W_PER_OCCUPANT * n
|
||||
cooking = 35.0 + 7.0 * n
|
||||
# Appendix L (L13) + (L14) + (L16a): appliances energy by month,
|
||||
# converted to a watt heat-gain (100% of appliance energy stays internal).
|
||||
e_a_annual = 207.8 * (total_floor_area_m2 * n) ** 0.4714
|
||||
appliances_month_factor = 1.0 + 0.157 * cos(2.0 * pi * (month - 1.78) / 12.0)
|
||||
e_a_m_kwh = e_a_annual * appliances_month_factor * n_m / 365.0
|
||||
appliances = e_a_m_kwh * 1000.0 / (24.0 * n_m)
|
||||
# Appendix L lighting — existing-dwelling fallback path (L5b, L8c, L9c-d, L10, L12).
|
||||
lambda_b = 11.2 * 59.73 * (total_floor_area_m2 * n) ** 0.4714
|
||||
c_daylight = _DAYLIGHT_FACTOR_NO_BONUS
|
||||
lambda_req = (2.0 / 3.0) * lambda_b * c_daylight
|
||||
c_l_fixed = _FIXED_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2
|
||||
c_l_ref = _REFERENCE_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2
|
||||
lambda_prov = lambda_req * c_l_fixed / c_l_ref if c_l_ref > 0 else 0.0
|
||||
e_l_fixed = (lambda_prov if lambda_req >= lambda_prov else lambda_req) / _FIXED_LIGHTING_EFFICACY_LM_PER_W
|
||||
e_l_topup = max(0.0, lambda_req / 3.0 - lambda_prov) / _FIXED_LIGHTING_EFFICACY_LM_PER_W
|
||||
e_l_portable = (1.0 / 3.0) * lambda_b * c_daylight / _FIXED_LIGHTING_EFFICACY_LM_PER_W
|
||||
e_l_annual = e_l_fixed + e_l_topup + e_l_portable
|
||||
lighting_month_factor = 1.0 + 0.5 * cos(2.0 * pi * (month - 0.2) / 12.0)
|
||||
e_l_m_kwh = e_l_annual * lighting_month_factor * n_m / 365.0
|
||||
lighting = e_l_m_kwh * _LIGHTING_INTERNAL_FRACTION * 1000.0 / (24.0 * n_m)
|
||||
return InternalGainsBreakdown(
|
||||
metabolic_w=metabolic,
|
||||
cooking_w=cooking,
|
||||
appliances_w=appliances,
|
||||
lighting_w=lighting,
|
||||
total_w=metabolic + cooking + appliances + lighting,
|
||||
)
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
"""Tests for SAP 10.3 §5 internal gains.
|
||||
|
||||
Per Appendix L of SAP 10.3 + the §5 prose: internal gains from metabolic
|
||||
(occupants), cooking (L18), appliances (L13-L16), and lighting (L1-L12),
|
||||
each computed monthly. Occupancy defaults via Appendix J Table 1b when
|
||||
the caller doesn't supply it.
|
||||
|
||||
Reference: SAP 10.3 specification (13-01-2026), §5 (page 25),
|
||||
Appendix L (pages 88-90), Appendix J (Table 1b for occupancy).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap.worksheet.internal_gains import (
|
||||
InternalGainsBreakdown,
|
||||
internal_gains_w,
|
||||
)
|
||||
|
||||
|
||||
def test_cooking_gains_match_appendix_l_18_formula() -> None:
|
||||
# Arrange — Appendix L equation (L18): G_C = 35 + 7 × N. Cooking gains
|
||||
# don't vary by month. For two occupants: G_C = 35 + 14 = 49 W.
|
||||
|
||||
# Act
|
||||
result = internal_gains_w(
|
||||
total_floor_area_m2=80.0,
|
||||
month=1,
|
||||
occupancy=2.0,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, InternalGainsBreakdown)
|
||||
assert result.cooking_w == pytest.approx(49.0, abs=0.5)
|
||||
|
||||
|
||||
def test_metabolic_gains_are_60w_per_occupant() -> None:
|
||||
# Arrange — SAP convention: 60 W of metabolic heat per occupant,
|
||||
# year-round. Two occupants -> 120 W.
|
||||
|
||||
# Act
|
||||
result = internal_gains_w(
|
||||
total_floor_area_m2=80.0,
|
||||
month=7,
|
||||
occupancy=2.0,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.metabolic_w == pytest.approx(120.0, abs=0.5)
|
||||
|
||||
|
||||
def test_default_occupancy_via_appendix_j_for_typical_tfa() -> None:
|
||||
# Arrange — SAP 10.3 Appendix J Table 1b: when TFA > 13.9, occupancy is
|
||||
# N = 1 + 1.76 × (1 - exp(-0.000349 × (TFA - 13.9)²)) + 0.0013 × (TFA - 13.9)
|
||||
# For TFA = 80 m², N ≈ 2.46.
|
||||
|
||||
# Act
|
||||
result = internal_gains_w(total_floor_area_m2=80.0, month=1)
|
||||
|
||||
# Assert
|
||||
assert result.cooking_w == pytest.approx(35.0 + 7.0 * 2.46, abs=0.5)
|
||||
assert result.metabolic_w == pytest.approx(60.0 * 2.46, abs=1.0)
|
||||
|
||||
|
||||
def test_default_occupancy_for_tiny_tfa_returns_one_occupant() -> None:
|
||||
# Arrange — TFA ≤ 13.9 m² is below the Table 1b knee; occupancy = 1.0.
|
||||
|
||||
# Act
|
||||
result = internal_gains_w(total_floor_area_m2=10.0, month=1)
|
||||
|
||||
# Assert
|
||||
assert result.metabolic_w == pytest.approx(60.0, abs=0.5) # 60 × 1
|
||||
assert result.cooking_w == pytest.approx(42.0, abs=0.5) # 35 + 7
|
||||
|
||||
|
||||
def test_appliances_gain_varies_by_month_per_l13_l14_formula() -> None:
|
||||
# Arrange — Appendix L equations (L13) and (L14):
|
||||
# E_A = 207.8 × (TFA × N)^0.4714 (kWh/yr)
|
||||
# E_A,m = E_A × [1 + 0.157 × cos(2π × (m - 1.78) / 12)] × n_m / 365
|
||||
# G_A,m = E_A,m × 1000 / (24 × n_m) (W)
|
||||
# For TFA = 80 m², N = 2.0, January (m=1, n_m=31):
|
||||
# E_A = 207.8 × 160^0.4714 ≈ 2273 kWh/yr
|
||||
# Jan factor = 1 + 0.157 × cos(2π × -0.78 / 12) ≈ 1.144
|
||||
# E_A,Jan ≈ 220.9 kWh -> G_A ≈ 297 W.
|
||||
|
||||
# Act
|
||||
jan = internal_gains_w(total_floor_area_m2=80.0, month=1, occupancy=2.0)
|
||||
jul = internal_gains_w(total_floor_area_m2=80.0, month=7, occupancy=2.0)
|
||||
|
||||
# Assert
|
||||
assert jan.appliances_w == pytest.approx(297.0, abs=8.0)
|
||||
# Appliance gains dip in July (trough near month 7.78).
|
||||
assert jul.appliances_w < jan.appliances_w
|
||||
|
||||
|
||||
def test_lighting_gains_use_existing_dwelling_fallback_l9d() -> None:
|
||||
# Arrange — Appendix L existing-dwelling fallback (L5b, L8c, L9c, L10, L12):
|
||||
# ε_fixed = 21.3 lm/W (no fixed-lighting efficacy data)
|
||||
# C_L,fixed = 185 × TFA (lumens; existing-dwelling default)
|
||||
# C_L,ref = 330 × TFA (reference upper limit)
|
||||
# Λ_B = 11.2 × 59.73 × (TFA × N)^0.4714 (klm·h/yr)
|
||||
# C_daylight = 1.433 (no daylight bonus, G_L = 0)
|
||||
# Λ_req = 2/3 × Λ_B × C_daylight
|
||||
# Λ_prov = Λ_req × C_L,fixed / C_L,ref
|
||||
# E_L,fixed = Λ_prov / ε_fixed (since Λ_req > Λ_prov)
|
||||
# E_L,portable = 1/3 × Λ_B × C_daylight / ε_fixed
|
||||
# E_L = E_L,fixed + E_L,portable
|
||||
# E_L,m = E_L × [1 + 0.5 × cos(2π × (m - 0.2) / 12)] × n_m / 365
|
||||
# G_L,m = E_L,m × 0.85 × 1000 / (24 × n_m)
|
||||
# For TFA = 80 m², N = 2.0, January: G_L ≈ 49 W (hand-computed above).
|
||||
|
||||
# Act
|
||||
jan = internal_gains_w(total_floor_area_m2=80.0, month=1, occupancy=2.0)
|
||||
jul = internal_gains_w(total_floor_area_m2=80.0, month=7, occupancy=2.0)
|
||||
|
||||
# Assert
|
||||
assert jan.lighting_w == pytest.approx(49.0, abs=2.0)
|
||||
# Lighting peaks in December (cosine peak at m=0.2 ≈ January); July is the trough.
|
||||
assert jul.lighting_w < jan.lighting_w
|
||||
|
||||
|
||||
def test_total_w_equals_sum_of_four_components() -> None:
|
||||
# Arrange — total_w is the arithmetic sum of metabolic + cooking +
|
||||
# appliances + lighting. Verifies the orchestrator can trust the field
|
||||
# without re-summing.
|
||||
|
||||
# Act
|
||||
result = internal_gains_w(total_floor_area_m2=100.0, month=4, occupancy=3.0)
|
||||
|
||||
# Assert
|
||||
assert result.total_w == pytest.approx(
|
||||
result.metabolic_w + result.cooking_w + result.appliances_w + result.lighting_w,
|
||||
abs=0.01,
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_month_raises_value_error() -> None:
|
||||
# Arrange — month must be 1-12 to index the days-in-month lookup. Out-of-
|
||||
# range fails fast.
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
internal_gains_w(total_floor_area_m2=80.0, month=0, occupancy=2.0)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
internal_gains_w(total_floor_area_m2=80.0, month=13, occupancy=2.0)
|
||||
Loading…
Add table
Reference in a new issue