From 1dcbdb28e6f455fd616577e269cec2d3ac2c6df3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 15:51:14 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A74=20slice=204:=20hot=5Fwater=5Fmixer=5Fsh?= =?UTF-8?q?owers=5Fmonthly=5Fl=5Fper=5Fday=20(line=20(42a)m)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appendix J equations J1–J3. Per-day hot water draw for mixer showers combines the per-day shower count (rising with N, depressed slightly when a bath is also present) with each outlet's flow × 6 min × Table J5 behavioural factor, then multiplied by the cold-water-dependent hot fraction (41 °C delivery vs 52 °C hot supply, Tcold from J1). Multi-outlet handling: N_shower is split across outlets so a dwelling with two identical mixers produces the same (42a)m total as a single outlet — the count only matters when outlets have different flow rates. Instantaneous electric showers belong in (64a)m and must be excluded from the input. Validated against the Elmhurst non-RR fixtures (both 1 vented mixer at 7 L/min, mains Tcold): - 000490 N=2.1468 → Jan V_d,hot = 52.6878 - 000474 N=1.8896 → Jan V_d,hot = 48.9139 Co-Authored-By: Claude Opus 4.7 --- .../sap/worksheet/tests/test_water_heating.py | 113 ++++++++++++++++++ .../src/domain/sap/worksheet/water_heating.py | 48 ++++++++ 2 files changed, 161 insertions(+) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_water_heating.py b/packages/domain/src/domain/sap/worksheet/tests/test_water_heating.py index a4baa49d..99623502 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_water_heating.py @@ -19,6 +19,7 @@ from domain.sap.worksheet.water_heating import ( TABLE_J1_TCOLD_FROM_MAINS_C, 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, ) @@ -212,6 +213,118 @@ def test_hot_water_baths_low_water_use_reduces_warm_water_by_5pct() -> None: assert l == pytest.approx(n * 0.95, abs=1e-9), f"month {m+1}" +def test_hot_water_mixer_showers_matches_elmhurst_worksheet_000490() -> None: + """SAP10.2 §4 line (42a)m via Appendix J equations J1–J3. + + Bath present so: + N_shower = 0.45 × N + 0.65 (J1) + V_warm,i[m] = flow × 6 × J5_fbeh[m] (step 1d) + V_d,warm,i[m] = V_warm,i × N_shower / N_outlets (J2) + V_d,hot,i[m] = V_d,warm,i × (41−Tcold[m])/(52−Tcold[m]) (J3) + + 000490: 1 vented mixer outlet at 7 L/min, mains Tcold, + N=2.1468, bath present → Jan V_d,hot ≈ 52.69 L/day. + """ + # Arrange + expected = ( + 52.6878, 51.8960, 50.7422, 48.5346, 46.9054, 45.0886, + 44.0560, 45.2010, 46.4562, 48.4069, 50.6619, 52.4859, + ) + + # Act + monthly = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=_w000490.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, + ) + + # 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_mixer_showers_matches_elmhurst_worksheet_000474() -> None: + """Same (42a)m formula for 000474 (N=1.8896, 1 vented mixer at 7 L/min).""" + # Arrange + expected = ( + 48.9139, 48.1788, 47.1076, 45.0582, 43.5457, 41.8591, + 40.9004, 41.9634, 43.1287, 44.9397, 47.0332, 48.7265, + ) + + # Act + monthly = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=_w000474.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, + ) + + # 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_mixer_showers_zero_when_no_outlets() -> None: + """Appendix J J1 third branch: no shower outlets → N_shower=0 → (42a)m + is all zeros regardless of N.""" + # Arrange / Act + monthly = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=3.0, + has_bath=True, + mixer_shower_flow_rates_l_per_min=(), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + + # Assert + assert all(v == pytest.approx(0.0, abs=1e-9) for v in monthly) + + +def test_hot_water_mixer_showers_no_bath_present_uses_higher_n_shower_branch() -> None: + """J1 second branch: with no bath the per-day shower count rises from + 0.45N+0.65 to 0.58N+0.83 (higher slope and intercept). For N=2 the + ratio is (0.58×2+0.83)/(0.45×2+0.65) = 1.99/1.55 = 1.284.""" + # Arrange / Act + with_bath = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=2.0, has_bath=True, + mixer_shower_flow_rates_l_per_min=(8.0,), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + no_bath = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=2.0, has_bath=False, + mixer_shower_flow_rates_l_per_min=(8.0,), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + + # Assert — V_d,hot scales linearly with N_shower; ratio is identical + # for every month and equals (1.99/1.55). + expected_ratio = (0.58 * 2.0 + 0.83) / (0.45 * 2.0 + 0.65) + for m, (b, nb) in enumerate(zip(with_bath, no_bath)): + assert nb == pytest.approx(b * expected_ratio, abs=1e-6), f"month {m+1}" + + +def test_hot_water_mixer_showers_two_outlets_distributes_shower_count_evenly() -> None: + """V_d,warm,i = V_warm,i × N_shower / N_outlets — adding a second + identical outlet halves the per-outlet daily warm water, but two + outlets summed equals one outlet's contribution. Net (42a)m is + unchanged for identical flow rates.""" + # Arrange / Act + single = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=2.0, has_bath=True, + mixer_shower_flow_rates_l_per_min=(8.0,), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + twin = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=2.0, has_bath=True, + mixer_shower_flow_rates_l_per_min=(8.0, 8.0), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + + # Assert + for m, (s, t) in enumerate(zip(single, twin)): + assert t == pytest.approx(s, 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.""" diff --git a/packages/domain/src/domain/sap/worksheet/water_heating.py b/packages/domain/src/domain/sap/worksheet/water_heating.py index 7eea59f7..59d332c0 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -61,6 +61,9 @@ _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 +_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 def assumed_occupancy(total_floor_area_m2: float) -> float: @@ -77,6 +80,51 @@ 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_mixer_showers_monthly_l_per_day( + *, + n_occupants: float, + has_bath: bool, + mixer_shower_flow_rates_l_per_min: tuple[float, ...], + cold_water_temps_c: tuple[float, ...], +) -> tuple[float, ...]: + """SAP 10.2 §4 line (42a)m via Appendix J equations J1, J2, J3. + + Mixer showers draw from the main hot water system (cylinder or combi) + and mix with cold to a 41 °C delivery. The per-day shower count is + + N_shower = 0.45 × N + 0.65 (bath present) + = 0.58 × N + 0.83 (no bath) + = 0 (no shower outlets) + + Each outlet's warm-water draw for a month is the flow rate × 6 min × + Table J5 fbeh. The hot fraction is (41 − Tcold[m])/(52 − Tcold[m]). + Per-outlet daily warm water is scaled by N_shower / N_outlets, then + summed across outlets to give (42a)m. + + Instantaneous electric showers belong to worksheet (64a)m, not (42a)m + — those should be excluded from `mixer_shower_flow_rates_l_per_min`. + """ + n_outlets = len(mixer_shower_flow_rates_l_per_min) + if n_outlets == 0: + return tuple(0.0 for _ in range(12)) + if has_bath: + n_shower = 0.45 * n_occupants + 0.65 + else: + n_shower = 0.58 * n_occupants + 0.83 + monthly: list[float] = [] + for fbeh, tcold in zip(TABLE_J5_BEHAVIOURAL_FACTOR, cold_water_temps_c): + f_hot = (_WARM_SHOWER_TEMPERATURE_C - tcold) / ( + _HOT_DELIVERY_TEMPERATURE_C - tcold + ) + v_hot_total = 0.0 + for flow in mixer_shower_flow_rates_l_per_min: + v_warm_per_outlet = flow * _SHOWER_DURATION_MIN * fbeh + v_d_warm = v_warm_per_outlet * n_shower / n_outlets + v_hot_total += v_d_warm * f_hot + monthly.append(v_hot_total) + return tuple(monthly) + + def hot_water_baths_monthly_l_per_day( *, n_occupants: float,