Slice S0380.68: Appendix H (H7)m flux helper + top-level orchestrator

Builds on S0380.66 (Appendix H pure helpers) + S0380.67 (W·h → kWh
unit fix) to assemble the spec-ordered H7 → H9 → … → H24 cascade
into a single entry point. Cert 000565's complete Appendix H input
set now flows through one call:

    h24 = solar_water_heating_input_monthly_kwh(
        collector_orientation=Orientation.W, collector_pitch_deg=30.0,
        region=0,                               # UK average per rating
        aperture_area_m2=3.0,                   # (H1)
        zero_loss_efficiency=0.8,               # (H2)
        linear_heat_loss_a1=4.0,                # (H3)
        second_order_heat_loss_a2=0.01,         # (H4)
        loop_efficiency=0.9,                    # (H5)
        incidence_angle_modifier=0.94,          # (H6)
        overshading_factor=0.8,                 # (H8) Table H2 "Modest"
        overall_heat_loss_coefficient_from_test=6.5,  # (H10) override
        dedicated_solar_storage_volume_l=53.0,  # (H12)
        combined_cylinder_total_volume_l=160.0, # (H13)
        hot_water_demand_monthly_kwh=...,       # (62)m
        wwhrs_monthly_kwh=(0,)*12,              # (63a)m
        cold_water_temperatures_monthly_c=TABLE_J1_TCOLD_FROM_MAINS_C,
        external_temperatures_monthly_c=...,    # (96)m
        solar_hot_water_only=True,
    )

New module surface:
- `monthly_collector_solar_flux_w_per_m2` — thin 12-month wrapper over
  the existing `surface_solar_flux_w_per_m2` (Appendix U §U3.2
  orientation + tilt polynomial). Cert 000565 collector: W, 30° pitch,
  Thames Valley.
- `solar_water_heating_input_monthly_kwh` — chains all line-ref
  helpers in spec order; returns (H24)m as a 12-tuple.

Tests:
- `test_monthly_collector_solar_flux_h7_returns_twelve_values_
  matching_appendix_u` — smoke test pinning Jan < May < Jun shape
  for the W-facing 30°-pitched collector.
- `test_solar_water_heating_input_monthly_kwh_returns_winter_zero_
  summer_peak_shape` — orchestrator shape pin: 12-month tuple, all
  non-negative, winter clamp to zero (Jan/Feb/Nov/Dec via Equation
  H1's negative-X dominance), monotone Mar < May, Sep < Jun.

Magnitude pin against worksheet line 415 (Σ(H24)1..12 = 281.3478)
is DEFERRED to the next slice: current orchestrator output is
~510 kWh annual (1.8× the worksheet), traced to a spec ambiguity
between the top-level Equation H1 Y formula
(Y = Px · Aap · IAM · η0 · ηloop · Im · Hm / (1000 · Dm) — excludes
overshading H8) and the line-ref (H23) formulation
(Y = [(H18) · (H6) · (H5) · (H9) · ((41) · 24)] / [1000 · (H17)],
where (H9) = (H1) · (H2) · (H7) · (H8) includes H8). Both are
present in SAP 10.2 spec page 75-76 and differ by a factor of H8
(0.8 for cert 000565). Picking the spec-correct branch requires
either a worksheet trace of one cert's (H22)/(H23) intermediates or
a confirmed errata; the next slice runs that down and pins the
magnitude.

Test suite: 279 pass + 9 expected 000565 cascade-gap fails (unchanged
— orchestrator is not yet wired into `water_heating_from_cert`).
Pyright net-zero on both touched files.

Spec source: SAP 10.2 specification (14-03-2025) Appendix H pp.74-78
+ Appendix U §U3.2 page 127.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 10:02:29 +00:00
parent 2795e2569d
commit f0ab7446b2
2 changed files with 239 additions and 1 deletions

View file

@ -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/).
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,
)

View file

@ -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