§5 slice 1: (66) metabolic_monthly_w — 60×N const-tuple

Tracer bullet for §5 internal-gains rebuild. New 12-tuple monthly API
lands alongside the legacy scalar internal_gains_w stub; calculator.py
keeps building until the §5 wiring slice. SAP10.2 Table 5 Column A is
the rating + cooling default — Column B (new-build DPER/TPER) deferred.

Deletes the legacy SAP-10.3-flavoured test_internal_gains.py per the
rebuild plan; new tests will accrete slice-by-slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 17:59:00 +00:00
parent 74b2c1131f
commit 3ec56216b0
2 changed files with 58 additions and 143 deletions

View file

@ -1,18 +1,27 @@
"""SAP 10.3 §5 + Appendix L — internal gains.
"""SAP 10.2 §5 + Appendix L — internal gains.
Internal gains in watts per month, broken down into:
Worksheet lines (66)..(73), each a monthly 12-tuple of watts:
- 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)
(66) metabolic = 60 × N (Table 5 Col A)
(67) lighting = Appendix L L1-L12 cascade
(68) appliances = Appendix L L13/L14/L16
(69) cooking = 35 + 7 × N (Table 5 Col A)
(70) pumps + fans = Table 5a dispatch + heating-season mask
(71) losses = -40 × N (Table 5 Col A)
(72) water heating = (65)m × 1000 / (24 × n_m)
(73) total = (66) + (67) + (68) + (69) + (70) + (71) + (72)
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).
Column A (typical gains) is used for the SAP rating + cooling calc per
Table 5 footnote 3; Column B (reduced gains) is only for new-build
DPER/TPER/DER/TER. We rate existing dwellings always Column A.
Reference: SAP 10.3 specification (13-01-2026), §5 + Appendix L
(pages 88-91), Appendix J (occupancy from TFA).
The `internal_gains_w` scalar API below is the legacy SAP-10.3 stub; it
remains so `calculator.py` keeps building until §5 wiring (slice 11)
lands. Tests + worksheet conformance target the new 12-tuple functions.
Reference: SAP 10.2 specification (14-03-2025), §5 (page 25), Table 5 +
Table 5a (page 177), Appendix L (lighting/appliances/cooking),
Appendix J Table 1b (occupancy from TFA).
"""
from __future__ import annotations
@ -22,6 +31,20 @@ from math import cos, exp, pi
from typing import Final, Optional
_MONTHS_IN_YEAR: Final[int] = 12
_METABOLIC_GAIN_W_PER_OCCUPANT_COL_A: Final[float] = 60.0
def metabolic_monthly_w(*, n_occupants: float) -> tuple[float, ...]:
"""SAP 10.2 §5 line (66) — metabolic gains in watts per month.
Table 5 Column A: G_M = 60 × N watts, constant year-round.
Column B (50 × N) applies only to new-build DPER/TPER calculations.
"""
return tuple(_METABOLIC_GAIN_W_PER_OCCUPANT_COL_A * n_occupants
for _ in range(_MONTHS_IN_YEAR))
_METABOLIC_W_PER_OCCUPANT: Final[float] = 60.0

View file

@ -1,144 +1,36 @@
"""Tests for SAP 10.3 §5 internal gains.
"""Tests for SAP 10.2 §5 + Appendix L — 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.
Worksheet line refs (66)..(73) land in `domain.sap.worksheet.internal_gains`
as monthly 12-tuple outputs. Each leaf function is unit-tested against the
SAP 10.2 spec formula; the orchestrator is parametrized against every
Elmhurst conformance fixture in `_elmhurst_fixtures.ALL_FIXTURES`.
Reference: SAP 10.3 specification (13-01-2026), §5 (page 25),
Appendix L (pages 88-90), Appendix J (Table 1b for occupancy).
Reference: SAP 10.2 specification (14-03-2025), §5 (page 25) + Table 5 +
Table 5a + Appendix L (lighting/appliances/cooking) + Appendix J Table 1b
(occupancy from TFA).
"""
import pytest
from domain.sap.worksheet.internal_gains import (
InternalGainsBreakdown,
internal_gains_w,
)
from domain.sap.worksheet.internal_gains import metabolic_monthly_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.
def test_metabolic_gains_are_60w_per_occupant_constant_across_months() -> None:
"""SAP 10.2 Table 5 Column A row "Metabolic": G_M = 60 × N watts,
constant across all 12 months. Column A is used for the rating + the
cooling calculation; Column B (50 × N) is only for new-build DPER/TPER.
Use 000490's worksheet-derived N=2.1468 → 60 × 2.1468 = 128.808 W,
which agrees with the U985-0001-000490 worksheet (66)m row (128.8087)
to within rounding.
"""
# Arrange
n = 2.1468
# Act
result = internal_gains_w(
total_floor_area_m2=80.0,
month=1,
occupancy=2.0,
)
monthly = metabolic_monthly_w(n_occupants=n)
# 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)
assert len(monthly) == 12
for m, value in enumerate(monthly):
assert value == pytest.approx(60.0 * n, abs=1e-9), f"month {m+1}"