mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§8c slice 1: space_cooling_monthly_kwh orchestrator + utilisation_factor_loss leaf + 7 tests
Tables 10a (η_loss with γ rounding to 8 dp + L=0 sentinel) and 10b (Q_cool with Jun-Aug inclusion mask + post-f_C × f_intermittent 1-kWh clamp per spec line 10321). Internal temperature hardcoded at 24 °C per Table 10a; intermittency factor scalar in / worksheet-shape tuple out. Synthetic positive test (γ=1 closed-form branch) hand-computes the Jul-only 4.65 kWh end-to-end; synthetic zero test pins f_C=0 collapse. Leaf tested across all three γ-branches plus the rounding boundary and the L=0 sentinel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a4dfb7a021
commit
cf28eec44d
2 changed files with 382 additions and 0 deletions
178
packages/domain/src/domain/sap/worksheet/space_cooling.py
Normal file
178
packages/domain/src/domain/sap/worksheet/space_cooling.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""SAP 10.2 §8c Space cooling — Tables 10a (η_loss) + 10b (Q_cool).
|
||||
|
||||
Spec lines 7864–7893 (worksheet block §8c) and 10274–10328 (Table 10a/10b
|
||||
algebra). All cooling lines are 0 for dwellings without a fixed air-
|
||||
conditioning system (cooled-area fraction f_C = 0 collapses Q_cool to 0).
|
||||
Inclusion rule: only June, July, August contribute; other months zeroed.
|
||||
|
||||
Reference: SAP 10.2 specification (14-03-2025) Tables 10a/10b (page 186).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
|
||||
_COOLING_INTERNAL_TEMP_C: Final[float] = 24.0 # spec line 10281 — fixed
|
||||
_MIN_KWH_PER_MONTH: Final[float] = 1.0
|
||||
_WH_TO_KWH_PER_DAY: Final[float] = 0.024
|
||||
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
# Spec line 10325: include Jun..Aug, disregard Sep..May. Zero-based indices.
|
||||
_COOLING_MONTH_INDICES: Final[frozenset[int]] = frozenset({5, 6, 7})
|
||||
|
||||
|
||||
def utilisation_factor_loss(
|
||||
*,
|
||||
total_gains_w: float,
|
||||
heat_loss_rate_w: float,
|
||||
time_constant_h: float,
|
||||
) -> float:
|
||||
"""SAP 10.2 Table 10a — cooling utilisation factor η_loss.
|
||||
|
||||
Inverse of Table 9a heating η: γ = G/L (same definition) but exponents
|
||||
flip sign because Table 10a credits the LOSSES (cooling demand offset)
|
||||
rather than the gains (heating demand offset). η_loss ∈ (0, 1]; returns
|
||||
1 when γ ≤ 0 (losses negative or zero → utilisation of losses
|
||||
undefined).
|
||||
"""
|
||||
if heat_loss_rate_w == 0.0:
|
||||
gamma = 1_000_000.0 # spec Table 10a L=0 sentinel (formula → ≈1)
|
||||
else:
|
||||
gamma = total_gains_w / heat_loss_rate_w
|
||||
gamma = round(gamma, 8)
|
||||
a = 1.0 + time_constant_h / 15.0
|
||||
if gamma <= 0.0:
|
||||
return 1.0
|
||||
if gamma == 1.0:
|
||||
return a / (a + 1.0)
|
||||
return (1.0 - gamma ** -a) / (1.0 - gamma ** -(a + 1.0))
|
||||
|
||||
|
||||
def monthly_cool_requirement_kwh(
|
||||
*,
|
||||
heat_transfer_coefficient_w_per_k: float,
|
||||
external_temperature_c: float,
|
||||
total_gains_w: float,
|
||||
time_constant_h: float,
|
||||
days_in_month: int,
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""SAP 10.2 Table 10b — Q_whole single month, pre f_C × f_intermittent.
|
||||
|
||||
Returns the (100)..(104) line refs as a 4-tuple: (L_m, η_loss, useful
|
||||
loss = η×L, Q_whole). Q_whole is the signed (104)m value — spec line
|
||||
10321 clamps the final Q_cool (107), not (104). Inclusion mask
|
||||
(Jun-Aug only) + the f_C × f_intermittent multiplication + the negative-
|
||||
or-sub-1-kWh clamp all live in the orchestrator.
|
||||
"""
|
||||
l_m = heat_transfer_coefficient_w_per_k * (_COOLING_INTERNAL_TEMP_C - external_temperature_c)
|
||||
eta = utilisation_factor_loss(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=l_m,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
useful_loss_w = eta * l_m
|
||||
q_whole = _WH_TO_KWH_PER_DAY * (total_gains_w - useful_loss_w) * days_in_month
|
||||
return l_m, eta, useful_loss_w, q_whole
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpaceCoolingResult:
|
||||
"""SAP 10.2 §8c worksheet line refs (100)..(108).
|
||||
|
||||
Returned by `space_cooling_monthly_kwh`. Downstream calculator consumes
|
||||
`space_cooling_monthly_kwh` (107) directly; per-line tuples are
|
||||
exposed for worksheet conformance + audit. Field names mirror the line
|
||||
refs.
|
||||
"""
|
||||
|
||||
heat_loss_rate_monthly_w: tuple[float, ...] # (100)
|
||||
utilisation_factor_loss_monthly: tuple[float, ...] # (101)
|
||||
useful_loss_monthly_w: tuple[float, ...] # (102)
|
||||
cooling_gains_monthly_w: tuple[float, ...] # (103)
|
||||
cooling_requirement_monthly_kwh: tuple[float, ...] # (104)
|
||||
cooling_requirement_kwh_per_yr: float # Σ(104)
|
||||
cooled_area_fraction: float # (105)
|
||||
intermittency_factor_monthly: tuple[float, ...] # (106)
|
||||
space_cooling_monthly_kwh: tuple[float, ...] # (107)
|
||||
space_cooling_kwh_per_yr: float # Σ(107)
|
||||
space_cooling_per_m2_kwh: float # (108)
|
||||
|
||||
|
||||
def space_cooling_monthly_kwh(
|
||||
*,
|
||||
monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...],
|
||||
monthly_external_temperature_c: tuple[float, ...],
|
||||
monthly_total_gains_w: tuple[float, ...],
|
||||
total_floor_area_m2: float,
|
||||
thermal_mass_parameter_kj_per_m2_k: float,
|
||||
cooled_area_fraction: float = 0.0,
|
||||
intermittency_factor: float = 0.25,
|
||||
) -> SpaceCoolingResult:
|
||||
"""SAP 10.2 §8c orchestrator — produce (100)..(108) line refs.
|
||||
|
||||
Inputs are length-12 Jan..Dec tuples; `monthly_total_gains_w` is the
|
||||
cooling-specific gains tuple (Table 5a items already excluded by
|
||||
caller — see `cert_to_inputs`). Internal temperature is fixed at
|
||||
24 °C per Table 10a; not a parameter.
|
||||
|
||||
Inclusion rule (spec line 10325): only Jun-Aug contribute to (104)
|
||||
and (107). Other months return 0 at those lines regardless of
|
||||
computed value. (100)..(103) and (106) follow worksheet shape — see
|
||||
Q4/Q7 grilling decisions for intermittency tuple semantics.
|
||||
"""
|
||||
l_list: list[float] = []
|
||||
eta_list: list[float] = []
|
||||
useful_loss_list: list[float] = []
|
||||
q104_list: list[float] = []
|
||||
intermittency_list: list[float] = []
|
||||
q107_list: list[float] = []
|
||||
|
||||
hlp_m_per_m2_k = tuple(
|
||||
h / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
|
||||
for h in monthly_heat_transfer_coefficient_w_per_k
|
||||
)
|
||||
tau_m_h = tuple(
|
||||
thermal_mass_parameter_kj_per_m2_k / (3.6 * hlp) if hlp > 0 else 0.0
|
||||
for hlp in hlp_m_per_m2_k
|
||||
)
|
||||
|
||||
for m in range(12):
|
||||
l_m, eta_m, useful_loss_m, q_whole_m = monthly_cool_requirement_kwh(
|
||||
heat_transfer_coefficient_w_per_k=monthly_heat_transfer_coefficient_w_per_k[m],
|
||||
external_temperature_c=monthly_external_temperature_c[m],
|
||||
total_gains_w=monthly_total_gains_w[m],
|
||||
time_constant_h=tau_m_h[m],
|
||||
days_in_month=_DAYS_IN_MONTH[m],
|
||||
)
|
||||
l_list.append(l_m)
|
||||
eta_list.append(eta_m)
|
||||
useful_loss_list.append(useful_loss_m)
|
||||
if m in _COOLING_MONTH_INDICES:
|
||||
q104_list.append(q_whole_m)
|
||||
intermittency_list.append(intermittency_factor)
|
||||
q_cool_m = q_whole_m * cooled_area_fraction * intermittency_factor
|
||||
# Spec Table 10b: "Set Qcool to zero if negative or less than 1 kWh."
|
||||
q107_list.append(q_cool_m if q_cool_m >= _MIN_KWH_PER_MONTH else 0.0)
|
||||
else:
|
||||
q104_list.append(0.0)
|
||||
intermittency_list.append(0.0)
|
||||
q107_list.append(0.0)
|
||||
|
||||
annual_104 = sum(q104_list)
|
||||
annual_107 = sum(q107_list)
|
||||
per_m2 = annual_107 / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
|
||||
|
||||
return SpaceCoolingResult(
|
||||
heat_loss_rate_monthly_w=tuple(l_list),
|
||||
utilisation_factor_loss_monthly=tuple(eta_list),
|
||||
useful_loss_monthly_w=tuple(useful_loss_list),
|
||||
cooling_gains_monthly_w=tuple(monthly_total_gains_w),
|
||||
cooling_requirement_monthly_kwh=tuple(q104_list),
|
||||
cooling_requirement_kwh_per_yr=annual_104,
|
||||
cooled_area_fraction=cooled_area_fraction,
|
||||
intermittency_factor_monthly=tuple(intermittency_list),
|
||||
space_cooling_monthly_kwh=tuple(q107_list),
|
||||
space_cooling_kwh_per_yr=annual_107,
|
||||
space_cooling_per_m2_kwh=per_m2,
|
||||
)
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
"""Tests for SAP 10.2 §8c Space cooling — Tables 10a (η_loss) + 10b (Q_cool).
|
||||
|
||||
Reference: SAP 10.2 specification (14-03-2025) Tables 10a/10b (page 186).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap.worksheet.space_cooling import (
|
||||
space_cooling_monthly_kwh,
|
||||
utilisation_factor_loss,
|
||||
)
|
||||
|
||||
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C: float = -10.0
|
||||
_JULY_TE_C: float = 22.0
|
||||
_JULY_GAINS_W: float = 200.0
|
||||
_CONSTANT_H_W_PER_K: float = 100.0
|
||||
_TMP_FOR_A_EQUALS_THREE: float = 108.0 # → τ=30 → a=3 → γ=1 closed-form η=0.75
|
||||
|
||||
|
||||
def test_utilisation_factor_loss_returns_one_for_negative_gamma() -> None:
|
||||
"""Spec Table 10a: 'if γ ≤ 0: η = 1'. When external temperature exceeds the
|
||||
cooling internal temperature (24 °C), L = H(Ti − Te) is negative and γ = G/L
|
||||
is negative; the dwelling is gaining heat from outside so utilisation of
|
||||
losses is meaningless — η_loss collapses to 1."""
|
||||
# Arrange
|
||||
total_gains_w = 500.0
|
||||
heat_loss_rate_w = -120.0 # L < 0 → γ < 0
|
||||
time_constant_h = 30.0
|
||||
|
||||
# Act
|
||||
eta = utilisation_factor_loss(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=heat_loss_rate_w,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert eta == 1.0
|
||||
|
||||
|
||||
def test_utilisation_factor_loss_returns_closed_form_for_gamma_one() -> None:
|
||||
"""Spec Table 10a: 'if γ = 1: η = a / (a + 1)'. The general formula
|
||||
(1 − γ^−a) / (1 − γ^−(a+1)) is 0/0 at γ = 1; spec mandates the closed-
|
||||
form limit instead. With τ = 30 h, a = 1 + 30/15 = 3, so η = 3/4."""
|
||||
# Arrange
|
||||
total_gains_w = 200.0
|
||||
heat_loss_rate_w = 200.0 # G = L → γ = 1
|
||||
time_constant_h = 30.0 # → a = 3
|
||||
|
||||
# Act
|
||||
eta = utilisation_factor_loss(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=heat_loss_rate_w,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert eta == 0.75
|
||||
|
||||
|
||||
def test_utilisation_factor_loss_matches_formula_for_typical_gamma() -> None:
|
||||
"""Spec Table 10a main branch: 'if γ > 0 and γ ≠ 1:
|
||||
η = (1 − γ^−a) / (1 − γ^−(a+1))'. With G = 400, L = 200 → γ = 2; τ = 15
|
||||
h → a = 2. Expected η = (1 − 1/4) / (1 − 1/8) = 6/7."""
|
||||
# Arrange
|
||||
total_gains_w = 400.0
|
||||
heat_loss_rate_w = 200.0 # γ = 2
|
||||
time_constant_h = 15.0 # → a = 2
|
||||
|
||||
# Act
|
||||
eta = utilisation_factor_loss(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=heat_loss_rate_w,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert eta == 6.0 / 7.0
|
||||
|
||||
|
||||
def test_utilisation_factor_loss_rounds_gamma_near_one_to_closed_form() -> None:
|
||||
"""Spec Table 10a note: 'to avoid instability when γ is close to 1 round
|
||||
γ to 8 decimal places'. γ_raw = 1.0000000049 (9 dp) — the unrounded
|
||||
formula gives 0.7500000028..., but pre-branch rounding coerces γ to 1.0
|
||||
and the closed-form branch returns exactly a/(a+1) = 0.75."""
|
||||
# Arrange
|
||||
total_gains_w = 1.0e10 + 49.0 # γ_raw = 1.0000000049
|
||||
heat_loss_rate_w = 1.0e10
|
||||
time_constant_h = 30.0 # → a = 3 → closed-form a/(a+1) = 0.75
|
||||
|
||||
# Act
|
||||
eta = utilisation_factor_loss(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=heat_loss_rate_w,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert eta == 0.75
|
||||
|
||||
|
||||
def test_utilisation_factor_loss_handles_zero_loss_rate_with_sentinel() -> None:
|
||||
"""Spec Table 10a note: 'if L = 0 set γ = 10^6'. With heat-loss rate
|
||||
zero the dwelling can't shed heat at all; η_loss collapses to ~1 via
|
||||
the spec sentinel (γ = 10^6 → γ^−a ≈ 0 → formula ≈ 1.0). Orchestrator
|
||||
multiplies η × L anyway so the result is benign; this test pins the
|
||||
leaf's behaviour at the boundary."""
|
||||
# Arrange
|
||||
total_gains_w = 500.0
|
||||
heat_loss_rate_w = 0.0
|
||||
time_constant_h = 30.0
|
||||
|
||||
# Act
|
||||
eta = utilisation_factor_loss(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=heat_loss_rate_w,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert eta == 1.0
|
||||
|
||||
|
||||
def test_space_cooling_monthly_kwh_returns_positive_for_warm_july_with_cooling() -> None:
|
||||
"""Synthetic positive — orchestrator end-to-end. Only July contributes:
|
||||
T_e = 22 °C, G = 200 W, with H = 100 W/K → L = 100×(24−22) = 200 W and
|
||||
γ = G/L = 1 → η = a/(a+1) = 0.75 (a = 3 via TMP = 108, HLP = 1). Useful
|
||||
loss = 150 W; Q_whole = 0.024 × (200 − 150) × 31 = 37.2 kWh; (107) =
|
||||
37.2 × 0.5 × 0.25 = 4.65 kWh. Jun + Aug have zero gains and cold T_e,
|
||||
so Q_whole goes negative → clamped to 0. Other months masked by spec
|
||||
Jun-Aug inclusion rule."""
|
||||
# Arrange
|
||||
monthly_h = (_CONSTANT_H_W_PER_K,) * 12
|
||||
monthly_te: tuple[float, ...] = (
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Jan
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Feb
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Mar
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Apr
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # May
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Jun
|
||||
_JULY_TE_C, # Jul
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Aug
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Sep
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Oct
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Nov
|
||||
_FULLY_INACTIVE_GAINS_WINTER_TE_C, # Dec
|
||||
)
|
||||
monthly_gains = (0.0,) * 6 + (_JULY_GAINS_W,) + (0.0,) * 5
|
||||
|
||||
# Act
|
||||
result = space_cooling_monthly_kwh(
|
||||
monthly_heat_transfer_coefficient_w_per_k=monthly_h,
|
||||
monthly_external_temperature_c=monthly_te,
|
||||
monthly_total_gains_w=monthly_gains,
|
||||
total_floor_area_m2=100.0,
|
||||
thermal_mass_parameter_kj_per_m2_k=_TMP_FOR_A_EQUALS_THREE,
|
||||
cooled_area_fraction=0.5,
|
||||
intermittency_factor=0.25,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.space_cooling_kwh_per_yr == pytest.approx(4.65, abs=1e-9)
|
||||
assert result.space_cooling_monthly_kwh[6] == pytest.approx(4.65, abs=1e-9)
|
||||
assert sum(result.space_cooling_monthly_kwh[:5]) == 0.0
|
||||
assert result.space_cooling_monthly_kwh[5] == 0.0
|
||||
assert result.space_cooling_monthly_kwh[7] == 0.0
|
||||
assert sum(result.space_cooling_monthly_kwh[8:]) == 0.0
|
||||
assert result.cooled_area_fraction == 0.5
|
||||
assert result.space_cooling_per_m2_kwh == pytest.approx(0.0465, abs=1e-9)
|
||||
|
||||
|
||||
def test_space_cooling_monthly_kwh_collapses_to_zero_when_f_cool_zero() -> None:
|
||||
"""Synthetic zero — even with the same hot-July inputs that produced
|
||||
4.65 kWh above, setting f_C = 0 (no cooled area) collapses (107)m and
|
||||
the annual to zero. This is the dominant path: every RdSAP cert
|
||||
without a fixed AC system has f_C = 0."""
|
||||
# Arrange — identical to the positive case except f_C = 0.
|
||||
monthly_h = (_CONSTANT_H_W_PER_K,) * 12
|
||||
monthly_te: tuple[float, ...] = (
|
||||
(_FULLY_INACTIVE_GAINS_WINTER_TE_C,) * 6
|
||||
+ (_JULY_TE_C,)
|
||||
+ (_FULLY_INACTIVE_GAINS_WINTER_TE_C,) * 5
|
||||
)
|
||||
monthly_gains = (0.0,) * 6 + (_JULY_GAINS_W,) + (0.0,) * 5
|
||||
|
||||
# Act
|
||||
result = space_cooling_monthly_kwh(
|
||||
monthly_heat_transfer_coefficient_w_per_k=monthly_h,
|
||||
monthly_external_temperature_c=monthly_te,
|
||||
monthly_total_gains_w=monthly_gains,
|
||||
total_floor_area_m2=100.0,
|
||||
thermal_mass_parameter_kj_per_m2_k=_TMP_FOR_A_EQUALS_THREE,
|
||||
cooled_area_fraction=0.0, # f_C = 0
|
||||
intermittency_factor=0.25,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.space_cooling_kwh_per_yr == 0.0
|
||||
assert result.space_cooling_monthly_kwh == (0.0,) * 12
|
||||
assert result.space_cooling_per_m2_kwh == 0.0
|
||||
assert result.cooled_area_fraction == 0.0
|
||||
Loading…
Add table
Reference in a new issue