diff --git a/packages/domain/src/domain/sap/worksheet/space_cooling.py b/packages/domain/src/domain/sap/worksheet/space_cooling.py new file mode 100644 index 00000000..f21d0ff8 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/space_cooling.py @@ -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, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_space_cooling.py b/packages/domain/src/domain/sap/worksheet/tests/test_space_cooling.py new file mode 100644 index 00000000..fbc38446 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_space_cooling.py @@ -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