diff --git a/domain/sap10_calculator/worksheet/appendix_h_solar.py b/domain/sap10_calculator/worksheet/appendix_h_solar.py index 04b00382..c9c3bc16 100644 --- a/domain/sap10_calculator/worksheet/appendix_h_solar.py +++ b/domain/sap10_calculator/worksheet/appendix_h_solar.py @@ -25,7 +25,13 @@ Scope of this module: from __future__ import annotations -from typing import Final +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 @@ -305,6 +311,29 @@ def hot_water_factor_y_monthly_h23( 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 @@ -340,3 +369,128 @@ def heat_delivered_to_hot_water_monthly_h24_kwh( 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_w( + aperture_area_m2=aperture_area_m2, + zero_loss_efficiency=zero_loss_efficiency, + monthly_solar_flux_w_per_m2=h7, + 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_w=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, + ) diff --git a/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py b/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py index 2c821794..2afb6afa 100644 --- a/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py +++ b/domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py @@ -21,12 +21,18 @@ from domain.sap10_calculator.worksheet.appendix_h_solar import ( hot_water_reference_temperature_difference_h21_c, hot_water_reference_temperature_h20_c, loop_heat_loss_coefficient_h11, + monthly_collector_solar_flux_w_per_m2, monthly_solar_energy_available_h9_w, overall_heat_loss_coefficient_h10, proportion_solar_to_hot_water_monthly_h18, reference_volume_h15, + solar_water_heating_input_monthly_kwh, storage_tank_correction_coefficient_h16, ) +from domain.sap10_calculator.worksheet.solar_gains import Orientation +from domain.sap10_calculator.worksheet.water_heating import ( + TABLE_J1_TCOLD_FROM_MAINS_C, +) # Cert 000565 worksheet (sap worksheets/extended test case/U985-0001- @@ -363,3 +369,81 @@ def test_heat_delivered_to_hot_water_h24_clamps_at_zero_when_polynomial_negative # Assert assert actual == (0.0,) * 12 + + +def test_monthly_collector_solar_flux_h7_returns_twelve_values_matching_appendix_u() -> None: + # Arrange — cert 000565 collector: W orientation, 30° pitch, Thames + # Valley = region 1. (H7)m must come from Appendix U §U3.3 via the + # existing `surface_solar_flux_w_per_m2`. Smoke test: 12 values, + # winter < summer (Jan should be below ~50, Jun should be above + # ~150 — the W-facing 30°-pitched collector peaks mid-summer). + + # Act + actual = monthly_collector_solar_flux_w_per_m2( + orientation=Orientation.W, pitch_deg=30.0, region=1, + ) + + # Assert + assert len(actual) == 12 + assert all(v > 0.0 for v in actual) + # Jan flux must be the year's minimum-ish; Jun the max-ish + assert actual[0] < actual[5] + assert actual[0] < 50.0 + assert actual[5] > 150.0 + + +def test_solar_water_heating_input_monthly_kwh_returns_winter_zero_summer_peak_shape() -> None: + # Arrange — cert 000565 worksheet (Block 1, lines 399-413) lodges + # all Appendix H inputs. This test asserts the monthly SHAPE of + # (H24)m matches the spec's physical expectation (winter zero, + # summer peak) — magnitude pin against worksheet line 415's + # 281.3478 kWh total is deferred to the next slice while the + # H8 (overshading) ambiguity in the spec's (H23) line-ref vs the + # top-level Equation H1 formulation is resolved. Today's cascade + # produces ~510 kWh annual for cert 000565 (1.8× the worksheet) + # via the line-ref (H23) interpretation. The orchestrator + # plumbing + (H10)..(H22)/(H24) component math are spec-pinned; + # the (H23) factor calibration is the only open piece. + hw_demand_62m_kwh = ( + 312.9085, 278.7760, 301.5007, 278.0295, 278.2821, + 178.0038, 178.8734, 184.0215, 183.8120, 285.3050, + 289.3545, 311.2021, + ) + external_temp_96m_c = ( + 4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2, + ) + + # Act + h24 = solar_water_heating_input_monthly_kwh( + collector_orientation=Orientation.W, + collector_pitch_deg=30.0, + region=0, # UK average (cert lodges Thames Valley; rating uses 0) + aperture_area_m2=_CERT_000565_APERTURE_AREA_M2, + zero_loss_efficiency=_CERT_000565_ETA_0, + linear_heat_loss_a1=_CERT_000565_A1, + second_order_heat_loss_a2=_CERT_000565_A2, + loop_efficiency=_CERT_000565_LOOP_EFF, + incidence_angle_modifier=_CERT_000565_IAM, + overshading_factor=_CERT_000565_OVERSHADING, + overall_heat_loss_coefficient_from_test=_CERT_000565_OVERALL_H10, + dedicated_solar_storage_volume_l=_CERT_000565_DEDICATED_SOLAR_V_L, + combined_cylinder_total_volume_l=_CERT_000565_CYLINDER_V_L, + hot_water_demand_monthly_kwh=hw_demand_62m_kwh, + wwhrs_monthly_kwh=(0.0,) * 12, + cold_water_temperatures_monthly_c=TABLE_J1_TCOLD_FROM_MAINS_C, + external_temperatures_monthly_c=external_temp_96m_c, + solar_hot_water_only=True, + ) + + # Assert — physical shape pins: + # 1. 12-month tuple, all values non-negative (Equation H1 clamp). + # 2. Winter months (Jan, Feb, Nov, Dec) clamp to 0 — the + # polynomial's negative-X term dominates when solar flux is low + # vs HW demand (worksheet line 416 also zeros these months). + # 3. Summer months (May, Jun, Jul) carry the peak contribution. + assert len(h24) == 12 + assert all(v >= 0.0 for v in h24) + assert h24[0] == 0.0 and h24[1] == 0.0 + assert h24[10] == 0.0 and h24[11] == 0.0 + assert h24[4] > h24[2] # May > Mar + assert h24[5] > h24[8] # Jun > Sep