Slice S0380.67: Appendix H (H9) helper + fix (H23) W·h → kWh units

S0380.66 landed (H23) with an incorrect unit treatment: the spec
formula on SAP 10.2 p.76 is

    Y_HW = [(H18)m × (H6) × (H5) × (H9)m × (41)m × 24] ÷ [1000 × (H17)m]

and Appendix U (per `domain/sap10_calculator/climate/appendix_u.
horizontal_solar_irradiance_w_per_m2`) returns (H7)m as a monthly-
average flux in W/m². That makes (H9)m = (H1) × (H2) × (H7)m × (H8)
an instantaneous power in W — the `× hours × 24 / 1000` factor in
the (H23) formula is what time-integrates W·h → kWh so the Y_HW
ratio lands dimensionless against (H17)m (kWh/month).

S0380.66's (H23) elided the time integration by absorbing it into
the input parameter (a kWh/m²/month name) — that broke unit
consistency with the downstream Appendix U integration this module
will consume in the next slice.

Changes:
- New `monthly_solar_energy_available_h9_w` — pure (H9)m calculator
  taking aperture, η₀, (H7)m flux tuple, and overshading. Returns W.
- `hot_water_factor_y_monthly_h23`: parameter renamed
  `monthly_solar_energy_available_h9_w` (was `..._kwh_per_m2`); new
  `hours_in_month` parameter; formula now includes the spec's
  `× hours / 1000` time integration explicitly.

Tests:
- `test_monthly_solar_energy_available_h9_applies_spec_formula` —
  cert 000565 H1/H2/H8 with flat 100 W/m² flux → 192 W (the spec
  multiplicand 3 × 0.8 × 100 × 0.8).
- `test_hot_water_factor_y_h23_applies_w_to_kwh_time_integration` —
  unit-consistency pin: H9=1000 W, hours=744, H17=744 kWh → Y=1.0.
- `test_hot_water_factor_y_h23_clamps_lower_bound_at_zero` updated
  to the new parameter name and supplies `hours_in_month`.

Test suite: 277 pass + 9 expected 000565 cascade-gap fails. Pyright
net-zero on both touched files.

Spec source: SAP 10.2 specification (14-03-2025) Appendix H p.76.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 09:46:12 +00:00
parent db4f1b3167
commit 2795e2569d
2 changed files with 73 additions and 13 deletions

View file

@ -247,13 +247,35 @@ def hot_water_factor_x_monthly_h22(
return tuple(out)
def monthly_solar_energy_available_h9_w(
*,
aperture_area_m2: float, # (H1)
zero_loss_efficiency: float, # (H2)
monthly_solar_flux_w_per_m2: tuple[float, ...], # (H7)m from Appendix U §U3.3
overshading_factor: float, # (H8)
) -> tuple[float, ...]:
"""SAP 10.2 (H9)m — solar energy available on collector aperture (W).
Spec p.76: "(H1) × (H2) × (H7)m × (H8)". The result is an
instantaneous (monthly-average) power in watts the worksheet's
downstream (H22) and (H23) formulas multiply by `(41)m × 24`
(hours-in-month) and divide by 1000 to convert to kWh/month, so
keeping (H9)m in W here matches the spec's stepwise units.
"""
return tuple(
aperture_area_m2 * zero_loss_efficiency * flux * overshading_factor
for flux in monthly_solar_flux_w_per_m2
)
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
proportion_solar_to_hw_h18: tuple[float, ...], # (H18)m
incidence_angle_modifier: float, # (H6)
loop_efficiency: float, # (H5)
monthly_solar_energy_available_h9_w: tuple[float, ...], # (H9)m in W (per `monthly_solar_energy_available_h9_w`)
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.
@ -261,11 +283,9 @@ def hot_water_factor_y_monthly_h23(
[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.
(H9)m is in W (per `monthly_solar_energy_available_h9_w`); the
`× hours_in_month / 1000` factor here converts W·h kWh so the
ratio Y_HW lands dimensionless against (H17)m (kWh/month).
"""
out: list[float] = []
for m in range(12):
@ -277,9 +297,10 @@ def hot_water_factor_y_monthly_h23(
proportion_solar_to_hw_h18[m]
* incidence_angle_modifier
* loop_efficiency
* monthly_solar_energy_available_h9_kwh_per_m2[m]
* monthly_solar_energy_available_h9_w[m]
* hours_in_month[m]
)
y = numerator / h17
y = numerator / (1000.0 * h17)
out.append(max(0.0, y))
return tuple(out)

View file

@ -21,6 +21,7 @@ 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_solar_energy_available_h9_w,
overall_heat_loss_coefficient_h10,
proportion_solar_to_hot_water_monthly_h18,
reference_volume_h15,
@ -257,6 +258,23 @@ def test_hot_water_factor_x_h22_clamps_upper_bound_at_18() -> None:
assert actual == (18.0,) * 12
def test_monthly_solar_energy_available_h9_applies_spec_formula() -> None:
# Arrange — SAP 10.2 (H9)m spec p.76: (H1) × (H2) × (H7)m × (H8)
# Cert 000565 worksheet (H1)=3.0, (H2)=0.8, (H8)=0.8. With a flat
# 100 W/m² flux input, expected = 3 × 0.8 × 100 × 0.8 = 192 W.
# Act
actual = monthly_solar_energy_available_h9_w(
aperture_area_m2=_CERT_000565_APERTURE_AREA_M2,
zero_loss_efficiency=_CERT_000565_ETA_0,
monthly_solar_flux_w_per_m2=(100.0,) * 12,
overshading_factor=_CERT_000565_OVERSHADING,
)
# Assert — H1=3, H2=0.8, H7=100, H8=0.8 → 192 W/month
assert all(abs(h9 - 192.0) < 1e-9 for h9 in actual)
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.
@ -266,7 +284,8 @@ def test_hot_water_factor_y_h23_clamps_lower_bound_at_zero() -> None:
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,
monthly_solar_energy_available_h9_w=(-5.0,) * 12,
hours_in_month=(744,) * 12,
hw_demand_seen_by_solar_h17=(100.0,) * 12,
)
@ -274,6 +293,26 @@ def test_hot_water_factor_y_h23_clamps_lower_bound_at_zero() -> None:
assert actual == (0.0,) * 12
def test_hot_water_factor_y_h23_applies_w_to_kwh_time_integration() -> None:
# Arrange — spec p.76: Y_HW = [(H18) × (H6) × (H5) × (H9) × hours]
# ÷ [1000 × (H17)]. With H18=1, H6=1, H5=1, H9=1000 W,
# hours=744 (Jan), H17=744 kWh → numerator = 1000 × 744 = 744000 W·h
# ÷ 1000 = 744 kWh, ÷ H17 = 744/744 = 1.0 ✓ dimensionless.
# Act
actual = hot_water_factor_y_monthly_h23(
proportion_solar_to_hw_h18=(1.0,) * 12,
incidence_angle_modifier=1.0,
loop_efficiency=1.0,
monthly_solar_energy_available_h9_w=(1000.0,) * 12,
hours_in_month=(744,) * 12,
hw_demand_seen_by_solar_h17=(744.0,) * 12,
)
# Assert
assert all(abs(y - 1.0) < 1e-9 for y in actual)
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