Slice 102f-prep.4: Equation N5 zone-mean blending leaf

SAP 10.2 Appendix N3.5 Equation N5 (PDF p.107):

    T = [N24,9 × Th + N16,9 × T_uni + (Nm − N16,9 − N24,9) × T_bi] / Nm

`extended_zone_mean_temperature_c` is the pure-math leaf: takes
pre-computed bimodal (9-hour heating, two off periods) and unimodal
(16-hour heating, one 8-hour off period per Table N7 footnote b)
zone temperatures and the per-month day allocations, blends across
the three heating patterns (Th for 24-hour days, T_uni for 16-hour,
T_bi for the standard 9-hour SAP schedule).

Pinned against cert 0380's January living-area MIT: Th=21, T_bi
=18.5551 (worksheet "Living" row), T_uni back-solved from (87)
= 19.6153, N24=3, N16=28, Nm=31 → 19.7493 (worksheet (87) Jan).

Collapses cleanly: N24=N16=0 → T_bi (warm months / non-HP certs);
N24=Nm → Th (full 24-hour heating).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 13:28:00 +00:00 committed by Jun-te Kim
parent 80d3b9efd6
commit a486e97d06
2 changed files with 107 additions and 0 deletions

View file

@ -146,6 +146,45 @@ def allocate_extended_heating_days_to_months(
return tuple(allocations)
def extended_zone_mean_temperature_c(
*,
heating_temperature_c: float,
t_bimodal_c: float,
t_unimodal_c: float,
n24_9_m: int,
n16_9_m: int,
days_in_month: int,
) -> float:
"""SAP 10.2 Appendix N3.5 Equation N5 (PDF p.107) — blend a zone's
monthly mean temperature across three heating patterns:
T = [N24,9 × Th + N16,9 × T_uni + (Nm N16,9 N24,9) × T_bi] / Nm
The three patterns differ in their daily off-period structure:
- 24-hour day: zero off periods T = Th
- Unimodal (16-hour day): one off period of 8h (0700-2300 heating
period per Table N7 footnote b) T = Th u1(8h)
- Bimodal (9-hour day): two off periods (7+8h for living-area and
elsewhere control-type 1/2; 9+8h for elsewhere control-type 3
per Table N7) T = Th u1 u2
The caller passes the pre-computed `t_bimodal_c` and `t_unimodal_c`
(already reduced from Th by the relevant Table 9b u terms); this
helper just does the day-weighted blend.
When `n24_9_m = n16_9_m = 0` the formula collapses to `t_bimodal_c`,
so non-HP certs and warm months flow through unchanged.
"""
if days_in_month <= 0:
return t_bimodal_c
bimodal_days = days_in_month - n16_9_m - n24_9_m
return (
n24_9_m * heating_temperature_c
+ n16_9_m * t_unimodal_c
+ bimodal_days * t_bimodal_c
) / days_in_month
def elsewhere_heating_temperature_c(
*,
heat_loss_parameter: float,

View file

@ -20,6 +20,7 @@ from domain.sap10_calculator.worksheet.mean_internal_temperature import (
allocate_extended_heating_days_to_months,
elsewhere_heating_temperature_c,
extended_heating_days_from_psr_variable,
extended_zone_mean_temperature_c,
mean_internal_temperature_monthly,
off_period_temperature_reduction_c,
)
@ -431,6 +432,73 @@ def test_allocate_extended_heating_days_zero_is_all_zero() -> None:
assert monthly == ((0, 0),) * 12
def test_extended_zone_mean_temperature_matches_cert_0380_january_living() -> None:
"""SAP 10.2 Appendix N3.5 Equation N5 (PDF p.107):
T = [N24,9 × Th + N16,9 × T_uni + (Nm N16,9 N24,9) × T_bi] / Nm
Cert 0380 January (Mitsubishi PUZ-WM50VHA, PSR 1.43):
Living-area bimodal "Living" row = 18.5551 (worksheet)
N24,9 = 3, N16,9 = 28 (Jan), Nm = 31, Th = 21
Back-solving the worksheet's MIT_living(87) = 19.7493:
31 × 19.7493 = 612.228
612.228 = 3 × 21 + 28 × T_uni + 0 × 18.5551
T_uni = (612.228 63) / 28 = 19.6153
So this leaf, given Th=21, T_bi=18.5551, T_uni=19.6153, N24=3,
N16=28, Nm=31, must return 19.7493.
"""
# Arrange / Act
t = extended_zone_mean_temperature_c(
heating_temperature_c=21.0,
t_bimodal_c=18.5551,
t_unimodal_c=19.6153,
n24_9_m=3,
n16_9_m=28,
days_in_month=31,
)
# Assert
assert abs(t - 19.7493) < 1e-4
def test_extended_zone_mean_temperature_collapses_to_bimodal_when_zero_extension() -> None:
"""When N24,9_m = N16,9_m = 0, Equation N5 reduces to T_bi — i.e.
the standard SAP heating schedule is unchanged. This guards the
"no extended heating" path so that warm months (Jun..Sep) and
non-HP certs flow through the legacy bimodal calculation."""
# Arrange / Act
t = extended_zone_mean_temperature_c(
heating_temperature_c=21.0,
t_bimodal_c=18.0,
t_unimodal_c=19.5, # Should be ignored when n16_9_m = 0
n24_9_m=0,
n16_9_m=0,
days_in_month=30,
)
# Assert
assert t == 18.0
def test_extended_zone_mean_temperature_collapses_to_th_for_full_24h_month() -> None:
"""When N24,9_m = days_in_month, Equation N5 reduces to Th — every
day operates at the heating temperature with no off period."""
# Arrange / Act
t = extended_zone_mean_temperature_c(
heating_temperature_c=21.0,
t_bimodal_c=18.0,
t_unimodal_c=19.5,
n24_9_m=31,
n16_9_m=0,
days_in_month=31,
)
# Assert
assert t == 21.0
def test_allocate_extended_heating_days_variable_year_totals_are_preserved() -> None:
"""The helper's invariant for the Variable case: every input
(N24,9, N16,9) day from Table N5 must land in some cold month