§7 slice 1: mean_internal_temperature_monthly orchestrator with per-zone η

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 21:28:32 +00:00
parent 34f4fa8bef
commit fa49d7b946
2 changed files with 223 additions and 0 deletions

View file

@ -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 24 (or 56) 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 19 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),
)

View file

@ -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):