Slice 102a: gate Table 3a combi-loss default by main heating category

SAP 10.2 §4 line 7702 (full spec PDF p.137): "Combi loss for each month
from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". The cascade
in `_water_heating_worksheet_and_gains` was falling through to the
Table 3a keep-hot 600 kWh/yr default whenever no PCDB Table 105 boiler
record was found — including every heat-pump cert (Table 105 only
contains gas/oil boilers).

Open EPC API certs typically lodge `sap_main_heating_code = None`, so
the gate keys off `main_heating_category` instead: {1, 2} for the
gas/oil/solid-fuel boiler family + {3, 6} for community heat networks
(preserves the existing DLF-scaling regression test). Categories 4
(heat pump), 5 (warm air), 7 (electric storage), 10 (room heaters) and
all other non-combi mains zero (61)m per the spec parenthetical.

Cert 0380 (Mitsubishi ASHP, cat=4): HW kWh/yr drops 503.08 → 242.21,
removing the bogus 600 kWh × 0.18 £/kWh = £77/yr inflation. Closed
boiler certs (001479, 0330, 9501 — all cat=2) and heat-network cert
parity unchanged.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 11:00:56 +00:00 committed by Jun-te Kim
parent ac867499ea
commit 5e8ba9773f
2 changed files with 89 additions and 0 deletions

View file

@ -1835,6 +1835,33 @@ def pcdb_combi_loss_override(
return None
# SAP 10.2 §4 line 7702 gates the Table 3a keep-hot combi loss default
# to combi boilers ("enter '0' if not a combi boiler"). The Open EPC API
# typically lodges `sap_main_heating_code = None` so we cannot key off the
# precise SAP code; the next best signal is `main_heating_category`.
# Categories 1 and 2 enumerate the gas / oil / solid-fuel boiler family
# (which contains all combi boilers); categories 3 and 6 are community
# heat networks (treated as boiler-like by the cascade and the existing
# DLF-scaling regression test). Categories 4 (heat pump), 5 (warm air),
# 7 (electric storage), 10 (room heaters) etc. are never combis and must
# zero (61)m per the spec.
_TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset(
{1, 2, 3, 6}
)
def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool:
"""Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2
§4 line 7702. Returns True only when the main heating system is in the
boiler family or a community heat network outside that set the spec's
"enter '0' if not a combi boiler" rule fires and the cascade must zero
(61)m.
"""
if main is None:
return False
return main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES
def _water_heating_worksheet_and_gains(
*,
epc: EpcPropertyData,
@ -1869,6 +1896,14 @@ def _water_heating_worksheet_and_gains(
energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
)
# SAP 10.2 §4 line 7702: 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.
if combi_loss_override is None and not _table_3a_combi_loss_default_applies(
_first_main_heating(epc)
):
combi_loss_override = zero_monthly
wh_result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),

View file

@ -29,6 +29,7 @@ from domain.sap10_ml.tests._fixtures import (
)
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_water_heating_worksheet_and_gains,
cert_to_demand_inputs,
cert_to_inputs,
pcdb_combi_loss_override,
@ -1013,3 +1014,56 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis()
)
is None
)
def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None:
"""SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for
each month from Table 3a, 3b or 3c (enter "0" if not a combi
boiler)'. Air Source Heat Pump main heating (main_heating_category=4)
is NOT a combi boiler the Table 3a keep-hot 600 kWh/yr fall-through
in `water_heating_from_cert` must be gated by a main-heating-category
check so non-combi certs receive (61)m = 0 per the spec parenthetical.
Without this gate the cascade silently inflates HW fuel by ~260 kWh/yr
(~1.6 SAP points) on every HP cert that lacks a PCDB Table 105 record
(i.e. every HP cert Table 105 only contains gas/oil boilers).
"""
# Arrange — synthetic semi-detached, ASHP main heating
# (main_heating_category=4, fuel=29 electricity), hot water from
# cylinder via main heating (water_heating_code=901).
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2206,
main_heating_category=4, # heat pump
sap_main_heating_code=None, # Open EPC API typically lodges None
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part()],
sap_heating=make_sap_heating(
main_heating_details=[hp_main],
water_heating_code=901, # from main heating
),
)
# Act — run the §4 worksheet cascade as the orchestrator does.
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=1.7, # arbitrary; combi loss is upstream of η
is_instantaneous=False,
primary_age="D",
pcdb_record=None, # no PCDB Table 105 boiler record for an HP
)
# Assert — (61)m = (0,)*12 for a non-combi main.
assert wh_result is not None
for month_idx, value in enumerate(wh_result.combi_loss_monthly_kwh):
assert value == 0.0, (
f"month {month_idx}: combi loss {value!r} should be 0 for "
f"non-combi main heating per SAP 10.2 §4 line 7702"
)