§8 slice 1: space_heating_monthly_kwh orchestrator + summer clamp + SpaceHeatingResult

Adds the §8 orchestrator producing (95)..(99) line refs for all 12 months.
Composes the existing monthly_heat_requirement_kwh leaf with the spec
inclusion rule (Table 9c step 10 final clause):

  "Include the heating requirement for each month from October to May
   (disregarding June to September)"

Jun..Sep are zeroed regardless of computed value, on top of the per-month
value clamp (< 1 kWh / negative).

SpaceHeatingResult exposes (95) useful gains, (97) heat loss rate, (98a)
space heating requirement, (98b) solar space heating (always 0 — Appendix
H deferred), (98c) total, Σ(98c) annual + (99) per-m². All length-12
tuples + 2 scalars.

Driven by Elmhurst 000490 (98c) annual = 11183.2752 kWh to abs=5e-3 kWh.
Without the summer clamp the current calculator over-predicts annual by
+1575 kWh (+14%) on this fixture; the clamp closes the gap to spec.

Slice 3 wires CalculatorInputs.space_heating_monthly_kwh + cert_to_inputs;
calculator stops calling monthly_heat_requirement_kwh inline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 22:11:22 +00:00
parent eec8fb6f4f
commit 9113f30aa8
2 changed files with 150 additions and 1 deletions

View file

@ -15,11 +15,17 @@ Reference: SAP 10.3 specification (13-01-2026) Table 9c (page 185).
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
_MIN_KWH_PER_MONTH: Final[float] = 1.0
_WH_TO_KWH_PER_DAY: Final[float] = 0.024 # 24 h / 1000
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
# SAP10.2 Table 9c step 10: "Include the heating requirement for each month
# from October to May (disregarding June to September)." Set Q_heat to zero
# in Jun..Sep regardless of computed value. Indices 5..8 inclusive (zero-based).
_SUMMER_MONTH_INDICES: Final[frozenset[int]] = frozenset({5, 6, 7, 8})
def monthly_heat_requirement_kwh(
@ -43,3 +49,89 @@ def monthly_heat_requirement_kwh(
if q_heat < _MIN_KWH_PER_MONTH:
return 0.0
return q_heat
@dataclass(frozen=True)
class SpaceHeatingResult:
"""SAP 10.2 §8 worksheet line refs (95)..(99).
Returned by `space_heating_monthly_kwh`. Downstream calculator consumes
`total_space_heating_monthly_kwh` (98c) directly to drive fuel-cost +
rating chains; per-line tuples are exposed for worksheet conformance +
audit. Field names mirror the SAP 10.2 line refs.
"""
useful_gains_monthly_w: tuple[float, ...] # (95)
heat_loss_rate_monthly_w: tuple[float, ...] # (97)
space_heating_requirement_monthly_kwh: tuple[float, ...] # (98a)
solar_space_heating_monthly_kwh: tuple[float, ...] # (98b)
total_space_heating_monthly_kwh: tuple[float, ...] # (98c)
total_space_heating_kwh_per_yr: float # Σ(98c)
space_heating_per_m2_kwh: float # (99)
def space_heating_monthly_kwh(
*,
monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...],
monthly_internal_temperature_c: tuple[float, ...],
monthly_external_temperature_c: tuple[float, ...],
monthly_utilisation_factor: tuple[float, ...],
monthly_total_gains_w: tuple[float, ...],
total_floor_area_m2: float,
) -> SpaceHeatingResult:
"""SAP 10.2 §8 orchestrator — produce (95)..(99) line refs for all months.
Composes the existing single-month leaf with the spec inclusion rule:
Jun..Sep are zeroed (Table 9c step 10) regardless of computed value,
on top of the per-month value clamp (< 1 kWh or negative).
Solar space heating (98b) Appendix H is always 0 in this slice; no
Elmhurst fixture lodges a solar space heating system. (98c) = (98a) for
the current corpus.
Inputs are length-12 Jan..Dec tuples. `total_floor_area_m2` only drives
the (99) per- aggregate; everything else is per-month physics.
"""
useful_gains: list[float] = []
heat_loss_rate: list[float] = []
q_heat_98a: list[float] = []
q_solar_98b: list[float] = []
q_total_98c: list[float] = []
for m in range(12):
h = monthly_heat_transfer_coefficient_w_per_k[m]
t_i = monthly_internal_temperature_c[m]
t_e = monthly_external_temperature_c[m]
eta = monthly_utilisation_factor[m]
gains = monthly_total_gains_w[m]
useful_gains.append(eta * gains)
heat_loss_rate.append(h * (t_i - t_e))
q98a = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=h,
internal_temperature_c=t_i,
external_temperature_c=t_e,
utilisation_factor=eta,
total_gains_w=gains,
days_in_month=_DAYS_IN_MONTH[m],
)
# Spec inclusion rule: Jun..Sep do not contribute regardless of value.
if m in _SUMMER_MONTH_INDICES:
q98a = 0.0
q_heat_98a.append(q98a)
q_solar_98b.append(0.0)
q_total_98c.append(q98a)
annual_98c = sum(q_total_98c)
per_m2_99 = annual_98c / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
return SpaceHeatingResult(
useful_gains_monthly_w=tuple(useful_gains),
heat_loss_rate_monthly_w=tuple(heat_loss_rate),
space_heating_requirement_monthly_kwh=tuple(q_heat_98a),
solar_space_heating_monthly_kwh=tuple(q_solar_98b),
total_space_heating_monthly_kwh=tuple(q_total_98c),
total_space_heating_kwh_per_yr=annual_98c,
space_heating_per_m2_kwh=per_m2_99,
)

View file

@ -9,7 +9,64 @@ 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
from domain.sap.worksheet.space_heating import (
SpaceHeatingResult,
monthly_heat_requirement_kwh,
space_heating_monthly_kwh,
)
# Worksheet U985-0001-000490 (UK-avg weather, region 0) inputs for §8.
# (93)m, (94)m from §7; (84)m gains = §5 (73) + §6 (83); (39)m HTC pinned
# from PDF; (96)m ext temp from Appendix U Table U1 region 0; TFA pinned.
_W000490_HTC_W_PER_K: tuple[float, ...] = (
289.6265, 288.8665, 288.1216, 284.6229, 283.9683, 280.9211,
280.9211, 280.3568, 282.0949, 283.9683, 285.2926, 286.6770,
)
_W000490_T_INT_C: tuple[float, ...] = (
15.1899, 15.4380, 15.9663, 16.7359, 17.5529, 18.3114,
18.7103, 18.6666, 18.1104, 17.0915, 16.0323, 15.1662,
)
_W000490_T_EXT_C: tuple[float, ...] = (
4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2,
)
_W000490_ETA_WHOLE: tuple[float, ...] = (
0.9735, 0.9659, 0.9524, 0.9261, 0.8752, 0.7738,
0.6027, 0.6474, 0.8348, 0.9303, 0.9644, 0.9759,
)
_W000490_TOTAL_GAINS_W: tuple[float, ...] = (
595.2863, 661.0571, 716.2901, 763.4028, 790.9684, 764.1081,
732.0878, 691.6074, 654.2542, 610.6983, 573.2833, 568.9492,
)
_W000490_TFA_M2: float = 66.06
# (98c) annual from PDF — Σ Jan..May + Σ Oct..Dec, Jun..Sep clamped to 0
# per SAP10.2 Table 9c step 10 inclusion rule.
_W000490_ANNUAL_98C_KWH: float = 11183.2752
def test_space_heating_monthly_kwh_reproduces_000490_annual_with_summer_clamp() -> None:
# Arrange — Elmhurst 000490 §7 outputs + §3/§5/§6 inputs above. Expected
# annual (98c) = 11183.2752 kWh. Spec inclusion rule (Table 9c step 10):
# June-September contribute zero regardless of computed value.
# Act
result = space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=_W000490_HTC_W_PER_K,
monthly_internal_temperature_c=_W000490_T_INT_C,
monthly_external_temperature_c=_W000490_T_EXT_C,
monthly_utilisation_factor=_W000490_ETA_WHOLE,
monthly_total_gains_w=_W000490_TOTAL_GAINS_W,
total_floor_area_m2=_W000490_TFA_M2,
)
# Assert
assert isinstance(result, SpaceHeatingResult)
assert result.total_space_heating_kwh_per_yr == pytest.approx(
_W000490_ANNUAL_98C_KWH, abs=5e-3
)
# Summer-clamp invariant — Jun..Sep months always zero per spec.
for m in range(5, 9): # indices 5..8 = Jun..Sep
assert result.total_space_heating_monthly_kwh[m] == 0.0, f"(98c) month {m+1}"
def test_typical_winter_month_returns_positive_kwh() -> None: