mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
e403e2302c
commit
8c21b399c6
2 changed files with 350 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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 = (1−1)(21−2) + 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)
|
||||
Loading…
Add table
Reference in a new issue