From f8b585110a3a85b7ed4638ba0d1a2c8ef4ce3855 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 09:41:22 +0000 Subject: [PATCH] Slice S0380.66: SAP 10.2 Appendix H solar HW pure math module (HW path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module `domain/sap10_calculator/worksheet/appendix_h_solar.py` implements line refs (H10), (H11), (H14)..(H16), (H17)..(H24) for the hot-water solar contribution path. The space-heating path (H25)..(H29) is deferred until a fixture exercises it — cert 000565 lodges solar HW only (worksheet line 414 H29=0 across all months). Algorithm per SAP 10.2 specification §Appendix H pages 74-78 (14-03-2025 revision). The monthly procedure follows EN 15316-4-3:2017: the collector + cylinder + demand parameters feed dimensionless X and Y ratios into Equation H1 with Table H3 (p.78) correlation factors: Q_s,w = (Ca·Y + Cb·X + Cc·Y² + Cd·X² + Ce·Y³ + Cf·X³) × (H17)m clamped to [0, (H17)m] per spec p.76. Cert 000565 worksheet line 415 shows total Q_s,w = 281.3478 kWh/year delivered to HW from a 3 m² flat-plate collector + 53 L dedicated solar storage in a 160 L combined cylinder, W orientation, 30° pitch, Thames Valley region. Helpers implemented: - `overall_heat_loss_coefficient_h10` — 5 + 0.5×(H1) or test-cert - `loop_heat_loss_coefficient_h11` — (H3) + 40·(H4) + (H10)/(H1) - `effective_solar_volume_h14` — separate / combined cylinder - `reference_volume_h15` — 75 × (H1) - `storage_tank_correction_coefficient_h16` — [(H15)/(H14)]^0.25 - `hot_water_demand_monthly_h17_kwh` — (62)m − (63a)m - `proportion_solar_to_hot_water_monthly_h18` — HW-only/SH-only/blend - `hot_water_reference_temperature_h20_c` — 55 + 3.86·Tcold − 1.32·Te - `hot_water_reference_temperature_difference_h21_c` — (H20) − (96) - `hot_water_factor_x_monthly_h22` — clamp [0, 18] - `hot_water_factor_y_monthly_h23` — clamp ≥ 0 - `heat_delivered_to_hot_water_monthly_h24_kwh` — Equation H1 + clamp [0, (H17)m] 18 unit tests cover: - Spec-default vs test-certificate (H10) - Cert 000565 worksheet pinned (H11) ≈ 6.5667 (line 407) - Cert 000565 worksheet pinned (H14) = 85.1 (line 410) - Cert 000565 worksheet pinned (H15) = 225 (line 411) - Cert 000565 worksheet pinned (H16) ≈ 1.2752 (line 412) - Separate-tank vs combined-cylinder branches of (H14) - All three branches of (H18) (HW-only, SH-only, blend formula) - (H20)/(H21) spec formulas verbatim - (H22) zero-demand short-circuit + upper clamp at 18 - (H23) negative-input lower clamp at 0 - (H24) Equation H1 polynomial with Table H3 factors - (H24) demand-cap clamp when Y dominates positive - (H24) zero-floor clamp when X dominates negative Scope EXCLUDES (deferred to follow-on slices): - Appendix U §U3.3 monthly solar radiation lookup for the collector's orientation/tilt → (H7)m → (H9)m - Solar SH path (H25-H29) - Appendix H §H2 primary-loss reduction Table H4 - EpcPropertyData solar collector field schema additions - Elmhurst + API extractor / mapper updates - Cascade integration via `water_heating_from_cert.solar_monthly_kwh` Pyright: 0 errors on both touched files. 275 pass + 9 expected 000565 cascade-gap fails on the handover test suite (unchanged from S0380.65). Co-Authored-By: Claude Opus 4.7 --- .../worksheet/appendix_h_solar.py | 321 +++++++++++++++++ .../worksheet/tests/test_appendix_h_solar.py | 326 ++++++++++++++++++ 2 files changed, 647 insertions(+) create mode 100644 domain/sap10_calculator/worksheet/appendix_h_solar.py create mode 100644 domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py diff --git a/domain/sap10_calculator/worksheet/appendix_h_solar.py b/domain/sap10_calculator/worksheet/appendix_h_solar.py new file mode 100644 index 00000000..dee7fdfe --- /dev/null +++ b/domain/sap10_calculator/worksheet/appendix_h_solar.py @@ -0,0 +1,321 @@ +"""SAP 10.2 Appendix H — Solar thermal contribution to water heating. + +Implements line refs (H1)..(H24) for the hot-water solar path. The +space-heating contribution (H25)..(H29) is deferred until a fixture +exercises it (cert 000565 lodges solar HW only, H29=0 across all +months per the worksheet). + +The procedure follows SAP 10.2 specification §Appendix H (p.74-78), +which is an implementation of the EN 15316-4-3:2017 monthly method. +The collector + system parameters feed a polynomial fit (Equation H1 +with Table H3 correlation factors) over the dimensionless `X` and `Y` +ratios of monthly demand-weighted heat loss / heat gain to monthly +demand, yielding the kWh of solar heat actually delivered to the hot- +water cylinder per month. + +Spec reference: SAP 10.2 specification (14-03-2025), Appendix H pages +74-78. Equation H1 is on p.75; Table H3 (correlation factors) on p.78. + +Scope of this module: +- Pure math: takes inputs as primitives + 12-tuples, returns 12-tuples. +- HW path only (H25-H29 SH path deferred). +- No cascade integration (`cert_to_inputs.py` wires this into + `water_heating_from_cert.solar_monthly_kwh` in a follow-on slice). +""" + +from __future__ import annotations + +from typing import Final + + +# SAP 10.2 Table H3 (p.78) — correlation factors of Equation H1. These +# are the Cx coefficients of the polynomial: +# Qs = ((Ca·Y) + (Cb·X) + (Cc·Y²) + (Cd·X²) + (Ce·Y³) + (Cf·X³)) · Dm +_CA: Final[float] = 1.029 +_CB: Final[float] = -0.065 +_CC: Final[float] = -0.245 +_CD: Final[float] = 0.0018 +_CE: Final[float] = 0.0215 +_CF: Final[float] = 0.0 + + +# SAP 10.2 Appendix U Table U1 footnote (used by H20) — number of hours +# in each calendar month (line ref (41)m on the main worksheet). +_HOURS_IN_MONTH: Final[tuple[int, ...]] = ( + 31 * 24, 28 * 24, 31 * 24, 30 * 24, 31 * 24, 30 * 24, + 31 * 24, 31 * 24, 30 * 24, 31 * 24, 30 * 24, 31 * 24, +) + + +def overall_heat_loss_coefficient_h10( + aperture_area_m2: float, + from_test_certificate: float | None = None, +) -> float: + """SAP 10.2 (H10) — overall heat loss coefficient of solar system + (W/K). When test data is available, use the lodged value; otherwise + the spec default per p.76: + + (H10) = 5 + 0.5 × (H1) + """ + if from_test_certificate is not None: + return from_test_certificate + return 5.0 + 0.5 * aperture_area_m2 + + +def loop_heat_loss_coefficient_h11( + *, + linear_heat_loss_a1: float, # (H3) + second_order_heat_loss_a2: float, # (H4) + overall_heat_loss_h10: float, # (H10) + aperture_area_m2: float, # (H1) +) -> float: + """SAP 10.2 (H11) — loop heat loss coefficient `U_loop` (W/m²K). + + (H11) = (H3) + [(H4) × 40] + [(H10) ÷ (H1)] + """ + return ( + linear_heat_loss_a1 + + second_order_heat_loss_a2 * 40.0 + + overall_heat_loss_h10 / aperture_area_m2 + ) + + +def effective_solar_volume_h14( + *, + dedicated_solar_storage_volume_l: float, # (H12) + combined_cylinder_total_volume_l: float | None, # (H13) +) -> float: + """SAP 10.2 (H14) — effective solar storage volume `V_eff` (litres). + + Separate pre-heat solar storage: + (H14) = (H12) + Combined cylinder (single vessel split into solar pre-heat + boiler- + heated zones): + (H14) = (H12) + 0.3 × [(H13) - (H12)] + """ + if combined_cylinder_total_volume_l is None: + return dedicated_solar_storage_volume_l + return ( + dedicated_solar_storage_volume_l + + 0.3 * ( + combined_cylinder_total_volume_l - dedicated_solar_storage_volume_l + ) + ) + + +def reference_volume_h15(aperture_area_m2: float) -> float: + """SAP 10.2 (H15) — reference volume (litres) = 75 × (H1).""" + return 75.0 * aperture_area_m2 + + +def storage_tank_correction_coefficient_h16( + *, + reference_volume_h15_l: float, + effective_solar_volume_h14_l: float, +) -> float: + """SAP 10.2 (H16) — storage tank correction `f_st`. + + (H16) = [(H15) ÷ (H14)]^0.25 + """ + return (reference_volume_h15_l / effective_solar_volume_h14_l) ** 0.25 + + +def hot_water_demand_monthly_h17_kwh( + *, + hot_water_demand_monthly_kwh: tuple[float, ...], # (62)m + wwhrs_monthly_kwh: tuple[float, ...], # (63a)m +) -> tuple[float, ...]: + """SAP 10.2 (H17)m — HW demand seen by solar = (62)m − (63a)m. + + Per spec footnote 20 (p.77): PV diverters are ignored here when + solar water heating is present, so they do not enter (H17)m. + """ + return tuple( + d - w for d, w in zip(hot_water_demand_monthly_kwh, wwhrs_monthly_kwh) + ) + + +def proportion_solar_to_hot_water_monthly_h18( + *, + hw_demand_seen_by_solar_monthly_kwh: tuple[float, ...], # (H17)m + space_heating_demand_monthly_kwh: tuple[float, ...], # (98a)m + solar_hot_water_only: bool, + solar_space_heating_only: bool, +) -> tuple[float, ...]: + """SAP 10.2 (H18)m — proportion of solar input to HW. + + Spec p.77: + - HW-only system: (H18)m = 1.0 + - SH-only system: (H18)m = 0.0 + - else: (H18)m = (H17)m ÷ [(H17)m + (98a)m] + """ + if solar_hot_water_only: + return (1.0,) * 12 + if solar_space_heating_only: + return (0.0,) * 12 + return tuple( + h / (h + s) if (h + s) > 0.0 else 0.0 + for h, s in zip( + hw_demand_seen_by_solar_monthly_kwh, + space_heating_demand_monthly_kwh, + ) + ) + + +def hot_water_reference_temperature_h20_c( + *, + cold_water_temperatures_monthly_c: tuple[float, ...], # T_cold from Table J1 + external_temperatures_monthly_c: tuple[float, ...], # (96)m +) -> tuple[float, ...]: + """SAP 10.2 (H20)m — HW reference temperature (°C). + + (H20)m = 55 + 3.86 × T_cold,m − 1.32 × (96)m + """ + return tuple( + 55.0 + 3.86 * tc - 1.32 * te + for tc, te in zip( + cold_water_temperatures_monthly_c, + external_temperatures_monthly_c, + ) + ) + + +def hot_water_reference_temperature_difference_h21_c( + *, + hw_reference_temperature_monthly_c: tuple[float, ...], # (H20)m + external_temperatures_monthly_c: tuple[float, ...], # (96)m +) -> tuple[float, ...]: + """SAP 10.2 (H21)m — HW reference temperature difference (K). + + (H21)m = (H20)m − (96)m + """ + return tuple( + h20 - te + for h20, te in zip( + hw_reference_temperature_monthly_c, + external_temperatures_monthly_c, + ) + ) + + +def hot_water_factor_x_monthly_h22( + *, + proportion_solar_to_hw_h18: tuple[float, ...], # (H18)m + aperture_area_m2: float, # (H1) + loop_heat_loss_h11: float, # (H11) + loop_efficiency: float, # (H5) + hw_reference_temp_diff_h21: tuple[float, ...], # (H21)m + storage_tank_correction_h16: float, # (H16) + hours_in_month: tuple[int, ...], # (41)m + hw_demand_seen_by_solar_h17: tuple[float, ...], # (H17)m +) -> tuple[float, ...]: + """SAP 10.2 (H22)m — HW factor X. + + X_HW = [(H18)m × (H1) × (H11) × (H5) × (H21)m × (H16) × + ((41)m × 24)] ÷ [1000 × (H17)m] + + Clamped to the range [0, 18] per spec p.76 (`if X < 0, enter zero; + if X > 18, enter 18`). + + NB: The spec writes `(41)m × 24` for hours-in-month — this is a + typo (`(41)m` IS already hours-in-month per Appendix U Table U1 + footnote). Implemented as hours-in-month directly to match the + worksheet's per-month accounting. + """ + out: list[float] = [] + for m in range(12): + h17 = hw_demand_seen_by_solar_h17[m] + if h17 <= 0.0: + out.append(0.0) + continue + numerator = ( + proportion_solar_to_hw_h18[m] + * aperture_area_m2 + * loop_heat_loss_h11 + * loop_efficiency + * hw_reference_temp_diff_h21[m] + * storage_tank_correction_h16 + * hours_in_month[m] + ) + x = numerator / (1000.0 * h17) + if x < 0.0: + out.append(0.0) + elif x > 18.0: + out.append(18.0) + else: + out.append(x) + return tuple(out) + + +def hot_water_factor_y_monthly_h23( + *, + proportion_solar_to_hw_h18: tuple[float, ...], # (H18)m + incidence_angle_modifier: float, # (H6) + loop_efficiency: float, # (H5) + monthly_solar_energy_available_h9_kwh_per_m2: tuple[float, ...], # (H9)m as ENERGY in kWh/m²/month + hw_demand_seen_by_solar_h17: tuple[float, ...], # (H17)m +) -> tuple[float, ...]: + """SAP 10.2 (H23)m — HW factor Y. + + Y_HW = [(H18)m × (H6) × (H5) × (H9)m × ((41)m × 24)] ÷ + [1000 × (H17)m] + + Clamped to a lower bound of 0 per spec p.76 (`if Y < 0, enter zero`). + The `(H9)m × hours_in_month × 24 / 1000` shape in the spec captures + a flux-to-energy conversion; in this module (H9)m is supplied + directly as energy in kWh/m²/month (the upstream computation + `aperture × η₀ × overshading × monthly_radiation` already includes + the time integration), so the conversion factors collapse out. + """ + out: list[float] = [] + for m in range(12): + h17 = hw_demand_seen_by_solar_h17[m] + if h17 <= 0.0: + out.append(0.0) + continue + numerator = ( + proportion_solar_to_hw_h18[m] + * incidence_angle_modifier + * loop_efficiency + * monthly_solar_energy_available_h9_kwh_per_m2[m] + ) + y = numerator / h17 + out.append(max(0.0, y)) + return tuple(out) + + +def heat_delivered_to_hot_water_monthly_h24_kwh( + *, + factor_x_h22: tuple[float, ...], # (H22)m + factor_y_h23: tuple[float, ...], # (H23)m + hw_demand_seen_by_solar_h17: tuple[float, ...], # (H17)m +) -> tuple[float, ...]: + """SAP 10.2 (H24)m — Equation H1 applied to HW (Q_s,w). + + Q_s,w = [Ca·Y + Cb·X + Cc·Y² + Cd·X² + Ce·Y³ + Cf·X³] × (H17)m + + Clamped per spec p.76: + - if Q_s,w > (H17)m, enter (H17)m (cannot deliver more than demand) + - if Q_s,w < 0, enter zero + """ + out: list[float] = [] + for m in range(12): + x = factor_x_h22[m] + y = factor_y_h23[m] + h17 = hw_demand_seen_by_solar_h17[m] + poly = ( + _CA * y + + _CB * x + + _CC * y * y + + _CD * x * x + + _CE * y * y * y + + _CF * x * x * x + ) + q = poly * h17 + if q < 0.0: + out.append(0.0) + elif q > h17: + out.append(h17) + else: + out.append(q) + return tuple(out) diff --git a/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py b/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py new file mode 100644 index 00000000..257c1f42 --- /dev/null +++ b/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py @@ -0,0 +1,326 @@ +"""SAP 10.2 Appendix H unit tests. + +Verifies the pure-math implementation of (H10), (H11), (H14)..(H16) and +(H17)..(H24) for the HW path. Cascade integration into +`water_heating_from_cert` is deferred to a follow-on slice — this +module's tests use synthetic inputs that exercise the spec formulas +directly so failures localise to the math itself, not to upstream +demand-profile or Appendix U solar-radiation lookups. + +Spec reference: SAP 10.2 specification (14-03-2025), Appendix H pages +74-78. +""" + +from __future__ import annotations + +from domain.sap10_calculator.worksheet.appendix_h_solar import ( + effective_solar_volume_h14, + heat_delivered_to_hot_water_monthly_h24_kwh, + hot_water_factor_x_monthly_h22, + hot_water_factor_y_monthly_h23, + hot_water_reference_temperature_difference_h21_c, + hot_water_reference_temperature_h20_c, + loop_heat_loss_coefficient_h11, + overall_heat_loss_coefficient_h10, + proportion_solar_to_hot_water_monthly_h18, + reference_volume_h15, + storage_tank_correction_coefficient_h16, +) + + +# Cert 000565 worksheet (sap worksheets/extended test case/U985-0001- +# 000565.pdf, Block 1 lines 399-413) lodges the following Appendix H +# inputs — manufacturer-tested values, NOT Table H1 defaults: +_CERT_000565_APERTURE_AREA_M2 = 3.0 # (H1) +_CERT_000565_ETA_0 = 0.8 # (H2) +_CERT_000565_A1 = 4.0 # (H3) +_CERT_000565_A2 = 0.01 # (H4) +_CERT_000565_LOOP_EFF = 0.9 # (H5) +_CERT_000565_IAM = 0.94 # (H6) +_CERT_000565_OVERSHADING = 0.8 # (H8) "Modest" 20-60% blocked +_CERT_000565_OVERALL_H10 = 6.5 # (H10) from test certificate +_CERT_000565_DEDICATED_SOLAR_V_L = 53.0 # (H12) +_CERT_000565_CYLINDER_V_L = 160.0 # (H13) + + +def test_overall_heat_loss_coefficient_h10_defaults_to_spec_formula_when_no_test_certificate() -> None: + # Arrange — SAP 10.2 (H10) spec p.76: "Overall heat loss coefficient + # of system, from test certificate or 5 + [0.5 × (H1)]". Default + # path applies when manufacturer test data is unavailable. + + # Act + actual = overall_heat_loss_coefficient_h10(aperture_area_m2=3.0) + + # Assert — 5 + 0.5 × 3 = 6.5 + assert actual == 6.5 + + +def test_overall_heat_loss_coefficient_h10_uses_test_certificate_value_when_provided() -> None: + # Arrange — manufacturer-lodged H10 overrides the spec default + # (cert 000565 worksheet line 406 lodges 6.5 from cert). + + # Act + actual = overall_heat_loss_coefficient_h10( + aperture_area_m2=3.0, from_test_certificate=7.2, + ) + + # Assert + assert actual == 7.2 + + +def test_loop_heat_loss_coefficient_h11_matches_cert_000565_worksheet_line_407() -> None: + # Arrange — cert 000565 (H11) worksheet line 407 = 6.5667. + # Spec p.76: (H11) = (H3) + (H4) × 40 + (H10) ÷ (H1) + # = 4.0 + 0.01 × 40 + 6.5 ÷ 3.0 + # = 4.0 + 0.4 + 2.1667 = 6.5667 + + # Act + actual = loop_heat_loss_coefficient_h11( + linear_heat_loss_a1=_CERT_000565_A1, + second_order_heat_loss_a2=_CERT_000565_A2, + overall_heat_loss_h10=_CERT_000565_OVERALL_H10, + aperture_area_m2=_CERT_000565_APERTURE_AREA_M2, + ) + + # Assert + assert abs(actual - 6.5667) < 1e-3 + + +def test_effective_solar_volume_h14_combined_cylinder_uses_spec_formula() -> None: + # Arrange — cert 000565 worksheet line 410 = 85.1 litres (combined + # cylinder path). Spec p.76: (H14) = (H12) + 0.3 × [(H13) − (H12)] + # = 53 + 0.3 × (160 − 53) = 85.1 + + # Act + actual = effective_solar_volume_h14( + dedicated_solar_storage_volume_l=_CERT_000565_DEDICATED_SOLAR_V_L, + combined_cylinder_total_volume_l=_CERT_000565_CYLINDER_V_L, + ) + + # Assert + assert abs(actual - 85.1) < 1e-9 + + +def test_effective_solar_volume_h14_separate_pre_heat_tank_returns_dedicated_volume() -> None: + # Arrange — Figure H2 arrangement (a): separate pre-heat tank. + # Spec p.76: (H14) = (H12) when no combined cylinder. + + # Act + actual = effective_solar_volume_h14( + dedicated_solar_storage_volume_l=120.0, + combined_cylinder_total_volume_l=None, + ) + + # Assert + assert actual == 120.0 + + +def test_reference_volume_h15_is_75_times_aperture_area() -> None: + # Arrange / Act — spec p.76: (H15) = 75 × (H1) = 75 × 3 = 225. + + # Assert — matches cert 000565 worksheet line 411 = 225. + assert reference_volume_h15(3.0) == 225.0 + + +def test_storage_tank_correction_h16_matches_cert_000565_worksheet_line_412() -> None: + # Arrange — cert 000565 (H16) worksheet line 412 = 1.2752. + # Spec p.76: (H16) = [(H15) ÷ (H14)]^0.25 = (225 ÷ 85.1)^0.25 + # ≈ (2.64395)^0.25 ≈ 1.2752 + + # Act + actual = storage_tank_correction_coefficient_h16( + reference_volume_h15_l=225.0, + effective_solar_volume_h14_l=85.1, + ) + + # Assert + assert abs(actual - 1.2752) < 1e-4 + + +def test_proportion_solar_to_hot_water_h18_returns_one_when_solar_hw_only() -> None: + # Arrange — cert 000565 lodges solar HW only (worksheet line 414 + # H29=0). Per spec p.77 "if solar heating only provides hot water, + # enter 1". + + # Act + actual = proportion_solar_to_hot_water_monthly_h18( + hw_demand_seen_by_solar_monthly_kwh=(100.0,) * 12, + space_heating_demand_monthly_kwh=(500.0,) * 12, + solar_hot_water_only=True, + solar_space_heating_only=False, + ) + + # Assert + assert actual == (1.0,) * 12 + + +def test_proportion_solar_to_hot_water_h18_returns_zero_when_solar_sh_only() -> None: + # Arrange — spec p.77 "if solar heating only provides space + # heating, enter 0". + + # Act + actual = proportion_solar_to_hot_water_monthly_h18( + hw_demand_seen_by_solar_monthly_kwh=(100.0,) * 12, + space_heating_demand_monthly_kwh=(500.0,) * 12, + solar_hot_water_only=False, + solar_space_heating_only=True, + ) + + # Assert + assert actual == (0.0,) * 12 + + +def test_proportion_solar_to_hot_water_h18_blends_by_demand_when_solar_serves_both() -> None: + # Arrange — spec p.77 blend formula: + # (H18)m = (H17)m ÷ [(H17)m + (98a)m] + + # Act + actual = proportion_solar_to_hot_water_monthly_h18( + hw_demand_seen_by_solar_monthly_kwh=(100.0,) * 12, + space_heating_demand_monthly_kwh=(400.0,) * 12, + solar_hot_water_only=False, + solar_space_heating_only=False, + ) + + # Assert — 100 / (100 + 400) = 0.2 for every month + assert all(abs(x - 0.2) < 1e-9 for x in actual) + + +def test_hot_water_reference_temperature_h20_applies_spec_formula() -> None: + # Arrange — SAP 10.2 (H20)m spec p.77: + # (H20)m = 55 + 3.86 × T_cold,m − 1.32 × (96)m + # For T_cold=10, (96)=4.3 (cert 000565 Jan ext temp): + # 55 + 3.86×10 − 1.32×4.3 = 55 + 38.6 − 5.676 = 87.924 + + # Act + actual = hot_water_reference_temperature_h20_c( + cold_water_temperatures_monthly_c=(10.0,) * 12, + external_temperatures_monthly_c=(4.3,) * 12, + ) + + # Assert + assert all(abs(x - 87.924) < 1e-6 for x in actual) + + +def test_hot_water_reference_temperature_difference_h21_is_h20_minus_external() -> None: + # Arrange — spec p.77: (H21)m = (H20)m − (96)m. + + # Act + actual = hot_water_reference_temperature_difference_h21_c( + hw_reference_temperature_monthly_c=(80.0,) * 12, + external_temperatures_monthly_c=(4.3,) * 12, + ) + + # Assert — 80 − 4.3 = 75.7 + assert all(abs(x - 75.7) < 1e-9 for x in actual) + + +def test_hot_water_factor_x_h22_returns_zero_for_zero_demand_month() -> None: + # Arrange — months where (H17)m = 0 must short-circuit to factor=0 + # to avoid divide-by-zero (spec is silent on the boundary case; the + # natural interpretation is zero demand → zero solar contribution + # → zero factor). + + # Act + actual = hot_water_factor_x_monthly_h22( + proportion_solar_to_hw_h18=(1.0,) * 12, + aperture_area_m2=3.0, + loop_heat_loss_h11=6.5667, + loop_efficiency=0.9, + hw_reference_temp_diff_h21=(75.7,) * 12, + storage_tank_correction_h16=1.2752, + hours_in_month=(744, 672, 744, 720, 744, 720, 744, 744, 720, 744, 720, 744), + hw_demand_seen_by_solar_h17=(0.0,) * 12, + ) + + # Assert + assert actual == (0.0,) * 12 + + +def test_hot_water_factor_x_h22_clamps_upper_bound_at_18() -> None: + # Arrange — spec p.76 "if X_HW > 18, enter 18". Choose inputs that + # blow past 18 so the clamp fires (tiny demand → huge factor). + + # Act + actual = hot_water_factor_x_monthly_h22( + proportion_solar_to_hw_h18=(1.0,) * 12, + aperture_area_m2=3.0, + loop_heat_loss_h11=6.5667, + loop_efficiency=0.9, + hw_reference_temp_diff_h21=(75.7,) * 12, + storage_tank_correction_h16=1.2752, + hours_in_month=(744,) * 12, + hw_demand_seen_by_solar_h17=(0.001,) * 12, # tiny denominator + ) + + # Assert — all months hit the upper clamp + assert actual == (18.0,) * 12 + + +def test_hot_water_factor_y_h23_clamps_lower_bound_at_zero() -> None: + # Arrange — spec p.76 "if Y_HW < 0, enter zero". Negative solar + # energy available (theoretical edge case) must not flow through. + + # Act + actual = hot_water_factor_y_monthly_h23( + proportion_solar_to_hw_h18=(1.0,) * 12, + incidence_angle_modifier=0.94, + loop_efficiency=0.9, + monthly_solar_energy_available_h9_kwh_per_m2=(-5.0,) * 12, + hw_demand_seen_by_solar_h17=(100.0,) * 12, + ) + + # Assert + assert actual == (0.0,) * 12 + + +def test_heat_delivered_to_hot_water_h24_applies_equation_h1_polynomial() -> None: + # Arrange — spec Equation H1 with Table H3 (p.78) factors: + # Q = (Ca·Y + Cb·X + Cc·Y² + Cd·X² + Ce·Y³ + Cf·X³) × demand + # For X=1, Y=1 and demand=100: + # = (1.029·1 + −0.065·1 + −0.245·1 + 0.0018·1 + 0.0215·1 + 0·1) × 100 + # = (1.029 − 0.065 − 0.245 + 0.0018 + 0.0215) × 100 + # = 0.7423 × 100 = 74.23 + + # Act + actual = heat_delivered_to_hot_water_monthly_h24_kwh( + factor_x_h22=(1.0,) * 12, + factor_y_h23=(1.0,) * 12, + hw_demand_seen_by_solar_h17=(100.0,) * 12, + ) + + # Assert + assert all(abs(q - 74.23) < 1e-2 for q in actual) + + +def test_heat_delivered_to_hot_water_h24_clamps_at_demand_when_polynomial_overshoots() -> None: + # Arrange — spec p.76 "if Q_s,w > (H17)m, enter (H17)m". Picks + # Y=2 / X=0 so the polynomial returns Ca·Y + Cc·Y² + Ce·Y³ ≈ + # 2.058 − 0.98 + 0.172 = 1.250 → poly × demand = 1.250 × 100 = 125 + # > 100 → clamp to demand 100. + + # Act + actual = heat_delivered_to_hot_water_monthly_h24_kwh( + factor_x_h22=(0.0,) * 12, + factor_y_h23=(2.0,) * 12, + hw_demand_seen_by_solar_h17=(100.0,) * 12, + ) + + # Assert — clamp to demand + assert all(abs(q - 100.0) < 1e-9 for q in actual) + + +def test_heat_delivered_to_hot_water_h24_clamps_at_zero_when_polynomial_negative() -> None: + # Arrange — spec p.76 "if Q_s,w < 0 enter zero". Choose X huge / Y=0 + # so Cb·X + Cd·X² dominates negatively. With X=10, Y=0: + # poly = −0.065·10 + 0.0018·100 + 0 = −0.65 + 0.18 = −0.47 → 0 + + # Act + actual = heat_delivered_to_hot_water_monthly_h24_kwh( + factor_x_h22=(10.0,) * 12, + factor_y_h23=(0.0,) * 12, + hw_demand_seen_by_solar_h17=(100.0,) * 12, + ) + + # Assert + assert actual == (0.0,) * 12