§4 slice 6: lines (45)m energy content + (46)m distribution loss

(45)m = 4.18 × V_d,m × n_m × (52 − Tcold[m]) / 3600    [kWh/month]
                                          Appendix J equation J14
  (46)m = 0.15 × (45)m                    spec §4 step 7 (normal systems)
        = 0                                (instantaneous at point of use,
                                            hot water codes 907 / 909)

4.18 J/(g·K) is the specific heat of water; / 3600 converts to kWh. The
J14 transform converts daily L of hot water at delivery temperature into
the monthly sensible-heat requirement.

Both Elmhurst non-RR fixtures use a combi boiler from a central system
(neither 907 nor 909), so distribution loss is the full 15 % of (45)m.
Lodged LINE_45_M and LINE_46_M arrays on both fixtures for forward use.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 15:58:40 +00:00
parent 702b1c6ce6
commit a3c687f1b0
4 changed files with 112 additions and 0 deletions

View file

@ -189,3 +189,11 @@ LINE_44_M_DAILY_HW_USAGE_L: tuple[float, ...] = (
110.1180, 107.7045, 104.8007, 100.4697, 96.9221, 93.1204,
91.7218, 94.6138, 97.6550, 101.6380, 106.0332, 109.8446,
)
LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = (
174.4000, 153.3698, 161.0747, 137.5380, 130.4758, 114.5024,
110.9296, 117.1517, 120.4183, 137.9218, 151.0638, 171.9901,
)
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,
)

View file

@ -177,3 +177,11 @@ LINE_44_M_DAILY_HW_USAGE_L: tuple[float, ...] = (
118.6172, 116.0171, 112.8890, 108.2238, 104.4023, 100.3071,
98.8008, 101.9162, 105.1922, 109.4827, 114.2171, 118.3228,
)
LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = (
187.8607, 165.2069, 173.5062, 148.1530, 140.5456, 123.3393,
119.4910, 126.1935, 129.7125, 148.5670, 162.7232, 185.2648,
)
LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
28.1791, 24.7810, 26.0259, 22.2229, 21.0818, 18.5009,
17.9237, 18.9290, 19.4569, 22.2851, 24.4085, 27.7897,
)

View file

@ -20,6 +20,8 @@ from domain.sap.worksheet.water_heating import (
annual_average_hot_water_l_per_day,
annual_average_hot_water_other_uses_l_per_day,
assumed_occupancy,
distribution_loss_monthly_kwh,
energy_content_of_hot_water_monthly_kwh,
hot_water_baths_monthly_l_per_day,
hot_water_mixer_showers_monthly_l_per_day,
hot_water_other_uses_monthly_l_per_day,
@ -400,6 +402,61 @@ def test_annual_average_hot_water_matches_elmhurst_line_43(fixture) -> None: #
)
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
def test_energy_content_of_hot_water_matches_elmhurst_line_45(fixture) -> None: # type: ignore[no-untyped-def]
"""SAP10.2 §4 line (45)m via Appendix J equation J14:
(45)m = 4.18 × V_d,m × n_m × (52 Tcold[m]) / 3600 [kWh/month]
4.18 J/g/K is the specific heat of water, dividing by 3600 converts
to kWh. Both fixtures heat hot water to 52°C from mains-temperature
cold water (Table J1 mains column).
"""
# Arrange / Act
monthly_kwh = energy_content_of_hot_water_monthly_kwh(
monthly_hot_water_l_per_day=fixture.LINE_44_M_DAILY_HW_USAGE_L,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly_kwh, fixture.LINE_45_M_HW_ENERGY_CONTENT_KWH)):
assert actual == pytest.approx(exp, abs=1e-2), f"month {m+1}"
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
def test_distribution_loss_matches_elmhurst_line_46_for_non_instantaneous(
fixture, # type: ignore[no-untyped-def]
) -> None:
"""SAP10.2 §4 line (46)m: for non-instantaneous systems
(46)m = 0.15 × (45)m
Both Elmhurst fixtures run a combi boiler with hot water from a
main central system (not codes 907/909 instantaneous), so distribution
loss is 15% of the energy content."""
# Arrange / Act
monthly_loss = distribution_loss_monthly_kwh(
monthly_energy_content_kwh=fixture.LINE_45_M_HW_ENERGY_CONTENT_KWH,
is_instantaneous_at_point_of_use=False,
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly_loss, fixture.LINE_46_M_DISTRIBUTION_LOSS_KWH)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_distribution_loss_zero_for_instantaneous_point_of_use_water_heating() -> None:
"""SAP10.2 §4: hot water codes 907 and 909 are instantaneous at the
point of use no distribution pipework, so (46)m is zero for every
month regardless of (45)m."""
# Arrange / Act
loss = distribution_loss_monthly_kwh(
monthly_energy_content_kwh=(100.0,) * 12,
is_instantaneous_at_point_of_use=True,
)
# Assert
assert all(v == pytest.approx(0.0, abs=1e-9) for v in loss)
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

@ -190,6 +190,45 @@ def total_hot_water_monthly_l_per_day(
return tuple(s + b + o for s, b, o in zip(showers, baths, other_uses))
def energy_content_of_hot_water_monthly_kwh(
*,
monthly_hot_water_l_per_day: tuple[float, ...],
cold_water_temps_c: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (45)m via Appendix J equation J14:
(45)m = 4.18 × V_d,m × n_m × (52 Tcold[m]) / 3600 [kWh/month]
Sensible heat to raise the monthly hot water volume from Tcold[m] to
the 52 °C delivery temperature. 4.18 J/(g·K) is the specific heat of
water; dividing by 3600 converts J/g to Wh/g (= kWh/kg, since 1 L of
water 1 kg).
"""
return tuple(
4.18 * vd * n * (_HOT_DELIVERY_TEMPERATURE_C - tcold) / 3600.0
for vd, n, tcold in zip(
monthly_hot_water_l_per_day, _DAYS_IN_MONTH, cold_water_temps_c
)
)
def distribution_loss_monthly_kwh(
*,
monthly_energy_content_kwh: tuple[float, ...],
is_instantaneous_at_point_of_use: bool,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (46)m.
Distribution loss is a flat 15% of (45)m unless the water heating is
instantaneous at the point of use (Table 4a hot water codes 907 and
909) those have no distribution pipework so the loss is zero. Heat
networks still incur the 15% loss whether or not a hot water cylinder
is present (spec §4 step 7).
"""
if is_instantaneous_at_point_of_use:
return tuple(0.0 for _ in range(12))
return tuple(0.15 * e for e in monthly_energy_content_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