mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
4350c71bdd
commit
02092c8041
3 changed files with 143 additions and 1 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue