Charge an oil storage-combi its PCDB internal-store water-heating loss 🟩

An oil STORAGE combi (Worcester Greenstar Heatslave II, PCDB 18415,
store_type 1) zeroed its internal-store loss: pcdb_combi_loss_override
defers on store_type in {1,2,3} and a combi has no cylinder, so line
(56) dropped to zero — under-costing hot water and over-rating SAP by
+2.0..+4.9 across the five LRHA WAVE 3 oil combis.

SAP 10.2 Table 2 note b) now includes the store loss when the combi's
efficiency is from the PCDB: the PCDB store volume (field 42) and
insulation thickness (field 44) feed Table 2/2a with the Table 2b
storage-combi temperature factor, reproducing the accredited Elmhurst
P960 worksheet exactly (5.4175 kWh/day, Jan 167.94, ~1977 kWh/yr).

Cohort: within-0.5 55.3% -> 58.3%, MAE 0.675 -> 0.531; ratchet
tightened accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-27 12:43:52 +00:00
parent a21d2cc579
commit ca27f0b9d8
8 changed files with 322 additions and 6 deletions

View file

@ -119,8 +119,17 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# no-op. Treat the constants as a regression tripwire, NOT a cohort accuracy
# figure; re-baseline (coverage growth is not loosening) as further mapper fixes
# unblock fixtures.
_MIN_WITHIN_HALF: float = 0.55
_MAX_SAP_MAE: float = 0.68
# 2026-07-27 (#A1): an oil STORAGE combi (Worcester Greenstar Heatslave II,
# PCDB 18415, store_type 1) zeroed its internal-store water-heating loss —
# `pcdb_combi_loss_override` defers on store_type∈{1,2,3} and a combi has no
# cylinder — under-costing hot water and over-rating SAP. SAP 10.2 Table 2
# note b now includes the store loss from the PCDB store volume + insulation
# (Table 2/2a × Table 2b storage-combi TF), matching the accredited Elmhurst
# P960 worksheet exactly (5.4175 kWh/day). The 5 oil storage-combis collapse
# from +2.0..+4.9 into the ±1.5 spread: within-0.5 55.3% → 58.3%, MAE
# 0.675 → 0.531.
_MIN_WITHIN_HALF: float = 0.58
_MAX_SAP_MAE: float = 0.54
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "
@ -203,6 +212,29 @@ def test_dual_main_heating_models_both_systems() -> None:
assert outcome.diff < 0.5, f"dual-main not modelled: diff {outcome.diff:.2f}"
@pytest.mark.skipif(
"461996268736" not in _BY_ID, reason="oil storage-combi fixture not in manifest"
)
def test_oil_storage_combi_applies_pcdb_store_loss() -> None:
"""Regression (#A1): an oil STORAGE combi (Worcester Greenstar Heatslave
II, PCDB 18415, store_type 1) must incur its internal-store water-heating
loss. `pcdb_combi_loss_override` defers on store_type{1,2,3} and the
combi has no cylinder, so the store loss (56) was ZEROED hot water
under-costed SAP over-rated +4.9. SAP 10.2 Table 2 note b now includes
the store loss from the PCDB store volume (63 L) + insulation (25 mm):
Table 2 × 2a × Table 2b storage-combi TF 2.8946 5.4175 kWh/day
1977 kWh/yr, matching the accredited Elmhurst P960 worksheet for this
boiler exactly. Fixture 461996268736 +4.93 +0.13.
The five oil storage-combis (461996268736, 461989738731, 497712315613,
497602074839, 462051619031) all collapse from +2..+4.9 into the cohort's
normal ±1.5 spread; only this one is pinned (the others' residuals are
PasHub-vs-Elmhurst divergence, not the store-loss bug)."""
outcome = _evaluate("461996268736")
assert outcome.diff is not None
assert outcome.diff < 0.5, f"oil storage-combi store loss not applied: diff {outcome.diff:.2f}"
@pytest.mark.skipif(
"497712825571" not in _BY_ID or "461386632387" not in _BY_ID,
reason="direct-acting electric boiler fixtures not in manifest",

View file

@ -210,6 +210,7 @@ from domain.sap10_calculator.worksheet.water_heating import (
cylinder_storage_loss_monthly_kwh,
cylinder_volume_factor_table_2a,
primary_loss_monthly_kwh,
storage_combi_temperature_factor_table_2b,
water_efficiency_monthly_via_equation_d1,
water_heating_from_cert,
)
@ -6201,6 +6202,11 @@ def _apply_rdsap_no_water_heating_system_default(
# cylinder of 110 litres and a factory insulation thickness of 50 mm".
_HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM: Final[int] = 50
# SAP 10.2 Table 2 note c) (PDF p.158): when a storage combi's factory
# insulation thickness is unknown, a 13 mm default applies. Used by
# `_storage_combi_store_loss_override` when the PCDB record omits it.
_STORE_COMBI_DEFAULT_INSULATION_MM: Final[float] = 13.0
def _apply_heat_network_hiu_default_store(
epc: EpcPropertyData,
@ -7132,6 +7138,14 @@ def _water_heating_worksheet_and_gains(
# combi systems, so the override is only built when the cert explicitly
# lodges a cylinder.
storage_loss_override = _cylinder_storage_loss_override(epc, main)
if storage_loss_override is None:
# SAP 10.2 Table 2 note b) — a storage combi with no separate cylinder
# still incurs its internal store loss (56)m. `pcdb_record` is the
# same boiler record `pcdb_combi_loss_override` used above, so the
# store loss and the combi loss (61)=0 stay on the one heat generator.
storage_loss_override = _storage_combi_store_loss_override(
epc, pcdb_record
)
# SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss
# (59)m. Only fires for indirect cylinders; HPs with integral
# vessels and combi boilers are in the spec's zero list. The gate
@ -7415,6 +7429,62 @@ def _cylinder_storage_loss_override(
return tuple(s * factor for s in storage_56m)
def _storage_combi_store_loss_override(
epc: EpcPropertyData,
pcdb_record: Optional[GasOilBoilerRecord],
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 §4 (56)m for the internal water store of a STORAGE COMBI
boiler the loss a lodged-cylinder dwelling gets from
`_cylinder_storage_loss_override`, but which a combi (no cylinder)
otherwise drops to zero, over-rating the dwelling.
Table 2 note b) (PDF p.158): "the loss is to be included for a storage
combination boiler if its efficiency ... is obtained from the PCDB (in
which case its insulation thickness and volume are also ... obtained
from the PCDB), using the loss factor for a factory insulated cylinder."
So when a PCDB Table 105 record with `store_type {1, 2}` (primary /
secondary water store) drives the combi's efficiency, its store volume
(field 42) and insulation thickness (field 44) size a Table 2 store loss
with the Table 2b storage-combi temperature factor (much larger than a
cylinder's 0.60 — the store is kept hot continuously).
Returns None (leaving the existing zero default) when there is a lodged
cylinder (that path already runs), when the main is not a PCDB storage
combi, or when the PCDB store volume is unknown (Table 2 note c defaults
deferred no cohort fixture yet). Verified against the Elmhurst P960
worksheet for pcdb_id 18415 (Worcester Greenstar Heatslave II): store
volume 63 L / insulation 25 mm (55) 5.4175 kWh/day, (56) Jan 167.94."""
if epc.has_hot_water_cylinder:
return None
if pcdb_record is None or pcdb_record.store_type not in (1, 2):
return None
volume_l = pcdb_record.store_boiler_volume_l
if volume_l is None:
return None
thickness_mm = pcdb_record.store_insulation_thickness_mm
if thickness_mm is None:
# Table 2 note c) default store insulation (13 mm) when the PCDB
# thickness is unlodged — kept explicit so the loss is still applied
# rather than dropped, mirroring the cylinder Table 29 fallback.
thickness_mm = _STORE_COMBI_DEFAULT_INSULATION_MM
temperature_factor = storage_combi_temperature_factor_table_2b(
store_type=pcdb_record.store_type, volume_l=volume_l,
)
return cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
# SAP 10.2 Table 2 note 2: the water store in a storage combi uses
# the factory-insulated cylinder loss factor regardless of material.
insulation_type="factory_insulated",
thickness_mm=thickness_mm,
# The Table 2b storage-combi temperature factor replaces the cylinder
# 0.60 base; the thermostat / separate-timing multipliers do not
# apply, so these two flags are inert (override supplied).
has_cylinder_thermostat=True,
separately_timed_dhw=False,
temperature_factor_override=temperature_factor,
)
def _apply_water_efficiency(
*,
wh_output_monthly_kwh: tuple[float, ...],

View file

@ -75,6 +75,29 @@ _TABLE_362_JSONL: Final[Path] = (
)
# PCDF Spec Rev 6b (0-idx) raw-row positions for the storage-combi store
# fields not surfaced as top-level NDJSON keys. Parsed from `raw` at load
# time so no NDJSON rebuild is needed. Field 42 = store boiler volume (L),
# field 44 = store insulation thickness (mm).
_RAW_STORE_VOLUME_IDX: Final[int] = 41
_RAW_STORE_INSULATION_IDX: Final[int] = 43
def _raw_float(raw: list[str], idx: int) -> Optional[float]:
"""Parse a float from the raw PCDB row at `idx`; None if the row is too
short, the cell is blank, or it does not parse (mirrors the blank-store
convention for non-storage combis)."""
if idx >= len(raw):
return None
value = raw[idx].strip()
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _load_table_105() -> dict[int, GasOilBoilerRecord]:
"""Read the Table 105 NDJSON at import time and build a by-pcdb-id
dict. ~5MB / ~4000 rows; one-off ~50ms cost. The Python runtime
@ -98,6 +121,12 @@ def _load_table_105() -> dict[int, GasOilBoilerRecord]:
final_year_of_manufacture=data["final_year_of_manufacture"],
subsidiary_type=data.get("subsidiary_type"),
store_type=data.get("store_type"),
store_boiler_volume_l=_raw_float(
data["raw"], _RAW_STORE_VOLUME_IDX
),
store_insulation_thickness_mm=_raw_float(
data["raw"], _RAW_STORE_INSULATION_IDX
),
separate_dhw_tests=data.get("separate_dhw_tests"),
rejected_energy_proportion_r1=data.get("rejected_energy_proportion_r1"),
loss_factor_f1_kwh_per_day=data.get("loss_factor_f1_kwh_per_day"),

View file

@ -81,8 +81,20 @@ class GasOilBoilerRecord:
subsidiary_type: Optional[int]
# PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary
# water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in
# Table 3b/3c (deferred until a fixture exercises).
# Table 3b/3c and the SAP 10.2 Table 2 store-loss path below.
store_type: Optional[int]
# PCDF Spec Rev 6b field 42 (0-idx 41): "Store boiler volume" — the water
# volume (litres) of the internal hot-water store heatable by the boiler
# (total store less the store solar volume, field 43). Blank / None for a
# non-storage combi. PCDF Spec Rev 6b field 44 (0-idx 43): "Store
# insulation" — the store insulation thickness in mm. Together they drive
# the SAP 10.2 Table 2 store-loss factor (factory-insulated formula, Note
# 2) for a storage combi whose efficiency is from the PCDB (Table 2 note
# b). Verified against the Elmhurst P960 worksheet for pcdb_id 18415
# (Worcester Greenstar Heatslave II): 63 L / 25 mm → L 0.0240, store loss
# (55) 5.4175 kWh/day.
store_boiler_volume_l: Optional[float]
store_insulation_thickness_mm: Optional[float]
separate_dhw_tests: Optional[int]
rejected_energy_proportion_r1: Optional[float]
loss_factor_f1_kwh_per_day: Optional[float]
@ -466,6 +478,12 @@ def parse_table_105_row(row: str) -> GasOilBoilerRecord:
comparative_hot_water_efficiency_pct=_parse_optional_float(fields[28]),
subsidiary_type=_parse_optional_int(fields[15]),
store_type=_parse_optional_int(fields[38]),
store_boiler_volume_l=(
_parse_optional_float(fields[41]) if len(fields) > 41 else None
),
store_insulation_thickness_mm=(
_parse_optional_float(fields[43]) if len(fields) > 43 else None
),
separate_dhw_tests=_parse_optional_int(fields[47]),
rejected_energy_proportion_r1=_parse_optional_float(fields[50]),
loss_factor_f1_kwh_per_day=_parse_optional_float(fields[51]),

View file

@ -537,6 +537,38 @@ def cylinder_temperature_factor_table_2b(
return factor
def storage_combi_temperature_factor_table_2b(
*,
store_type: int,
volume_l: float,
) -> float:
"""SAP 10.2 Table 2b (PDF p.159), "loss from Table 2" column, for a
storage combi boiler's internal water store (as opposed to a cylinder):
primary store (store_type 1): Vc >= 115 2.54
Vc < 115 2.54 + 0.00682 × (115 Vc)
secondary store (store_type 2): Vc >= 115 1.86
Vc < 115 1.86 + 0.00496 × (115 Vc)
These are much larger than the 0.60 cylinder base the store in a combi
is kept hot continuously, so SAP applies a heavier standing-loss factor.
Verified against the accredited Elmhurst P960 worksheet for pcdb_id 18415
(Worcester Greenstar Heatslave II, primary store 63 L 2.8946).
store_type 3 (CPSU) is a distinct Table 2b row (integrated thermal store /
CPSU) with its own airing-cupboard / separate-timer notes; no fixture
exercises it yet, so it strict-raises rather than silently mis-rate."""
if store_type == 1:
return 2.54 if volume_l >= 115.0 else 2.54 + 0.00682 * (115.0 - volume_l)
if store_type == 2:
return 1.86 if volume_l >= 115.0 else 1.86 + 0.00496 * (115.0 - volume_l)
raise NotImplementedError(
f"Table 2b storage-combi temperature factor for store_type={store_type} "
"(CPSU / integrated thermal store) is not yet wired — no fixture "
"exercises it (SAP 10.2 Table 2b, PDF p.159)."
)
# SAP 10.2 Table 3 (PDF p.159) — primary circuit loss for boilers and
# heat pumps connected to a hot water cylinder via insulated or
# uninsulated primary pipework. The spec lists the zero-loss
@ -629,6 +661,7 @@ def cylinder_storage_loss_monthly_kwh(
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
declared_loss_kwh_per_day: Optional[float] = None,
temperature_factor_override: Optional[float] = None,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136).
@ -649,9 +682,13 @@ def cylinder_storage_loss_monthly_kwh(
solar storage is present in the vessel callers handling solar
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`.
"""
TF = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
TF = (
temperature_factor_override
if temperature_factor_override is not None
else cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
)
)
if declared_loss_kwh_per_day is not None:
# SAP 10.2 §4 (PDF p.136) branch a) — the lodged manufacturer's

View file

@ -866,6 +866,74 @@ def test_cylinder_size_not_determined_still_incurs_storage_loss() -> None:
assert sum(storage_56m) > 0.0
def test_storage_combi_store_loss_matches_elmhurst_worksheet_18415() -> None:
# Arrange — SAP 10.2 Table 2 note b): a storage combi whose efficiency is
# from the PCDB includes its internal-store loss (56)m, which a combi
# (no cylinder) otherwise zeroes. Worcester Greenstar Heatslave II
# (pcdb_id 18415, store_type 1 primary, store volume 63 L, insulation
# 25 mm) → Table 2 L 0.0240 × Table 2a VF (120/63)^⅓ × Table 2b storage-
# combi TF 2.8946 = (55) 5.4175 kWh/day. Pinned to the accredited Elmhurst
# P960 worksheet for this boiler ((47) 63, (55) 5.4175, (56) Jan 167.94).
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_storage_combi_store_loss_override, # pyright: ignore[reportPrivateUsage]
)
record = gas_oil_boiler_record(18415)
assert record is not None
combi_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
country_code="ENG",
has_hot_water_cylinder=False,
sap_building_parts=[make_building_part(construction_age_band="F")],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type=28, heat_emitter_type=1,
emitter_temperature="Unknown", main_heating_control=2106,
main_heating_index_number=18415,
),
],
),
)
# Act
storage_56m = _storage_combi_store_loss_override(combi_epc, record)
# Assert — (56) Jan (31 days) = 5.4175 × 31 = 167.94; annual ≈ 1977 kWh.
assert storage_56m is not None
assert abs(storage_56m[0] / 31.0 - 5.4175) <= 1e-3
assert abs(storage_56m[0] - 167.94) <= 5e-2
assert abs(sum(storage_56m) - 1977.4) <= 1.0
def test_storage_combi_store_loss_none_without_pcdb_store() -> None:
# Arrange — the override must NOT fire for (a) a lodged cylinder (the
# cylinder path handles it), (b) a non-storage combi (store_type 0), or
# (c) a None PCDB record. Each returns None so the cascade keeps its
# existing behaviour.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_storage_combi_store_loss_override, # pyright: ignore[reportPrivateUsage]
)
storage_record = gas_oil_boiler_record(18415)
non_storage_record = gas_oil_boiler_record(18204) # store_type 0
combi_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
has_hot_water_cylinder=False,
sap_building_parts=[make_building_part(construction_age_band="F")],
sap_heating=make_sap_heating(main_heating_details=[]),
)
cyl_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
has_hot_water_cylinder=True,
sap_building_parts=[make_building_part(construction_age_band="F")],
sap_heating=make_sap_heating(main_heating_details=[]),
)
# Act / Assert
assert _storage_combi_store_loss_override(cyl_epc, storage_record) is None
assert _storage_combi_store_loss_override(combi_epc, non_storage_record) is None
assert _storage_combi_store_loss_override(combi_epc, None) is None
def test_cylinder_size_inaccessible_code_5_solid_fuel_boiler_uses_160l() -> None:
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
# heated "from a solid fuel boiler" uses 160 litres.
@ -6721,6 +6789,8 @@ def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage
final_year_of_manufacture=None,
subsidiary_type=0,
store_type=0,
store_boiler_volume_l=None,
store_insulation_thickness_mm=None,
separate_dhw_tests=2,
rejected_energy_proportion_r1=0.015,
loss_factor_f1_kwh_per_day=0.5,

View file

@ -30,6 +30,38 @@ def test_gas_oil_boiler_record_returns_verified_baxi_98() -> None:
assert record.summer_efficiency_pct == 56.0
def test_gas_oil_boiler_record_surfaces_storage_combi_store_fields() -> None:
"""Worcester GREENSTAR HEATSLAVE II (pcdb_id 18415) is an oil storage
combi: PCDF field 42 (store boiler volume) = 63 L, field 44 (store
insulation thickness) = 25 mm. These drive the SAP 10.2 Table 2 store
loss (note b: included when combi efficiency is from the PCDB). Verified
against the accredited Elmhurst P960 worksheet for this boiler (store
volume 63 L, loss factor 0.0240 = factory-insulated 25 mm)."""
# Arrange
# Act
record = gas_oil_boiler_record(18415)
# Assert
assert record is not None
assert record.store_type == 1 # primary water store
assert record.store_boiler_volume_l == 63.0
assert record.store_insulation_thickness_mm == 25.0
def test_gas_oil_boiler_record_store_fields_none_for_non_storage_combi() -> None:
"""A non-storage combi (store_type 0) leaves the store volume /
insulation fields blank None, so the store-loss path is not triggered."""
# Arrange
# Act
record = gas_oil_boiler_record(18204)
# Assert
assert record is not None
assert record.store_type == 0
assert record.store_boiler_volume_l is None
assert record.store_insulation_thickness_mm is None
def test_gas_oil_boiler_record_returns_none_for_unknown_pcdb_id() -> None:
"""`main_heating_index_number` values not in Table 105 return None so
`cert_to_inputs` can fall back to the Table 4a/4b category default."""

View file

@ -676,6 +676,34 @@ def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_mont
assert monthly[0] == pytest.approx(num / denom, abs=1e-6)
def test_storage_combi_temperature_factor_table_2b_primary_store() -> None:
# Arrange — SAP 10.2 Table 2b (PDF p.159), "Storage combi boiler,
# primary store", loss-from-Table-2 column:
# Vc >= 115 L → 2.54
# Vc < 115 L → 2.54 + 0.00682 × (115 Vc)
# Verified against the accredited Elmhurst P960 worksheet for pcdb_id
# 18415 (Worcester Greenstar Heatslave II): Vc 63 L → 2.8946.
from domain.sap10_calculator.worksheet.water_heating import (
storage_combi_temperature_factor_table_2b,
)
# Act / Assert — Vc 63 L primary store reproduces the worksheet's (53).
assert abs(
storage_combi_temperature_factor_table_2b(store_type=1, volume_l=63.0)
- 2.8946
) <= 1e-4
# Vc >= 115 L flattens to the 2.54 floor.
assert abs(
storage_combi_temperature_factor_table_2b(store_type=1, volume_l=150.0)
- 2.54
) <= 1e-9
# Secondary store (store_type 2): 1.86 + 0.00496 × (115 Vc).
assert abs(
storage_combi_temperature_factor_table_2b(store_type=2, volume_l=63.0)
- (1.86 + 0.00496 * 52)
) <= 1e-9
def test_cylinder_storage_loss_uses_declared_loss_factor_times_temp_factor() -> None:
# Arrange — SAP 10.2 §4 branch a) (PDF p.136): when the manufacturer's
# declared cylinder loss factor (kWh/day) is lodged, storage loss