Slice S0380.156: SAP 10.2 Table 3 WHC=903 electric-immersion zero-loss guard

SAP 10.2 Table 3 (PDF p.160) verbatim:

  Primary loss is set to zero for the following:
      Electric immersion heater
      Combi boiler ...
      CPSU ...
      Boiler and thermal store within a single casing
      Separate boiler and thermal store connected by no more than 1.5
      m of insulated pipework
      Direct-acting electric boiler
      Heat pump (...) with hot water vessel integral to package

The Elmhurst WHC=903 lodging signals exactly the first row: "HW from
a separate electric immersion heater" — the cylinder is heated by an
immersion element inside the tank, no primary pipework between any
heat generator and the cylinder. The rule is universal: regardless
of what main heating exists for space heating, electric immersion
means no primary circuit means no primary loss.

Pre-slice `_primary_loss_applies` only consulted `water_heating_code`
in the Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat
4 HP branch returned True unconditionally when no PCDB record was
lodged; the Cat 1/2 boiler branch returned True unconditionally; the
PCDB Table 322 + Table 4b non-PCDB branches likewise. For the
electric 2 corpus variant (sap_main_heating_code=524 Cat 5 warm-air
ASHP, main_heating_category=4 per Elmhurst mapper, no PCDB record,
WHC=903 + cylinder), the Cat-4 branch falsely returned True and the
cascade added ~510 kWh/yr primary loss to a system with no primary
circuit at all.

Per-line walk discipline applied: cascade `water_heating_from_cert`
output dump showed `primary_loss_monthly_kwh_annual = 509.98` while
worksheet (59)m = 0 every month → spec lookup found Table 3 verbatim
"Electric immersion heater" zero-loss line.

Adds `_WHC_ELECTRIC_IMMERSION: Final[int] = 903` constant + a
top-of-function `if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False` guard that fires before any of the system-type-keyed
branches.

Closures electric 2:
  HW kWh 2849.22 → 2339.24 (matches worksheet (62)/(64) = 2384.12
  within the residual ~45 kWh storage-loss gap)
  ΔSAP −0.4584 → +0.8118 (cascade swung past the worksheet by +1.27
  — the pre-slice 'near-correct' value was offsetting cascade bugs
  per [[feedback-software-no-special-handling]]; the +0.81 residual
  exposes a separate upstream gap to chase in a follow-up slice)
  Δcost +£10.56 → −£18.71
  ΔCO2 +47.89 → −7.21 kg
  ΔPE +443.13 → −161.68 kWh

No regressions on the other 24 cohort variants — only electric 2 has
the (Cat 4 HP, no PCDB, WHC=903) combination in the corpus.
Extended handover suite: 900 pass / 0 fail (was 899 — +1 from the
new AAA test). Pyright net-zero (43 → 43).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 19:28:25 +00:00
parent 4350c71bdd
commit 02092c8041
3 changed files with 143 additions and 1 deletions

View file

@ -218,10 +218,28 @@ class _CorpusExpectation:
# per affected variant, SAP residuals shift ±0.15 across 16 variants;
# the SH+Sec demand mismatch for electric 3/6/7 (Table 11 fraction
# for codes 401/402) remains the open driver of those SAP residuals.
#
# Slice S0380.156 added the universal SAP 10.2 Table 3 (PDF p.160)
# zero-loss guard for WHC=903 (electric immersion HW) at the top of
# `_primary_loss_applies`. Pre-slice the Cat 4 HP branch returned
# True unconditionally when no PCDB record was lodged — so for
# electric 2 (sap_main_heating_code=524 Cat 5 warm-air ASHP, mapped
# to main_heating_category=4, WHC=903 + cylinder), the cascade
# falsely added ~510 kWh/yr primary loss to a system whose cylinder
# is heated directly by an immersion element with no primary
# pipework. Per Table 3 verbatim: "Primary loss is set to zero for
# the following: Electric immersion heater ...". Electric 2 SAP
# residual 0.4584 → +0.8118 (cascade swung past the worksheet — the
# pre-slice 'near-correct' value was masking an offsetting upstream
# gap that the spec-correct fix has exposed); cost +£10.56 →
# £18.71; CO2 +47.89 → 7.21 kg; PE +443.13 → 161.68. No
# regressions on the other 24 variants — the new guard is gated on
# WHC=903 and only electric 2 has the (Cat 4 HP, no PCDB, WHC=903)
# combination in the corpus.
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=-0.0240, expected_cost_resid_gbp=+0.5536, expected_co2_resid_kg=+7.3267, expected_pe_resid_kwh=+36.3435),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.4584, expected_cost_resid_gbp=+10.5613, expected_co2_resid_kg=+47.8864, expected_pe_resid_kwh=+443.1346),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.8118, expected_cost_resid_gbp=-18.7061, expected_co2_resid_kg=-7.2129, expected_pe_resid_kwh=-161.6840),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.1215, expected_cost_resid_gbp=-2.8003, expected_co2_resid_kg=+6.7227, expected_pe_resid_kwh=-5.9859),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.1759, expected_cost_resid_gbp=+27.0929, expected_co2_resid_kg=+62.7232, expected_pe_resid_kwh=+438.0333),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.3225, expected_pe_resid_kwh=+0.1603),

View file

@ -533,6 +533,13 @@ _INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = (
# because there's no cylinder and no primary circuit.
_INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
# Elmhurst WHC code for "HW from a separate electric immersion heater":
# cylinder lodged but heated by an immersion element inside the tank, no
# primary pipework between any heat generator and the cylinder. SAP 10.2
# Table 3 (PDF p.160) puts "Electric immersion heater" first in its
# zero-loss list, so primary loss is zero whenever this code is lodged.
_WHC_ELECTRIC_IMMERSION: Final[int] = 903
# SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed
# per array. The module efficiency constant (0.8), orientation-dependent
@ -4114,6 +4121,19 @@ def _primary_loss_applies(
return False
if main is None:
return False
# SAP 10.2 Table 3 (PDF p.160) zero-loss list — verbatim:
# "Primary loss is set to zero for the following: Electric immersion
# heater ...". Elmhurst WHC=903 lodges "HW from a separate electric
# immersion heater": the cylinder is heated by an immersion element
# inside the tank, no primary pipework between any heat generator
# and the cylinder. Applies universally — regardless of which main
# heating system exists for space heating (Cat 4 HP, Cat 1/2 boiler,
# Table 4b non-PCDB, PCDB Table 322). Pre-slice the WHC check only
# gated the Table 4a wet-boiler branch; the other branches falsely
# returned True for HP / boiler mains with WHC=903, adding ~510
# kWh/yr primary loss to a system with no primary circuit at all.
if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False
if main.main_heating_category == 4:
if hp_record is None:
# No PCDB record → assume separate-vessel (conservative; the

View file

@ -4192,6 +4192,110 @@ def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinde
)
def test_sap_table_3_primary_loss_skipped_for_whc_903_electric_immersion_with_heat_pump_main() -> None:
"""SAP 10.2 Table 3 (PDF p.160) zero-loss list — verbatim:
Primary loss is set to zero for the following:
Electric immersion heater
Combi boiler ...
CPSU ...
...
The rule is universal: when HW is heated by an electric immersion
inside the cylinder (no primary pipework between any heat generator
and the cylinder), primary loss is zero regardless of the main
heating system. The Elmhurst WHC=903 lodging signals exactly this
arrangement: "HW from a separate electric immersion heater".
Pre-slice `_primary_loss_applies` only checked the WHC for the
Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat 4
heat-pump branch returned True unconditionally when no PCDB record
was lodged, the Cat 1/2 boiler branch returned True unconditionally,
and the PCDB Table 322 + Table 4b non-PCDB branches likewise. For
the warm-air HP cert with code 524 + main_heating_category=4 +
WHC=903 + cylinder, the cat-4 branch falsely returned True and the
cascade added ~510 kWh/yr primary loss to a system with no primary
circuit at all.
Worksheet evidence electric 2 (sap_main_heating_code=524 Cat 5
warm-air ASHP, main_heating_category=4 per mapper, WHC=903 electric
immersion, 110 L cylinder + cylinder thermostat lodged): the P960
block-11a (59)m row reads 0.0000 every month, annual sum = 0.
"""
# Arrange — electric 2 corpus variant: Table 4a code 524 (warm-air
# ASHP) + main_heating_category=4 (Cat 4 HP per Elmhurst mapper) +
# WHC=903 (electric immersion HW) + 110 L cylinder + cylinder
# thermostat lodged. No PCDB heat-pump record lodged.
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
corpus_electric_2 = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples/electric 2"
)
summary_pdf = next(corpus_electric_2.glob("Summary_*.pdf"))
info = subprocess.run(
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
).stdout
pc_match = re.search(r"Pages:\s+(\d+)", info)
assert pc_match is not None
pc = int(pc_match.group(1))
pages: list[str] = []
for i in range(1, pc + 1):
layout = subprocess.run(
["pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(summary_pdf), "-"],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(notes)
main = epc.sap_heating.main_heating_details[0]
assert epc.has_hot_water_cylinder is True
assert main.sap_main_heating_code == 524
assert main.main_heating_category == 4
assert main.main_heating_index_number is None
assert epc.sap_heating.water_heating_code == 903
# Act — drive §4 (45..65) via the cascade helper. Pre-slice the Cat
# 4 branch falsely returned True for `_primary_loss_applies` because
# WHC=903 was only consulted in the Table 4a wet-boiler branch.
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=1.0,
is_instantaneous=False,
primary_age="G",
pcdb_record=None,
)
assert wh_result is not None
# Assert — (59)m annual must be 0 because the cylinder is heated by
# an electric immersion element (no primary pipework between any
# heat generator and the cylinder), matching the worksheet's all-zero
# (59)m row.
annual_primary = sum(wh_result.primary_loss_monthly_kwh)
assert abs(annual_primary - 0.0) <= 1e-4, (
f"electric 2 (Table 4a code 524 Cat 4 HP, WHC=903 immersion) "
f"primary loss annual = {annual_primary:.4f} kWh/yr; pre-slice "
f"the cascade Cat-4 branch returned True even though WHC=903 "
f"means electric immersion heats the cylinder directly. Per "
f"SAP 10.2 Table 3 zero-loss list ('Electric immersion heater') "
f"primary loss must be 0."
)
def test_sap_table_4f_circulation_pump_dispatches_per_central_heating_pump_age() -> None:
"""SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and
other auxiliary uses" — Heating system circulation pump rows: