From 4e07991f8f89c7159783aff7c6a557ee17c49478 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 13:25:53 +0000 Subject: [PATCH] Slice 102f-prep.3: Table N5 day allocation Jan/Dec/Feb/Mar/Nov/Apr/Oct/May MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../worksheet/mean_internal_temperature.py | 54 +++++++++++ .../tests/test_mean_internal_temperature.py | 94 +++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index 9a5e2d76..7f8a0989 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -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, diff --git a/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py b/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py index b32f8093..0afc78a5 100644 --- a/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py @@ -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 +