§4 slice 5: lines (43) annual avg + (44)m monthly total

Two thin wrappers landing the aggregation step:

  (44)m = (42a)m + (42b)m + (42c)m        Appendix J equation J13
  (43)  = V_d,shower,ave + V_d,bath,ave + V_d,other,ave   J12

A subtle spec point caught here: (43) is the SUM OF THE COMPONENT
ANNUAL AVERAGES (per the J12 text), not the days-weighted mean of (44)m.
The two are arithmetically different because Table J2's days-weighted
mean is 0.99973 rather than 1.0 — the "other uses" term contributes its
unmodulated baseline (9.8N+14), and only the showers + baths terms get
the days-weighted reduction. Spec-following the J12 wording matches the
Elmhurst (43) values to 1e-3 L/day on both fixtures.

  annual_average_hot_water_other_uses_l_per_day  exposes V_d,other,ave
  annual_average_hot_water_l_per_day              composes the J12 sum
  total_hot_water_monthly_l_per_day               J13 (44)m sum

LINE_43 + LINE_44_M lodged on 000474 and 000490 fixtures.

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

View file

@ -184,3 +184,8 @@ DOOR_COUNT: int = 2 # cascade default 1.85 m²/door → 3.70 m² matches worksh
# §4 Water heating energy requirements
LINE_42_OCCUPANCY: float = 1.8896
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 101.1966
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,
)

View file

@ -172,3 +172,8 @@ DOOR_COUNT: int = 2 # cascade default 1.85 m²/door → 3.70 m² matches worksh
# §4 Water heating energy requirements
LINE_42_OCCUPANCY: float = 2.1468
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 109.0070
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,
)

View file

@ -17,10 +17,13 @@ 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,
annual_average_hot_water_l_per_day,
annual_average_hot_water_other_uses_l_per_day,
assumed_occupancy,
hot_water_baths_monthly_l_per_day,
hot_water_mixer_showers_monthly_l_per_day,
hot_water_other_uses_monthly_l_per_day,
total_hot_water_monthly_l_per_day,
)
@ -325,6 +328,78 @@ def test_hot_water_mixer_showers_two_outlets_distributes_shower_count_evenly() -
assert t == pytest.approx(s, abs=1e-9), f"month {m+1}"
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture) -> None: # type: ignore[no-untyped-def]
"""SAP10.2 §4 line (44)m via Appendix J equation J13:
V_d,m = V_d,shower[m] + V_d,bath[m] + V_d,other[m]
A pure sum each component already validated independently but the
end-to-end product is what callers compare against the worksheet."""
# Arrange
showers = hot_water_mixer_showers_monthly_l_per_day(
n_occupants=fixture.LINE_42_OCCUPANCY,
has_bath=True,
mixer_shower_flow_rates_l_per_min=(7.0,),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
)
baths = hot_water_baths_monthly_l_per_day(
n_occupants=fixture.LINE_42_OCCUPANCY, has_bath=True, has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=False,
)
other = hot_water_other_uses_monthly_l_per_day(
n_occupants=fixture.LINE_42_OCCUPANCY, low_water_use=False,
)
# Act
monthly = total_hot_water_monthly_l_per_day(
showers=showers, baths=baths, other_uses=other
)
# Assert
for m, (actual, exp) in enumerate(zip(monthly, fixture.LINE_44_M_DAILY_HW_USAGE_L)):
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
def test_annual_average_hot_water_matches_elmhurst_line_43(fixture) -> None: # type: ignore[no-untyped-def]
"""SAP10.2 §4 line (43) via Appendix J equation J12 (also (44)m days-
weighted form):
V_d,ave = Σ V_d,m × n_m / 365
Feed the live computed (44)m (chained from (42a)+(42b)+(42c)) so
no rounding noise from the fixture's 4-d.p. display values leaks
into the days-weighted sum.
"""
# Arrange — same (42a)m + (42b)m inputs as the worksheet used; "other"
# contributes its unmodulated annual baseline V_d,other,ave (9.8N+14),
# not the days-weighted mean of (42c)m.
showers = hot_water_mixer_showers_monthly_l_per_day(
n_occupants=fixture.LINE_42_OCCUPANCY,
has_bath=True,
mixer_shower_flow_rates_l_per_min=(7.0,),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
)
baths = hot_water_baths_monthly_l_per_day(
n_occupants=fixture.LINE_42_OCCUPANCY, has_bath=True, has_shower=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=False,
)
other_avg = annual_average_hot_water_other_uses_l_per_day(
n_occupants=fixture.LINE_42_OCCUPANCY, low_water_use=False,
)
# Act
annual_avg = annual_average_hot_water_l_per_day(
showers_monthly=showers,
baths_monthly=baths,
other_uses_annual_avg=other_avg,
)
# Assert
assert annual_avg == pytest.approx(
fixture.LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY, abs=1e-3
)
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

@ -65,6 +65,13 @@ _WARM_SHOWER_TEMPERATURE_C: Final[float] = 41.0
# Appendix J step 1d: assumed shower duration in minutes per event.
_SHOWER_DURATION_MIN: Final[float] = 6.0
# Days per month (non-leap year) — used by Appendix J J4 / J9 / J12 to
# turn monthly daily-rate arrays into days-weighted annual averages.
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
)
_DAYS_IN_YEAR: Final[int] = sum(_DAYS_IN_MONTH)
def assumed_occupancy(total_floor_area_m2: float) -> float:
"""SAP 10.2 §4 line (42) / Appendix J Table 1b.
@ -167,6 +174,65 @@ def hot_water_baths_monthly_l_per_day(
return tuple(monthly)
def total_hot_water_monthly_l_per_day(
*,
showers: tuple[float, ...],
baths: tuple[float, ...],
other_uses: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (44)m via Appendix J equation J13:
V_d,m = V_d,shower[m] + V_d,bath[m] + V_d,other[m]
A pure element-wise sum of the three monthly demand streams. All
three inputs must be 12-tuples caller is responsible for ensuring
they were computed against the same Tcold table.
"""
return tuple(s + b + o for s, b, o in zip(showers, baths, other_uses))
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
def annual_average_hot_water_other_uses_l_per_day(
*, n_occupants: float, low_water_use: bool
) -> float:
"""SAP 10.2 §4 — V_d,other,ave per Appendix J step 3a: 9.8 × N + 14,
less 5% for the low-water-use target. The annual average is computed
BEFORE Table J2 monthly modulation, so it's the unmodulated baseline
value (the days-weighted mean of (42c)m would drift slightly off
because Table J2 doesn't days-average to exactly 1)."""
annual = 9.8 * n_occupants + 14.0
if low_water_use:
annual *= 1.0 - _LOW_WATER_USE_REDUCTION
return annual
def annual_average_hot_water_l_per_day(
*,
showers_monthly: tuple[float, ...],
baths_monthly: tuple[float, ...],
other_uses_annual_avg: float,
) -> float:
"""SAP 10.2 §4 line (43) via Appendix J equation J12:
V_d,ave = V_d,shower,ave + V_d,bath,ave + V_d,other,ave
Per the spec text after J12, (43) is the sum of the three component
annual averages NOT the days-weighted average of (44)m. The
distinction only matters for "other uses": its monthly array (42c)m
is the unmodulated annual baseline times Table J2 factors, and the
days-weighted average of those factors is 0.9997 (not exactly 1.0),
so taking the days-weighted mean of (42c)m would drift slightly low.
Showers and baths only have annual averages via J4 / J9.
"""
return (
_days_weighted_average(showers_monthly)
+ _days_weighted_average(baths_monthly)
+ other_uses_annual_avg
)
def hot_water_other_uses_monthly_l_per_day(
*, n_occupants: float, low_water_use: bool
) -> tuple[float, ...]: