diff --git a/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py b/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py new file mode 100644 index 00000000..30f29062 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/mean_internal_temperature.py @@ -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 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 new file mode 100644 index 00000000..5adbdb47 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_mean_internal_temperature.py @@ -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)