Slice S0380.20: extract PCDB keep-hot fields + strict-raise for no-keep-hot combis

Surfaces the SAP 10.2 Appendix J Table 3a sub-row dispatch gap that
masked +0.2..+0.4 SAP residuals on 11 cohort-2 PCDB-listed combi
certs. Identified via cert 7800-1501-0922-7127-3563 (Potterton Promax
Combi 28 HE Plus A, PCDF 15709): cascade used the keep-hot 600 kWh/yr
default; worksheet (61) sums to ~428 kWh/yr via the no-keep-hot
sub-row formula.

Root cause: the PCDB Table 105 record carries keep-hot metadata at
field positions 58 (`keep_hot_facility`) and 59 (`keep_hot_timer`)
per the SAP 10 PCDB spec (private feed for SAP software vendors —
not surfaced on the public PCDB website nor the Open EPC API). The
parser preserved these in `raw=fields` but didn't surface them as
typed attributes, so the cascade had no signal to dispatch the right
Table 3a sub-row.

Two-part change:

1. `domain/sap10_calculator/tables/pcdb/parser.py` — adds typed
   `keep_hot_facility` and `keep_hot_timer` fields to
   `GasOilBoilerRecord`, parsed from fields[57] and fields[58].
   Field enums (per BRE STP09-B04 + SAP 10 PCDB spec):
     Field 58: 0=no keep-hot, 1=fuel keep-hot, 2=electric keep-hot,
               3=gas+electric keep-hot
     Field 59: 0=no timer, 1=overnight time-switch
   Verified against cohort-1 fixture 000490 (Vaillant Ecotec Pro 28,
   PCDF 10328) — record lodges keep_hot_facility=1, keep_hot_timer=1,
   exactly matching the hand-built fixture comment "Combi keep hot
   type = Gas/Oil, time clock" at `_elmhurst_worksheet_000490.py:
   277-280`.

2. `domain/sap10_calculator/rdsap/cert_to_inputs.py` — adds
   `UnresolvedPcdbCombiLoss` exception. `pcdb_combi_loss_override`
   now raises (instead of silently returning None) when the PCDB
   record has `separate_dhw_tests=0/None` AND
   `keep_hot_facility=0/None`. The cascade's only implemented Table
   3a row is "with keep-hot, time clock" (600 kWh/yr), which is the
   wrong spec row for no-keep-hot combis — silently using it masked
   the cohort-2 negative band.

The ETL was re-run to refresh `pcdb_table_105_gas_oil_boilers.jsonl`
with the new typed fields (raw fields unchanged, just additional
columns surfacing what was previously buried).

Cohort distribution after slice:

  cohort-1 cert 000490 (Vaillant PCDF 10328, kh=1): NO RAISE — cascade
    keep-hot 600 default IS the spec-correct row. Tests still GREEN.
  cohort-2: 10 exact + 13 sub-±0.07 + 2 ±0.07..0.5 + 1 ±0.5..1 +
            1 ±5+ + 11 RAISES.

The 11 raising certs are now blocked until the Table 3a no-keep-hot
sub-row is implemented (BRE STP09-B04 methodology — pending slice).
Previously these certs silently produced +0.2..+0.4 SAP errors AND
ranged into the big-gap band; raising surfaces the gap rather than
shipping wrong numbers.

Two golden cert tests blocked alongside (Firebird oil PCDF 9005 also
hits this path):
  - test_golden_cert_residual_matches_pin[0390-2954-3640-2196-4175]
  - test_api_to_domain_mapper_preserves_main_heating_index_number[0390-2954-3640-2196-4175]
Re-enable when the Table 3a no-keep-hot row lands.

Two other tests updated:
  - test_main_heating_index_number_in_pcdb_overrides_seasonal_efficiency:
    switched from Baxi 98 (sdt=0, kh=None, would raise) to Worcester
    PCDF 10241 (sdt=1, routes via Table 3b row 1). Asserts 0.885 not
    0.66.
  - test_pcdb_combi_loss_override_returns_none_or_raises_for_untested
    _or_storage_combis: renamed + extended to pin the new strict-raise
    behaviour.

Pyright net-zero per file:
  - domain/sap10_calculator/rdsap/cert_to_inputs.py: 35 (baseline 35)
  - domain/sap10_calculator/tables/pcdb/parser.py: 0
  - domain/sap10_calculator/tables/pcdb/__init__.py: 0
  - domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py: 13 (baseline 13)
  - domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py: 1 (was 2 — improved)

Regression baseline: 697 pass + 10 fail (= prior 699 + 10 - 2 dropped
golden parametrize entries for cert 0390-2954-3640-2196-4175).

Spec refs:
- SAP 10 PCDB spec (private SAP software vendor feed) — keep-hot
  facility / timer / electric-heater fields at positions 58 / 59 / 60.
- BRE STP09-B04 (combi boiler test methodology) — origin of the
  keep-hot Table 3a derivation. URL: https://bregroup.com/documents/d
  /bre-group/stp09-b04_combi_boiler_tests
- SAP 10.2 Appendix J Table 3a row-selection — to be implemented per
  PCDB keep-hot dispatch in a follow-up slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 08:10:27 +00:00 committed by Jun-te Kim
parent 15b3df1778
commit 0adb34eaf2
6 changed files with 7375 additions and 7273 deletions

View file

@ -1788,6 +1788,45 @@ def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
return n is None or n >= 1
class UnresolvedPcdbCombiLoss(ValueError):
"""Raised when a cert lodges a PCDB Table 105 combi without enough
metadata for the cascade to dispatch the correct SAP 10.2 Appendix
J Table 3a sub-row.
Trigger: `separate_dhw_tests` is 0 / None (no EN 13203-2 lab data
so Tables 3b/3c don't apply) AND `keep_hot_facility` is 0 / None
(the PCDB record lodges no keep-hot the cascade's only
implemented Table 3a row is "with keep-hot, time clock" 600 kWh/yr,
spec-wrong for no-keep-hot combis).
Cohort-2 cert 7800-1501-0922-7127-3563 (PCDF 15709 Potterton
Promax Combi 28 HE+A): worksheet (61) sums to ~428 kWh/yr via the
no-keep-hot sub-row formula vs the cascade's 600 → +172 kWh/yr
excess HW demand -0.24 SAP. 10 other cohort-2 certs (PCDF 15709
/ PCDF 10315) hit the same gap.
Cohort-1 cert 000490 (PCDF 10328 Vaillant Ecotec Pro 28): same
sdt=0 but lodges `keep_hot_facility=1` (fuel keep-hot) cascade
default 600 IS the spec-correct row no raise.
Surface the gap rather than silently mis-route same strict-
coverage pattern as `UnmappedElmhurstLabel`. Fixed by implementing
the Appendix J Table 3a no-keep-hot sub-row formula (BRE STP09-B04
methodology) in a follow-up slice.
"""
def __init__(self, *, pcdf_index: Optional[int], boiler: str) -> None:
super().__init__(
f"PCDB combi {boiler!r} (PCDF {pcdf_index}) lodges "
f"separate_dhw_tests=0 + keep_hot_facility=None — the cascade "
f"can't dispatch the SAP 10.2 Table 3a sub-row. Implement "
f"the no-keep-hot Table 3a row OR confirm cert-level keep-hot "
f"lodging before this cert can be cascaded."
)
self.pcdf_index = pcdf_index
self.boiler = boiler
def pcdb_combi_loss_override(
pcdb_record: Optional[GasOilBoilerRecord],
*,
@ -1802,8 +1841,11 @@ def pcdb_combi_loss_override(
= 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.
= 0 / None falls through to Table 3a, dispatched by the PCDB
keep-hot fields (`keep_hot_facility`, `keep_hot_timer`) raises
`UnresolvedPcdbCombiLoss` when no keep-hot is lodged because the
cascade's only implemented Table 3a row is the keep-hot one
(Slice S0380.20 strict-raise context).
Storage-FGHRS and storage-combi variants (`subsidiary_type` {1, 2,
3} integral FGHRS / HP+boiler combinations; `store_type` {1, 2,
@ -1818,10 +1860,24 @@ def pcdb_combi_loss_override(
return None
if pcdb_record.store_type not in (None, 0):
return None
sdt = pcdb_record.separate_dhw_tests
if sdt in (0, None):
# No EN 13203-2 lab data → fall through to Table 3a. Cascade's
# only implemented Table 3a row is "with keep-hot, time clock"
# (600 kWh/yr); use it only when the PCDB lodges keep-hot.
if pcdb_record.keep_hot_facility in (0, None):
raise UnresolvedPcdbCombiLoss(
pcdf_index=pcdb_record.pcdb_id,
boiler=(
f"{pcdb_record.brand_name} {pcdb_record.model_name} "
f"{pcdb_record.model_qualifier}".strip()
),
)
return None # keep-hot lodged → cascade caller uses Table 3a default
r1 = pcdb_record.rejected_energy_proportion_r1
if r1 is None:
return None
match pcdb_record.separate_dhw_tests:
match sdt:
case 1:
f1 = pcdb_record.loss_factor_f1_kwh_per_day
if f1 is None:
@ -1838,7 +1894,7 @@ def pcdb_combi_loss_override(
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"
"M+L" if sdt == 2 else "M+S"
)
return combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
rejected_energy_proportion_r1=r1,

View file

@ -562,10 +562,17 @@ def test_main_heating_efficiency_reads_sap_main_heating_code() -> None:
def test_main_heating_index_number_in_pcdb_overrides_seasonal_efficiency() -> None:
"""SAP 10.2 Appendix D2.1 precedence: when a cert lodges a PCDB index
number that resolves to a Table 105 record, the PCDB winter seasonal
efficiency overrides the Table 4a/4b category default. Baxi Heating
pcdb_id=98 has winter eff 66.0% (vs the 84% default for a gas combi
Table 4b code 102) the cert path must produce 0.66, not 0.84."""
# Arrange — typical gas-combi cert plus a PCDB pointer to Baxi 000098.
efficiency overrides the Table 4a/4b category default. Worcester
Heat Systems pcdb_id=10241 (Greenstar 30 Si) has winter eff 88.5%
(vs the 84% default for a gas combi Table 4b code 102) the cert
path must produce 0.885, not 0.84.
Worcester PCDF 10241 has `separate_dhw_tests=1` (Table 3b row 1 lab
data), so the combi-loss cascade also routes via the PCDB path
rather than the Table 3a no-keep-hot strict-raise added in Slice
S0380.20."""
# Arrange — typical gas-combi cert plus a PCDB pointer to Worcester
# Greenstar 30 Si.
base = _typical_semi_detached_epc()
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
@ -583,7 +590,7 @@ def test_main_heating_index_number_in_pcdb_overrides_seasonal_efficiency() -> No
main_heating_control=2106,
main_heating_category=2,
sap_main_heating_code=102,
main_heating_index_number=98, # PCDB pointer
main_heating_index_number=10241, # PCDB pointer
),
],
),
@ -593,7 +600,7 @@ def test_main_heating_index_number_in_pcdb_overrides_seasonal_efficiency() -> No
inputs = cert_to_inputs(epc)
# Assert
assert inputs.main_heating_efficiency == pytest.approx(0.66, abs=1e-9)
assert inputs.main_heating_efficiency == pytest.approx(0.885, abs=1e-9)
def test_gas_heating_with_electric_immersion_charges_hw_at_electricity_rate() -> None:
@ -937,16 +944,24 @@ def test_pcdb_combi_loss_override_preserves_separate_dhw_tests_1_routing_to_tabl
)
def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis() -> None:
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 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)."""
to Table 3a whenever the PCDB record lodges keep-hot facility 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).
Per Slice S0380.20: when the PCDB record lodges sdt=0 AND
keep_hot_facility {None, 0}, raises `UnresolvedPcdbCombiLoss`
instead of returning None the cascade's only implemented Table
3a row is "with keep-hot" (600 kWh/yr), which is the wrong spec
row for no-keep-hot combis (cohort-2 cert 7800 had ~+172 kWh/yr
over-prediction)."""
# Arrange — a minimal record skeleton, mutated per scenario via
# dataclasses.replace.
from dataclasses import replace
from domain.sap10_calculator.rdsap.cert_to_inputs import UnresolvedPcdbCombiLoss
energy_content = _w000477.LINE_45_M_HW_ENERGY_CONTENT_KWH
daily_hw = _w000477.LINE_44_M_DAILY_HW_USAGE_L
base = GasOilBoilerRecord(
@ -966,6 +981,8 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis()
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_timer=1,
raw=(),
)
@ -978,7 +995,8 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis()
)
is None
)
# separate_dhw_tests=0 → None (no PCDB test data).
# separate_dhw_tests=0 + keep_hot_facility=1 → None (no PCDB DHW
# test data, but cascade's keep-hot row IS the right spec row).
assert (
pcdb_combi_loss_override(
replace(base, separate_dhw_tests=0),
@ -987,6 +1005,16 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis()
)
is None
)
# separate_dhw_tests=0 + keep_hot_facility=None → RAISES (cascade's
# keep-hot row is wrong for no-keep-hot combis; Table 3a no-keep-hot
# row not yet implemented per Slice S0380.20).
with pytest.raises(UnresolvedPcdbCombiLoss) as excinfo:
pcdb_combi_loss_override(
replace(base, separate_dhw_tests=0, keep_hot_facility=None),
energy_content_monthly_kwh=energy_content,
daily_hot_water_monthly_l_per_day=daily_hw,
)
assert excinfo.value.pcdf_index == 99999
# Integral FGHRS (subsidiary_type=1) → row 2/3 deferred → None.
assert (
pcdb_combi_loss_override(

View file

@ -117,25 +117,12 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"CO2 -0.27 → -0.23."
),
),
_GoldenExpectation(
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-7,
expected_pe_resid_kwh_per_m2=-26.0093,
expected_co2_resid_tonnes_per_yr=-2.5211,
notes=(
"Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges "
"has_draught_lobby=true and a 160 L factory-insulated cylinder. "
"Slice 97 added glazing_type=2 — windows now drop to spec U=2.0, "
"widening PE → -28.68 and CO2 → -2.76. Slice 102b then applied "
"SAP 10.2 Tables 2/2a/2b cylinder storage loss (~432 kWh/yr), "
"tightening PE -28.68 → -27.50 and CO2 -2.76 → -2.66. Slice 102d "
"then added SAP 10.2 Table 3 primary circuit loss (~516 kWh/yr "
"uninsulated, age band F → A-J default p=0.0), tightening PE "
"-27.50 → -26.01, CO2 -2.66 → -2.52, and shifting SAP residual "
"-6 → -7 (cost of the higher HW fuel)."
),
),
# Slice S0380.20: cert 0390-2954-3640-2196-4175 (Firebird oil PCDF
# 9005) lodges separate_dhw_tests=0 + keep_hot_facility=None, which
# the new strict-raise (`UnresolvedPcdbCombiLoss`) catches before
# the cascade can compute. Re-enable this golden cert once the
# Table 3a no-keep-hot sub-row is implemented (BRE STP09-B04
# methodology) and the PCDB keep-hot dispatch lands.
_GoldenExpectation(
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
@ -401,8 +388,11 @@ def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> No
# 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain
# must surface that PCDB winter efficiency on `inputs.main_heating_efficiency`
# rather than falling back to the Table 4a oil-boiler category default.
_PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float], ...] = (
("0390-2954-3640-2196-4175", 9005, 0.864), # Firebird oil PCDB-listed
# Slice S0380.20: cert 0390-2954-3640-2196-4175 (Firebird oil PCDF
# 9005) lodges separate_dhw_tests=0 + keep_hot_facility=None, raising
# `UnresolvedPcdbCombiLoss` from `cert_to_inputs`. Re-add once the
# Table 3a no-keep-hot sub-row lands.
_PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float | None], ...] = (
("7536-3827-0600-0600-0276", 17679, None), # Vaillant gas PCDB-listed
("0300-2747-7640-2526-2135", 17992, None), # gas PCDB-listed
("8135-1728-8500-0511-3296", 17702, None), # gas PCDB-listed

View file

@ -78,6 +78,8 @@ def _load_table_105() -> dict[int, GasOilBoilerRecord]:
loss_factor_f1_kwh_per_day=data.get("loss_factor_f1_kwh_per_day"),
loss_factor_f2_kwh_per_day=data.get("loss_factor_f2_kwh_per_day"),
rejected_factor_f3_per_litre=data.get("rejected_factor_f3_per_litre"),
keep_hot_facility=data.get("keep_hot_facility"),
keep_hot_timer=data.get("keep_hot_timer"),
raw=tuple(data["raw"]),
)
records_by_id[record.pcdb_id] = record

View file

@ -88,6 +88,30 @@ class GasOilBoilerRecord:
loss_factor_f1_kwh_per_day: Optional[float]
loss_factor_f2_kwh_per_day: Optional[float]
rejected_factor_f3_per_litre: Optional[float]
# PCDF Spec Rev 6b (SAP10 boiler PCDB feed): "keep-hot facility"
# metadata used by SAP Appendix J Table 3a sub-row dispatch.
# Source: BRE STP09-B04 + the SAP 10 PCDB spec (private feed for
# SAP software vendors — not surfaced on the public PCDB website
# or the Open EPC API). Confirmed by cohort-2 cert 7800-1501-0922-
# 7127-3563's PCDF 15709 lodging field 58 = "" (no keep-hot)
# vs the cohort-1 fixture 000490's PCDF 10328 (Vaillant Ecotec
# Pro 28) lodging "1" (fuel keep-hot) + field 59 = "1" (timer)
# — exactly matches the hand-built comment "Combi keep hot type =
# Gas/Oil, time clock" at `_elmhurst_worksheet_000490.py:277-280`.
#
# Field 58 enum (1-indexed): 0 = no keep-hot, 1 = fuel keep-hot,
# 2 = electric keep-hot, 3 = gas + electric keep-hot.
# Field 59 enum: 0 = no timer, 1 = overnight time-switch.
#
# Empty-string lodging is treated as None (i.e. unknown). Empirically
# the cohort lodges empty for "no keep-hot" too — but some boilers
# genuinely have keep-hot data missing because they predate SAP10's
# PCDB spec, so None can't be unambiguously equated with 0. The
# cascade dispatch in `cert_to_inputs.pcdb_combi_loss_override`
# treats None and 0 identically for the Table 3a row choice
# (Slice S0380.20 strict-raise context).
keep_hot_facility: Optional[int]
keep_hot_timer: Optional[int]
raw: tuple[str, ...]
@ -393,5 +417,7 @@ def parse_table_105_row(row: str) -> GasOilBoilerRecord:
loss_factor_f1_kwh_per_day=_parse_optional_float(fields[51]),
loss_factor_f2_kwh_per_day=_parse_optional_float(fields[55]),
rejected_factor_f3_per_litre=_parse_optional_float(fields[56]),
keep_hot_facility=_parse_optional_int(fields[57]) if len(fields) > 57 else None,
keep_hot_timer=_parse_optional_int(fields[58]) if len(fields) > 58 else None,
raw=fields,
)