Model/domain/sap10_calculator/worksheet/appendix_h_solar.py
Khalim Conn-Kowlessar 3bf728ce2f Slice S0380.74: Appendix H (H7) U3.3 monthly-integrated convention closes 1.81× over-count
Root cause: SAP 10.2 has an internal unit-convention ambiguity for
(H7)m between page 75 (Equation H1 implies W/m² 24-hour-average flux)
and page 76 (verbatim "Monthly solar radiation per m² from U3.3 in
Appendix U", i.e. kWh/m²/month monthly integrated). Page 77 (H23)
formula's `× hours / 1000` term double-converts when (H7) is W/m².

The cascade's `surface_solar_flux_w_per_m2` returns the §U3.2 24h-avg
flux in W/m² (verified bit-exact vs Elmhurst worksheet line 295: SE
90° Jan region 0 = 36.7938 W/m²). The (H9) helper was using this
directly without applying the U3.3 conversion that page 76's "from
U3.3" cross-reference calls for. Elmhurst-certified software follows
the U3.3 reading.

SAP 10.2 spec p.76 line (H7): "Monthly solar radiation per m² from
U3.3 in Appendix U". Appendix U §U3.3 (p.130) defines the conversion
S_monthly = 0.024 × n_m × S(orient,p,m), where S(orient,p,m) is the
§U3.2 24-hour-average flux in W/m². Therefore:

  (H7)m_U3.3 [kWh/m²/month] = flux_U3.2 [W/m²] × hours / 1000

Option A fix (per ChatGPT-mediated research): apply the U3.3
conversion inside the (H9) helper, so (H9) is in kWh/month rather
than W. Spec p.77 (H23) formula then carries the conversion's
dimensional residue correctly without double-counting.

Diagnostic that closed the trap: back-solving poly(X_cas, Y_eff) =
ws_H24/H17 at fixed X across 24 worksheet-positive observations from
4 cert fixtures (000565 + new A/B/C at sap worksheets/Solar PV tests/)
revealed Y_eff/Y_cascade took ONLY two distinct values:
- 0.7200 (exact) for every 30-day month observation
- 0.7440 (exact) for every 31-day month observation
i.e. exactly days × 24 / 1000. No utilizability function, no missing
constant — a per-month unit-conversion factor that the polynomial
non-linearity had been masking.

Closure metrics (HEAD post-fix):
- 000565 (W-30, modest): annual Δ −0.0000 kWh (every month exact)
- A-baseline (S-30, modest): annual Δ +0.0001 kWh
- B-highY (S-30, none): annual Δ −0.0000 kWh (incl Oct 10.5905)
- C-lowY (N-60, signif): annual Δ −4.36 kWh (polynomial zero-clamp
  boundary; worksheet poly = 0.0024 → 0.41 kWh, cascade poly =
  −0.04 → 0)

47/48 month-observations pin at <1e-4 kWh.

Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]`
cascade-gap fails (unchanged — orchestrator still NOT integrated
into water_heating.py:943; that's the follow-on slice that closes
cert 000565's HW pin +272 → ~0).

Pyright net-zero on both touched files.

Files:
- domain/sap10_calculator/worksheet/appendix_h_solar.py: rename
  `monthly_solar_energy_available_h9_w` → `_h9_kwh_per_month`,
  add `hours_in_month` param, apply U3.3 conversion. Y23 param
  renamed accordingly. Orchestrator updated.
- domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py:
  add cert 000565 (H24)m monthly magnitude pin at abs < 1e-3 kWh;
  update H9 + Y23 unit tests for new kWh/month units.
- BRIEF_APPENDIX_H_EN_15316_RESEARCH.md: new "Closure" section with
  the days-in-month diagnostic, root cause, and lessons.
- HANDOVER_POST_4_CERT_EMPIRICAL.md: NEW — closure handover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:02:35 +00:00

522 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
)