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