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 5c4816af..a4baa49d 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 @@ -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.""" diff --git a/packages/domain/src/domain/sap/worksheet/water_heating.py b/packages/domain/src/domain/sap/worksheet/water_heating.py index bb780468..7eea59f7 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -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, ...]: