diff --git a/packages/domain/src/domain/sap/tables/pcdb/parser.py b/packages/domain/src/domain/sap/tables/pcdb/parser.py index 163ee812..58439f64 100644 --- a/packages/domain/src/domain/sap/tables/pcdb/parser.py +++ b/packages/domain/src/domain/sap/tables/pcdb/parser.py @@ -64,18 +64,24 @@ class GasOilBoilerRecord: comparative_hot_water_efficiency_pct: Optional[float] output_kw_max: Optional[float] final_year_of_manufacture: Optional[int] - # SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF - # Spec v1.0 §7.11 fields 48 / 51 / 52 / 56 / 57. Populated only for - # boilers EN 13203-2 / OPS 26 tested; SAP-default boilers leave them - # all blank → `separate_dhw_tests=0` and (61)m falls back to Table 3a. - # BRE PCDF Spec v1.0 §7.11 field 16 (0-idx 15): 0=normal, 1=integral - # FGHRS, 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the - # Table 3b/3c row selection — only `subsidiary_type=0` exercises the + # SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF Spec + # Rev 6b (12 May 2021), Gas and Oil Boiler Table, fields 48 / 51 / 52 + # / 56 / 57 (see `docs/sap-spec/PCDF_Spec_Rev-06b_12_May_2021.pdf` + # pp. 14-15). Populated only for boilers EN 13203-2 / OPS 26 tested; + # SAP-default boilers leave them all blank → `separate_dhw_tests=0` + # and (61)m falls back to Table 3a. Field 48 encodes the test + # schedules: 0=none, 1=schedule 2 only (profile M → Table 3b row 1), + # 2=schedules 2 and 3 (profiles M+L → Table 3c), 3=schedules 2 and 1 + # (profiles M+S → Table 3c). Field 55 (r2) is lodged but explicitly + # excluded from SAP assessments ("only r1") so it is not surfaced. + # PCDF Spec Rev 6b field 16 (0-idx 15): 0=normal, 1=integral FGHRS, + # 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the Table + # 3b/3c row selection — only `subsidiary_type=0` exercises the # "Instantaneous with non-storage FGHRS or without FGHRS" row 1. subsidiary_type: Optional[int] - # BRE PCDF Spec v1.0 §7.11 field 39 (0-idx 38): 0=not storage combi, - # 1=primary water store, 2=secondary store, 3=CPSU. Gates storage- - # combi rows in Table 3b/3c (deferred until a fixture exercises). + # PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary + # water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in + # Table 3b/3c (deferred until a fixture exercises). store_type: Optional[int] separate_dhw_tests: Optional[int] rejected_energy_proportion_r1: Optional[float] 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 b99d2eb6..1c9424e6 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 @@ -14,6 +14,7 @@ import pytest from domain.sap.worksheet.tests import ( _elmhurst_worksheet_000474 as _w000474, + _elmhurst_worksheet_000477 as _w000477, _elmhurst_worksheet_000490 as _w000490, ) from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id @@ -25,6 +26,7 @@ from domain.sap.worksheet.water_heating import ( assumed_occupancy, combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, combi_loss_monthly_kwh_table_3b_row_1_instantaneous, + combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, water_efficiency_monthly_via_equation_d1, distribution_loss_monthly_kwh, energy_content_of_hot_water_monthly_kwh, @@ -509,6 +511,98 @@ def test_combi_loss_table_3b_row_1_matches_elmhurst_000474_pcdb_arithmetic() -> assert monthly[0] == pytest.approx(_w000474.LINE_61_M_COMBI_LOSS_KWH[0], abs=0.05) +def test_combi_loss_table_3c_two_profile_matches_elmhurst_000477_lodged_line_61() -> None: + """SAP10.2 Appendix J Table 3c row 1 (Instantaneous combi with non- + storage FGHRS or without FGHRS), two-profile tests: + (61)m = (45)m × [r1 + DVF × F3] × fu + [F2 × n_m] + + For 000477 (Vaillant ecoTEC sustain 24, PCDB 18118, separate_dhw_tests + = 2 → boiler tested under EN 13203-2 schedules 2 and 3, i.e. profiles + M and L per PCDF Spec Rev 6b field 48 encoding): r1 = 0.015, F2 = 0.0 + kWh/day, F3 = 0.00014 L⁻¹. + + V_d,m ∈ [94.7, 114.2] L/day for 000477 — straddles the M+L DVF cutoff + at V=100. Months with V<100 get DVF=0; months with V∈[100, 199.8] get + DVF=100.2-V (negative, reducing r1's contribution). Element-wise pin + against the U985 worksheet line ref (61)m at abs=1e-3 validates the + full piecewise + formula.""" + # Arrange + r1 = 0.015 + f2 = 0.0 + f3 = 0.00014 + energy_content_45 = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH + daily_hw_44 = _w000477.LINE_44_M_DAILY_HW_USAGE_L + + # Act + monthly = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous( + rejected_energy_proportion_r1=r1, + loss_factor_f2_kwh_per_day=f2, + rejected_factor_f3_per_litre=f3, + profile_pair="M+L", + energy_content_monthly_kwh=energy_content_45, + daily_hot_water_monthly_l_per_day=daily_hw_44, + ) + + # Assert — element-wise pin against U985 line ref (61)m. + for month_idx, (got, want) in enumerate( + zip(monthly, _w000477.LINE_61_M_COMBI_LOSS_KWH) + ): + assert got == pytest.approx(want, abs=1e-3), ( + f"month {month_idx}: got {got:.4f}, want {want:.4f}" + ) + + +@pytest.mark.parametrize( + "profile_pair, daily_hot_water_l_per_day, expected_dvf", + [ + # M+L: 0 for V<100, 100.2-V for V∈[100, 199.8], -99.6 for V>199.8. + ("M+L", 50.0, 0.0), + ("M+L", 99.99, 0.0), + ("M+L", 100.0, 0.2), + ("M+L", 150.0, -49.8), + ("M+L", 199.8, -99.6), + ("M+L", 200.0, -99.6), + # M+S: 64.2 for V<36, 100.2-V for V∈[36, 100.2], 0 for V>100.2. + ("M+S", 20.0, 64.2), + ("M+S", 35.99, 64.2), + ("M+S", 36.0, 64.2), + ("M+S", 80.0, 20.2), + ("M+S", 100.2, 0.0), + ("M+S", 110.0, 0.0), + ], +) +def test_combi_loss_table_3c_dvf_branches_match_spec_piecewise( + profile_pair: str, + daily_hot_water_l_per_day: float, + expected_dvf: float, +) -> None: + """SAP10.2 Appendix J Table 3c — Daily Volume Factor (DVF) is piecewise + in V_d,m, gated by the profile pair (M+L for separate_dhw_tests=2, + M+S for separate_dhw_tests=3). The DVF helper is private; this test + exercises it through the public Table 3c orchestrator by choosing + r1=0, F2=0, (45)m=1.0, V_d,m so that (61)m collapses to fu × F3 × DVF + (with fu=1.0 when V≥100). Solving (61)m for DVF recovers the helper's + output one branch at a time.""" + # Arrange — collapse the formula so (61)m == fu × F3 × DVF. + f3 = 1.0 + energy_content = (1.0,) * 12 + daily_hw_monthly = (daily_hot_water_l_per_day,) * 12 + fu = daily_hot_water_l_per_day / 100.0 if daily_hot_water_l_per_day < 100.0 else 1.0 + + # Act + monthly = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous( + rejected_energy_proportion_r1=0.0, + loss_factor_f2_kwh_per_day=0.0, + rejected_factor_f3_per_litre=f3, + profile_pair=profile_pair, # type: ignore[arg-type] + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw_monthly, + ) + + # Assert — recovered DVF matches the spec's piecewise output. + assert monthly[0] == pytest.approx(fu * f3 * expected_dvf, abs=1e-9) + + def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_month() -> None: """SAP10.2 Appendix D §D2.1 (2) Equation D1: diff --git a/packages/domain/src/domain/sap/worksheet/water_heating.py b/packages/domain/src/domain/sap/worksheet/water_heating.py index 3c6b49f4..339a10ab 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -25,7 +25,7 @@ from __future__ import annotations from dataclasses import dataclass from math import exp -from typing import Final, Optional +from typing import Final, Literal, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData @@ -338,6 +338,82 @@ def combi_loss_monthly_kwh_table_3b_row_1_instantaneous( ) +_DVF_M_AND_L_LOWER_V_L_PER_DAY: Final[float] = 100.0 +_DVF_M_AND_L_UPPER_V_L_PER_DAY: Final[float] = 199.8 +_DVF_M_AND_L_UPPER_CLAMP: Final[float] = -99.6 +_DVF_M_AND_S_LOWER_V_L_PER_DAY: Final[float] = 36.0 +_DVF_M_AND_S_UPPER_V_L_PER_DAY: Final[float] = 100.2 +_DVF_M_AND_S_LOWER_CLAMP: Final[float] = 64.2 +_DVF_LINEAR_INTERCEPT: Final[float] = 100.2 + + +def _table_3c_dvf( + daily_hot_water_l_per_day: float, + profile_pair: Literal["M+L", "M+S"], +) -> float: + """SAP 10.2 Appendix J Table 3c — Daily Volume Factor. + + Piecewise function of V_d,m gated on which two EN 13203-2 / OPS 26 + profiles the combi was tested with (encoded in PCDF Table 105 field + 48 = separate_dhw_tests: 2 → schedules 2 and 3 = M+L; 3 → schedules + 2 and 1 = M+S). + """ + v = daily_hot_water_l_per_day + if profile_pair == "M+L": + if v < _DVF_M_AND_L_LOWER_V_L_PER_DAY: + return 0.0 + if v > _DVF_M_AND_L_UPPER_V_L_PER_DAY: + return _DVF_M_AND_L_UPPER_CLAMP + return _DVF_LINEAR_INTERCEPT - v + if v < _DVF_M_AND_S_LOWER_V_L_PER_DAY: + return _DVF_M_AND_S_LOWER_CLAMP + if v > _DVF_M_AND_S_UPPER_V_L_PER_DAY: + return 0.0 + return _DVF_LINEAR_INTERCEPT - v + + +def combi_loss_monthly_kwh_table_3c_two_profile_instantaneous( + *, + rejected_energy_proportion_r1: float, + loss_factor_f2_kwh_per_day: float, + rejected_factor_f3_per_litre: float, + profile_pair: Literal["M+L", "M+S"], + energy_content_monthly_kwh: tuple[float, ...], + daily_hot_water_monthly_l_per_day: tuple[float, ...], +) -> tuple[float, ...]: + """SAP 10.2 Appendix J Table 3c row 1 (Instantaneous combi with non- + storage FGHRS or without FGHRS), two-profile tests: + + (61)m = (45)m × [r1 + DVF × F3] × fu + [F2 × n_m] + + where r1, F2 (kWh/day), F3 (litres⁻¹, can be negative) are PCDF + Table 105 fields 51 / 56 / 57, DVF is the per-month Daily Volume + Factor from `_table_3c_dvf`, and fu = V_d,m / 100 when V_d,m < 100 + else 1.0. + + Applies to PCDB combis with separate_dhw_tests ∈ {2, 3}: =2 means + schedules 2 and 3 (profiles M and L); =3 means schedules 2 and 1 + (profiles M and S). The profile pair selects the DVF piecewise + branch — see `_table_3c_dvf`. r2 (PCDF field 55) is lodged but the + spec explicitly excludes it from SAP assessments ("only r1"). + + Storage-FGHRS / storage-combi variants (Table 3c rows 2-5) are + deferred until a fixture exercises them — mirrors the row-1-only + coverage of Table 3b (see `_pcdb_combi_loss_override`). + """ + return tuple( + e + * (rejected_energy_proportion_r1 + _table_3c_dvf(v, profile_pair) * rejected_factor_f3_per_litre) + * (v / 100.0 if v < 100.0 else 1.0) + + loss_factor_f2_kwh_per_day * n + for e, v, n in zip( + energy_content_monthly_kwh, + daily_hot_water_monthly_l_per_day, + _DAYS_IN_MONTH, + ) + ) + + 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.