slice S-A5c: heating utilisation factor η (SAP 10.3 Table 9a)

Seventh slice of the SAP10 Calculator Session A (ADR-0009). Ships
utilisation_factor(*, total_gains_w, heat_loss_rate_w, time_constant_h)
implementing SAP 10.3 Table 9a:

  a  = 1 + τ / 15
  γ  = G / L
  if γ > 0 and γ ≠ 1:  η = (1 − γ^a) / (1 − γ^(a+1))
  if γ = 1:            η = a / (a + 1)
  if heat_loss_rate ≤ 0: η = 1   (dwelling in net surplus)

η caps the contribution of internal + solar gains when they outpace the
heat-loss rate. The orchestrator computes time_constant_h = TMP /
(3.6 × HLP) and passes it in here; that's a future slice.

5 AAA cycles cover: small γ → η ≈ 1, γ = 1 special-case formula,
zero/negative heat loss returning η = 1, large γ dropping η well below
0.5, and higher τ (more thermal mass) raising η for the same γ.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 08:38:03 +00:00
parent 57bf7833a9
commit e403e2302c
2 changed files with 147 additions and 0 deletions

View file

@ -0,0 +1,104 @@
"""Tests for SAP 10.3 Table 9a — heating utilisation factor η.
Reduces the contribution of internal + solar gains when they exceed the
heat loss rate (a hot dwelling can't bank extra heat). Function of the
gain-to-loss ratio γ and the dwelling's time constant τ.
Reference: SAP 10.3 specification (13-01-2026) Table 9a (page 184).
"""
import pytest
from domain.sap.worksheet.utilisation_factor import utilisation_factor
def test_small_gain_loss_ratio_returns_eta_close_to_one() -> None:
# Arrange — Per Table 9a, when γ = G/L is small, η is close to 1
# (almost all gains usefully offset losses). Hand-computed:
# G = 100 W, L = 1000 W -> γ = 0.1
# τ = 10 h -> a = 1 + 10/15 ≈ 1.667
# η = (1 - 0.1^1.667) / (1 - 0.1^2.667)
# = (1 - 0.02147) / (1 - 0.00461)
# ≈ 0.978 / 0.995
# ≈ 0.983
# Act
result = utilisation_factor(
total_gains_w=100.0,
heat_loss_rate_w=1000.0,
time_constant_h=10.0,
)
# Assert
assert result == pytest.approx(0.983, abs=0.005)
def test_gain_loss_ratio_equal_one_returns_a_over_a_plus_one() -> None:
# Arrange — Per Table 9a the γ = 1 branch: η = a / (a + 1). For τ = 15 h
# this gives a = 2 and η = 2/3 ≈ 0.667. Avoids the 0/0 in the general
# formula at γ = 1.
# Act
result = utilisation_factor(
total_gains_w=500.0,
heat_loss_rate_w=500.0,
time_constant_h=15.0,
)
# Assert
assert result == pytest.approx(2.0 / 3.0, abs=0.005)
def test_zero_or_negative_heat_loss_returns_eta_one() -> None:
# Arrange — Table 9a edge case: when L ≤ 0 (the dwelling is in net heat
# surplus over the month), η = 1 by convention so the heating
# requirement Q_heat = 0.024 × (L η·G) × n_m clamps to zero.
# Act
zero_loss = utilisation_factor(
total_gains_w=500.0, heat_loss_rate_w=0.0, time_constant_h=10.0,
)
negative_loss = utilisation_factor(
total_gains_w=500.0, heat_loss_rate_w=-100.0, time_constant_h=10.0,
)
# Assert
assert zero_loss == 1.0
assert negative_loss == 1.0
def test_large_gain_loss_ratio_drops_eta_well_below_half() -> None:
# Arrange — When γ = 3 (gains three times loss rate), η drops to ≈ 0.30
# for τ = 10 h (a = 1.667). Real-world scenario: well-insulated home
# with strong solar gains in a mild month — most of the gain doesn't
# offset heating because the dwelling overheats.
# Act
result = utilisation_factor(
total_gains_w=1500.0,
heat_loss_rate_w=500.0, # γ = 3.0
time_constant_h=10.0,
)
# Assert
assert result == pytest.approx(0.296, abs=0.01)
assert result < 0.5
def test_higher_time_constant_increases_eta_for_same_gain_loss_ratio() -> None:
# Arrange — Heavier dwellings (more thermal mass, higher τ) use gains
# better because the building doesn't overheat as quickly. Holding
# γ = 1.5 fixed, doubling τ from 10 h to 40 h should noticeably raise η.
# Act
light = utilisation_factor(
total_gains_w=750.0, heat_loss_rate_w=500.0, time_constant_h=10.0,
)
heavy = utilisation_factor(
total_gains_w=750.0, heat_loss_rate_w=500.0, time_constant_h=40.0,
)
# Assert
assert heavy > light
assert light == pytest.approx(0.496, abs=0.01)
assert heavy == pytest.approx(0.608, abs=0.01)

View file

@ -0,0 +1,43 @@
"""SAP 10.3 Table 9a — heating utilisation factor η.
η reduces the contribution of internal + solar gains when they outpace the
dwelling's heat-loss rate. A well-insulated dwelling with large solar gains
in October can't fully use those gains — it's already warm enough.
Formula per Table 9a:
a = 1 + τ / 15 where τ is the dwelling time constant (h)
γ = G / L gain-to-loss ratio (W / W)
if γ > 0 and γ 1: η = (1 γ^a) / (1 γ^(a+1))
if γ = 1: η = a / (a + 1)
if γ 0: η = 1
The time constant τ = TMP / (3.6 × HLP) comes from the dwelling's thermal
mass parameter and heat-loss parameter; computed by the orchestrator and
passed in here.
Reference: SAP 10.3 specification (13-01-2026) Table 9a (page 184).
"""
from __future__ import annotations
def utilisation_factor(
*,
total_gains_w: float,
heat_loss_rate_w: float,
time_constant_h: float,
) -> float:
"""SAP 10.3 Table 9a heating utilisation factor η.
γ = total_gains_w / heat_loss_rate_w; η (0, 1]. When the heat-loss
rate is non-positive (dwelling already balanced or gaining), η = 1
so the gains are fully credited.
"""
if heat_loss_rate_w <= 0:
return 1.0
gamma = total_gains_w / heat_loss_rate_w
a = 1.0 + time_constant_h / 15.0
if gamma == 1.0:
return a / (a + 1.0)
return (1.0 - gamma**a) / (1.0 - gamma ** (a + 1.0))