diff --git a/packages/domain/src/domain/sap/worksheet/space_heating.py b/packages/domain/src/domain/sap/worksheet/space_heating.py new file mode 100644 index 00000000..4659a359 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/space_heating.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py b/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py new file mode 100644 index 00000000..f0245fd3 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_space_heating.py @@ -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