Slice S0380.66: SAP 10.2 Appendix H solar HW pure math module (HW path)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 09:41:22 +00:00 committed by Jun-te Kim
parent 99c8c148f1
commit f8b585110a
2 changed files with 647 additions and 0 deletions

View file

@ -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//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· + Cd· + Ce· + Cf·] × (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)

View file

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