diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index 7f8a0989..cbd21244 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -146,6 +146,45 @@ def allocate_extended_heating_days_to_months( return tuple(allocations) +def extended_zone_mean_temperature_c( + *, + heating_temperature_c: float, + t_bimodal_c: float, + t_unimodal_c: float, + n24_9_m: int, + n16_9_m: int, + days_in_month: int, +) -> float: + """SAP 10.2 Appendix N3.5 Equation N5 (PDF p.107) — blend a zone's + monthly mean temperature across three heating patterns: + + T = [N24,9 × Th + N16,9 × T_uni + (Nm − N16,9 − N24,9) × T_bi] / Nm + + The three patterns differ in their daily off-period structure: + - 24-hour day: zero off periods → T = Th + - Unimodal (16-hour day): one off period of 8h (0700-2300 heating + period per Table N7 footnote b) → T = Th − u1(8h) + - Bimodal (9-hour day): two off periods (7+8h for living-area and + elsewhere control-type 1/2; 9+8h for elsewhere control-type 3 + per Table N7) → T = Th − u1 − u2 + + The caller passes the pre-computed `t_bimodal_c` and `t_unimodal_c` + (already reduced from Th by the relevant Table 9b u terms); this + helper just does the day-weighted blend. + + When `n24_9_m = n16_9_m = 0` the formula collapses to `t_bimodal_c`, + so non-HP certs and warm months flow through unchanged. + """ + if days_in_month <= 0: + return t_bimodal_c + bimodal_days = days_in_month - n16_9_m - n24_9_m + return ( + n24_9_m * heating_temperature_c + + n16_9_m * t_unimodal_c + + bimodal_days * t_bimodal_c + ) / days_in_month + + 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 0afc78a5..d209a1d1 100644 --- a/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py @@ -20,6 +20,7 @@ from domain.sap10_calculator.worksheet.mean_internal_temperature import ( allocate_extended_heating_days_to_months, elsewhere_heating_temperature_c, extended_heating_days_from_psr_variable, + extended_zone_mean_temperature_c, mean_internal_temperature_monthly, off_period_temperature_reduction_c, ) @@ -431,6 +432,73 @@ def test_allocate_extended_heating_days_zero_is_all_zero() -> None: assert monthly == ((0, 0),) * 12 +def test_extended_zone_mean_temperature_matches_cert_0380_january_living() -> None: + """SAP 10.2 Appendix N3.5 Equation N5 (PDF p.107): + + T = [N24,9 × Th + N16,9 × T_uni + (Nm − N16,9 − N24,9) × T_bi] / Nm + + Cert 0380 January (Mitsubishi PUZ-WM50VHA, PSR 1.43): + Living-area bimodal "Living" row = 18.5551 (worksheet) + N24,9 = 3, N16,9 = 28 (Jan), Nm = 31, Th = 21 + + Back-solving the worksheet's MIT_living(87) = 19.7493: + 31 × 19.7493 = 612.228 + 612.228 = 3 × 21 + 28 × T_uni + 0 × 18.5551 + T_uni = (612.228 − 63) / 28 = 19.6153 + + So this leaf, given Th=21, T_bi=18.5551, T_uni=19.6153, N24=3, + N16=28, Nm=31, must return 19.7493. + """ + # Arrange / Act + t = extended_zone_mean_temperature_c( + heating_temperature_c=21.0, + t_bimodal_c=18.5551, + t_unimodal_c=19.6153, + n24_9_m=3, + n16_9_m=28, + days_in_month=31, + ) + + # Assert + assert abs(t - 19.7493) < 1e-4 + + +def test_extended_zone_mean_temperature_collapses_to_bimodal_when_zero_extension() -> None: + """When N24,9_m = N16,9_m = 0, Equation N5 reduces to T_bi — i.e. + the standard SAP heating schedule is unchanged. This guards the + "no extended heating" path so that warm months (Jun..Sep) and + non-HP certs flow through the legacy bimodal calculation.""" + # Arrange / Act + t = extended_zone_mean_temperature_c( + heating_temperature_c=21.0, + t_bimodal_c=18.0, + t_unimodal_c=19.5, # Should be ignored when n16_9_m = 0 + n24_9_m=0, + n16_9_m=0, + days_in_month=30, + ) + + # Assert + assert t == 18.0 + + +def test_extended_zone_mean_temperature_collapses_to_th_for_full_24h_month() -> None: + """When N24,9_m = days_in_month, Equation N5 reduces to Th — every + day operates at the heating temperature with no off period.""" + # Arrange / Act + t = extended_zone_mean_temperature_c( + heating_temperature_c=21.0, + t_bimodal_c=18.0, + t_unimodal_c=19.5, + n24_9_m=31, + n16_9_m=0, + days_in_month=31, + ) + + # Assert + assert t == 21.0 + + 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