mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§7 slice 1: mean_internal_temperature_monthly orchestrator with per-zone η
Adds MeanInternalTemperatureResult + mean_internal_temperature_monthly, implementing SAP10.2 §7 Table 9c steps 1-9 sequentially: - (86) η_living = f(Ti = T_h1 = 21°C) - (89) η_elsewhere = f(Ti = T_h2 from Table 9) - (94) η_whole = f(Ti = (93)m adjusted MIT) Three distinct η values per month, each computed from its own zone's Ti via the existing utilisation_factor leaf. Closes the 6.6e-3 °C drift on 000490 (92)m Jan that the prior single-η implementation produced. Driven by 000490 Jan worksheet (92)m = 15.1899 to abs=5e-3 °C. Other 11 months + per-zone line refs are exercised by the ALL_FIXTURES e2e test in slice 3. Legacy `mean_internal_temperature_c` retained (still used by calculator _solve_month iteration); slice 4 deletes both when calculator wires the new orchestrator's (93)m + (94)m fields. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
34f4fa8bef
commit
fa49d7b946
2 changed files with 223 additions and 0 deletions
|
|
@ -20,11 +20,16 @@ Table 9b (page 184), Table 9c (page 185).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from domain.sap.worksheet.utilisation_factor import utilisation_factor
|
||||
|
||||
|
||||
_T_H1_C: Final[float] = 21.0
|
||||
_HLP_CLAMP_FOR_T_H2: Final[float] = 6.0
|
||||
_MONTHS: Final[range] = range(12)
|
||||
_TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6
|
||||
|
||||
|
||||
def elsewhere_heating_temperature_c(
|
||||
|
|
@ -159,3 +164,175 @@ def mean_internal_temperature_c(
|
|||
)
|
||||
t_internal = living_area_fraction * t_1 + (1.0 - living_area_fraction) * t_2
|
||||
return t_internal + control_temperature_adjustment_c
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeanInternalTemperatureResult:
|
||||
"""SAP 10.2 §7 worksheet line refs (85)..(94).
|
||||
|
||||
Returned by `mean_internal_temperature_monthly`. Downstream §8 space
|
||||
heating consumes `adjusted_mean_internal_temp_monthly` (93) and
|
||||
`utilisation_factor_whole_monthly` (94) directly; per-zone tuples are
|
||||
exposed for worksheet conformance + audit.
|
||||
"""
|
||||
|
||||
living_area_heating_temp_c: float # (85)
|
||||
utilisation_factor_living_monthly: tuple[float, ...] # (86)
|
||||
mean_internal_temp_living_monthly: tuple[float, ...] # (87)
|
||||
elsewhere_heating_temp_monthly: tuple[float, ...] # (88)
|
||||
utilisation_factor_elsewhere_monthly: tuple[float, ...] # (89)
|
||||
mean_internal_temp_elsewhere_monthly: tuple[float, ...] # (90)
|
||||
living_area_fraction: float # (91)
|
||||
mean_internal_temp_monthly: tuple[float, ...] # (92)
|
||||
adjusted_mean_internal_temp_monthly: tuple[float, ...] # (93)
|
||||
utilisation_factor_whole_monthly: tuple[float, ...] # (94)
|
||||
|
||||
|
||||
def _zone_mean_temp_with_per_zone_eta(
|
||||
*,
|
||||
heating_temperature_c: float,
|
||||
off_hours_first: float,
|
||||
off_hours_second: float,
|
||||
external_temp_c: float,
|
||||
responsiveness: float,
|
||||
total_gains_w: float,
|
||||
heat_transfer_coefficient_w_per_k: float,
|
||||
time_constant_h: float,
|
||||
) -> tuple[float, float]:
|
||||
"""SAP 10.2 Table 9c steps 2–4 (or 5–6) for one zone.
|
||||
|
||||
Computes η using L = H(Th − Te) — the zone's own heating temperature —
|
||||
then folds it into Table 9b u1+u2 to yield zone mean. Returns (η, T_zone).
|
||||
Distinct from `_zone_mean_temperature_c` which takes a precomputed η
|
||||
that's shared across zones (the pre-fix shape).
|
||||
"""
|
||||
loss_rate_w = max(0.0, heat_transfer_coefficient_w_per_k * (heating_temperature_c - external_temp_c))
|
||||
eta = utilisation_factor(
|
||||
total_gains_w=total_gains_w,
|
||||
heat_loss_rate_w=loss_rate_w,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
common = dict(
|
||||
heating_temperature_c=heating_temperature_c,
|
||||
external_temperature_c=external_temp_c,
|
||||
responsiveness=responsiveness,
|
||||
total_gains_w=total_gains_w,
|
||||
heat_transfer_coefficient_w_per_k=heat_transfer_coefficient_w_per_k,
|
||||
utilisation_factor=eta,
|
||||
time_constant_h=time_constant_h,
|
||||
)
|
||||
u1 = off_period_temperature_reduction_c(off_period_hours=off_hours_first, **common)
|
||||
u2 = off_period_temperature_reduction_c(off_period_hours=off_hours_second, **common)
|
||||
return eta, heating_temperature_c - u1 - u2
|
||||
|
||||
|
||||
def mean_internal_temperature_monthly(
|
||||
*,
|
||||
monthly_external_temp_c: tuple[float, ...],
|
||||
monthly_total_gains_w: tuple[float, ...],
|
||||
monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...],
|
||||
thermal_mass_parameter_kj_per_m2_k: float,
|
||||
total_floor_area_m2: float,
|
||||
control_type: int,
|
||||
responsiveness: float,
|
||||
living_area_fraction: float,
|
||||
control_temperature_adjustment_c: float = 0.0,
|
||||
) -> MeanInternalTemperatureResult:
|
||||
"""SAP 10.2 §7 orchestrator — chain Table 9c steps 1–9 for all 12 months.
|
||||
|
||||
Per-zone η is the load-bearing detail vs the legacy single-η path:
|
||||
- (86) η_living = f(Ti = T_h1 = 21°C)
|
||||
- (89) η_elsewhere = f(Ti = T_h2 from Table 9)
|
||||
- (94) η_whole = f(Ti = (93) adjusted MIT) — feeds §8 step 9.
|
||||
|
||||
Inputs:
|
||||
monthly_* length-12 tuples covering Jan..Dec
|
||||
thermal_mass_parameter_kj_per_m2_k TMP (35) for τ = TMP·TFA/(3.6·HLC)
|
||||
total_floor_area_m2 (4) for τ derivation
|
||||
control_type 1/2/3 buckets feed Table 9 T_h2 + Table 9 off-hours
|
||||
responsiveness Table 4d R for the Table 9b u-formula
|
||||
living_area_fraction (91) for (92) zone blending
|
||||
control_temperature_adjustment_c (93) Table 4e adj (defaults 0 — all 6
|
||||
Elmhurst fixtures have (93) = (92), so this is a
|
||||
known shortcut; cert-side mapping is a future slice).
|
||||
"""
|
||||
elsewhere_off_hours = (
|
||||
_ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12
|
||||
)
|
||||
|
||||
eta_living: list[float] = []
|
||||
t_1: list[float] = []
|
||||
t_h2: list[float] = []
|
||||
eta_elsewhere: list[float] = []
|
||||
t_2: list[float] = []
|
||||
t_internal: list[float] = []
|
||||
t_adj: list[float] = []
|
||||
eta_whole: list[float] = []
|
||||
|
||||
for m in _MONTHS:
|
||||
ext = monthly_external_temp_c[m]
|
||||
gains = monthly_total_gains_w[m]
|
||||
h = monthly_heat_transfer_coefficient_w_per_k[m]
|
||||
hlp = h / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
|
||||
tau = (
|
||||
thermal_mass_parameter_kj_per_m2_k * total_floor_area_m2
|
||||
/ (_TIME_CONSTANT_DIVISOR_KJ_TO_WH * h)
|
||||
if h > 0 else float("inf")
|
||||
)
|
||||
|
||||
# Living area — steps 1-4
|
||||
eta_l, t_l = _zone_mean_temp_with_per_zone_eta(
|
||||
heating_temperature_c=_T_H1_C,
|
||||
off_hours_first=_LIVING_AREA_OFF_HOURS[0],
|
||||
off_hours_second=_LIVING_AREA_OFF_HOURS[1],
|
||||
external_temp_c=ext, responsiveness=responsiveness,
|
||||
total_gains_w=gains, heat_transfer_coefficient_w_per_k=h,
|
||||
time_constant_h=tau,
|
||||
)
|
||||
eta_living.append(eta_l)
|
||||
t_1.append(t_l)
|
||||
|
||||
# Elsewhere — steps 5-6
|
||||
t_h2_m = elsewhere_heating_temperature_c(
|
||||
heat_loss_parameter=hlp, control_type=control_type,
|
||||
)
|
||||
t_h2.append(t_h2_m)
|
||||
eta_e, t_e = _zone_mean_temp_with_per_zone_eta(
|
||||
heating_temperature_c=t_h2_m,
|
||||
off_hours_first=elsewhere_off_hours[0],
|
||||
off_hours_second=elsewhere_off_hours[1],
|
||||
external_temp_c=ext, responsiveness=responsiveness,
|
||||
total_gains_w=gains, heat_transfer_coefficient_w_per_k=h,
|
||||
time_constant_h=tau,
|
||||
)
|
||||
eta_elsewhere.append(eta_e)
|
||||
t_2.append(t_e)
|
||||
|
||||
# Blend (step 7) + Table 4e adj (step 8)
|
||||
t_int = living_area_fraction * t_l + (1.0 - living_area_fraction) * t_e
|
||||
t_internal.append(t_int)
|
||||
t_adjusted = t_int + control_temperature_adjustment_c
|
||||
t_adj.append(t_adjusted)
|
||||
|
||||
# Step 9 — η_whole at the adjusted MIT for §8 space heating
|
||||
loss_whole_w = max(0.0, h * (t_adjusted - ext))
|
||||
eta_whole.append(
|
||||
utilisation_factor(
|
||||
total_gains_w=gains,
|
||||
heat_loss_rate_w=loss_whole_w,
|
||||
time_constant_h=tau,
|
||||
)
|
||||
)
|
||||
|
||||
return MeanInternalTemperatureResult(
|
||||
living_area_heating_temp_c=_T_H1_C,
|
||||
utilisation_factor_living_monthly=tuple(eta_living),
|
||||
mean_internal_temp_living_monthly=tuple(t_1),
|
||||
elsewhere_heating_temp_monthly=tuple(t_h2),
|
||||
utilisation_factor_elsewhere_monthly=tuple(eta_elsewhere),
|
||||
mean_internal_temp_elsewhere_monthly=tuple(t_2),
|
||||
living_area_fraction=living_area_fraction,
|
||||
mean_internal_temp_monthly=tuple(t_internal),
|
||||
adjusted_mean_internal_temp_monthly=tuple(t_adj),
|
||||
utilisation_factor_whole_monthly=tuple(eta_whole),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,12 +13,58 @@ Table 9b (page 184), Table 9c (page 185).
|
|||
import pytest
|
||||
|
||||
from domain.sap.worksheet.mean_internal_temperature import (
|
||||
MeanInternalTemperatureResult,
|
||||
elsewhere_heating_temperature_c,
|
||||
mean_internal_temperature_c,
|
||||
mean_internal_temperature_monthly,
|
||||
off_period_temperature_reduction_c,
|
||||
)
|
||||
|
||||
|
||||
# Worksheet U985-0001-000490 (UK-avg weather, region 0) inputs for §7.
|
||||
# 3 habitable rooms → Table 27 f_LA = 0.25 (PDF (91) = 0.2501 rounding).
|
||||
# Vaillant Ecotec Pro combi w/ programmer + room thermostat → control_type = 2,
|
||||
# responsiveness = 1.0 (Table 4d gas radiators). TFA = 66.06 m², TMP = 250
|
||||
# (SAP10 mass-medium default). No Table 4e adjustment.
|
||||
_W000490_EXT_TEMP_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_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_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_TFA_M2: float = 66.06
|
||||
_W000490_TMP_KJ_PER_M2_K: float = 250.0
|
||||
_W000490_CONTROL_TYPE: int = 2
|
||||
_W000490_RESPONSIVENESS: float = 1.0
|
||||
_W000490_LIVING_AREA_FRACTION: float = 0.2501
|
||||
|
||||
|
||||
def test_mean_internal_temperature_monthly_reproduces_000490_line_92_jan() -> None:
|
||||
# Arrange — Elmhurst 000490 worksheet inputs above; expected (92)m Jan
|
||||
# blended MIT = 15.1899 °C from the PDF.
|
||||
|
||||
# Act
|
||||
result = mean_internal_temperature_monthly(
|
||||
monthly_external_temp_c=_W000490_EXT_TEMP_C,
|
||||
monthly_total_gains_w=_W000490_TOTAL_GAINS_W,
|
||||
monthly_heat_transfer_coefficient_w_per_k=_W000490_HTC_W_PER_K,
|
||||
thermal_mass_parameter_kj_per_m2_k=_W000490_TMP_KJ_PER_M2_K,
|
||||
total_floor_area_m2=_W000490_TFA_M2,
|
||||
control_type=_W000490_CONTROL_TYPE,
|
||||
responsiveness=_W000490_RESPONSIVENESS,
|
||||
living_area_fraction=_W000490_LIVING_AREA_FRACTION,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MeanInternalTemperatureResult)
|
||||
assert result.mean_internal_temp_monthly[0] == pytest.approx(15.1899, abs=5e-3)
|
||||
|
||||
|
||||
def test_elsewhere_temperature_control_type_2_uses_quadratic_drop() -> None:
|
||||
# Arrange — Table 9 elsewhere formula for control type 2 (programmer +
|
||||
# room thermostat, default for boiler systems with reasonable control):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue