From a6ef198740315bb98ec7ea8345a4ca12a973a365 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 13:16:44 +0000 Subject: [PATCH] Slice 102f-prep.2: Table N5 PSR interpolation (variable heating duration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix N3.5 + Table N5 (PDF p.107) — for heat pumps with "Variable" daily heating duration, the annual N24,9 and N16,9 totals (days operating at 24h or 16h instead of the standard 9h) are obtained by linear interpolation between Table N5 rows at the dwelling's plant size ratio, rounded to the nearest whole number of days. Clamps to the table bounds (PSR ≤ 0.2 → first row; PSR ≥ 1.2 → last row) per the same convention applied to PSR efficiency lookup in Appendix N (PDF p.101 lines 6007-6008). Cohort sanity: cert 0380's PSR ≈ 1.43 → (3, 38) per the last-row clamp; worksheet shows Jan N24,9=3 + Jan/Dec N16,9=28+10=38 — exact match to Table N5 row "1.2 or more". Co-Authored-By: Claude Opus 4.7 --- .../worksheet/mean_internal_temperature.py | 60 ++++++++++++++++++ .../tests/test_mean_internal_temperature.py | 62 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index cddd8a08..9a5e2d76 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -32,6 +32,66 @@ _MONTHS: Final[range] = range(12) _TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 +# SAP 10.2 PDF p.107 Table N5 — additional days at longer heating duration +# for variable heating duration packages. Each row gives (PSR, N24,9, N16,9): +# days per year operating at 24 / 16 hours respectively, instead of the +# standard 9 hours/day. "Use linear interpolation for intermediate values +# of plant size ratio, rounding the result to the nearest whole number of +# days." Clamped to the table's bounds per the same convention as PSR +# efficiency interpolation (PDF p.101 lines 6007-6008). +_TABLE_N5_VARIABLE_HEATING_DAYS: Final[tuple[tuple[float, int, int], ...]] = ( + (0.2, 218, 6), + (0.3, 191, 22), + (0.4, 168, 29), + (0.5, 128, 56), + (0.6, 94, 74), + (0.7, 50, 95), + (0.8, 26, 103), + (0.9, 14, 92), + (1.0, 8, 77), + (1.1, 4, 55), + (1.2, 3, 38), +) + + +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 + packages with `heating_duration_code = "V"` (Variable), linearly + interpolate annual N24,9 and N16,9 totals between the bracketing + Table N5 rows at the dwelling's plant size ratio, rounding the + result to the nearest whole number of days. + + Clamps to the table's bounds (PSR ≤ 0.2 → first row; PSR ≥ 1.2 → + last row) per the same convention as PSR efficiency interpolation + in Appendix N (PDF p.101 lines 6007-6008). + + For the legacy fixed durations: + "24" → (365, 0) + "16" → (0, 365) + "9" → (0, 0) + Those branches are caller responsibilities (Table N4) — this helper + only covers the Variable case (the only duration lodged on modern + PCDB Table 362 records per footnote 48). + """ + if psr <= _TABLE_N5_VARIABLE_HEATING_DAYS[0][0]: + return (_TABLE_N5_VARIABLE_HEATING_DAYS[0][1], _TABLE_N5_VARIABLE_HEATING_DAYS[0][2]) + if psr >= _TABLE_N5_VARIABLE_HEATING_DAYS[-1][0]: + return (_TABLE_N5_VARIABLE_HEATING_DAYS[-1][1], _TABLE_N5_VARIABLE_HEATING_DAYS[-1][2]) + for low, high in zip( + _TABLE_N5_VARIABLE_HEATING_DAYS, + _TABLE_N5_VARIABLE_HEATING_DAYS[1:], + ): + low_psr, low_n24, low_n16 = low + high_psr, high_n24, high_n16 = high + if low_psr <= psr <= high_psr: + span = high_psr - low_psr + t = (psr - low_psr) / span if span > 0 else 0.0 + n24_f = low_n24 + (high_n24 - low_n24) * t + n16_f = low_n16 + (high_n16 - low_n16) * t + return (round(n24_f), round(n16_f)) + raise AssertionError("PSR bracket not found despite range check") + + 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 e1923346..b32f8093 100644 --- a/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py @@ -18,6 +18,7 @@ from domain.sap10_calculator.climate.appendix_u import external_temperature_c from domain.sap10_calculator.worksheet.mean_internal_temperature import ( MeanInternalTemperatureResult, elsewhere_heating_temperature_c, + extended_heating_days_from_psr_variable, mean_internal_temperature_monthly, off_period_temperature_reduction_c, ) @@ -296,3 +297,64 @@ def test_long_off_period_temperature_reduction_uses_linear_branch() -> None: assert result == pytest.approx(8.02, abs=0.05) +def test_extended_heating_days_from_psr_variable_clamps_low() -> None: + """SAP 10.2 PDF p.107 Table N5: PSR ≤ 0.2 uses the first row's + (N24,9, N16,9) = (218, 6). + + Per spec PDF p.101 lines 6007-6008 the PSR is clamped to the + table's range (the same clamp policy already applied to PSR + efficiency lookup in slice 102c.2).""" + # Arrange / Act + n24_9, n16_9 = extended_heating_days_from_psr_variable(psr=0.1) + + # Assert + assert (n24_9, n16_9) == (218, 6) + + +def test_extended_heating_days_from_psr_variable_clamps_high() -> None: + """SAP 10.2 PDF p.107 Table N5: PSR ≥ 1.2 uses the last row's + (N24,9, N16,9) = (3, 38). Cert 0380's PSR ≈ 1.43 lands here — + worksheet shows N24,9 = 3 (Jan) and N16,9 = 28 + 10 = 38 (Jan + + Dec) for cert 0380, which is exactly this row.""" + # Arrange / Act + n24_9, n16_9 = extended_heating_days_from_psr_variable(psr=1.4266) + + # Assert + assert (n24_9, n16_9) == (3, 38) + + +def test_extended_heating_days_from_psr_variable_interpolates_midpoint() -> None: + """SAP 10.2 PDF p.107 Table N5: "Use linear interpolation for + intermediate values of plant size ratio, rounding the result to + the nearest whole number of days." + + Midpoint between PSR 0.5 (128, 56) and PSR 0.6 (94, 74): + N24,9 at PSR 0.55 = (128 + 94) / 2 = 111 (exact) + N16,9 at PSR 0.55 = (56 + 74) / 2 = 65 (exact) + """ + # Arrange / Act + n24_9, n16_9 = extended_heating_days_from_psr_variable(psr=0.55) + + # Assert + assert (n24_9, n16_9) == (111, 65) + + +def test_extended_heating_days_from_psr_variable_rounds_to_nearest_day() -> None: + """SAP 10.2 PDF p.107 Table N5: "Use linear interpolation … rounding + the result to the nearest whole number of days." + + Between PSR 0.5 (128, 56) and PSR 0.6 (94, 74) at PSR 0.53: + t = 0.3 + N24,9 = 128 + 0.3 × (94 − 128) = 128 − 10.2 = 117.8 → 118 + N16,9 = 56 + 0.3 × (74 − 56) = 56 + 5.4 = 61.4 → 61 + + PSR 0.53 lands clear of any half-step where IEEE-754 rounding is + ambiguous; the production helper just calls Python `round`. + """ + # Arrange / Act + n24_9, n16_9 = extended_heating_days_from_psr_variable(psr=0.53) + + # Assert + assert n24_9 == 118 + assert n16_9 == 61 +