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:
Khalim Conn-Kowlessar 2026-05-17 22:42:20 +00:00
parent 732eef6adb
commit c317a72b71
2 changed files with 239 additions and 0 deletions

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

View file

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