§4 slice 8: line (64)m output from water heater

(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)

The four (63 a-d) inputs are WWHRS, PV-diverter, solar HW and FGHRS
contributions — entered as negative quantities so the formula uses +,
not −. The max-clamp guards "if (64)m < 0 then set to 0" per the spec
worksheet text: a renewable-heavy summer can't show negative delivered
heat.

Both Elmhurst non-RR fixtures lodge zero for all four (no WWHRS, no PV
diverter, no solar, no FGHRS), so (64)m = (62)m for every month.
Validated end-to-end on both with abs=1e-3 kWh tolerance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 16:08:18 +00:00
parent bfba610b70
commit feef819814
4 changed files with 104 additions and 0 deletions

View file

@ -197,3 +197,21 @@ LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
26.1600, 23.0055, 24.1612, 20.6307, 19.5714, 17.1754,
16.6394, 17.5728, 18.0628, 20.6883, 22.6596, 25.7985,
)
LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder
LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary circuit
# (61)m combi loss values from worksheet — derived from Table 3b (PCDB-
# tested boiler). Not yet computable by our cascade; lodged for assertion.
LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = (
28.7238, 25.9337, 28.6905, 27.7191, 28.6040, 27.6419,
28.5422, 28.5649, 27.6693, 28.6326, 27.7530, 28.7178,
)
LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
203.1238, 179.3035, 189.7652, 165.2572, 159.0798, 142.1443,
139.4718, 145.7166, 148.0876, 166.5545, 178.8167, 200.7079,
)
LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12
LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12
LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12
LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12
LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH

View file

@ -196,3 +196,8 @@ LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
238.8196, 211.2342, 224.4651, 197.4680, 191.5045, 172.6544,
170.4499, 177.1525, 179.0276, 199.5259, 212.0383, 236.2237,
)
LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12 # no WWHRS
LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12 # no PV diverter
LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12 # no FGHRS
LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH # no reductions

View file

@ -26,6 +26,7 @@ from domain.sap.worksheet.water_heating import (
hot_water_baths_monthly_l_per_day,
hot_water_mixer_showers_monthly_l_per_day,
hot_water_other_uses_monthly_l_per_day,
output_from_water_heater_monthly_kwh,
total_hot_water_monthly_l_per_day,
total_water_heating_demand_monthly_kwh,
)
@ -503,6 +504,56 @@ def test_total_water_heating_demand_matches_elmhurst_line_62_for_000490() -> Non
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
def test_output_from_water_heater_matches_elmhurst_line_64(fixture) -> None: # type: ignore[no-untyped-def]
"""SAP10.2 §4 line (64)m:
(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)
The (63 a-d) WWHRS / PV-diverter / Solar / FGHRS inputs are entered
as negative quantities (heat displaced FROM the water heater). The
max-clamp guards against the worksheet text "if (64)m < 0 then set
to 0" — a renewable-heavy system would otherwise show negative
delivered heat for the warmest months.
Both fixtures have all four inputs zero (no WWHRS, no PV diverter,
no solar HW, no FGHRS), so (64)m = (62)m for every month.
"""
# Arrange / Act
monthly = output_from_water_heater_monthly_kwh(
total_demand_monthly_kwh=fixture.LINE_62_M_TOTAL_WH_KWH,
wwhrs_monthly_kwh=fixture.LINE_63A_M_WWHRS_KWH,
pv_diverter_monthly_kwh=fixture.LINE_63B_M_PV_DIVERTER_KWH,
solar_monthly_kwh=fixture.LINE_63C_M_SOLAR_KWH,
fghrs_monthly_kwh=fixture.LINE_63D_M_FGHRS_KWH,
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly, fixture.LINE_64_M_OUTPUT_FROM_WH_KWH)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_output_from_water_heater_clamps_to_zero_when_renewables_exceed_demand() -> None:
"""(64)m floor at zero per spec: a solar HW system that contributes
more in July than the dwelling demands shouldn't show negative
delivered heat for that month."""
# Arrange — 100 kWh demand, -150 kWh solar input (overshoots)
demand = tuple(100.0 for _ in range(12))
solar = tuple(-150.0 for _ in range(12))
zero = tuple(0.0 for _ in range(12))
# Act
monthly = output_from_water_heater_monthly_kwh(
total_demand_monthly_kwh=demand,
wwhrs_monthly_kwh=zero,
pv_diverter_monthly_kwh=zero,
solar_monthly_kwh=solar,
fghrs_monthly_kwh=zero,
)
# Assert
assert all(v == pytest.approx(0.0, abs=1e-9) for v in monthly)
def test_assumed_occupancy_floor_at_n_eq_1_for_small_dwellings() -> None:
"""Appendix J piecewise definition: TFA ≤ 13.9 m² → N=1 exactly. A
tiny studio flat at the boundary is the most common trigger."""

View file

@ -272,6 +272,36 @@ def total_water_heating_demand_monthly_kwh(
)
def output_from_water_heater_monthly_kwh(
*,
total_demand_monthly_kwh: tuple[float, ...],
wwhrs_monthly_kwh: tuple[float, ...],
pv_diverter_monthly_kwh: tuple[float, ...],
solar_monthly_kwh: tuple[float, ...],
fghrs_monthly_kwh: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (64)m:
(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)
Output from the water heater after subtracting renewable / heat-
recovery contributions. The four reduction inputs are entered as
negative quantities (heat displaced FROM the boiler/cylinder), so
the formula uses + not -. Spec note "if (64)m < 0 then set to 0"
floors the result month-by-month a renewable-heavy system can't
show negative delivered heat for the warmest months.
"""
return tuple(
max(0.0, t + w + pv + s + f)
for t, w, pv, s, f in zip(
total_demand_monthly_kwh,
wwhrs_monthly_kwh,
pv_diverter_monthly_kwh,
solar_monthly_kwh,
fghrs_monthly_kwh,
)
)
def _days_weighted_average(monthly: tuple[float, ...]) -> float:
"""Σ value[m] × n_m / 365 — used by Appendix J equations J4 and J9."""
return sum(v * d for v, d in zip(monthly, _DAYS_IN_MONTH)) / _DAYS_IN_YEAR