Cohort residual slice 7: PCDB override routes separate_dhw_tests∈{2,3} through Table 3c

Renames `_pcdb_table_3b_combi_loss_override` → `pcdb_combi_loss_override`
(drop the underscore now that it has a unit-testable contract; helper
is now a public boundary of cert_to_inputs). The gate routes on PCDF
Spec Rev 6b field 48:

    = 1 → Table 3b row 1 (profile M only)         — existing
    = 2 → Table 3c row 1 with DVF branch "M+L"    — new (schedules 2+3)
    = 3 → Table 3c row 1 with DVF branch "M+S"    — new (schedules 2+1)
    other / missing factors → None (Table 3a)

Storage-FGHRS (subsidiary_type ∈ {1, 2, 3}) and storage-combi
(store_type ∈ {1, 2, 3}) configurations stay rejected — they gate
Rows 2-5 of both Tables 3b and 3c, deferred until a fixture exercises
them.

Tests (4 new):
  - PCDB 18118 (Vaillant ecoTEC sustain 24, sep_dhw=2) routes through
    Table 3c with M+L. Element-wise match at abs=1e-12 against direct
    Table 3c invocation with the same inputs.
  - PCDB 16952 (Fondital Itaca KC 24, sep_dhw=3 — the M+S branch) routes
    through Table 3c with M+S. No Elmhurst fixture lodges this record;
    borrow 000477's monthly inputs as the deterministic vehicle.
  - PCDB 16839 (sep_dhw=1) preserves the existing Table 3b row 1 path —
    regression guard.
  - Synthetic skeleton record exercises None-returning branches:
    null record, sep_dhw=0, integral FGHRS subsidiary_type=1, primary
    store store_type=1, missing F2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 13:51:27 +00:00
parent b01164a2b6
commit 62bbf863ff
3 changed files with 254 additions and 23 deletions

View file

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

View file

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

View file

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