"""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, Union from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate from domain.sap10_calculator.worksheet.solar_gains import ( Orientation, surface_solar_flux_w_per_m2, ) # 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 monthly_solar_energy_available_h9_kwh_per_month( *, aperture_area_m2: float, # (H1) zero_loss_efficiency: float, # (H2) monthly_solar_flux_w_per_m2: tuple[float, ...], # U3.2 flux in W/m² (24h avg) hours_in_month: tuple[int, ...], # (41)m × 24 overshading_factor: float, # (H8) ) -> tuple[float, ...]: """SAP 10.2 (H9)m — solar energy available on collector aperture in **kWh/month** (NOT W). Spec p.76 line for (H9): "Solar energy available, (H1) × (H2) × (H7)m × (H8)". Spec p.76 line for (H7): "Monthly solar radiation per m² from U3.3 in Appendix U" — i.e. the integrated monthly irradiation `0.024 × n_m × S(orient,p,m)` in kWh/m²/month, NOT the §U3.2 24-hour-average flux S(orient,p,m) in W/m². The cascade's `surface_solar_flux_w_per_m2` returns the §U3.2 flux in W/m² (verified bit-exact against Elmhurst worksheet line 295: SE 90° Jan region 0 = 36.7938 W/m²). To reach the §U3.3 integrated value the SAP spec calls for, multiply by `hours_in_month / 1000` (W·h → kWh): (H7)m_U3.3 [kWh/m²/month] = flux_U3.2 [W/m²] × hours / 1000 (H9)m therefore lands in kWh/month: (H9)m = (H1) × (H2) × (H7)m_U3.3 × (H8) Reading the spec page 77 (H23) formula `H18·H6·H5·H9·hours / (1000·H17)` with (H9) in W instead of kWh/month over-counts Y by exactly `hours/1000` (= 0.720 for 30-day months, 0.744 for 31-day months) — the long-running 1.81× cascade-vs-worksheet gap on cert 000565 closes to <1e-3 kWh/month across 4 fixtures once (H9) carries the U3.3 conversion. """ return tuple( aperture_area_m2 * zero_loss_efficiency * (flux * hours / 1000.0) * overshading_factor for flux, hours in zip(monthly_solar_flux_w_per_m2, hours_in_month) ) 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_month: tuple[float, ...], # (H9)m kWh/month hours_in_month: tuple[int, ...], # (41)m × 24 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`). (H9)m is in kWh/month (per `monthly_solar_energy_available_h9_ kwh_per_month` — the §U3.3 monthly-integrated convention SAP p.76 references). The `× hours / 1000` term then carries the dimensional residue inherent to SAP's stepwise units. """ 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_month[m] * hours_in_month[m] ) y = numerator / (1000.0 * h17) out.append(max(0.0, y)) return tuple(out) def monthly_collector_solar_flux_w_per_m2( *, orientation: Orientation, pitch_deg: float, region: Union[int, PostcodeClimate], ) -> tuple[float, ...]: """SAP 10.2 (H7)m — monthly solar flux on the collector aperture (W/m²). Thin 12-month wrapper around `solar_gains.surface_solar_flux_w_per _m2`, which implements the Appendix U §U3.3 polynomial conversion from the horizontal irradiance in Table U3 to any orientation / tilt combination. Cert 000565's collector is W-facing, 30° pitch, Thames Valley (region 1).""" return tuple( surface_solar_flux_w_per_m2( orientation=orientation, pitch_deg=pitch_deg, region=region, month=m, ) for m in range(1, 13) ) 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) def solar_water_heating_input_monthly_kwh( *, # Collector geometry + region (drives Appendix U §U3.3 lookup for H7m) collector_orientation: Orientation, collector_pitch_deg: float, region: Union[int, PostcodeClimate], # Collector params lodged by cert or backed by Table H1 default aperture_area_m2: float, # (H1) zero_loss_efficiency: float, # (H2) linear_heat_loss_a1: float, # (H3) second_order_heat_loss_a2: float, # (H4) loop_efficiency: float, # (H5) incidence_angle_modifier: float, # (H6) overshading_factor: float, # (H8) Table H2 overall_heat_loss_coefficient_from_test: float | None = None, # (H10) override # Cylinder / storage volume inputs dedicated_solar_storage_volume_l: float, # (H12) combined_cylinder_total_volume_l: float | None, # (H13) # Monthly demand + climate inputs hot_water_demand_monthly_kwh: tuple[float, ...], # (62)m wwhrs_monthly_kwh: tuple[float, ...], # (63a)m cold_water_temperatures_monthly_c: tuple[float, ...], # Table J1 Tcold,m external_temperatures_monthly_c: tuple[float, ...], # (96)m Appendix U §U3.1 # Solar contribution routing (cert 000565 lodges HW-only) space_heating_demand_monthly_kwh: tuple[float, ...] = (0.0,) * 12, solar_hot_water_only: bool = True, solar_space_heating_only: bool = False, ) -> tuple[float, ...]: """SAP 10.2 Appendix H top-level orchestrator — returns (H24)m kWh of solar heat delivered to the hot-water cylinder per month. Chains the per-line helpers in spec order (p.75-77): (H7)m Appendix U §U3.3 flux on collector aperture (H9)m = (H1) × (H2) × (H7)m × (H8) (H10) = 5 + 0.5 × (H1) [or from test certificate] (H11) = (H3) + 40·(H4) + (H10)/(H1) (H14) = (H12) [separate] OR (H12) + 0.3·((H13)−(H12)) [combined] (H15) = 75 × (H1) (H16) = ((H15)/(H14))^0.25 (H17)m = (62)m − (63a)m (H18)m HW-share of demand (1 / 0 / blend per `solar_*_only` flags) (H20)m = 55 + 3.86·Tcold,m − 1.32·(96)m (H21)m = (H20)m − (96)m (H22)m X factor (clamp [0, 18]) (H23)m Y factor (clamp ≥ 0) (H24)m Equation H1 polynomial (clamp [0, (H17)m]) Space-heating contribution (H25)..(H29) is NOT computed here. Pass `solar_hot_water_only=True` (default) for the cert 000565 shape; other shapes will need an SH orchestrator in a follow-on slice. """ h7 = monthly_collector_solar_flux_w_per_m2( orientation=collector_orientation, pitch_deg=collector_pitch_deg, region=region, ) h9 = monthly_solar_energy_available_h9_kwh_per_month( aperture_area_m2=aperture_area_m2, zero_loss_efficiency=zero_loss_efficiency, monthly_solar_flux_w_per_m2=h7, hours_in_month=_HOURS_IN_MONTH, overshading_factor=overshading_factor, ) h10 = overall_heat_loss_coefficient_h10( aperture_area_m2=aperture_area_m2, from_test_certificate=overall_heat_loss_coefficient_from_test, ) h11 = loop_heat_loss_coefficient_h11( linear_heat_loss_a1=linear_heat_loss_a1, second_order_heat_loss_a2=second_order_heat_loss_a2, overall_heat_loss_h10=h10, aperture_area_m2=aperture_area_m2, ) h14 = effective_solar_volume_h14( dedicated_solar_storage_volume_l=dedicated_solar_storage_volume_l, combined_cylinder_total_volume_l=combined_cylinder_total_volume_l, ) h15 = reference_volume_h15(aperture_area_m2) h16 = storage_tank_correction_coefficient_h16( reference_volume_h15_l=h15, effective_solar_volume_h14_l=h14, ) h17 = hot_water_demand_monthly_h17_kwh( hot_water_demand_monthly_kwh=hot_water_demand_monthly_kwh, wwhrs_monthly_kwh=wwhrs_monthly_kwh, ) h18 = proportion_solar_to_hot_water_monthly_h18( hw_demand_seen_by_solar_monthly_kwh=h17, space_heating_demand_monthly_kwh=space_heating_demand_monthly_kwh, solar_hot_water_only=solar_hot_water_only, solar_space_heating_only=solar_space_heating_only, ) h20 = hot_water_reference_temperature_h20_c( cold_water_temperatures_monthly_c=cold_water_temperatures_monthly_c, external_temperatures_monthly_c=external_temperatures_monthly_c, ) h21 = hot_water_reference_temperature_difference_h21_c( hw_reference_temperature_monthly_c=h20, external_temperatures_monthly_c=external_temperatures_monthly_c, ) h22 = hot_water_factor_x_monthly_h22( proportion_solar_to_hw_h18=h18, aperture_area_m2=aperture_area_m2, loop_heat_loss_h11=h11, loop_efficiency=loop_efficiency, hw_reference_temp_diff_h21=h21, storage_tank_correction_h16=h16, hours_in_month=_HOURS_IN_MONTH, hw_demand_seen_by_solar_h17=h17, ) h23 = hot_water_factor_y_monthly_h23( proportion_solar_to_hw_h18=h18, incidence_angle_modifier=incidence_angle_modifier, loop_efficiency=loop_efficiency, monthly_solar_energy_available_h9_kwh_per_month=h9, hours_in_month=_HOURS_IN_MONTH, hw_demand_seen_by_solar_h17=h17, ) return heat_delivered_to_hot_water_monthly_h24_kwh( factor_x_h22=h22, factor_y_h23=h23, hw_demand_seen_by_solar_h17=h17, )