From c317a72b7151925fc6e9938f007334433cea05b1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 22:42:20 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A5a:=20internal=20gains=20(SAP=2010.3?= =?UTF-8?q?=20=C2=A75=20+=20Appendix=20L)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../domain/sap/worksheet/internal_gains.py | 95 ++++++++++++ .../worksheet/tests/test_internal_gains.py | 144 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/internal_gains.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py new file mode 100644 index 00000000..9829a351 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -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, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py new file mode 100644 index 00000000..a79d976b --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py @@ -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)