slice S-A5d: mean internal temperature (SAP 10.3 Tables 9 + 9b + 9c)

Eighth slice of the SAP10 Calculator Session A (ADR-0009). Implements
SAP 10.3 mean internal temperature with three public helpers under
domain.sap.worksheet.mean_internal_temperature:

  elsewhere_heating_temperature_c(hlp, control_type)
    -> Table 9 T_h2 formula:
       control type 1:        T_h2 = 21 − 0.5 × HLP
       control type 2 or 3:   T_h2 = 21 − HLP + HLP² / 12
       HLP clamped to 6.0 per Table 9 note (e).

  off_period_temperature_reduction_c(t_off, T_h, T_e, R, G, H, η, τ)
    -> Table 9b u value (°C drop below T_h over an off-period):
       t_c   = 4 + 0.25·τ
       T_sc  = (1−R)(T_h−2) + R·(T_e + η·G/H)
       quadratic branch when t_off ≤ t_c, linear when t_off > t_c.

  mean_internal_temperature_c(...)
    -> Table 9c steps 1-8: living-area zone (off 7+8 h, T_h1=21°C) and
       elsewhere zone (off 7+8 h for control 1/2 or 9+8 h for control 3,
       T_h2 from above), blended by living_area_fraction, plus the
       Table 4e control-type temperature adjustment.

Step 9 (re-compute utilisation factor with the new T_i) and step 10
(Q_heat = 0.024 × (L − η·G) × n_m) live in the next slice's monthly loop.

7 AAA cycles cover: T_h2 formulas for control types 1 vs 2, HLP > 6 clamp
per note (e), off-period u quadratic branch (t_off ≤ t_c), off-period u
linear branch (t_off > t_c), full mean_internal_temperature hand-computed
worked example, and control-type-3 longer first off-period dropping mean
temp slightly below control-type-2.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 08:52:11 +00:00
parent e403e2302c
commit 8c21b399c6
2 changed files with 350 additions and 0 deletions

View file

@ -0,0 +1,161 @@
"""SAP 10.3 mean internal temperature (Tables 9, 9b, 9c).
The dwelling has two temperature zones during heating periods:
- Living area at T_h1 = 21 °C
- Elsewhere at T_h2 (Table 9 formulas, depending on control type)
When the heating is off, both zones cool toward a 'steady-cool' temperature
T_sc; the time-weighted reduction over the off-period is u (Table 9b).
Mean temperature = T_h (u1 + u2) summed over the day's off-periods, then
the two zones are blended by living-area fraction (Table 9c step 7).
Standard SAP heating schedule:
- 9 hours heating per day, two off-periods of 7 h and 8 h (control types 1, 2)
- Control type 3 zones the heating: 9 h and 8 h off in 'elsewhere'
Reference: SAP 10.3 specification (13-01-2026) Table 9 (page 183),
Table 9b (page 184), Table 9c (page 185).
"""
from __future__ import annotations
from typing import Final
_T_H1_C: Final[float] = 21.0
_HLP_CLAMP_FOR_T_H2: Final[float] = 6.0
def elsewhere_heating_temperature_c(
*,
heat_loss_parameter: float,
control_type: int,
) -> float:
"""Rest-of-dwelling temperature during heating periods (Table 9).
Control type 1 (e.g. boiler system without sufficient controls):
T_h2 = 21 0.5 × HLP
Control type 2 or 3 (programmer + room thermostat or better):
T_h2 = 21 HLP + HLP² / 12
HLP is clamped at 6.0 W/m²K for this computation per Table 9 note (e).
"""
hlp = min(heat_loss_parameter, _HLP_CLAMP_FOR_T_H2)
if control_type == 1:
return _T_H1_C - 0.5 * hlp
return _T_H1_C - hlp + (hlp * hlp) / 12.0
def off_period_temperature_reduction_c(
*,
off_period_hours: float,
heating_temperature_c: float,
external_temperature_c: float,
responsiveness: float,
total_gains_w: float,
heat_transfer_coefficient_w_per_k: float,
utilisation_factor: float,
time_constant_h: float,
) -> float:
"""SAP 10.3 Table 9b — temperature reduction `u` during a heating-off
period, in °C below the heating-period temperature.
t_c = 4 + 0.25 × τ
T_sc = (1 R)(T_h 2) + R × (T_e + η·G / H)
if t_off t_c: u = 0.5 × t_off² × (T_h T_sc) / (24 × t_c)
if t_off > t_c: u = (T_h T_sc) × (t_off 0.5·t_c) / 24
"""
t_c = 4.0 + 0.25 * time_constant_h
h_safe = heat_transfer_coefficient_w_per_k if heat_transfer_coefficient_w_per_k > 0 else 1.0
t_sc = (1.0 - responsiveness) * (heating_temperature_c - 2.0) + responsiveness * (
external_temperature_c + utilisation_factor * total_gains_w / h_safe
)
delta_t = heating_temperature_c - t_sc
if off_period_hours <= t_c:
return 0.5 * off_period_hours * off_period_hours * delta_t / (24.0 * t_c)
return delta_t * (off_period_hours - 0.5 * t_c) / 24.0
# Standard SAP heating schedule (Table 9):
# Living area: off (7, 8) regardless of control type.
# Elsewhere control type 1,2: off (7, 8).
# Elsewhere control type 3: off (9, 8).
_LIVING_AREA_OFF_HOURS: Final[tuple[float, float]] = (7.0, 8.0)
_ELSEWHERE_OFF_HOURS_TYPE_12: Final[tuple[float, float]] = (7.0, 8.0)
_ELSEWHERE_OFF_HOURS_TYPE_3: Final[tuple[float, float]] = (9.0, 8.0)
def _zone_mean_temperature_c(
*,
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,
utilisation_factor: float,
time_constant_h: float,
) -> float:
"""Mean temperature for one heating zone = T_h u1 u2."""
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=utilisation_factor,
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 heating_temperature_c - u1 - u2
def mean_internal_temperature_c(
*,
external_temp_c: float,
heat_transfer_coefficient_w_per_k: float,
total_gains_w: float,
utilisation_factor: float,
time_constant_h: float,
heat_loss_parameter: float,
living_area_fraction: float,
control_type: int,
responsiveness: float,
control_temperature_adjustment_c: float = 0.0,
) -> float:
"""SAP 10.3 Table 9c steps 1-8 — whole-dwelling mean internal temperature
for the month. Blends living-area + rest-of-dwelling zone means by the
living-area fraction, then applies the Table 4e control-type temperature
adjustment."""
t_h2 = elsewhere_heating_temperature_c(
heat_loss_parameter=heat_loss_parameter,
control_type=control_type,
)
elsewhere_off_hours = (
_ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12
)
common = dict(
external_temp_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=utilisation_factor,
time_constant_h=time_constant_h,
)
t_1 = _zone_mean_temperature_c(
heating_temperature_c=_T_H1_C,
off_hours_first=_LIVING_AREA_OFF_HOURS[0],
off_hours_second=_LIVING_AREA_OFF_HOURS[1],
**common,
)
t_2 = _zone_mean_temperature_c(
heating_temperature_c=t_h2,
off_hours_first=elsewhere_off_hours[0],
off_hours_second=elsewhere_off_hours[1],
**common,
)
t_internal = living_area_fraction * t_1 + (1.0 - living_area_fraction) * t_2
return t_internal + control_temperature_adjustment_c

View file

@ -0,0 +1,189 @@
"""Tests for SAP 10.3 mean internal temperature (Tables 9, 9b, 9c).
The living area is held at T_h1 = 21 °C during heating periods; the rest of
the dwelling (T_h2) is colder by an amount that depends on the heating
control type and the dwelling's HLP. During off-periods the temperature
falls toward a 'steady-cool' value per Table 9b; the monthly mean comes
from weighting living-area + rest-of-dwelling by the living-area fraction.
Reference: SAP 10.3 (13-01-2026) Table 9 (page 183),
Table 9b (page 184), Table 9c (page 185).
"""
import pytest
from domain.sap.worksheet.mean_internal_temperature import (
elsewhere_heating_temperature_c,
mean_internal_temperature_c,
off_period_temperature_reduction_c,
)
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):
# T_h2 = T_h1 HLP + HLP² / 12
# For T_h1 = 21 °C and HLP = 2.0:
# T_h2 = 21 2 + 4/12 ≈ 19.333 °C
# Act
result = elsewhere_heating_temperature_c(
heat_loss_parameter=2.0,
control_type=2,
)
# Assert
assert result == pytest.approx(19.333, abs=0.01)
def test_elsewhere_temperature_control_type_1_uses_linear_drop() -> None:
# Arrange — Table 9 elsewhere formula for control type 1 (no time or
# thermostat control of room temperature, plus a few other cases):
# T_h2 = T_h1 0.5 × HLP
# HLP = 2.0 -> T_h2 = 21 1 = 20 °C.
# Act
result = elsewhere_heating_temperature_c(
heat_loss_parameter=2.0,
control_type=1,
)
# Assert
assert result == pytest.approx(20.0, abs=0.01)
def test_elsewhere_temperature_clamps_hlp_above_6_per_note_e() -> None:
# Arrange — Table 9 note (e): "If HLP > 6.0 use HLP = 6.0 for calculation
# of T_h2". Without the clamp a very leaky dwelling would give a
# ridiculously low elsewhere temperature. With clamp at 6:
# Control type 2: T_h2 = 21 6 + 36/12 = 18 °C.
# Act
leaky = elsewhere_heating_temperature_c(
heat_loss_parameter=10.0, # would give T_h2 = 21 - 10 + 8.33 ≈ 19.33 (different from clamped)
control_type=2,
)
at_cap = elsewhere_heating_temperature_c(
heat_loss_parameter=6.0,
control_type=2,
)
# Assert — both produce 18.0 because HLP clamps to 6.
assert leaky == pytest.approx(18.0, abs=0.01)
assert at_cap == pytest.approx(18.0, abs=0.01)
assert leaky == pytest.approx(at_cap, abs=0.001)
def test_short_off_period_temperature_reduction_uses_quadratic_branch() -> None:
# Arrange — Table 9b: when t_off ≤ t_c the reduction uses the quadratic
# branch u = 0.5 × t_off² × (T_h T_sc) / (24 × t_c).
# Hand-computed for: T_h = 21, T_e = 5, R = 1.0 (responsive radiators),
# G = 200 W, H = 200 W/K, η = 0.9, τ = 50 h:
# t_c = 4 + 0.25 × 50 = 16.5 h
# T_sc = (11)(212) + 1×(5 + 0.9×200/200) = 5 + 0.9 = 5.9 °C
# t_off = 7 h < t_c → quadratic branch
# u = 0.5 × 49 × (21 5.9) / (24 × 16.5)
# ≈ 0.5 × 49 × 15.1 / 396 ≈ 0.934 °C
# Act
result = off_period_temperature_reduction_c(
off_period_hours=7.0,
heating_temperature_c=21.0,
external_temperature_c=5.0,
responsiveness=1.0,
total_gains_w=200.0,
heat_transfer_coefficient_w_per_k=200.0,
utilisation_factor=0.9,
time_constant_h=50.0,
)
# Assert
assert result == pytest.approx(0.934, abs=0.01)
def test_long_off_period_temperature_reduction_uses_linear_branch() -> None:
# Arrange — Table 9b linear branch fires when t_off > t_c:
# u = (T_h T_sc) × (t_off 0.5·t_c) / 24
# Light-mass dwelling with τ = 10 h → t_c = 4 + 2.5 = 6.5 h.
# With t_off = 16 h > t_c, and the rest same as the short-off case:
# T_sc = 5.9 °C → ΔT = 15.1 °C
# u = 15.1 × (16 3.25) / 24 = 15.1 × 12.75 / 24 ≈ 8.02 °C
# Act
result = off_period_temperature_reduction_c(
off_period_hours=16.0,
heating_temperature_c=21.0,
external_temperature_c=5.0,
responsiveness=1.0,
total_gains_w=200.0,
heat_transfer_coefficient_w_per_k=200.0,
utilisation_factor=0.9,
time_constant_h=10.0,
)
# Assert
assert result == pytest.approx(8.02, abs=0.05)
def test_mean_internal_temperature_blends_living_and_elsewhere_by_la_fraction() -> None:
# Arrange — Hand-computed worked example following Table 9c steps 1-7.
# Inputs: HLP=2 (control type 2 -> T_h2 = 19.333), τ=50h, R=1.0,
# f_LA=0.3, T_e=5, G=200 W, H=200 W/K, η=0.9.
# Standard living-area off-hours (7, 8). With T_sc = 5.9 °C:
# Living (T_h=21): u1=0.934, u2=1.220, T_1 = 21 0.934 1.220 = 18.846
# Elsewhere (T_h=19.333): u1=0.831, u2=1.085, T_2 = 19.333 1.916 = 17.417
# T_int = 0.3 × 18.846 + 0.7 × 17.417 ≈ 17.846 °C.
# Act
result = mean_internal_temperature_c(
external_temp_c=5.0,
heat_transfer_coefficient_w_per_k=200.0,
total_gains_w=200.0,
utilisation_factor=0.9,
time_constant_h=50.0,
heat_loss_parameter=2.0,
living_area_fraction=0.3,
control_type=2,
responsiveness=1.0,
)
# Assert
assert result == pytest.approx(17.85, abs=0.05)
def test_control_type_3_uses_longer_first_off_period_for_elsewhere_zone() -> None:
# Arrange — Table 9 footnote (b): control type 3 (time + temperature
# zone control) shifts the first off-period in the rest-of-dwelling zone
# from 7 h to 9 h. The longer off-period drops T_2 further, so a
# control-3 dwelling has a slightly lower mean internal temperature than
# the same dwelling under control-2 — even though T_h2 formula is
# identical between the two.
# Act
control_2 = mean_internal_temperature_c(
external_temp_c=5.0,
heat_transfer_coefficient_w_per_k=200.0,
total_gains_w=200.0,
utilisation_factor=0.9,
time_constant_h=50.0,
heat_loss_parameter=2.0,
living_area_fraction=0.3,
control_type=2,
responsiveness=1.0,
)
control_3 = mean_internal_temperature_c(
external_temp_c=5.0,
heat_transfer_coefficient_w_per_k=200.0,
total_gains_w=200.0,
utilisation_factor=0.9,
time_constant_h=50.0,
heat_loss_parameter=2.0,
living_area_fraction=0.3,
control_type=3,
responsiveness=1.0,
)
# Assert — Same T_h2 formula but longer first off-period for elsewhere
# zone in control type 3 means a slightly lower mean.
assert control_3 < control_2
assert (control_2 - control_3) == pytest.approx(0.25, abs=0.2)