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 6bb99857..1dafc14d 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 @@ -197,3 +197,21 @@ 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, ) +LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder +LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW +LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary circuit +# (61)m combi loss values from worksheet — derived from Table 3b (PCDB- +# tested boiler). Not yet computable by our cascade; lodged for assertion. +LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = ( + 28.7238, 25.9337, 28.6905, 27.7191, 28.6040, 27.6419, + 28.5422, 28.5649, 27.6693, 28.6326, 27.7530, 28.7178, +) +LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = ( + 203.1238, 179.3035, 189.7652, 165.2572, 159.0798, 142.1443, + 139.4718, 145.7166, 148.0876, 166.5545, 178.8167, 200.7079, +) +LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12 +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 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 5a297793..0956a0c6 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 @@ -196,3 +196,8 @@ LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = ( 238.8196, 211.2342, 224.4651, 197.4680, 191.5045, 172.6544, 170.4499, 177.1525, 179.0276, 199.5259, 212.0383, 236.2237, ) +LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12 # no WWHRS +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 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 a3517d70..db8b87d4 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 @@ -26,6 +26,7 @@ from domain.sap.worksheet.water_heating import ( hot_water_baths_monthly_l_per_day, hot_water_mixer_showers_monthly_l_per_day, hot_water_other_uses_monthly_l_per_day, + output_from_water_heater_monthly_kwh, total_hot_water_monthly_l_per_day, total_water_heating_demand_monthly_kwh, ) @@ -503,6 +504,56 @@ def test_total_water_heating_demand_matches_elmhurst_line_62_for_000490() -> Non assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}" +@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490")) +def test_output_from_water_heater_matches_elmhurst_line_64(fixture) -> None: # type: ignore[no-untyped-def] + """SAP10.2 §4 line (64)m: + (64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m) + + The (63 a-d) WWHRS / PV-diverter / Solar / FGHRS inputs are entered + as negative quantities (heat displaced FROM the water heater). The + max-clamp guards against the worksheet text "if (64)m < 0 then set + to 0" — a renewable-heavy system would otherwise show negative + delivered heat for the warmest months. + + Both fixtures have all four inputs zero (no WWHRS, no PV diverter, + no solar HW, no FGHRS), so (64)m = (62)m for every month. + """ + # Arrange / Act + monthly = output_from_water_heater_monthly_kwh( + total_demand_monthly_kwh=fixture.LINE_62_M_TOTAL_WH_KWH, + wwhrs_monthly_kwh=fixture.LINE_63A_M_WWHRS_KWH, + pv_diverter_monthly_kwh=fixture.LINE_63B_M_PV_DIVERTER_KWH, + solar_monthly_kwh=fixture.LINE_63C_M_SOLAR_KWH, + fghrs_monthly_kwh=fixture.LINE_63D_M_FGHRS_KWH, + ) + + # Assert + for m, (actual, exp) in enumerate(zip(monthly, fixture.LINE_64_M_OUTPUT_FROM_WH_KWH)): + assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}" + + +def test_output_from_water_heater_clamps_to_zero_when_renewables_exceed_demand() -> None: + """(64)m floor at zero per spec: a solar HW system that contributes + more in July than the dwelling demands shouldn't show negative + delivered heat for that month.""" + # Arrange — 100 kWh demand, -150 kWh solar input (overshoots) + demand = tuple(100.0 for _ in range(12)) + solar = tuple(-150.0 for _ in range(12)) + zero = tuple(0.0 for _ in range(12)) + + # Act + monthly = output_from_water_heater_monthly_kwh( + total_demand_monthly_kwh=demand, + wwhrs_monthly_kwh=zero, + pv_diverter_monthly_kwh=zero, + solar_monthly_kwh=solar, + fghrs_monthly_kwh=zero, + ) + + # Assert + assert all(v == pytest.approx(0.0, abs=1e-9) for v in monthly) + + 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 4b20a1ed..9a20560d 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -272,6 +272,36 @@ def total_water_heating_demand_monthly_kwh( ) +def output_from_water_heater_monthly_kwh( + *, + total_demand_monthly_kwh: tuple[float, ...], + wwhrs_monthly_kwh: tuple[float, ...], + pv_diverter_monthly_kwh: tuple[float, ...], + solar_monthly_kwh: tuple[float, ...], + fghrs_monthly_kwh: tuple[float, ...], +) -> tuple[float, ...]: + """SAP 10.2 §4 line (64)m: + (64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m) + + Output from the water heater after subtracting renewable / heat- + recovery contributions. The four reduction inputs are entered as + negative quantities (heat displaced FROM the boiler/cylinder), so + the formula uses + not -. Spec note "if (64)m < 0 then set to 0" + floors the result month-by-month — a renewable-heavy system can't + show negative delivered heat for the warmest months. + """ + return tuple( + max(0.0, t + w + pv + s + f) + for t, w, pv, s, f in zip( + total_demand_monthly_kwh, + wwhrs_monthly_kwh, + pv_diverter_monthly_kwh, + solar_monthly_kwh, + fghrs_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