From 702b1c6ce6823a8fc900fb896ce15261bc3f9670 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 15:56:23 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A74=20slice=205:=20lines=20(43)=20annual=20?= =?UTF-8?q?avg=20+=20(44)m=20monthly=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/_elmhurst_worksheet_000474.py | 5 ++ .../tests/_elmhurst_worksheet_000490.py | 5 ++ .../sap/worksheet/tests/test_water_heating.py | 75 +++++++++++++++++++ .../src/domain/sap/worksheet/water_heating.py | 66 ++++++++++++++++ 4 files changed, 151 insertions(+) diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py index c4d0b534..a8c5287e 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py @@ -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, +) diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py index 11bf5f27..441ab273 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py @@ -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, +) 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 99623502..96b6d036 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 @@ -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.""" diff --git a/packages/domain/src/domain/sap/worksheet/water_heating.py b/packages/domain/src/domain/sap/worksheet/water_heating.py index 59d332c0..118b09f7 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -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, ...]: