Slice S0380.157: SAP 10.2 Table 2b note b) WHC=903 electric-immersion guard

SAP 10.2 Table 2b note b) (PDF p.159) — verbatim:

  Multiply Temperature Factor by 0.9 if there is separate time
  control of domestic hot water (boiler systems, warm air systems
  and heat pump systems).

The parenthetical list restricts the rule to systems where the heat
generator (boiler / warm-air / HP) is the device heating the
cylinder. Electric immersion is NOT in that list because the
immersion isn't a heat-generator system feeding DHW — it sits inside
the cylinder. The ×0.9 multiplier reflects shorter cylinder-heating
periods when a boiler / HP / warm-air operates on a separate timer
for DHW vs SH; if the heat generator doesn't feed the cylinder at all
(because the immersion does), there's no such timing effect.

Pre-slice `_separately_timed_dhw` returned True for any Cat 4 HP
main BEFORE consulting WHC (line 3872 `if main.main_heating_category
== 4: return True`). For electric 2 (sap_main_heating_code=524 Cat 5
warm-air ASHP, main_heating_category=4 per Elmhurst mapper, WHC=903
electric immersion + cylinder + cylinder thermostat lodged), the
cat-4 branch fired before the existing `_is_electric_water` check
could route the cert to False. The cascade applied ×0.9 to the
Temperature Factor (53), pulling (55) from 1.2294 → 1.1064 → cascade
annual (56) = 403.87 vs worksheet (56) annual = 448.73.

Same WHC=903 principle as the prior slice S0380.156 (Table 3 zero-
loss list for electric immersion): when HW is independent of the
main heating, main-heating-specific DHW rules don't apply — even
when the main happens to be a HP / boiler / warm-air system.

Fix: new top-of-function `if epc.sap_heating.water_heating_code ==
_WHC_ELECTRIC_IMMERSION: return False` guard in
`_separately_timed_dhw`. Reuses the constant introduced in S0380.156.

Closures electric 2:
  Cylinder (56) storage loss annual 403.87 → 448.73 (matches
  worksheet 1.2294 × 365 = 448.73 EXACT within rounding)
  HW kWh demand 2339.24 → 2384.12 (matches worksheet (62)/(64) =
  2384.116 EXACT)
  ΔSAP +0.8118 → +0.7002
  Δcost −£18.71 → −£16.14
  ΔCO2 −7.21 → −2.37 kg
  ΔPE −161.68 → −108.58 kWh

The remaining +0.70 SAP residual is a separate upstream gap (likely
warm-air-HP SH cascade or Table 4a SH efficiency for code 524) —
follow-up slice.

No regressions on the other 24 cohort variants. Cohort-1 ASHP certs
(Cat 4 HP + WHC=901 = HW from HP + cylinder) keep ×0.9 as before
because their WHC=901 doesn't trigger the new guard. Extended
handover suite: 901 pass / 0 fail (was 900 — +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 20:56:52 +00:00
parent 02092c8041
commit a2a4b6824a
3 changed files with 147 additions and 1 deletions

View file

@ -236,10 +236,29 @@ class _CorpusExpectation:
# 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.
#
# Slice S0380.157 added the companion SAP 10.2 Table 2b note b)
# WHC=903 guard at the top of `_separately_timed_dhw`. Pre-slice the
# Cat 4 HP branch (line 3872 `if main.main_heating_category == 4:
# return True`) returned True before consulting WHC, so for electric
# 2 (Cat 4 HP + WHC=903 immersion + cylinder) the cascade applied
# the Table 2b note b ×0.9 Temperature Factor multiplier to a
# cylinder fed by an electric immersion (not by the HP). Per the
# spec's verbatim system-type list "boiler systems, warm air systems
# and heat pump systems", electric immersion is not in scope.
# Worksheet electric 2 lodges (53) = 0.6000 / (55) = 1.2294 (=
# 0.0181 × 1.0294 × 0.6 × 110 — no ×0.9). Cascade cylinder storage
# loss annual 403.87 → 448.73 (matches worksheet). HW kWh demand
# 2339.24 → 2384.12 (EXACT match to worksheet (62)/(64)). SAP
# +0.8118 → +0.7002; cost £18.71 → £16.14; CO2 7.21 → 2.37 kg;
# PE 161.68 → 108.58 kWh. Same WHC=903 principle as .156 (HW
# independent of main heating → main-heating-specific DHW rules do
# not apply). No regressions on other variants — only electric 2 has
# the (Cat 4 HP + WHC=903 + cylinder) 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.8118, expected_cost_resid_gbp=-18.7061, expected_co2_resid_kg=-7.2129, expected_pe_resid_kwh=-161.6840),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.7002, expected_cost_resid_gbp=-16.1353, expected_co2_resid_kg=-2.3729, expected_pe_resid_kwh=-108.5828),
_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

@ -3869,6 +3869,21 @@ def _separately_timed_dhw(
"""
if main is None:
return False
# SAP 10.2 Table 2b note b) verbatim system-type list — "boiler
# systems, warm air systems and heat pump systems". Electric
# immersion is not in that list because the immersion isn't a
# heat-generator system feeding DHW: it sits inside the cylinder.
# The ×0.9 multiplier reflects shorter cylinder-heating periods
# when a boiler / HP / warm-air operates on a separate timer for
# DHW vs SH — when the heat generator doesn't feed the cylinder at
# all (because the immersion does), there's no such timing effect.
# The Elmhurst WHC=903 lodging signals "HW from a separate electric
# immersion heater" — the cylinder is independent of the main
# heating, regardless of what the main heating is (HP / boiler /
# warm-air). Same principle as the [[S0380.156]] Table 3 primary-
# loss WHC=903 guard.
if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False
if main.main_heating_category == 4:
return True
if _is_electric_water(epc.sap_heating.water_heating_fuel):

View file

@ -4192,6 +4192,118 @@ def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinde
)
def test_sap_table_2b_temperature_factor_no_0p9_for_whc_903_electric_immersion_with_heat_pump_main() -> None:
"""SAP 10.2 Table 2b note b) (PDF p.159) — verbatim:
Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems).
The verbatim parenthetical list restricts the rule to systems where
the heat generator (boiler / warm-air / HP) is the device heating
the cylinder. Electric immersion is NOT in that list it's a
separate device on the cylinder, not a heat-generator system feeding
DHW. The principle: the ×0.9 reflects shorter cylinder-heating
periods when the boiler/HP operates on a separate timer for DHW vs
SH; if the heat generator doesn't feed the cylinder at all (because
an immersion does), the rule doesn't apply.
For electric 2 (sap_main_heating_code=524 Cat 5 warm-air ASHP,
main_heating_category=4 per Elmhurst mapper, WHC=903 electric
immersion + cylinder + cylinder thermostat lodged), the worksheet
block 11a §4 lodges:
Temperature factor from Table 2b 0.6000 (53)
Enter (49) or (54) in (55) 1.2294 (55)
(55) = 0.0181 × 1.0294 × 0.6 × 110 = 1.2294 no ×0.9. Pre-slice
`_separately_timed_dhw` returns True for any Cat 4 HP main BEFORE
consulting WHC, so the cascade applied ×0.9 (55) cascade = 1.1064
cascade (56) annual = 403.87 vs worksheet (56) annual = 448.74
(cascade UNDER by ~45 kWh storage loss).
Same principle as the Slice S0380.156 Table 3 primary-loss WHC=903
guard: when HW is from electric immersion, main-heating-specific
DHW rules don't apply, regardless of the main heating type.
"""
# Arrange — electric 2 corpus variant: same EPC shape as the .156
# test, but here we drive the §4 cascade and read (56) storage loss
# instead of (59) primary loss.
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 epc.sap_heating.water_heating_code == 903
assert epc.sap_heating.cylinder_thermostat == "Y"
# Act — drive §4 (45..65) via the cascade helper. Pre-slice the
# cat-4 branch in `_separately_timed_dhw` returns True before any
# WHC check, so the cascade's `cylinder_storage_loss_monthly_kwh`
# applies the ×0.9 Temperature Factor multiplier to a system whose
# cylinder is fed by an electric immersion (not by the HP).
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 — (56) annual must match the worksheet's 448.7370 kWh/yr
# (= 1.2294 kWh/day × 365). Cascade pre-slice: 403.87 kWh/yr (= ×0.9
# of the worksheet value, off by 44.87 kWh/yr).
expected_storage_annual = 1.2294 * 365.0 # = 448.731 kWh/yr
got_storage_annual = sum(wh_result.solar_storage_monthly_kwh)
assert abs(got_storage_annual - expected_storage_annual) <= 0.5, (
f"electric 2 (Cat 4 HP + WHC=903 immersion + cylinder) "
f"§4 (56) storage loss annual = {got_storage_annual:.4f} kWh/yr; "
f"want ≈ {expected_storage_annual:.4f} kWh/yr (1.2294 × 365). "
f"Pre-slice `_separately_timed_dhw` returned True for the Cat-4 "
f"HP main before consulting WHC, so the cascade applied the "
f"Table 2b note b ×0.9 multiplier to a cylinder fed by an "
f"electric immersion (not by the HP). Per the spec's verbatim "
f"system-type list 'boiler systems, warm air systems and heat "
f"pump systems', electric immersion is not in scope."
)
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: