slice S-A5e: monthly space-heating requirement (SAP 10.3 Table 9c step 10)

Ninth slice of the SAP10 Calculator Session A (ADR-0009). Ships
monthly_heat_requirement_kwh implementing the Table 9c step-10 formula:

    L_m       = H × (T_i,m − T_e,m)              (W)
    Q_heat,m  = 0.024 × (L_m − η_m × G_m) × n_m  (kWh)

with the table's clamp: Q_heat is set to 0 when negative or below 1 kWh
per month (summer months and well-insulated dwellings in shoulder
months).

The orchestrator (S-A6) iterates utilisation factor + mean internal
temperature until they converge before calling this function.

5 AAA cycles cover: typical-winter-month hand-computed worked example,
summer month with gains exceeding losses clamping to 0, gains-scaling
direction check, external-temperature direction check, and the sub-1-kWh
clamp per the Table 9c note.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 09:00:05 +00:00
parent 8c21b399c6
commit c0afe3592f
2 changed files with 159 additions and 0 deletions

View file

@ -0,0 +1,45 @@
"""SAP 10.3 Table 9c step 10 — monthly space-heating requirement.
Final step of the heating worksheet: the heat the heating system has to
deliver to maintain the mean internal temperature, given the loss rate to
the outside and the gains the dwelling has already accumulated.
L_m = H × (T_i,m T_e,m) (W)
Q_heat,m = 0.024 × (L_m η_m × G_m) × n_m (kWh)
If Q_heat would be negative or below 1 kWh in any month, set it to 0 per
the Table 9c clamp.
Reference: SAP 10.3 specification (13-01-2026) Table 9c (page 185).
"""
from __future__ import annotations
from typing import Final
_MIN_KWH_PER_MONTH: Final[float] = 1.0
_WH_TO_KWH_PER_DAY: Final[float] = 0.024 # 24 h / 1000
def monthly_heat_requirement_kwh(
*,
heat_transfer_coefficient_w_per_k: float,
internal_temperature_c: float,
external_temperature_c: float,
utilisation_factor: float,
total_gains_w: float,
days_in_month: int,
) -> float:
"""SAP 10.3 Table 9c step 10. Returns delivered kWh required for the
month; clamps to 0 when below 1 kWh or negative."""
loss_rate_w = heat_transfer_coefficient_w_per_k * (
internal_temperature_c - external_temperature_c
)
useful_loss_w = loss_rate_w - utilisation_factor * total_gains_w
if useful_loss_w <= 0:
return 0.0
q_heat = _WH_TO_KWH_PER_DAY * useful_loss_w * days_in_month
if q_heat < _MIN_KWH_PER_MONTH:
return 0.0
return q_heat

View file

@ -0,0 +1,114 @@
"""Tests for SAP 10.3 Table 9c step 10 — monthly space-heating requirement.
Final step of the heating worksheet: convert the monthly loss rate minus
utilised gains into a delivered-kWh figure. Clamped to zero if it would be
negative or below 1 kWh/month per the Table 9c note.
Reference: SAP 10.3 specification (13-01-2026) Table 9c (page 185).
"""
import pytest
from domain.sap.worksheet.space_heating import monthly_heat_requirement_kwh
def test_typical_winter_month_returns_positive_kwh() -> None:
# Arrange — Mid-mass dwelling, January conditions.
# H = 200 W/K, T_i = 18 °C, T_e = 5 °C, G = 300 W, η = 0.9, n_m = 31.
# L = 200 × (18 5) = 2600 W
# useful_loss = L η·G = 2600 0.9 × 300 = 2330 W
# Q_heat = 0.024 × 2330 × 31 ≈ 1733.5 kWh.
# Act
result = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=200.0,
internal_temperature_c=18.0,
external_temperature_c=5.0,
utilisation_factor=0.9,
total_gains_w=300.0,
days_in_month=31,
)
# Assert
assert result == pytest.approx(1733.5, abs=5.0)
def test_summer_month_with_gains_above_losses_clamps_to_zero() -> None:
# Arrange — July: T_i ≈ 21 °C indoor, T_e ≈ 17 °C, modest loss rate but
# huge solar + internal gains exceed it. Loss = 200 × 4 = 800 W, η·G = 0.5 × 2000 = 1000 W.
# useful_loss = -200 W → Q_heat = 0 by the Table 9c clamp.
# Act
result = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=200.0,
internal_temperature_c=21.0,
external_temperature_c=17.0,
utilisation_factor=0.5,
total_gains_w=2000.0,
days_in_month=31,
)
# Assert
assert result == 0.0
def test_more_gains_reduce_monthly_heat_requirement() -> None:
# Arrange — Direction check: higher internal+solar gains for the same
# losses must reduce the heating requirement linearly via the η·G term.
# Act
low_gains = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=200.0,
internal_temperature_c=18.0, external_temperature_c=5.0,
utilisation_factor=0.9, total_gains_w=100.0, days_in_month=31,
)
high_gains = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=200.0,
internal_temperature_c=18.0, external_temperature_c=5.0,
utilisation_factor=0.9, total_gains_w=600.0, days_in_month=31,
)
# Assert — drop = 0.9 × (600 100) × 0.024 × 31 = 334.8 kWh.
assert low_gains - high_gains == pytest.approx(334.8, abs=2.0)
def test_warmer_external_temperature_reduces_monthly_heat_requirement() -> None:
# Arrange — Direction check: same inputs but warmer outdoor temp drops
# loss rate proportionally. ΔL = 200 × ΔT × 0.024 × 31 = 148.8 kWh per °C.
# Act
cold = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=200.0,
internal_temperature_c=18.0, external_temperature_c=0.0,
utilisation_factor=0.9, total_gains_w=300.0, days_in_month=31,
)
mild = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=200.0,
internal_temperature_c=18.0, external_temperature_c=10.0,
utilisation_factor=0.9, total_gains_w=300.0, days_in_month=31,
)
# Assert
assert cold > mild
assert (cold - mild) == pytest.approx(10 * 200 * 0.024 * 31, abs=2.0)
def test_sub_one_kwh_requirement_clamps_to_zero_per_table_9c_note() -> None:
# Arrange — Table 9c clamp: "Set Q_heat to 0 if negative or less than
# 1 kWh." Engineer a barely-positive useful loss so the unclamped result
# falls below 1 kWh. With H=10, ΔT=0.1, gains=0, days=30:
# useful_loss = 1 W
# Q_heat = 0.024 × 1 × 30 = 0.72 kWh -> clamped to 0.
# Act
result = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=10.0,
internal_temperature_c=18.0,
external_temperature_c=17.9,
utilisation_factor=0.9,
total_gains_w=0.0,
days_in_month=30,
)
# Assert
assert result == 0.0