From bfba610b704b6dfa945c1315ea0ea136fb4cae44 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 16:06:05 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A74=20slice=207:=20combi=20loss=20(Table=20?= =?UTF-8?q?3a=20time=20clock)=20+=20(62)m=20total=20demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new public functions: combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() Table 3a row "Instantaneous, with keep-hot facility controlled by time clock" → 600 × n_m / 365 kWh/month (flat 600 kWh/year prorated by month length, no fu adjustment). total_water_heating_demand_monthly_kwh(...) Spec formula (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m. (56)m storage loss is intentionally absent — folded into storage- system efficiency at the (64)m stage. (46)m distribution loss appears here AND in (65)m heat gains (weight 0.8), per spec. 000490 close end-to-end through (62)m: combi with time-clock keep-hot, no storage, no solar, no primary loss → Jan = 0.85×187.86 + 28.18 + 0 + 0 + 50.96 = 238.82 matching the worksheet to 1e-3. 000474 deferred: its PCDF-listed Vaillant boiler uses Table 3b (tested to EN 13203-2) which needs PCDB-backed r1 + F1 parameters. The (61)m implementation for that branch lands in a future slice along with the PCDB stub plumbing. Co-Authored-By: Claude Opus 4.7 --- .../tests/_elmhurst_worksheet_000490.py | 11 +++++ .../sap/worksheet/tests/test_water_heating.py | 46 +++++++++++++++++++ .../src/domain/sap/worksheet/water_heating.py | 43 +++++++++++++++++ 3 files changed, 100 insertions(+) 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 b1bdd6dc..5a297793 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 @@ -185,3 +185,14 @@ 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, ) +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 +LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = ( + 50.9589, 46.0274, 50.9589, 49.3151, 50.9589, 49.3151, + 50.9589, 50.9589, 49.3151, 50.9589, 49.3151, 50.9589, +) +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, +) 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 253f8b11..a3517d70 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,12 +20,14 @@ 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, + combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, 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, total_hot_water_monthly_l_per_day, + total_water_heating_demand_monthly_kwh, ) @@ -457,6 +459,50 @@ def test_distribution_loss_zero_for_instantaneous_point_of_use_water_heating() - assert all(v == pytest.approx(0.0, abs=1e-9) for v in loss) +def test_combi_loss_table_3a_time_clock_keep_hot_matches_elmhurst_000490() -> None: + """SAP10.2 §4 line (61)m via Table 3a row "Instantaneous, with keep-hot + facility controlled by time clock": + (61)m = 600 × n_m / 365 [kWh/month] + + No `fu` factor for this row — the time-clock keep-hot loss is a flat + 600 kWh/year prorated by month length. 000490's combi (Vaillant + Ecotec Pro, "Combi keep hot type = Gas/Oil, time clock") lands on + this row; Jan = 600 × 31 / 365 = 50.9589 kWh/month exactly. + """ + # Arrange / Act + monthly = combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() + + # Assert + for m, (actual, exp) in enumerate(zip(monthly, _w000490.LINE_61_M_COMBI_LOSS_KWH)): + assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}" + + +def test_total_water_heating_demand_matches_elmhurst_line_62_for_000490() -> None: + """SAP10.2 §4 line (62)m per the spec formula: + (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m + + Note (56)m water-storage loss is NOT in (62)m — it's absorbed by the + storage-system efficiency elsewhere. (46)m distribution loss IS in + (62)m here, despite also being recycled into (65)m heat gains. + + Validated for 000490 (combi, no storage, no solar): + Jan = 0.85 × 187.8607 + 28.1791 + 0 + 0 + 50.9589 + = 159.6816 + 28.1791 + 50.9589 = 238.8196 ✓ + """ + # Arrange / Act + monthly = total_water_heating_demand_monthly_kwh( + energy_content_monthly_kwh=_w000490.LINE_45_M_HW_ENERGY_CONTENT_KWH, + distribution_loss_monthly_kwh=_w000490.LINE_46_M_DISTRIBUTION_LOSS_KWH, + solar_storage_monthly_kwh=_w000490.LINE_57_M_SOLAR_STORAGE_KWH, + primary_loss_monthly_kwh=_w000490.LINE_59_M_PRIMARY_LOSS_KWH, + combi_loss_monthly_kwh=_w000490.LINE_61_M_COMBI_LOSS_KWH, + ) + + # Assert + for m, (actual, exp) in enumerate(zip(monthly, _w000490.LINE_62_M_TOTAL_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 8e989d50..4b20a1ed 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -229,6 +229,49 @@ def distribution_loss_monthly_kwh( return tuple(0.15 * e for e in monthly_energy_content_kwh) +def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]: + """SAP 10.2 §4 line (61)m — Table 3a row "Instantaneous, with keep-hot + facility controlled by time clock": 600 × n_m / 365 kWh/month. + + A flat 600 kWh/year combi loss, prorated by month length. Unlike the + "without keep-hot" rows there is no `fu` adjustment for low-volume + draw — the loss is constant. Suitable for any standard non-PCDB- + tested combi that the cert lodges as having a time-clock keep-hot + facility (Appendix D section D1.16). + """ + return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH) + + +def total_water_heating_demand_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, ...], +) -> tuple[float, ...]: + """SAP 10.2 §4 line (62)m via the spec's formula: + (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m + + Note (56)m water-storage loss does NOT appear here — it's accounted + for through the storage system's efficiency in the (63a-d) inputs and + the (64)m output line. (46)m distribution loss appears in both (62)m + and (65)m heat gains (with weight 0.8); that's intentional per spec. + + All five monthly arrays must be 12-tuples in calendar order Jan..Dec. + """ + return tuple( + 0.85 * e + d + s + p + c + for e, d, s, p, c in zip( + energy_content_monthly_kwh, + distribution_loss_monthly_kwh, + solar_storage_monthly_kwh, + primary_loss_monthly_kwh, + combi_loss_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