mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
99c8c148f1
commit
f8b585110a
2 changed files with 647 additions and 0 deletions
321
domain/sap10_calculator/worksheet/appendix_h_solar.py
Normal file
321
domain/sap10_calculator/worksheet/appendix_h_solar.py
Normal 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/m²/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·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)
|
||||
326
domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py
Normal file
326
domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue