diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 54814ce2..a29be7d4 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -37,7 +37,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.3 specification from __future__ import annotations from dataclasses import dataclass -from typing import Callable, Final, Optional +from typing import Callable, Final, Literal, Optional from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, @@ -100,6 +100,7 @@ from domain.sap.worksheet.water_heating import ( TABLE_J1_TCOLD_FROM_MAINS_C, WaterHeatingResult, combi_loss_monthly_kwh_table_3b_row_1_instantaneous, + combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, water_efficiency_monthly_via_equation_d1, water_heating_from_cert, ) @@ -722,38 +723,68 @@ def _has_bath_from_cert(epc: EpcPropertyData) -> bool: return n is None or n >= 1 -def _pcdb_table_3b_combi_loss_override( +def pcdb_combi_loss_override( pcdb_record: Optional[GasOilBoilerRecord], *, energy_content_monthly_kwh: tuple[float, ...], daily_hot_water_monthly_l_per_day: tuple[float, ...], ) -> Optional[tuple[float, ...]]: - """Build a Table 3b row-1 combi-loss override when the PCDB record - lodges single-profile (Profile M only) test data for an instantaneous - combi with non-storage FGHRS or without FGHRS. Returns None for - every other PCDB combi configuration so the worksheet falls back to - the Table 3a default. Other Table 3b/3c rows (storage variants, - integral FGHRS, two-profile Table 3c) are deferred until a fixture - exercises them — defaulting to Table 3a is safe (matches the pre- - §4 behaviour) but loses spec accuracy for those configurations.""" + """Route a PCDB combi record to the matching SAP10.2 Appendix J row. + + PCDF Spec Rev 6b field 48 (`separate_dhw_tests`) encodes which EN + 13203-2 / OPS 26 schedules the lab tested under, and that selects + the SAP Table: + = 1 → schedule 2 only (profile M) → Table 3b row 1 + = 2 → schedules 2 and 3 (profiles M + L) → Table 3c, DVF = M+L + = 3 → schedules 2 and 1 (profiles M + S) → Table 3c, DVF = M+S + Any other value (0, None, or insufficient r1/F factors lodged) + returns None so the worksheet falls back to the Table 3a default. + + Storage-FGHRS and storage-combi variants (`subsidiary_type` ∈ {1, 2, + 3} → integral FGHRS / HP+boiler combinations; `store_type` ∈ {1, 2, + 3} → primary / secondary store / CPSU) gate Rows 2-5 of both Tables + 3b and 3c. Those rows are deferred until a fixture exercises them + — defaulting to Table 3a is safe (matches the pre-§4 behaviour) but + loses spec accuracy for those configurations. + """ if pcdb_record is None: return None - if pcdb_record.separate_dhw_tests != 1: - return None if pcdb_record.subsidiary_type not in (None, 0): return None if pcdb_record.store_type not in (None, 0): return None r1 = pcdb_record.rejected_energy_proportion_r1 - f1 = pcdb_record.loss_factor_f1_kwh_per_day - if r1 is None or f1 is None: + if r1 is None: return None - return combi_loss_monthly_kwh_table_3b_row_1_instantaneous( - rejected_energy_proportion_r1=r1, - loss_factor_f1_kwh_per_day=f1, - energy_content_monthly_kwh=energy_content_monthly_kwh, - daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day, - ) + match pcdb_record.separate_dhw_tests: + case 1: + f1 = pcdb_record.loss_factor_f1_kwh_per_day + if f1 is None: + return None + return combi_loss_monthly_kwh_table_3b_row_1_instantaneous( + rejected_energy_proportion_r1=r1, + loss_factor_f1_kwh_per_day=f1, + energy_content_monthly_kwh=energy_content_monthly_kwh, + daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day, + ) + case 2 | 3: + f2 = pcdb_record.loss_factor_f2_kwh_per_day + f3 = pcdb_record.rejected_factor_f3_per_litre + if f2 is None or f3 is None: + return None + profile_pair: Literal["M+L", "M+S"] = ( + "M+L" if pcdb_record.separate_dhw_tests == 2 else "M+S" + ) + return 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=profile_pair, + energy_content_monthly_kwh=energy_content_monthly_kwh, + daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day, + ) + case _: + return None def _water_heating_worksheet_and_gains( @@ -781,7 +812,7 @@ def _water_heating_worksheet_and_gains( cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=False, ) - combi_loss_override = _pcdb_table_3b_combi_loss_override( + combi_loss_override = pcdb_combi_loss_override( pcdb_record, energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh, daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly, diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 5c23f841..77270322 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -28,7 +28,13 @@ from domain.ml.tests._fixtures import ( make_window, ) from domain.sap.calculator import Sap10Calculator, SapResult -from domain.sap.rdsap.cert_to_inputs import cert_to_inputs +from domain.sap.rdsap.cert_to_inputs import pcdb_combi_loss_override, cert_to_inputs +from domain.sap.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record +from domain.sap.worksheet.tests import _elmhurst_worksheet_000477 as _w000477 +from domain.sap.worksheet.water_heating import ( + combi_loss_monthly_kwh_table_3b_row_1_instantaneous, + combi_loss_monthly_kwh_table_3c_two_profile_instantaneous, +) def _gas_boiler_detail(sap_main_heating_code: int = 102) -> MainHeatingDetail: @@ -720,3 +726,197 @@ def test_detached_house_dwelling_type_keeps_full_envelope_exposed() -> None: # Assert assert inputs.heat_transmission.floor_w_per_k > 0 assert inputs.heat_transmission.roof_w_per_k > 0 + + +def test_pcdb_combi_loss_override_routes_separate_dhw_tests_2_through_table_3c_m_plus_l() -> None: + """PCDF Spec Rev 6b (12 May 2021) field 48 = 2 encodes EN 13203-2 / + OPS 26 testing under schedules 2 and 3 = profiles M and L. The + override gate must route those PCDB records through SAP10.2 Appendix + J Table 3c with the M+L DVF branch — not the existing Table 3b row + 1 path (which is profile M only).""" + # Arrange — PCDB 18118 (Vaillant ecoTEC sustain 24) lodges + # separate_dhw_tests=2, r1=0.015, F2=0.0, F3=0.00014; 000477 ships + # the matching (44)m / (45)m worksheet inputs. + pcdb = gas_oil_boiler_record(18118) + assert pcdb is not None + assert pcdb.separate_dhw_tests == 2 + energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH + daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L + + # Act + override = pcdb_combi_loss_override( + pcdb, + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + + # Assert — override is exactly the Table 3c M+L output (no + # double-rounding, no transposed F1↔F2). Element-wise. + expected = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous( + rejected_energy_proportion_r1=0.015, + loss_factor_f2_kwh_per_day=0.0, + rejected_factor_f3_per_litre=0.00014, + profile_pair="M+L", + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + assert override is not None + for month_idx, (got, want) in enumerate(zip(override, expected)): + assert got == pytest.approx(want, abs=1e-12), ( + f"month {month_idx}: got {got!r}, want {want!r}" + ) + + +def test_pcdb_combi_loss_override_routes_separate_dhw_tests_3_through_table_3c_m_plus_s() -> None: + """PCDF Spec Rev 6b field 48 = 3 encodes EN 13203-2 / OPS 26 testing + under schedules 2 and 1 = profiles M and S. The override gate must + route those PCDB records through Table 3c with the M+S DVF branch. + """ + # Arrange — PCDB 16952 (Fondital Itaca KC 24) is one of the three + # boilers in pcdb10.dat with separate_dhw_tests=3. Borrow 000477's + # monthly inputs purely as a deterministic vehicle for the formula — + # there is no Elmhurst fixture lodging this PCDB record. + pcdb = gas_oil_boiler_record(16952) + assert pcdb is not None + assert pcdb.separate_dhw_tests == 3 + energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH + daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L + + # Act + override = pcdb_combi_loss_override( + pcdb, + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + + # Assert — override matches Table 3c with profile_pair="M+S". + assert pcdb.rejected_energy_proportion_r1 is not None + assert pcdb.loss_factor_f2_kwh_per_day is not None + assert pcdb.rejected_factor_f3_per_litre is not None + expected = combi_loss_monthly_kwh_table_3c_two_profile_instantaneous( + rejected_energy_proportion_r1=pcdb.rejected_energy_proportion_r1, + loss_factor_f2_kwh_per_day=pcdb.loss_factor_f2_kwh_per_day, + rejected_factor_f3_per_litre=pcdb.rejected_factor_f3_per_litre, + profile_pair="M+S", + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + assert override is not None + for month_idx, (got, want) in enumerate(zip(override, expected)): + assert got == pytest.approx(want, abs=1e-12), ( + f"month {month_idx}: got {got!r}, want {want!r}" + ) + + +def test_pcdb_combi_loss_override_preserves_separate_dhw_tests_1_routing_to_table_3b() -> None: + """Regression guard: separate_dhw_tests=1 (profile M only) must + continue to route through Table 3b row 1 — same path that closes + 000474 to delta=0 today. The Slice 6+7 Table 3c work must not + perturb this branch.""" + # Arrange — PCDB 16839 (Vaillant ecoTEC pro 28, used by 000474). + pcdb = gas_oil_boiler_record(16839) + assert pcdb is not None + assert pcdb.separate_dhw_tests == 1 + energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH + daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L + + # Act + override = pcdb_combi_loss_override( + pcdb, + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + + # Assert — override matches Table 3b row 1. + assert pcdb.rejected_energy_proportion_r1 is not None + assert pcdb.loss_factor_f1_kwh_per_day is not None + expected = combi_loss_monthly_kwh_table_3b_row_1_instantaneous( + rejected_energy_proportion_r1=pcdb.rejected_energy_proportion_r1, + loss_factor_f1_kwh_per_day=pcdb.loss_factor_f1_kwh_per_day, + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + assert override is not None + for month_idx, (got, want) in enumerate(zip(override, expected)): + assert got == pytest.approx(want, abs=1e-12), ( + f"month {month_idx}: got {got!r}, want {want!r}" + ) + + +def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis() -> None: + """The override gate returns None — letting the worksheet fall back + to Table 3a — whenever the PCDB record is missing test data (field + 48 ∈ {0, None}), lodges insufficient lab factors, or sits in a + storage / FGHRS row (Table 3b/3c rows 2-5, deferred until a fixture + exercises them).""" + # Arrange — a minimal record skeleton, mutated per scenario via + # dataclasses.replace. + from dataclasses import replace + + energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH + daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L + base = GasOilBoilerRecord( + pcdb_id=99999, + brand_name="X", + model_name="Y", + model_qualifier="", + winter_efficiency_pct=88.0, + summer_efficiency_pct=80.0, + comparative_hot_water_efficiency_pct=75.0, + output_kw_max=24.0, + final_year_of_manufacture=None, + subsidiary_type=0, + store_type=0, + separate_dhw_tests=2, + rejected_energy_proportion_r1=0.015, + loss_factor_f1_kwh_per_day=0.5, + loss_factor_f2_kwh_per_day=0.001, + rejected_factor_f3_per_litre=0.00014, + raw=(), + ) + + # Act / Assert — None record → None. + assert ( + pcdb_combi_loss_override( + None, + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + is None + ) + # separate_dhw_tests=0 → None (no PCDB test data). + assert ( + pcdb_combi_loss_override( + replace(base, separate_dhw_tests=0), + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + is None + ) + # Integral FGHRS (subsidiary_type=1) → row 2/3 deferred → None. + assert ( + pcdb_combi_loss_override( + replace(base, subsidiary_type=1), + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + is None + ) + # Storage combi (store_type=1 primary store) → row 4/5 deferred → None. + assert ( + pcdb_combi_loss_override( + replace(base, store_type=1), + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + is None + ) + # separate_dhw_tests=2 with F2 missing → insufficient lab data → None. + assert ( + pcdb_combi_loss_override( + replace(base, loss_factor_f2_kwh_per_day=None), + energy_content_monthly_kwh=energy_content, + daily_hot_water_monthly_l_per_day=daily_hw, + ) + is None + ) diff --git a/packages/domain/src/domain/sap/worksheet/water_heating.py b/packages/domain/src/domain/sap/worksheet/water_heating.py index 339a10ab..a51fcc36 100644 --- a/packages/domain/src/domain/sap/worksheet/water_heating.py +++ b/packages/domain/src/domain/sap/worksheet/water_heating.py @@ -399,7 +399,7 @@ def combi_loss_monthly_kwh_table_3c_two_profile_instantaneous( 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`). + coverage of Table 3b (see `pcdb_combi_loss_override`). """ return tuple( e