From 43da3ea0642ae43592568891d0d6a4aad9070d84 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 16:12:53 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A74=20slice=209:=20line=20(65)m=20heat=20ga?= =?UTF-8?q?ins=20from=20water=20heating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (65)m = 0.25 × [0.85 × (45)m + (61)m + (64a)m] + 0.80 × [(46)m + (57)m + (59)m] First bracket recovers 25% of delivered-heat losses (hot water at the tap + combi cycling + electric-shower waste heat); second bracket recovers 80% of pipe-side losses (distribution + solar storage + primary circuit) since pipework typically sits inside the heated envelope. Per spec footnote on xlsx row 302, callers should zero (57)m when the hot water store is OUTSIDE the heated space (e.g. communal heat networks). Validated against both Elmhurst fixtures to <1e-3 kWh: 000490 Jan: 0.25×(0.85×187.86 + 50.96 + 0) + 0.80×(28.18 + 0 + 0) = 0.25×210.64 + 0.80×28.18 = 52.66 + 22.54 = 75.20 ✓ 000474 Jan: 0.25×(0.85×174.40 + 28.72 + 0) + 0.80×(26.16 + 0 + 0) = 0.25×176.96 + 0.80×26.16 = 44.24 + 20.93 = 65.17 ✓ LINE_64A_M and LINE_65_M lodged on both 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 | 30 ++++++++++++++++ .../src/domain/sap/worksheet/water_heating.py | 36 +++++++++++++++++++ 4 files changed, 76 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 1dafc14d..0c6b1e5e 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 @@ -215,3 +215,8 @@ LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12 LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12 LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12 LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH +LINE_64A_M_ELECTRIC_SHOWER_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = ( + 65.1690, 57.4789, 60.7300, 52.6612, 50.5342, 44.9825, + 44.0196, 46.0942, 46.9564, 53.0172, 57.1669, 64.3662, +) 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 0956a0c6..98a96d96 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 @@ -201,3 +201,8 @@ LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12 # no PV diverter LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12 # no FGHRS LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH # no reductions +LINE_64A_M_ELECTRIC_SHOWER_KWH: tuple[float, ...] = (0.0,) * 12 # mixer showers only +LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = ( + 75.2034, 66.4381, 70.4305, 61.5896, 59.4711, 53.3391, + 52.4705, 54.6991, 55.4582, 62.1383, 66.4342, 74.3403, +) 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 db8b87d4..0c305ba6 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 @@ -23,6 +23,7 @@ from domain.sap.worksheet.water_heating import ( combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, distribution_loss_monthly_kwh, energy_content_of_hot_water_monthly_kwh, + heat_gains_from_water_heating_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, @@ -554,6 +555,35 @@ def test_output_from_water_heater_clamps_to_zero_when_renewables_exceed_demand() assert all(v == pytest.approx(0.0, abs=1e-9) for v in monthly) +@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490")) +def test_heat_gains_from_water_heating_matches_elmhurst_line_65(fixture) -> None: # type: ignore[no-untyped-def] + """SAP10.2 §4 line (65)m heat gains released into the heated space: + (65)m = 0.25 × [0.85 × (45)m + (61)m + (64a)m] + + 0.8 × [(46)m + (57)m + (59)m] + + The first bracket is delivered-heat losses (energy hot from the tap + + combi cycling losses + electric-shower waste heat) at 25% recovery; + the second is pipe-side losses (distribution + solar storage + + primary circuit) at 80% recovery. Per spec footnote (57)m is only + included when the hot water store is inside the dwelling (heat + networks excluded) — both fixtures have (57)m=0 so the conditional + doesn't bite. + """ + # Arrange / Act + monthly = heat_gains_from_water_heating_monthly_kwh( + energy_content_monthly_kwh=fixture.LINE_45_M_HW_ENERGY_CONTENT_KWH, + distribution_loss_monthly_kwh=fixture.LINE_46_M_DISTRIBUTION_LOSS_KWH, + solar_storage_monthly_kwh=fixture.LINE_57_M_SOLAR_STORAGE_KWH, + primary_loss_monthly_kwh=fixture.LINE_59_M_PRIMARY_LOSS_KWH, + combi_loss_monthly_kwh=fixture.LINE_61_M_COMBI_LOSS_KWH, + electric_shower_monthly_kwh=fixture.LINE_64A_M_ELECTRIC_SHOWER_KWH, + ) + + # Assert + for m, (actual, exp) in enumerate(zip(monthly, fixture.LINE_65_M_HEAT_GAINS_FROM_WH_KWH)): + assert actual == pytest.approx(exp, abs=1e-3), 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 9a20560d..039f6af9 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -302,6 +302,42 @@ def output_from_water_heater_monthly_kwh( ) +def heat_gains_from_water_heating_monthly_kwh( + *, + energy_content_monthly_kwh: tuple[float, ...], + distribution_loss_monthly_kwh: tuple[float, ...], + solar_storage_monthly_kwh: tuple[float, ...], + primary_loss_monthly_kwh: tuple[float, ...], + combi_loss_monthly_kwh: tuple[float, ...], + electric_shower_monthly_kwh: tuple[float, ...], +) -> tuple[float, ...]: + """SAP 10.2 §4 line (65)m heat gains released into the heated space: + (65)m = 0.25 × [0.85 × (45)m + (61)m + (64a)m] + + 0.80 × [(46)m + (57)m + (59)m] + + First bracket: delivered-heat losses (hot water at the tap + combi + cycling losses + electric-shower waste heat) at 25 % recovery into + the dwelling. Second bracket: pipe-side losses (distribution + + solar storage + primary circuit) at 80 % recovery — these run + through the heated envelope so most of the loss heats it. + + Per spec footnote at xlsx row 302, include (57)m only if the hot + water store is in the dwelling. Callers should pass zero for + out-of-dwelling stores (e.g. communal heat networks). + """ + return tuple( + 0.25 * (0.85 * e + c + es) + 0.80 * (d + s + p) + for e, d, s, p, c, es in zip( + energy_content_monthly_kwh, + distribution_loss_monthly_kwh, + solar_storage_monthly_kwh, + primary_loss_monthly_kwh, + combi_loss_monthly_kwh, + electric_shower_monthly_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