Slice 102f-prep.2: Table N5 PSR interpolation (variable heating duration)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 13:16:44 +00:00
parent 7adb6c7907
commit a6ef198740
2 changed files with 122 additions and 0 deletions

View file

@ -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,

View file

@ -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