From 3ec56216b05d121497dd39771e453a02326165ae Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 17:59:00 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A75=20slice=201:=20(66)=20metabolic=5Fmonth?= =?UTF-8?q?ly=5Fw=20=E2=80=94=2060=C3=97N=20const-tuple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../domain/sap/worksheet/internal_gains.py | 45 +++-- .../worksheet/tests/test_internal_gains.py | 156 +++--------------- 2 files changed, 58 insertions(+), 143 deletions(-) diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index 9829a351..aefbf5c6 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -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 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 index a79d976b..ae7b46c4 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py @@ -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}"