From 9113f30aa8f35409c0aaf94c0aae67eec2a94bd0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 22:11:22 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A78=20slice=201:=20space=5Fheating=5Fmonthl?= =?UTF-8?q?y=5Fkwh=20orchestrator=20+=20summer=20clamp=20+=20SpaceHeatingR?= =?UTF-8?q?esult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the §8 orchestrator producing (95)..(99) line refs for all 12 months. Composes the existing monthly_heat_requirement_kwh leaf with the spec inclusion rule (Table 9c step 10 final clause): "Include the heating requirement for each month from October to May (disregarding June to September)" Jun..Sep are zeroed regardless of computed value, on top of the per-month value clamp (< 1 kWh / negative). SpaceHeatingResult exposes (95) useful gains, (97) heat loss rate, (98a) space heating requirement, (98b) solar space heating (always 0 — Appendix H deferred), (98c) total, Σ(98c) annual + (99) per-m². All length-12 tuples + 2 scalars. Driven by Elmhurst 000490 (98c) annual = 11183.2752 kWh to abs=5e-3 kWh. Without the summer clamp the current calculator over-predicts annual by +1575 kWh (+14%) on this fixture; the clamp closes the gap to spec. Slice 3 wires CalculatorInputs.space_heating_monthly_kwh + cert_to_inputs; calculator stops calling monthly_heat_requirement_kwh inline. Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/worksheet/space_heating.py | 92 +++++++++++++++++++ .../sap/worksheet/tests/test_space_heating.py | 59 +++++++++++- 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/domain/sap/worksheet/space_heating.py b/packages/domain/src/domain/sap/worksheet/space_heating.py index 4659a359..fb3d1a59 100644 --- a/packages/domain/src/domain/sap/worksheet/space_heating.py +++ b/packages/domain/src/domain/sap/worksheet/space_heating.py @@ -15,11 +15,17 @@ Reference: SAP 10.3 specification (13-01-2026) Table 9c (page 185). from __future__ import annotations +from dataclasses import dataclass from typing import Final _MIN_KWH_PER_MONTH: Final[float] = 1.0 _WH_TO_KWH_PER_DAY: Final[float] = 0.024 # 24 h / 1000 +_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +# SAP10.2 Table 9c step 10: "Include the heating requirement for each month +# from October to May (disregarding June to September)." Set Q_heat to zero +# in Jun..Sep regardless of computed value. Indices 5..8 inclusive (zero-based). +_SUMMER_MONTH_INDICES: Final[frozenset[int]] = frozenset({5, 6, 7, 8}) def monthly_heat_requirement_kwh( @@ -43,3 +49,89 @@ def monthly_heat_requirement_kwh( if q_heat < _MIN_KWH_PER_MONTH: return 0.0 return q_heat + + +@dataclass(frozen=True) +class SpaceHeatingResult: + """SAP 10.2 §8 worksheet line refs (95)..(99). + + Returned by `space_heating_monthly_kwh`. Downstream calculator consumes + `total_space_heating_monthly_kwh` (98c) directly to drive fuel-cost + + rating chains; per-line tuples are exposed for worksheet conformance + + audit. Field names mirror the SAP 10.2 line refs. + """ + + useful_gains_monthly_w: tuple[float, ...] # (95) + heat_loss_rate_monthly_w: tuple[float, ...] # (97) + space_heating_requirement_monthly_kwh: tuple[float, ...] # (98a) + solar_space_heating_monthly_kwh: tuple[float, ...] # (98b) + total_space_heating_monthly_kwh: tuple[float, ...] # (98c) + total_space_heating_kwh_per_yr: float # Σ(98c) + space_heating_per_m2_kwh: float # (99) + + +def space_heating_monthly_kwh( + *, + monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...], + monthly_internal_temperature_c: tuple[float, ...], + monthly_external_temperature_c: tuple[float, ...], + monthly_utilisation_factor: tuple[float, ...], + monthly_total_gains_w: tuple[float, ...], + total_floor_area_m2: float, +) -> SpaceHeatingResult: + """SAP 10.2 §8 orchestrator — produce (95)..(99) line refs for all months. + + Composes the existing single-month leaf with the spec inclusion rule: + Jun..Sep are zeroed (Table 9c step 10) regardless of computed value, + on top of the per-month value clamp (< 1 kWh or negative). + + Solar space heating (98b) — Appendix H — is always 0 in this slice; no + Elmhurst fixture lodges a solar space heating system. (98c) = (98a) for + the current corpus. + + Inputs are length-12 Jan..Dec tuples. `total_floor_area_m2` only drives + the (99) per-m² aggregate; everything else is per-month physics. + """ + useful_gains: list[float] = [] + heat_loss_rate: list[float] = [] + q_heat_98a: list[float] = [] + q_solar_98b: list[float] = [] + q_total_98c: list[float] = [] + + for m in range(12): + h = monthly_heat_transfer_coefficient_w_per_k[m] + t_i = monthly_internal_temperature_c[m] + t_e = monthly_external_temperature_c[m] + eta = monthly_utilisation_factor[m] + gains = monthly_total_gains_w[m] + + useful_gains.append(eta * gains) + heat_loss_rate.append(h * (t_i - t_e)) + + q98a = monthly_heat_requirement_kwh( + heat_transfer_coefficient_w_per_k=h, + internal_temperature_c=t_i, + external_temperature_c=t_e, + utilisation_factor=eta, + total_gains_w=gains, + days_in_month=_DAYS_IN_MONTH[m], + ) + # Spec inclusion rule: Jun..Sep do not contribute regardless of value. + if m in _SUMMER_MONTH_INDICES: + q98a = 0.0 + q_heat_98a.append(q98a) + q_solar_98b.append(0.0) + q_total_98c.append(q98a) + + annual_98c = sum(q_total_98c) + per_m2_99 = annual_98c / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0 + + return SpaceHeatingResult( + useful_gains_monthly_w=tuple(useful_gains), + heat_loss_rate_monthly_w=tuple(heat_loss_rate), + space_heating_requirement_monthly_kwh=tuple(q_heat_98a), + solar_space_heating_monthly_kwh=tuple(q_solar_98b), + total_space_heating_monthly_kwh=tuple(q_total_98c), + total_space_heating_kwh_per_yr=annual_98c, + space_heating_per_m2_kwh=per_m2_99, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py b/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py index f0245fd3..983129f3 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py @@ -9,7 +9,64 @@ Reference: SAP 10.3 specification (13-01-2026) Table 9c (page 185). import pytest -from domain.sap.worksheet.space_heating import monthly_heat_requirement_kwh +from domain.sap.worksheet.space_heating import ( + SpaceHeatingResult, + monthly_heat_requirement_kwh, + space_heating_monthly_kwh, +) + + +# Worksheet U985-0001-000490 (UK-avg weather, region 0) inputs for §8. +# (93)m, (94)m from §7; (84)m gains = §5 (73) + §6 (83); (39)m HTC pinned +# from PDF; (96)m ext temp from Appendix U Table U1 region 0; TFA pinned. +_W000490_HTC_W_PER_K: tuple[float, ...] = ( + 289.6265, 288.8665, 288.1216, 284.6229, 283.9683, 280.9211, + 280.9211, 280.3568, 282.0949, 283.9683, 285.2926, 286.6770, +) +_W000490_T_INT_C: tuple[float, ...] = ( + 15.1899, 15.4380, 15.9663, 16.7359, 17.5529, 18.3114, + 18.7103, 18.6666, 18.1104, 17.0915, 16.0323, 15.1662, +) +_W000490_T_EXT_C: tuple[float, ...] = ( + 4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2, +) +_W000490_ETA_WHOLE: tuple[float, ...] = ( + 0.9735, 0.9659, 0.9524, 0.9261, 0.8752, 0.7738, + 0.6027, 0.6474, 0.8348, 0.9303, 0.9644, 0.9759, +) +_W000490_TOTAL_GAINS_W: tuple[float, ...] = ( + 595.2863, 661.0571, 716.2901, 763.4028, 790.9684, 764.1081, + 732.0878, 691.6074, 654.2542, 610.6983, 573.2833, 568.9492, +) +_W000490_TFA_M2: float = 66.06 +# (98c) annual from PDF — Σ Jan..May + Σ Oct..Dec, Jun..Sep clamped to 0 +# per SAP10.2 Table 9c step 10 inclusion rule. +_W000490_ANNUAL_98C_KWH: float = 11183.2752 + + +def test_space_heating_monthly_kwh_reproduces_000490_annual_with_summer_clamp() -> None: + # Arrange — Elmhurst 000490 §7 outputs + §3/§5/§6 inputs above. Expected + # annual (98c) = 11183.2752 kWh. Spec inclusion rule (Table 9c step 10): + # June-September contribute zero regardless of computed value. + + # Act + result = space_heating_monthly_kwh( + monthly_heat_transfer_coefficient_w_per_k=_W000490_HTC_W_PER_K, + monthly_internal_temperature_c=_W000490_T_INT_C, + monthly_external_temperature_c=_W000490_T_EXT_C, + monthly_utilisation_factor=_W000490_ETA_WHOLE, + monthly_total_gains_w=_W000490_TOTAL_GAINS_W, + total_floor_area_m2=_W000490_TFA_M2, + ) + + # Assert + assert isinstance(result, SpaceHeatingResult) + assert result.total_space_heating_kwh_per_yr == pytest.approx( + _W000490_ANNUAL_98C_KWH, abs=5e-3 + ) + # Summer-clamp invariant — Jun..Sep months always zero per spec. + for m in range(5, 9): # indices 5..8 = Jun..Sep + assert result.total_space_heating_monthly_kwh[m] == 0.0, f"(98c) month {m+1}" def test_typical_winter_month_returns_positive_kwh() -> None: