Slice 102f-prep.3: Table N5 day allocation Jan/Dec/Feb/Mar/Nov/Apr/Oct/May

SAP 10.2 Appendix N3.5 (PDF p.107): "Allocate these to months in the
following order: Jan, Dec, Feb, Mar, Nov, Apr, Oct, May (coldest to
the warmest), until all the days N24,9 and N16,9 have been allocated.
Days N24,9 are allocated first."

`allocate_extended_heating_days_to_months` distributes annual N24,9
and N16,9 totals (from Table N5) across the cold-first month order,
with N24 days filling first and N16 days filling whatever space
remains in each month afterward.

Cross-pinned against the spec's PSR=0.2 worked example (PDF p.107):
Jan-Oct each get max N24, May ends up with the residual (6, 6). And
against cert 0380's worksheet: PSR≈1.43 → row 1.2+ (3, 38) →
Jan(3, 28), Dec(0, 10) — matches the worksheet 24/9 + 16/9 rows.

The 8 cold-month order spans 243 days, exceeding every Table N5
row's combined total — no allocation is dropped for Variable
heating duration. Fixed durations ("24" / "16" from Table N4) live
beyond this helper's contract (caller decides when N24=365 means
"all months at Th"); slice 102f-prep.4 wires that in.

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

View file

@ -53,6 +53,15 @@ _TABLE_N5_VARIABLE_HEATING_DAYS: Final[tuple[tuple[float, int, int], ...]] = (
(1.2, 3, 38),
)
# SAP 10.2 Table 1a — calendar days per month (non-leap), used for
# Equation N5 + the N24,9 / N16,9 day allocation algorithm (PDF p.107).
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
# SAP 10.2 PDF p.107 — month indices (0-based Jan..Dec) in cold-to-warm
# order: Jan, Dec, Feb, Mar, Nov, Apr, Oct, May. The remaining four
# months (Jun, Jul, Aug, Sep) never receive any allocation — for cohort
# PSRs the year totals never exceed the sum of these eight cold months.
_TABLE_N5_ALLOCATION_ORDER: Final[tuple[int, ...]] = (0, 11, 1, 2, 10, 3, 9, 4)
def extended_heating_days_from_psr_variable(*, psr: float) -> tuple[int, int]:
"""SAP 10.2 Appendix N3.5 + Table N5 (PDF p.107) — for heat-pump
@ -92,6 +101,51 @@ def extended_heating_days_from_psr_variable(*, psr: float) -> tuple[int, int]:
raise AssertionError("PSR bracket not found despite range check")
def allocate_extended_heating_days_to_months(
*,
n24_9_year: int,
n16_9_year: int,
) -> tuple[tuple[int, int], ...]:
"""SAP 10.2 Appendix N3.5 (PDF p.107) — distribute the annual N24,9
and N16,9 day counts to months following the spec's allocation
order ("Jan, Dec, Feb, Mar, Nov, Apr, Oct, May (coldest to the
warmest)"). N24,9 days are filled first across the order, then
N16,9 days fill the days not yet claimed by N24,9.
Returns a length-12 tuple of `(N24,9_m, N16,9_m)` Jan..Dec. The
summer months (Jun, Jul, Aug, Sep) always return (0, 0) for the
PSR/duration cases this codebase handles they're outside the
allocation order.
"""
n24_remaining = n24_9_year
n16_remaining = n16_9_year
allocations: list[tuple[int, int]] = [(0, 0)] * 12
# Sweep 1 — N24,9: fill each cold month up to its day count.
for m_idx in _TABLE_N5_ALLOCATION_ORDER:
if n24_remaining <= 0:
break
month_days = _DAYS_IN_MONTH[m_idx]
take = min(n24_remaining, month_days)
allocations[m_idx] = (take, 0)
n24_remaining -= take
# Sweep 2 — N16,9: fill remaining month space (month_days N24,m).
for m_idx in _TABLE_N5_ALLOCATION_ORDER:
if n16_remaining <= 0:
break
month_days = _DAYS_IN_MONTH[m_idx]
n24_m, _ = allocations[m_idx]
space = month_days - n24_m
if space <= 0:
continue
take = min(n16_remaining, space)
allocations[m_idx] = (n24_m, take)
n16_remaining -= take
return tuple(allocations)
def elsewhere_heating_temperature_c(
*,
heat_loss_parameter: float,

View file

@ -17,6 +17,7 @@ import pytest
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
MeanInternalTemperatureResult,
allocate_extended_heating_days_to_months,
elsewhere_heating_temperature_c,
extended_heating_days_from_psr_variable,
mean_internal_temperature_monthly,
@ -358,3 +359,96 @@ def test_extended_heating_days_from_psr_variable_rounds_to_nearest_day() -> None
assert n24_9 == 118
assert n16_9 == 61
def test_allocate_extended_heating_days_matches_spec_psr_0_2_example() -> None:
"""SAP 10.2 PDF p.107 worked example — Variable heating duration
at PSR 0.2 (N24,9 = 218, N16,9 = 6):
January: N24,9,m=1 = 31. All days in January have been allocated
so N16,9,m=1 = 0. Remaining N24,9 = 187, N16,9 = 6.
continued for Dec, Feb, Mar, Nov, Apr, Oct after which
remaining N24,9 = 6 and N16,9 = 6.
For May: N24,9,m=5 = 6 and N16,9,m=5 = 6.
Allocation order is Jan, Dec, Feb, Mar, Nov, Apr, Oct, May (coldest
to warmest); N24,9 days are allocated first.
"""
# Arrange / Act
monthly = allocate_extended_heating_days_to_months(n24_9_year=218, n16_9_year=6)
# Assert — Jan/Dec/Feb/Mar/Nov/Apr/Oct fill with N24,9 up to month days.
assert monthly[0] == (31, 0) # Jan: 31 N24
assert monthly[11] == (31, 0) # Dec: 31 N24
assert monthly[1] == (28, 0) # Feb: 28 N24
assert monthly[2] == (31, 0) # Mar: 31 N24
assert monthly[10] == (30, 0) # Nov: 30 N24
assert monthly[3] == (30, 0) # Apr: 30 N24
assert monthly[9] == (31, 0) # Oct: 31 N24 (sum so far = 212)
# May: remaining N24 = 218 - 212 = 6; remaining N16 = 6.
assert monthly[4] == (6, 6)
# All other months (Jun, Jul, Aug, Sep) get nothing — summer.
assert monthly[5] == (0, 0)
assert monthly[6] == (0, 0)
assert monthly[7] == (0, 0)
assert monthly[8] == (0, 0)
# Year-total invariant
assert sum(n24 for n24, _ in monthly) == 218
assert sum(n16 for _, n16 in monthly) == 6
def test_allocate_extended_heating_days_matches_cert_0380_worksheet() -> None:
"""Cert 0380 (Mitsubishi PUZ-WM50VHA, PSR ≈ 1.43) lands on Table N5
row "1.2 or more": (N24,9, N16,9) = (3, 38). Worksheet for cert
0380 shows:
Jan row "24/9" = 3, row "16/9" = 28
Dec row "24/9" = 0, row "16/9" = 10
The N24 (=3) fits in Jan; N16 then fills Jan's remaining 31-3 = 28
days, and the final 10 N16 days land in Dec (next-coldest in the
Table N5 allocation order).
"""
# Arrange / Act
monthly = allocate_extended_heating_days_to_months(n24_9_year=3, n16_9_year=38)
# Assert
assert monthly[0] == (3, 28) # Jan: 3 N24 + 28 N16
assert monthly[11] == (0, 10) # Dec: 0 N24 + 10 N16 (remaining after Jan)
# All other months see no extended heating.
for m in range(1, 11):
assert monthly[m] == (0, 0), f"month {m+1} should be (0, 0)"
assert sum(n24 for n24, _ in monthly) == 3
assert sum(n16 for _, n16 in monthly) == 38
def test_allocate_extended_heating_days_zero_is_all_zero() -> None:
"""A "9"-hour heating duration package (or any case where N24,9 =
N16,9 = 0) collapses to the standard SAP heating schedule every
month is (0, 0)."""
# Arrange / Act
monthly = allocate_extended_heating_days_to_months(n24_9_year=0, n16_9_year=0)
# Assert
assert monthly == ((0, 0),) * 12
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
(Jan, Dec, Feb, Mar, Nov, Apr, Oct, May). The eight cold months
hold 31+31+28+31+30+30+31+31 = 243 days, larger than every Table
N5 row's combined total, so no allocation is dropped.
Pin the year totals at the largest Table N5 row sum (PSR 0.2
218 + 6 = 224 days) and at a row with non-trivial N16,9 (PSR 0.5
128 + 56 = 184).
"""
# Arrange / Act
psr_02 = allocate_extended_heating_days_to_months(n24_9_year=218, n16_9_year=6)
psr_05 = allocate_extended_heating_days_to_months(n24_9_year=128, n16_9_year=56)
# Assert — totals preserved.
assert sum(n24 for n24, _ in psr_02) == 218
assert sum(n16 for _, n16 in psr_02) == 6
assert sum(n24 for n24, _ in psr_05) == 128
assert sum(n16 for _, n16 in psr_05) == 56