From fa49d7b9460c3402a49083c8b2bc4f51e1d8842c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 21:28:32 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A77=20slice=201:=20mean=5Finternal=5Ftemper?= =?UTF-8?q?ature=5Fmonthly=20orchestrator=20with=20per-zone=20=CE=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MeanInternalTemperatureResult + mean_internal_temperature_monthly, implementing SAP10.2 §7 Table 9c steps 1-9 sequentially: - (86) η_living = f(Ti = T_h1 = 21°C) - (89) η_elsewhere = f(Ti = T_h2 from Table 9) - (94) η_whole = f(Ti = (93)m adjusted MIT) Three distinct η values per month, each computed from its own zone's Ti via the existing utilisation_factor leaf. Closes the 6.6e-3 °C drift on 000490 (92)m Jan that the prior single-η implementation produced. Driven by 000490 Jan worksheet (92)m = 15.1899 to abs=5e-3 °C. Other 11 months + per-zone line refs are exercised by the ALL_FIXTURES e2e test in slice 3. Legacy `mean_internal_temperature_c` retained (still used by calculator _solve_month iteration); slice 4 deletes both when calculator wires the new orchestrator's (93)m + (94)m fields. Co-Authored-By: Claude Opus 4.7 --- .../worksheet/mean_internal_temperature.py | 177 ++++++++++++++++++ .../tests/test_mean_internal_temperature.py | 46 +++++ 2 files changed, 223 insertions(+) diff --git a/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py b/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py index 30f29062..674a018a 100644 --- a/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py +++ b/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py @@ -20,11 +20,16 @@ Table 9b (page 184), Table 9c (page 185). from __future__ import annotations +from dataclasses import dataclass from typing import Final +from domain.sap.worksheet.utilisation_factor import utilisation_factor + _T_H1_C: Final[float] = 21.0 _HLP_CLAMP_FOR_T_H2: Final[float] = 6.0 +_MONTHS: Final[range] = range(12) +_TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 def elsewhere_heating_temperature_c( @@ -159,3 +164,175 @@ def mean_internal_temperature_c( ) t_internal = living_area_fraction * t_1 + (1.0 - living_area_fraction) * t_2 return t_internal + control_temperature_adjustment_c + + +@dataclass(frozen=True) +class MeanInternalTemperatureResult: + """SAP 10.2 §7 worksheet line refs (85)..(94). + + Returned by `mean_internal_temperature_monthly`. Downstream §8 space + heating consumes `adjusted_mean_internal_temp_monthly` (93) and + `utilisation_factor_whole_monthly` (94) directly; per-zone tuples are + exposed for worksheet conformance + audit. + """ + + living_area_heating_temp_c: float # (85) + utilisation_factor_living_monthly: tuple[float, ...] # (86) + mean_internal_temp_living_monthly: tuple[float, ...] # (87) + elsewhere_heating_temp_monthly: tuple[float, ...] # (88) + utilisation_factor_elsewhere_monthly: tuple[float, ...] # (89) + mean_internal_temp_elsewhere_monthly: tuple[float, ...] # (90) + living_area_fraction: float # (91) + mean_internal_temp_monthly: tuple[float, ...] # (92) + adjusted_mean_internal_temp_monthly: tuple[float, ...] # (93) + utilisation_factor_whole_monthly: tuple[float, ...] # (94) + + +def _zone_mean_temp_with_per_zone_eta( + *, + heating_temperature_c: float, + off_hours_first: float, + off_hours_second: float, + external_temp_c: float, + responsiveness: float, + total_gains_w: float, + heat_transfer_coefficient_w_per_k: float, + time_constant_h: float, +) -> tuple[float, float]: + """SAP 10.2 Table 9c steps 2–4 (or 5–6) for one zone. + + Computes η using L = H(Th − Te) — the zone's own heating temperature — + then folds it into Table 9b u1+u2 to yield zone mean. Returns (η, T_zone). + Distinct from `_zone_mean_temperature_c` which takes a precomputed η + that's shared across zones (the pre-fix shape). + """ + loss_rate_w = max(0.0, heat_transfer_coefficient_w_per_k * (heating_temperature_c - external_temp_c)) + eta = utilisation_factor( + total_gains_w=total_gains_w, + heat_loss_rate_w=loss_rate_w, + time_constant_h=time_constant_h, + ) + common = dict( + heating_temperature_c=heating_temperature_c, + external_temperature_c=external_temp_c, + responsiveness=responsiveness, + total_gains_w=total_gains_w, + heat_transfer_coefficient_w_per_k=heat_transfer_coefficient_w_per_k, + utilisation_factor=eta, + time_constant_h=time_constant_h, + ) + u1 = off_period_temperature_reduction_c(off_period_hours=off_hours_first, **common) + u2 = off_period_temperature_reduction_c(off_period_hours=off_hours_second, **common) + return eta, heating_temperature_c - u1 - u2 + + +def mean_internal_temperature_monthly( + *, + monthly_external_temp_c: tuple[float, ...], + monthly_total_gains_w: tuple[float, ...], + monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...], + thermal_mass_parameter_kj_per_m2_k: float, + total_floor_area_m2: float, + control_type: int, + responsiveness: float, + living_area_fraction: float, + control_temperature_adjustment_c: float = 0.0, +) -> MeanInternalTemperatureResult: + """SAP 10.2 §7 orchestrator — chain Table 9c steps 1–9 for all 12 months. + + Per-zone η is the load-bearing detail vs the legacy single-η path: + - (86) η_living = f(Ti = T_h1 = 21°C) + - (89) η_elsewhere = f(Ti = T_h2 from Table 9) + - (94) η_whole = f(Ti = (93) adjusted MIT) — feeds §8 step 9. + + Inputs: + monthly_* length-12 tuples covering Jan..Dec + thermal_mass_parameter_kj_per_m2_k TMP (35) for τ = TMP·TFA/(3.6·HLC) + total_floor_area_m2 (4) for τ derivation + control_type 1/2/3 buckets feed Table 9 T_h2 + Table 9 off-hours + responsiveness Table 4d R for the Table 9b u-formula + living_area_fraction (91) for (92) zone blending + control_temperature_adjustment_c (93) Table 4e adj (defaults 0 — all 6 + Elmhurst fixtures have (93) = (92), so this is a + known shortcut; cert-side mapping is a future slice). + """ + elsewhere_off_hours = ( + _ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12 + ) + + eta_living: list[float] = [] + t_1: list[float] = [] + t_h2: list[float] = [] + eta_elsewhere: list[float] = [] + t_2: list[float] = [] + t_internal: list[float] = [] + t_adj: list[float] = [] + eta_whole: list[float] = [] + + for m in _MONTHS: + ext = monthly_external_temp_c[m] + gains = monthly_total_gains_w[m] + h = monthly_heat_transfer_coefficient_w_per_k[m] + hlp = h / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0 + tau = ( + thermal_mass_parameter_kj_per_m2_k * total_floor_area_m2 + / (_TIME_CONSTANT_DIVISOR_KJ_TO_WH * h) + if h > 0 else float("inf") + ) + + # Living area — steps 1-4 + eta_l, t_l = _zone_mean_temp_with_per_zone_eta( + heating_temperature_c=_T_H1_C, + off_hours_first=_LIVING_AREA_OFF_HOURS[0], + off_hours_second=_LIVING_AREA_OFF_HOURS[1], + external_temp_c=ext, responsiveness=responsiveness, + total_gains_w=gains, heat_transfer_coefficient_w_per_k=h, + time_constant_h=tau, + ) + eta_living.append(eta_l) + t_1.append(t_l) + + # Elsewhere — steps 5-6 + t_h2_m = elsewhere_heating_temperature_c( + heat_loss_parameter=hlp, control_type=control_type, + ) + t_h2.append(t_h2_m) + eta_e, t_e = _zone_mean_temp_with_per_zone_eta( + heating_temperature_c=t_h2_m, + off_hours_first=elsewhere_off_hours[0], + off_hours_second=elsewhere_off_hours[1], + external_temp_c=ext, responsiveness=responsiveness, + total_gains_w=gains, heat_transfer_coefficient_w_per_k=h, + time_constant_h=tau, + ) + eta_elsewhere.append(eta_e) + t_2.append(t_e) + + # Blend (step 7) + Table 4e adj (step 8) + t_int = living_area_fraction * t_l + (1.0 - living_area_fraction) * t_e + t_internal.append(t_int) + t_adjusted = t_int + control_temperature_adjustment_c + t_adj.append(t_adjusted) + + # Step 9 — η_whole at the adjusted MIT for §8 space heating + loss_whole_w = max(0.0, h * (t_adjusted - ext)) + eta_whole.append( + utilisation_factor( + total_gains_w=gains, + heat_loss_rate_w=loss_whole_w, + time_constant_h=tau, + ) + ) + + return MeanInternalTemperatureResult( + living_area_heating_temp_c=_T_H1_C, + utilisation_factor_living_monthly=tuple(eta_living), + mean_internal_temp_living_monthly=tuple(t_1), + elsewhere_heating_temp_monthly=tuple(t_h2), + utilisation_factor_elsewhere_monthly=tuple(eta_elsewhere), + mean_internal_temp_elsewhere_monthly=tuple(t_2), + living_area_fraction=living_area_fraction, + mean_internal_temp_monthly=tuple(t_internal), + adjusted_mean_internal_temp_monthly=tuple(t_adj), + utilisation_factor_whole_monthly=tuple(eta_whole), + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_mean_internal_temperature.py b/packages/domain/src/domain/sap/worksheet/tests/test_mean_internal_temperature.py index 5adbdb47..135a4af3 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_mean_internal_temperature.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_mean_internal_temperature.py @@ -13,12 +13,58 @@ Table 9b (page 184), Table 9c (page 185). import pytest from domain.sap.worksheet.mean_internal_temperature import ( + MeanInternalTemperatureResult, elsewhere_heating_temperature_c, mean_internal_temperature_c, + mean_internal_temperature_monthly, off_period_temperature_reduction_c, ) +# Worksheet U985-0001-000490 (UK-avg weather, region 0) inputs for §7. +# 3 habitable rooms → Table 27 f_LA = 0.25 (PDF (91) = 0.2501 rounding). +# Vaillant Ecotec Pro combi w/ programmer + room thermostat → control_type = 2, +# responsiveness = 1.0 (Table 4d gas radiators). TFA = 66.06 m², TMP = 250 +# (SAP10 mass-medium default). No Table 4e adjustment. +_W000490_EXT_TEMP_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_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_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_TFA_M2: float = 66.06 +_W000490_TMP_KJ_PER_M2_K: float = 250.0 +_W000490_CONTROL_TYPE: int = 2 +_W000490_RESPONSIVENESS: float = 1.0 +_W000490_LIVING_AREA_FRACTION: float = 0.2501 + + +def test_mean_internal_temperature_monthly_reproduces_000490_line_92_jan() -> None: + # Arrange — Elmhurst 000490 worksheet inputs above; expected (92)m Jan + # blended MIT = 15.1899 °C from the PDF. + + # Act + result = mean_internal_temperature_monthly( + monthly_external_temp_c=_W000490_EXT_TEMP_C, + monthly_total_gains_w=_W000490_TOTAL_GAINS_W, + monthly_heat_transfer_coefficient_w_per_k=_W000490_HTC_W_PER_K, + thermal_mass_parameter_kj_per_m2_k=_W000490_TMP_KJ_PER_M2_K, + total_floor_area_m2=_W000490_TFA_M2, + control_type=_W000490_CONTROL_TYPE, + responsiveness=_W000490_RESPONSIVENESS, + living_area_fraction=_W000490_LIVING_AREA_FRACTION, + ) + + # Assert + assert isinstance(result, MeanInternalTemperatureResult) + assert result.mean_internal_temp_monthly[0] == pytest.approx(15.1899, abs=5e-3) + + def test_elsewhere_temperature_control_type_2_uses_quadratic_drop() -> None: # Arrange — Table 9 elsewhere formula for control type 2 (programmer + # room thermostat, default for boiler systems with reasonable control):