fix(water-heating): derive combi keep-hot from the PCDB record, default no-keep-hot

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 21:01:11 +00:00
parent b7455aabe5
commit 9694650abe
6 changed files with 97 additions and 36 deletions

View file

@ -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

View file

@ -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 = (

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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]]: