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 0c305ba6..8330e939 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 @@ -30,6 +30,7 @@ from domain.sap.worksheet.water_heating import ( output_from_water_heater_monthly_kwh, total_hot_water_monthly_l_per_day, total_water_heating_demand_monthly_kwh, + water_heating_from_cert, ) @@ -584,6 +585,70 @@ def test_heat_gains_from_water_heating_matches_elmhurst_line_65(fixture) -> None assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}" +def test_water_heating_from_cert_matches_elmhurst_worksheet_000490() -> None: + """End-to-end §4 orchestrator for the combi-time-clock-keep-hot path. + + For 000490: 1 vented mixer shower at 7 L/min, bath present, mains + cold water, combi gas with time-clock keep-hot. The orchestrator + chains every line ref from (42) through (65) using only the cert + inputs + the SAP defaults from Appendix J / Table 3a / Table J1. + + Asserts the worksheet's annual output (Σ (64)m) matches and the per- + line-ref intermediate dict pins the key monthly arrays. + """ + # Arrange + epc = _w000490.build_epc() + + # Act + result = water_heating_from_cert( + epc=epc, + mixer_shower_flow_rates_l_per_min=(7.0,), + has_bath=True, + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + low_water_use=False, + ) + + # Assert — annual output equals the days-month-weighted sum of (64)m + expected_annual = sum(_w000490.LINE_64_M_OUTPUT_FROM_WH_KWH) + assert result.output_kwh_per_yr == pytest.approx(expected_annual, abs=0.01) + # Per-line-ref values for audit + for m, exp in enumerate(_w000490.LINE_44_M_DAILY_HW_USAGE_L): + assert result.daily_hot_water_l_per_day_monthly[m] == pytest.approx(exp, abs=1e-3) + for m, exp in enumerate(_w000490.LINE_62_M_TOTAL_WH_KWH): + assert result.total_demand_monthly_kwh[m] == pytest.approx(exp, abs=1e-2) + for m, exp in enumerate(_w000490.LINE_65_M_HEAT_GAINS_FROM_WH_KWH): + assert result.heat_gains_monthly_kwh[m] == pytest.approx(exp, abs=1e-2) + + +def test_water_heating_from_cert_accepts_combi_loss_override_for_pcdb_boilers() -> None: + """000474's Vaillant ecoTEC pro is PCDB-tested → Table 3b combi loss + with PCDB-backed r1 + F1 params we haven't implemented yet. The + orchestrator accepts a `combi_loss_monthly_kwh` override so callers + that have the value (from PCDB or a worksheet) can inject it. With + the LINE_61_M values from the 000474 worksheet, the orchestrator + reproduces the rest of §4 end-to-end.""" + # Arrange + epc = _w000474.build_epc() + + # Act + result = water_heating_from_cert( + epc=epc, + mixer_shower_flow_rates_l_per_min=(7.0,), + has_bath=True, + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + low_water_use=False, + combi_loss_monthly_kwh_override=_w000474.LINE_61_M_COMBI_LOSS_KWH, + ) + + # Assert + expected_annual = sum(_w000474.LINE_64_M_OUTPUT_FROM_WH_KWH) + assert result.output_kwh_per_yr == pytest.approx(expected_annual, abs=0.01) + for m, exp in enumerate(_w000474.LINE_62_M_TOTAL_WH_KWH): + assert result.total_demand_monthly_kwh[m] == pytest.approx(exp, abs=1e-2) + for m, exp in enumerate(_w000474.LINE_65_M_HEAT_GAINS_FROM_WH_KWH): + assert result.heat_gains_monthly_kwh[m] == pytest.approx(exp, abs=1e-2) + + 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 039f6af9..58eae7e2 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -23,12 +23,47 @@ Reference: SAP 10.2 specification §4 (pages 22-31) + Appendix J (pages from __future__ import annotations +from dataclasses import dataclass from math import exp -from typing import Final +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData _OCCUPANCY_TFA_FLOOR_M2: Final[float] = 13.9 + +@dataclass(frozen=True) +class WaterHeatingResult: + """SAP 10.2 §4 worksheet outputs broken down per line ref so callers + can audit the cascade against the canonical xlsx or an Elmhurst + worksheet. Annual totals are days-weighted where appropriate. + + Field-to-line-ref mapping: + (42) occupancy + (43) annual_avg_hot_water_l_per_day + (44)m daily_hot_water_l_per_day_monthly + (45)m energy_content_monthly_kwh + (46)m distribution_loss_monthly_kwh + (61)m combi_loss_monthly_kwh + (62)m total_demand_monthly_kwh + (64)m output_monthly_kwh + (65)m heat_gains_monthly_kwh + Annual sum of (64)m is exposed as `output_kwh_per_yr` for the + calculator's `hot_water_kwh_per_yr` slot. + """ + + occupancy: float + annual_avg_hot_water_l_per_day: float + daily_hot_water_l_per_day_monthly: tuple[float, ...] + energy_content_monthly_kwh: tuple[float, ...] + distribution_loss_monthly_kwh: tuple[float, ...] + combi_loss_monthly_kwh: tuple[float, ...] + total_demand_monthly_kwh: tuple[float, ...] + output_monthly_kwh: tuple[float, ...] + heat_gains_monthly_kwh: tuple[float, ...] + output_kwh_per_yr: float + # Table J2 — monthly factors for hot water use (also used by Appendix J # equation J11 for "other uses"). Symmetric about the year midpoint. _TABLE_J2_MONTHLY_FACTORS: Final[tuple[float, ...]] = ( @@ -399,3 +434,113 @@ def hot_water_other_uses_monthly_l_per_day( if low_water_use: annual_average *= 1.0 - _LOW_WATER_USE_REDUCTION return tuple(annual_average * f for f in _TABLE_J2_MONTHLY_FACTORS) + + +def water_heating_from_cert( + *, + epc: EpcPropertyData, + mixer_shower_flow_rates_l_per_min: tuple[float, ...], + has_bath: bool, + cold_water_temps_c: tuple[float, ...], + low_water_use: bool, + combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None, +) -> WaterHeatingResult: + """SAP 10.2 §4 orchestrator — chain every line ref from (42) through + (65) for a combi-gas dwelling with optional PCDB-backed combi loss. + + Inputs the cert / site notes contribute: + - TFA → occupancy (line 42) + - bath presence → bath formula branch (J6) + - shower flow rates per mixer outlet → (42a)m + - cold water source (mains / header tank) → Tcold table + - low-water-use target flag → J7/J11 -5% reduction + + `combi_loss_monthly_kwh_override` lets callers inject a (61)m array + derived from PCDB Table 3b/3c (tested boilers). When omitted the + cascade defaults to Table 3a row "Instantaneous, with keep-hot + facility controlled by time clock" — the modal lodging for non-PCDB + combis. + + All remaining (47)–(60), (63a-d), (64a)m branches default to zero — + suits the combi-no-storage-no-solar-no-renewables population. Cylinder + + solar + WWHRS / PV-diverter / FGHRS + electric-shower paths land + in future slices. + """ + if epc.total_floor_area_m2 is None: + raise ValueError("EpcPropertyData.total_floor_area_m2 is required for §4") + n = assumed_occupancy(epc.total_floor_area_m2) + showers = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=n, + has_bath=has_bath, + mixer_shower_flow_rates_l_per_min=mixer_shower_flow_rates_l_per_min, + cold_water_temps_c=cold_water_temps_c, + ) + baths = hot_water_baths_monthly_l_per_day( + n_occupants=n, + has_bath=has_bath, + has_shower=len(mixer_shower_flow_rates_l_per_min) > 0, + cold_water_temps_c=cold_water_temps_c, + low_water_use=low_water_use, + ) + other = hot_water_other_uses_monthly_l_per_day( + n_occupants=n, low_water_use=low_water_use, + ) + daily_total = total_hot_water_monthly_l_per_day( + showers=showers, baths=baths, other_uses=other, + ) + other_avg = annual_average_hot_water_other_uses_l_per_day( + n_occupants=n, low_water_use=low_water_use, + ) + annual_avg = annual_average_hot_water_l_per_day( + showers_monthly=showers, + baths_monthly=baths, + other_uses_annual_avg=other_avg, + ) + energy_content = energy_content_of_hot_water_monthly_kwh( + monthly_hot_water_l_per_day=daily_total, + cold_water_temps_c=cold_water_temps_c, + ) + distribution = distribution_loss_monthly_kwh( + monthly_energy_content_kwh=energy_content, + is_instantaneous_at_point_of_use=False, + ) + combi = ( + combi_loss_monthly_kwh_override + if combi_loss_monthly_kwh_override is not None + else combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() + ) + zero12 = (0.0,) * 12 + total_demand = total_water_heating_demand_monthly_kwh( + energy_content_monthly_kwh=energy_content, + distribution_loss_monthly_kwh=distribution, + solar_storage_monthly_kwh=zero12, + primary_loss_monthly_kwh=zero12, + combi_loss_monthly_kwh=combi, + ) + output = output_from_water_heater_monthly_kwh( + total_demand_monthly_kwh=total_demand, + wwhrs_monthly_kwh=zero12, + pv_diverter_monthly_kwh=zero12, + solar_monthly_kwh=zero12, + fghrs_monthly_kwh=zero12, + ) + gains = heat_gains_from_water_heating_monthly_kwh( + energy_content_monthly_kwh=energy_content, + distribution_loss_monthly_kwh=distribution, + solar_storage_monthly_kwh=zero12, + primary_loss_monthly_kwh=zero12, + combi_loss_monthly_kwh=combi, + electric_shower_monthly_kwh=zero12, + ) + return WaterHeatingResult( + occupancy=n, + annual_avg_hot_water_l_per_day=annual_avg, + daily_hot_water_l_per_day_monthly=daily_total, + energy_content_monthly_kwh=energy_content, + distribution_loss_monthly_kwh=distribution, + combi_loss_monthly_kwh=combi, + total_demand_monthly_kwh=total_demand, + output_monthly_kwh=output, + heat_gains_monthly_kwh=gains, + output_kwh_per_yr=sum(output), + )