From 9694650abe31fea8d9f8489461743f82c7acecd7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 21:01:11 +0000 Subject: [PATCH] fix(water-heating): derive combi keep-hot from the PCDB record, default no-keep-hot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 3a (PDF p.160) additional combi loss (61)m. Two coupled defects, both surfaced by simulated case 49 (000565 + gas combi, U985 "Combi keep hot type = None") sitting at SAP 71.43 vs the worksheet's 72: 1. The cascade defaulted EVERY non-PCDB combi to the flat keep-hot time-clock row (600 × n/365). A combi WITHOUT a keep-hot facility uses row 1 (600 × fu × n/365, fu = V_d/100 when daily HW < 100 L/day) — over-counting (61)m for the no-keep-hot cohort. `water_heating_from_ cert` now defaults to the "without keep-hot" row. 2. `pcdb_combi_loss_override` returned None for keep_hot_facility=1/ timer=1, leaning on the OLD flat-600 default. So flipping the default silently turned 190 corpus PCDB keep-hot-time-clock combis into no-keep-hot. Fixed to return the flat keep-hot row EXPLICITLY. Key insight (the Summary is the input echo; the U985 keep-hot line is a computed OUTPUT, so it must be derivable): keep-hot rides on the PCDB boiler record (Table 105 keep_hot_facility/timer), resolved by `pcdb_combi_loss_override`. A generic SAP-code combi with no PCDB record (case 49, PCDF ref 0) has no keep-hot by construction → row 1. So the default is not a guess — it is the spec-correct value for non-PCDB combis. Worksheet-proven: case 49 → cost £726.696, SAP 72 — matching the accredited worksheet to the digit (continuous 71.6945 = the worksheet's own 71.6945). 000516 (keep-hot None) also exact (£860.716, SAP 63); 000490 (PCDB 10328, keep_hot_facility=1/timer=1) keeps its flat-600 via the PCDB path. Masked until now because every prior combi-loss worksheet fixture was keep-hot (000490/000474/000480 time-clock) or had V_d >= 100 every month (001431, rows coincide); case 49 is the first no-keep-hot one. Corpus within-0.5 72.7% -> 73.3%, MAE 0.781 -> 0.774, PE 3.5 -> 3.4; ratcheted _MAX_SAP_MAE 0.785 -> 0.775, _MAX_PE_PER_M2_MAE 3.6 -> 3.5. Note: pyright strict type gate not run locally (pyright not installed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 21 ++++-- .../worksheet/water_heating.py | 4 +- .../rdsap/test_cert_to_inputs.py | 73 ++++++++++++++----- .../worksheet/_elmhurst_worksheet_000490.py | 14 +++- .../worksheet/test_water_heating.py | 6 +- .../epc_client/test_sap_accuracy_corpus.py | 15 +++- 6 files changed, 97 insertions(+), 36 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7767c788..a58dcd57 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -200,6 +200,7 @@ from domain.sap10_calculator.worksheet.water_heating import ( PIPEWORK_INSULATED_UNINSULATED, TABLE_J1_TCOLD_FROM_MAINS_C, WaterHeatingResult, + combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot, combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock, combi_loss_monthly_kwh_table_3b_row_1_instantaneous, @@ -5385,10 +5386,11 @@ def pcdb_combi_loss_override( ) if kh == 1: if timer == 1: - # SAP 10.2 Table 3a row 3: 600 × n_m / 365. Cascade's - # `water_heating_from_cert` default — return None so the - # default fires. - return None + # SAP 10.2 Table 3a row 3: 600 × n_m / 365 (keep-hot, time + # clock). Returned EXPLICITLY — the cascade default is now + # the "without keep-hot" row, so the keep-hot value must be + # delivered here rather than leaned on the default. + return combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() # SAP 10.2 Table 3a row 4: 900 × n_m / 365 (no time-clock). return combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock() # kh ∈ {2, 3} — electric or mixed keep-hot. Table 3a Note 2 routes @@ -6540,11 +6542,14 @@ def _water_heating_worksheet_and_gains( main ): # SAP 10.2 §4 line 7702 fallback: non-combi main heating → (61)m - # = 0. Without this gate the cascade falls through to `combi_ - # loss_monthly_kwh_table_3a_keep_hot_time_clock()` (600 kWh/yr) - # on every cert lacking a PCDB Table 105 boiler record — - # including all heat pump certs. + # = 0. Without this gate the cascade falls through to the Table 3a + # "without keep-hot" default on every cert lacking a PCDB Table + # 105 boiler record — including all heat pump certs. combi_loss_override = zero_monthly + # A non-PCDB combi (override still None here) has no PCDB-declared + # keep-hot facility → `water_heating_from_cert` applies the Table 3a + # "without keep-hot" row (600 × fu × n/365). Keep-hot rides only on the + # PCDB boiler record, resolved above by `pcdb_combi_loss_override`. # SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss # (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous / # combi systems, so the override is only built when the cert explicitly diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index bf0dd475..dad601e2 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -960,7 +960,9 @@ def water_heating_from_cert( 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() + else combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot( + daily_hot_water_monthly_l_per_day=daily_total, + ) ) zero12 = (0.0,) * 12 solar_storage = ( diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index fa6dc2ba..01dbdcad 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -3865,6 +3865,38 @@ def test_mvhr_system_values_fall_back_to_table_4g_defaults_without_pcdb_index() assert abs(values.in_use_sfp_w_per_l_per_s - 5.0) <= 1e-9 +def test_pcdb_combi_loss_keep_hot_time_clock_returns_explicit_flat_600() -> None: + # Arrange — SAP 10.2 Table 3a (PDF p.160): a PCDB combi declaring a + # keep-hot facility on a time clock (keep_hot_facility=1, keep_hot_ + # timer=1) uses the flat 600 × n/365 row. The keep-hot status rides on + # the PCDB boiler record, NOT a separate cert field — a non-PCDB combi + # has no keep-hot and falls to the "without keep-hot" 600 × fu default. + # pcdb_combi_loss_override must return this row EXPLICITLY (it must not + # lean on the cascade default, which is now "without keep-hot"). + # Vaillant Ecotec Pro (PCDB 10328) is the 000490 boiler. + from domain.sap10_calculator.rdsap.cert_to_inputs import pcdb_combi_loss_override + from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record + from domain.sap10_calculator.worksheet.water_heating import ( + combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, + ) + + record = gas_oil_boiler_record(10328) + assert record is not None + assert record.keep_hot_facility == 1 + assert record.keep_hot_timer == 1 + monthly = (200.0,) * 12 # energy content / daily HW unused for this row + + # Act + override = pcdb_combi_loss_override( + record, + energy_content_monthly_kwh=monthly, + daily_hot_water_monthly_l_per_day=monthly, + ) + + # Assert — explicit flat keep-hot row, not None. + assert override == combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() + + def test_api_mechanical_ventilation_unmapped_code_raises() -> None: # Arrange — an out-of-range `mechanical_ventilation` integer is a # spec-coverage gap: raise rather than silently default to NATURAL @@ -5599,16 +5631,15 @@ def test_pcdb_combi_loss_override_preserves_separate_dhw_tests_1_routing_to_tabl def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage_combis() -> None: - """The override gate returns None — letting the worksheet fall back - to Table 3a row 3 (600 × n/365) — whenever the PCDB record lodges - keep-hot with a time clock but has insufficient EN 13203 lab data, - or sits in a storage / FGHRS row (Table 3b/3c rows 2-5, deferred - until a fixture exercises them). + """The override gate dispatches the PCDB record's keep-hot fields to + the matching Table 3a row, or returns None for a storage / FGHRS row + (Table 3b/3c rows 2-5, deferred until a fixture exercises them). - Per Slice S0380.21: keep_hot_facility ∈ {None, 0} dispatches to - Table 3a row 1 (`600 × fu × n/365`), keep_hot_facility=1 + no - timer dispatches to row 4 (`900 × n/365`). Only the electric - keep-hot variants (keep_hot_facility ∈ {2, 3}) now raise + keep_hot_facility ∈ {None, 0} → Table 3a row 1 (`600 × fu × n/365`, + without keep-hot); keep_hot_facility=1 + timer=1 → row 3 (`600 × + n/365`, EXPLICIT — the cascade default is now "without keep-hot"); + keep_hot_facility=1 + no timer → row 4 (`900 × n/365`). Only the + electric keep-hot variants (keep_hot_facility ∈ {2, 3}) raise `UnresolvedPcdbCombiLoss` — Table 3a Note 2 fuel-split deferred.""" # Arrange — a minimal record skeleton, mutated per scenario via # dataclasses.replace. @@ -5635,7 +5666,7 @@ def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage loss_factor_f1_kwh_per_day=0.5, loss_factor_f2_kwh_per_day=0.001, rejected_factor_f3_per_litre=0.00014, - keep_hot_facility=1, # lodges keep-hot → cascade default 600 is correct + keep_hot_facility=1, # lodges keep-hot, time clock → flat 600 row keep_hot_timer=1, raw=(), ) @@ -5649,17 +5680,19 @@ def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage ) is None ) - # separate_dhw_tests=0 + keep_hot_facility=1 + timer=1 → None (no - # PCDB DHW test data, but cascade's row 3 default IS the right spec - # row → return None and let the cascade default fire). - 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 + # separate_dhw_tests=0 + keep_hot_facility=1 + timer=1 → Table 3a row 3 + # (600 × n/365, keep-hot time clock) returned EXPLICITLY. The cascade + # default is now "without keep-hot" (600 × fu), so the keep-hot row + # must be delivered here, not leaned on the default. + from domain.sap10_calculator.worksheet.water_heating import ( + combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, ) + + 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, + ) == combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() # separate_dhw_tests=0 + keep_hot_facility=None → Table 3a row 1 # (600 × fu × n/365) — Slice S0380.21 dispatch. row_1 = pcdb_combi_loss_override( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py index 9bf66262..4184b799 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py @@ -42,7 +42,10 @@ from domain.sap10_ml.tests._fixtures import ( ) from domain.sap10_calculator.worksheet.solar_gains import RoofWindowInput, RooflightInput from domain.sap10_calculator.worksheet.ventilation import MechanicalVentilationKind -from domain.sap10_calculator.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C +from domain.sap10_calculator.worksheet.water_heating import ( + TABLE_J1_TCOLD_FROM_MAINS_C, + combi_loss_monthly_kwh_table_3a_keep_hot_time_clock, +) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( SECTION_8C_ALL_ZERO_MONTHLY, @@ -284,9 +287,12 @@ MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = (7.0,) COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C LOW_WATER_USE: bool = False # Vaillant Ecotec Pro combi, "Combi keep hot type = Gas/Oil, time clock": -# the orchestrator's default (Table 3a row "time-clock keep-hot") -# reproduces this fixture exactly. No override needed. -COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = None +# SAP 10.2 Table 3a flat keep-hot row (600 × n/365). The orchestrator's +# default is now "without keep-hot" (600 × fu × n/365, the modal lodging), +# so this keep-hot fixture supplies the override explicitly. +COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = ( + combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() +) ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = None # §4 Water heating energy requirements diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index 5a96deb5..c7a2db79 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -970,13 +970,17 @@ def test_water_heating_from_cert_matches_elmhurst_worksheet_000490() -> None: # Arrange epc = _w000490.build_epc() - # Act + # Act — 000490 lodges "Combi keep hot type = time clock" (U985 §15), + # so the (61)m combi loss is the Table 3a flat keep-hot row, not the + # "without keep-hot" 600 × fu default that `water_heating_from_cert` + # now applies when no override is supplied. 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=combi_loss_monthly_kwh_table_3a_keep_hot_time_clock(), ) # Assert — annual output equals the days-month-weighted sum of (64)m diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 2eb5b80a..307ebb1d 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -215,9 +215,20 @@ _MIN_WITHIN_HALF_SAP = 0.72 # (7a)=0 is explicit): case 49's (8) openings line is 0.0000 — our default had # added 20 m3/h (0.0723 ach), inflating (22b)/(25)/(38) and demand (SAP 70.90 # -> 71.43; the worksheet's own continuous SAP is 71.69 -> rounds to 72). -_MAX_SAP_MAE = 0.785 +# Then 0.781 -> 0.774 (within-0.5 72.7% -> 73.3%, PE 3.5 -> 3.4) via the combi +# keep-hot fix: SAP 10.2 Table 3a (PDF p.160) — keep-hot rides on the PCDB +# boiler record (Table 105 keep_hot_facility/timer), NOT a separate cert field. +# (a) A non-PCDB / generic-SAP-code combi has no keep-hot → "without keep-hot" +# row 600 × fu × n/365 (fu = V_d/100 when V_d < 100), now the cascade default; +# the cascade had wrongly defaulted every non-PCDB combi to the flat keep-hot +# 600 × n/365 row. (b) `pcdb_combi_loss_override` now returns the keep-hot row +# EXPLICITLY for keep_hot_facility=1/timer=1 (was returning None to lean on the +# old flat default — so flipping the default had silently turned 190 PCDB +# keep-hot combis into no-keep-hot). Closes case 49 EXACTLY (cost £726.696, +# SAP 72 = the worksheet to the digit). +_MAX_SAP_MAE = 0.775 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 3.6 # kWh / m2 / yr vs energy_consumption_current +_MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: