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 +