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 a8c5287e..6bb99857 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 @@ -189,3 +189,11 @@ 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, ) +LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = ( + 174.4000, 153.3698, 161.0747, 137.5380, 130.4758, 114.5024, + 110.9296, 117.1517, 120.4183, 137.9218, 151.0638, 171.9901, +) +LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = ( + 26.1600, 23.0055, 24.1612, 20.6307, 19.5714, 17.1754, + 16.6394, 17.5728, 18.0628, 20.6883, 22.6596, 25.7985, +) 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 441ab273..b1bdd6dc 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 @@ -177,3 +177,11 @@ 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, ) +LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = ( + 187.8607, 165.2069, 173.5062, 148.1530, 140.5456, 123.3393, + 119.4910, 126.1935, 129.7125, 148.5670, 162.7232, 185.2648, +) +LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = ( + 28.1791, 24.7810, 26.0259, 22.2229, 21.0818, 18.5009, + 17.9237, 18.9290, 19.4569, 22.2851, 24.4085, 27.7897, +) 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 96b6d036..253f8b11 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 @@ -20,6 +20,8 @@ from domain.sap.worksheet.water_heating import ( annual_average_hot_water_l_per_day, annual_average_hot_water_other_uses_l_per_day, assumed_occupancy, + distribution_loss_monthly_kwh, + energy_content_of_hot_water_monthly_kwh, hot_water_baths_monthly_l_per_day, hot_water_mixer_showers_monthly_l_per_day, hot_water_other_uses_monthly_l_per_day, @@ -400,6 +402,61 @@ def test_annual_average_hot_water_matches_elmhurst_line_43(fixture) -> None: # ) +@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490")) +def test_energy_content_of_hot_water_matches_elmhurst_line_45(fixture) -> None: # type: ignore[no-untyped-def] + """SAP10.2 §4 line (45)m via Appendix J equation J14: + (45)m = 4.18 × V_d,m × n_m × (52 − Tcold[m]) / 3600 [kWh/month] + + 4.18 J/g/K is the specific heat of water, dividing by 3600 converts + to kWh. Both fixtures heat hot water to 52°C from mains-temperature + cold water (Table J1 mains column). + """ + # Arrange / Act + monthly_kwh = energy_content_of_hot_water_monthly_kwh( + monthly_hot_water_l_per_day=fixture.LINE_44_M_DAILY_HW_USAGE_L, + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + + # Assert + for m, (actual, exp) in enumerate(zip(monthly_kwh, fixture.LINE_45_M_HW_ENERGY_CONTENT_KWH)): + assert actual == pytest.approx(exp, abs=1e-2), f"month {m+1}" + + +@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490")) +def test_distribution_loss_matches_elmhurst_line_46_for_non_instantaneous( + fixture, # type: ignore[no-untyped-def] +) -> None: + """SAP10.2 §4 line (46)m: for non-instantaneous systems + (46)m = 0.15 × (45)m + + Both Elmhurst fixtures run a combi boiler with hot water from a + main central system (not codes 907/909 instantaneous), so distribution + loss is 15% of the energy content.""" + # Arrange / Act + monthly_loss = distribution_loss_monthly_kwh( + monthly_energy_content_kwh=fixture.LINE_45_M_HW_ENERGY_CONTENT_KWH, + is_instantaneous_at_point_of_use=False, + ) + + # Assert + for m, (actual, exp) in enumerate(zip(monthly_loss, fixture.LINE_46_M_DISTRIBUTION_LOSS_KWH)): + assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}" + + +def test_distribution_loss_zero_for_instantaneous_point_of_use_water_heating() -> None: + """SAP10.2 §4: hot water codes 907 and 909 are instantaneous at the + point of use — no distribution pipework, so (46)m is zero for every + month regardless of (45)m.""" + # Arrange / Act + loss = distribution_loss_monthly_kwh( + monthly_energy_content_kwh=(100.0,) * 12, + is_instantaneous_at_point_of_use=True, + ) + + # Assert + assert all(v == pytest.approx(0.0, abs=1e-9) for v in loss) + + 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 118b09f7..8e989d50 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -190,6 +190,45 @@ def total_hot_water_monthly_l_per_day( return tuple(s + b + o for s, b, o in zip(showers, baths, other_uses)) +def energy_content_of_hot_water_monthly_kwh( + *, + monthly_hot_water_l_per_day: tuple[float, ...], + cold_water_temps_c: tuple[float, ...], +) -> tuple[float, ...]: + """SAP 10.2 §4 line (45)m via Appendix J equation J14: + (45)m = 4.18 × V_d,m × n_m × (52 − Tcold[m]) / 3600 [kWh/month] + + Sensible heat to raise the monthly hot water volume from Tcold[m] to + the 52 °C delivery temperature. 4.18 J/(g·K) is the specific heat of + water; dividing by 3600 converts J/g to Wh/g (= kWh/kg, since 1 L of + water ≈ 1 kg). + """ + return tuple( + 4.18 * vd * n * (_HOT_DELIVERY_TEMPERATURE_C - tcold) / 3600.0 + for vd, n, tcold in zip( + monthly_hot_water_l_per_day, _DAYS_IN_MONTH, cold_water_temps_c + ) + ) + + +def distribution_loss_monthly_kwh( + *, + monthly_energy_content_kwh: tuple[float, ...], + is_instantaneous_at_point_of_use: bool, +) -> tuple[float, ...]: + """SAP 10.2 §4 line (46)m. + + Distribution loss is a flat 15% of (45)m unless the water heating is + instantaneous at the point of use (Table 4a hot water codes 907 and + 909) — those have no distribution pipework so the loss is zero. Heat + networks still incur the 15% loss whether or not a hot water cylinder + is present (spec §4 step 7). + """ + if is_instantaneous_at_point_of_use: + return tuple(0.0 for _ in range(12)) + return tuple(0.15 * e for e in monthly_energy_content_kwh) + + 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