§4 slice 3: hot_water_baths_monthly_l_per_day (line (42b)m)

Appendix J equations J6, J7, J8. Daily hot water for bath fills depends
on N, presence of bath and/or shower, and monthly Tcold:

  N_bath  = 0                if no bath but a shower exists
          = 0.13×N + 0.19    if bath + shower
          = 0.35×N + 0.50    otherwise
  V_d,bath[m] = N_bath × 73 × J5_fbeh[m] × (42−Tcold[m])/(52−Tcold[m])

Tables J1 (mains + header tank Tcold) and J5 (behavioural factor) are
exported as module constants for reuse by (42a)m showers next.

Validated against the Elmhurst non-RR fixtures, both with bath + shower
and "Cold Water Source: From mains":
  - 000490 N=2.1468 → Jan V_d,bath = 27.3868
  - 000474 N=1.8896 → Jan V_d,bath = 25.4345

Also covers the zero-bath branch and the 5% low-water-use reduction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 15:48:26 +00:00
parent 5cc68ab3fd
commit dad7fbf31f
2 changed files with 155 additions and 0 deletions

View file

@ -16,7 +16,9 @@ from domain.sap.worksheet.tests import (
)
from domain.sap.worksheet.tests._xlsx_loader import load_cells
from domain.sap.worksheet.water_heating import (
TABLE_J1_TCOLD_FROM_MAINS_C,
assumed_occupancy,
hot_water_baths_monthly_l_per_day,
hot_water_other_uses_monthly_l_per_day,
)
@ -122,6 +124,94 @@ def test_hot_water_other_uses_low_water_use_target_reduces_by_5pct() -> None:
assert l == pytest.approx(n * 0.95, abs=1e-9), f"month {m+1}"
def test_hot_water_baths_matches_elmhurst_worksheet_000490() -> None:
"""SAP10.2 §4 line (42b)m via Appendix J equations J6, J7, J8.
Bath + shower present (typical home), N=2.1468:
N_bath = 0.13 × N + 0.19 = 0.4691 baths/day
V_d,warm,bath[m] = N_bath × 73 × J5_fbeh[m]
V_d,bath[m] = V_d,warm,bath[m] × (42 Tcold[m]) / (52 Tcold[m])
The cold-water table is Tcold from mains (Table J1) the 000490
cert lodges "Cold Water Source = From mains".
"""
# Arrange
expected = (
27.3868, 26.9801, 26.4073, 25.3512, 24.5605, 23.6836,
23.2100, 23.7787, 24.3980, 25.3363, 26.4141, 27.2942,
)
# Act
monthly = hot_water_baths_monthly_l_per_day(
n_occupants=_w000490.LINE_42_OCCUPANCY,
has_bath=True,
has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly, expected)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_hot_water_baths_matches_elmhurst_worksheet_000474() -> None:
"""Same (42b)m formulas for 000474 (N=1.8896, bath + shower)."""
# Arrange
expected = (
25.4345, 25.0567, 24.5248, 23.5440, 22.8096, 21.9953,
21.5554, 22.0836, 22.6587, 23.5301, 24.5311, 25.3485,
)
# Act
monthly = hot_water_baths_monthly_l_per_day(
n_occupants=_w000474.LINE_42_OCCUPANCY,
has_bath=True,
has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly, expected)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_hot_water_baths_zero_when_no_bath_present_and_a_shower_is() -> None:
"""Appendix J J6 first branch: N_bath = 0 when there's no bath but a
shower is present, regardless of N."""
# Arrange / Act
monthly = hot_water_baths_monthly_l_per_day(
n_occupants=3.0,
has_bath=False,
has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
)
# Assert
assert all(v == pytest.approx(0.0, abs=1e-9) for v in monthly)
def test_hot_water_baths_low_water_use_reduces_warm_water_by_5pct() -> None:
"""J7 footnote: ≤125 L/person/day target reduces V_d,warm,bath by 5%.
Since V_d,bath is linear in V_d,warm,bath, the monthly array shrinks
by exactly 5% too."""
# Arrange / Act
normal = hot_water_baths_monthly_l_per_day(
n_occupants=2.0, has_bath=True, has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=False,
)
lwu = hot_water_baths_monthly_l_per_day(
n_occupants=2.0, has_bath=True, has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=True,
)
# Assert
for m, (n, l) in enumerate(zip(normal, lwu)):
assert l == pytest.approx(n * 0.95, abs=1e-9), f"month {m+1}"
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

@ -39,6 +39,29 @@ _TABLE_J2_MONTHLY_FACTORS: Final[tuple[float, ...]] = (
# designed to ≤125 L/person/day total water use.
_LOW_WATER_USE_REDUCTION: Final[float] = 0.05
# Table J1 — cold-water inlet temperatures (°C). Two columns per the spec:
# from a header tank (cooler) vs from the mains. The cert lodges which
# applies; both populations need spec-exact monthly arrays.
TABLE_J1_TCOLD_FROM_HEADER_TANK_C: Final[tuple[float, ...]] = (
11.1, 11.3, 12.3, 14.5, 16.2, 18.8, 21.3, 19.3, 18.7, 16.2, 13.2, 11.2,
)
TABLE_J1_TCOLD_FROM_MAINS_C: Final[tuple[float, ...]] = (
8.0, 8.2, 9.3, 12.7, 14.6, 16.7, 18.4, 17.6, 16.6, 14.3, 11.1, 8.5,
)
# Table J5 — behavioural variation factor for showers AND baths. Used by
# (42a)m showers (Appendix J step 1d) and (42b)m baths (step 2b) alike.
TABLE_J5_BEHAVIOURAL_FACTOR: Final[tuple[float, ...]] = (
1.035, 1.021, 1.007, 0.993, 0.979, 0.965,
0.965, 0.979, 0.993, 1.007, 1.021, 1.035,
)
# Appendix J equation J7 constants.
_BATH_VOLUME_L: Final[float] = 73.0
# Appendix J equation J8 / J3 mixing temperatures.
_HOT_DELIVERY_TEMPERATURE_C: Final[float] = 52.0
_WARM_BATH_TEMPERATURE_C: Final[float] = 42.0
def assumed_occupancy(total_floor_area_m2: float) -> float:
"""SAP 10.2 §4 line (42) / Appendix J Table 1b.
@ -54,6 +77,48 @@ def assumed_occupancy(total_floor_area_m2: float) -> float:
return 1.0 + 1.76 * (1.0 - exp(-0.000349 * x * x)) + 0.0013 * x
def hot_water_baths_monthly_l_per_day(
*,
n_occupants: float,
has_bath: bool,
has_shower: bool,
cold_water_temps_c: tuple[float, ...],
low_water_use: bool,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (42b)m via Appendix J equations J6, J7, J8.
Per-day hot water for bath fills across the 12 months. Per J6:
N_bath = 0 if no bath (but a shower is present)
= 0.13 × N + 0.19 if shower is also present
= 0.35 × N + 0.50 if no shower present, or no bath
and no shower
Each bath fills 73 L of warm water at 42 °C; the hot fraction depends
on monthly cold-water temperature (Table J1, either header tank or
mains depending on the cert). `low_water_use` knocks 5% off the
warm-water term per the J7 footnote.
`cold_water_temps_c` must be a 12-tuple of monthly Tcold values
pass `TABLE_J1_TCOLD_FROM_MAINS_C` for the common case.
"""
if not has_bath and has_shower:
return tuple(0.0 for _ in range(12))
if has_bath and has_shower:
n_bath = 0.13 * n_occupants + 0.19
else:
n_bath = 0.35 * n_occupants + 0.50
lwu_factor = 1.0 - _LOW_WATER_USE_REDUCTION if low_water_use else 1.0
monthly: list[float] = []
for fbeh, tcold in zip(TABLE_J5_BEHAVIOURAL_FACTOR, cold_water_temps_c):
v_warm = n_bath * _BATH_VOLUME_L * fbeh * lwu_factor
f_hot = (_WARM_BATH_TEMPERATURE_C - tcold) / (
_HOT_DELIVERY_TEMPERATURE_C - tcold
)
monthly.append(v_warm * f_hot)
return tuple(monthly)
def hot_water_other_uses_monthly_l_per_day(
*, n_occupants: float, low_water_use: bool
) -> tuple[float, ...]: