mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
b7455aabe5
commit
9694650abe
6 changed files with 97 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue