mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.142: §4 (61)m/(59)m cascade — cylinder presence gates combi=0 + primary loss applies for PCDB Table 322 boilers
SAP 10.2 §4 line 7702 (PDF p.137):
Combi loss for each month from Table 3a, 3b or 3c (enter '0' if
not a combi boiler)
SAP 10.2 Table 3 (PDF p.160) zero-loss list for primary circuit loss:
Electric immersion heater
Combi boiler (including when it is part of a combined heat pump and
boiler package and provides all the hot water)
CPSU (including electric 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
Combi boilers are defined by Table 3's zero-loss list entry: they
provide instantaneous DHW with no storage vessel. A cert that lodges
a hot-water cylinder therefore has a non-combi heat generator —
the cylinder bypasses any instantaneous-DHW capability and the
boiler acts as a regular boiler for the DHW circuit.
Two compounding gaps for PCDB Table 322 (gas/oil boiler) records
with a lodged cylinder:
(a) (61)m combi loss: pre-slice the cascade routed every PCDB record
through `pcdb_combi_loss_override` regardless of cylinder
presence. For PCDB regular boilers (subsidiary_type=0, store_
type=0, separate_dhw_tests=0) this dispatched to Table 3a row 1
"Instantaneous without keep-hot" — 600 kWh/yr. Cert pcdb 1
(Potterton KOA PCDB 716 + 110 L cylinder) exposed this: worksheet
(61)m = 0 ; cascade was lodging 600 kWh/yr keep-hot loss on a
regular oil boiler.
(b) (59)m primary loss: `_primary_loss_applies` gated on
`main_heating_category in {1, 2}`. The Elmhurst path leaves
`main_heating_category=None`, so the gate returned False even
when the cert lodged a PCDB Table 322 (gas/oil boiler) record +
a cylinder. Worksheet (59)m sum ~1177 kWh ; cascade was zero.
Fix:
- `_water_heating_worksheet_and_gains` now zeroes combi_loss_override
whenever `epc.has_hot_water_cylinder` is True (top-level gate
preceding the `pcdb_combi_loss_override` dispatch). Preserves the
existing non-cylinder fallback for HP / no-PCDB / community-heat
certs that lack a main_heating_category lodgement.
- `_primary_loss_applies` extends the Elmhurst-path fallback: when
`main_heating_index_number` resolves to a PCDB Table 322 record,
return True (the cert is implicitly a boiler — Table 3 row 1 covers
any "heat generator (e.g. boiler) connected to a hot water storage
vessel via insulated or uninsulated pipes").
Corpus impact:
- pcdb 1 (Potterton KOA + cylinder, the only PCDB Table 322 + cylinder
combination in the corpus): SAP +3.40 → +2.86; cost -£75.68 →
-£63.22; CO2 -397.02 → -328.74; PE -1601.74 → -1257.97.
- Golden cert 0390-2954-3640-2196-4175 (Firebird oil combi PCDF 9005
+ cylinder): PE -26.37 → -28.50; CO2 -2.55 → -2.75. Combi-loss
removal (-600 kWh/yr) exceeded the primary-loss gain (~5-10 kWh
given the cert's insulated pipework + thermostat lodging), so the
net (62) shifted down. Direction is more spec-correct: the spec
treats a combi feeding a cylinder as a regular boiler for DHW,
matching the (61)m=0 + (59)m>0 worksheet behaviour.
Extended handover suite: 885 pass, 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6636f1c333
commit
7f9074fca9
4 changed files with 177 additions and 16 deletions
|
|
@ -233,7 +233,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
|
||||
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
|
||||
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=-271.4351),
|
||||
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+3.3965, expected_cost_resid_gbp=-75.6799, expected_co2_resid_kg=-397.0228, expected_pe_resid_kwh=-1601.7416),
|
||||
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+2.8556, expected_cost_resid_gbp=-63.2154, expected_co2_resid_kg=-328.7435, expected_pe_resid_kwh=-1257.9712),
|
||||
# Slice S0380.133 unblocked 10 solid-fuel variants by routing the
|
||||
# Elmhurst §14.0 "Main Heating EES Code" through the new
|
||||
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the
|
||||
|
|
|
|||
|
|
@ -3649,7 +3649,7 @@ def _primary_loss_applies(
|
|||
cylinder_present: bool,
|
||||
hp_record: Optional[HeatPumpRecord],
|
||||
) -> bool:
|
||||
"""SAP 10.2 Table 3 (PDF p.159) zero-loss configurations — primary
|
||||
"""SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary
|
||||
loss only fires when a cylinder is present AND the lodgement falls
|
||||
outside the zero list. The cohort path: heat-pump main heating with
|
||||
a separate (not integral) vessel per the PCDB Table 362 record.
|
||||
|
|
@ -3657,11 +3657,19 @@ def _primary_loss_applies(
|
|||
Combi boilers, CPSUs, thermal stores within 1.5 m insulated pipe,
|
||||
direct-acting electric boilers, electric immersion heaters, and
|
||||
HPs with `hw_vessel_mode = 1` (integral) all skip the loss. For
|
||||
cohort coverage we model two paths:
|
||||
cohort coverage we model three paths:
|
||||
- HP with PCDB record: gate on `hp_record.hw_vessel_mode != 1`
|
||||
- Boiler (cat 1, 2) with cylinder: primary loss applies (the
|
||||
cascade's pre-slice-102d behaviour was zero, masking ~516
|
||||
kWh/yr on certs with cylinders).
|
||||
- PCDB Table 322 (gas/oil boiler) record with cylinder, when
|
||||
main_heating_category is not lodged: primary loss applies
|
||||
(cylinder presence + PCDB boiler = "boiler connected to hot-
|
||||
water storage vessel" per Table 3 row 1 — the spec category
|
||||
for this fixture is 1, but the Elmhurst mapper currently
|
||||
leaves `main_heating_category=None`, so the cascade dispatch
|
||||
falls through to this branch instead of the boiler-category
|
||||
branch above).
|
||||
"""
|
||||
if not cylinder_present:
|
||||
return False
|
||||
|
|
@ -3676,7 +3684,18 @@ def _primary_loss_applies(
|
|||
# Spec p.159: zero for "Heat pump from PCDB with hot water vessel
|
||||
# integral to package". Vessel mode 1 = integral.
|
||||
return hp_record.hw_vessel_mode != 1
|
||||
return main.main_heating_category in {1, 2}
|
||||
if main.main_heating_category in {1, 2}:
|
||||
return True
|
||||
# Elmhurst-path fallback: when the cert lodges a PCDB Table 322
|
||||
# record (gas/oil boiler) but `main_heating_category` is None, the
|
||||
# presence of the PCDB boiler record is sufficient evidence that
|
||||
# the main is a boiler — Table 3 row 1 applies ("hot water is
|
||||
# heated by a heat generator (e.g. boiler) connected to a hot
|
||||
# water storage vessel via insulated or uninsulated pipes").
|
||||
if main.main_heating_index_number is not None:
|
||||
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row
|
||||
|
|
@ -3901,13 +3920,27 @@ def _water_heating_worksheet_and_gains(
|
|||
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
|
||||
)
|
||||
main = _first_main_heating(epc)
|
||||
# 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(
|
||||
# SAP 10.2 §4 line 7702 (PDF p.137): "Combi loss for each month
|
||||
# from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". The
|
||||
# SAP 10.2 Table 3 zero-loss list (PDF p.160) defines a combi boiler
|
||||
# by its instantaneous-DHW operation: combis don't feed a cylinder
|
||||
# because their heat exchanger heats DHW on demand. A lodged hot-
|
||||
# water cylinder therefore means the heat generator is NOT a combi
|
||||
# — even when the cert lodges a PCDB Table 105 record that would
|
||||
# otherwise route through `pcdb_combi_loss_override` to a Table 3a/
|
||||
# 3b/3c row. Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder)
|
||||
# exposes this: pre-slice the cascade applied Table 3a row 1
|
||||
# 600 kWh/yr "keep-hot" loss to a PCDB regular oil boiler.
|
||||
if epc.has_hot_water_cylinder:
|
||||
combi_loss_override = zero_monthly
|
||||
elif combi_loss_override is None and not _table_3a_combi_loss_default_applies(
|
||||
main
|
||||
):
|
||||
# SAP 10.2 §4 line 7702 fallback: 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.
|
||||
combi_loss_override = zero_monthly
|
||||
# SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss
|
||||
# (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous /
|
||||
|
|
|
|||
|
|
@ -2844,6 +2844,129 @@ def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_
|
|||
# PCDB Eq D1 step.
|
||||
|
||||
|
||||
def test_sap_4_lines_7700_7702_pcdb_regular_boiler_with_cylinder_zeroes_combi_loss_and_applies_primary_loss() -> None:
|
||||
"""SAP 10.2 §4 line 7702 (PDF p.137):
|
||||
|
||||
Combi loss for each month from Table 3a, 3b or 3c
|
||||
(enter "0" if not a combi boiler)
|
||||
|
||||
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss":
|
||||
|
||||
Primary circuit loss applies when hot water is heated by a
|
||||
heat generator (e.g. boiler) connected to a hot water storage
|
||||
vessel via insulated or uninsulated pipes (the primary
|
||||
pipework). 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
|
||||
|
||||
A PCDB regular gas/oil boiler (Table 322) feeding a hot-water
|
||||
cylinder is in neither of the (61)m / (59)m zero-loss lists:
|
||||
- cylinder presence → not a combi → (61)m = 0
|
||||
- boiler + cylinder + indirect pipework → (59)m applies
|
||||
|
||||
Pre-slice the cascade routed PCDB 716 (Potterton KOA, a regular
|
||||
oil boiler) through `pcdb_combi_loss_override` and got 600 kWh/yr
|
||||
"Table 3a row 1 keep-hot" (the spec's *combi* fall-through),
|
||||
while `_primary_loss_applies` returned False because the Elmhurst
|
||||
mapper leaves `main_heating_category=None` (cascade gates primary
|
||||
on `main_heating_category in {1, 2}`). Both gaps masked one
|
||||
another: −1177 kWh missing primary + +600 kWh excess combi netted
|
||||
to ~−577 kWh on (62).
|
||||
|
||||
This slice introduces the cylinder-presence gate for combi loss
|
||||
(combi boilers are by definition instantaneous per Table 3
|
||||
zero-loss list — a lodged cylinder means the heat generator is
|
||||
not a combi) and extends primary-loss eligibility to detect PCDB
|
||||
Table 322 (gas/oil boiler) records when the cascade can't read
|
||||
main_heating_category from the cert (Elmhurst path).
|
||||
"""
|
||||
# Arrange — pcdb 1 corpus variant: PCDB 716 Potterton KOA + 110 L
|
||||
# cylinder + Cylinder Stat: No (worksheet shows (61)m all zero
|
||||
# and (59)m monthly = 128.38 / 115.95 / 128.38 / ...; annual sum
|
||||
# ≈ 1176.79 kWh/yr).
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
|
||||
|
||||
corpus_pcdb_1 = (
|
||||
Path(__file__).parents[4]
|
||||
/ "sap worksheets/heating systems examples/pcdb 1"
|
||||
)
|
||||
summary_pdf = next(corpus_pcdb_1.glob("Summary_*.pdf"))
|
||||
info = subprocess.run(
|
||||
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr]
|
||||
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)
|
||||
|
||||
# Act — drive (45..65) directly via the §4 worksheet helper so the
|
||||
# combi-loss and primary-loss assertions read the per-month tuples
|
||||
# the cascade hands to `total_water_heating_demand_monthly_kwh`.
|
||||
main = epc.sap_heating.main_heating_details[0]
|
||||
pcdb_record = (
|
||||
gas_oil_boiler_record(main.main_heating_index_number)
|
||||
if main.main_heating_index_number is not None
|
||||
else None
|
||||
)
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=0.53,
|
||||
is_instantaneous=False,
|
||||
primary_age="G",
|
||||
pcdb_record=pcdb_record,
|
||||
)
|
||||
assert wh_result is not None
|
||||
|
||||
# Assert 1 — (61)m all zero per §4 line 7702 ("enter '0' if not a
|
||||
# combi boiler"). A lodged cylinder means the heat generator is
|
||||
# not a combi (combi boilers are instantaneous per Table 3
|
||||
# zero-loss list).
|
||||
assert sum(wh_result.combi_loss_monthly_kwh) == 0.0, (
|
||||
f"pcdb 1 combi loss annual: got {sum(wh_result.combi_loss_monthly_kwh)!r}, "
|
||||
f"want 0.0 per SAP 10.2 §4 line 7702 (cylinder lodged → main is "
|
||||
f"not a combi boiler → (61)m = 0)"
|
||||
)
|
||||
|
||||
# Assert 2 — (59)m annual ≈ 1176.79 kWh per Table 3 + RdSAP §S10.11
|
||||
# Table 29 defaults (uninsulated pipework p=0 for age G; no cylinder
|
||||
# thermostat → winter h=11, summer h=3). Worksheet pcdb 1 sum =
|
||||
# Jan 128.38 + Feb 115.95 + ... + Dec 128.38 ≈ 1176.79.
|
||||
expected_primary_annual = 1176.79
|
||||
got_primary_annual = sum(wh_result.primary_loss_monthly_kwh)
|
||||
assert abs(got_primary_annual - expected_primary_annual) < 1.0, (
|
||||
f"pcdb 1 primary loss annual: got {got_primary_annual!r}, "
|
||||
f"want {expected_primary_annual!r} per SAP 10.2 Table 3 "
|
||||
f"(PCDB gas/oil boiler + cylinder + uninsulated primary pipework + "
|
||||
f"no cylinder thermostat)"
|
||||
)
|
||||
|
||||
|
||||
def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None:
|
||||
"""SAP 10.2 §4 line 7693 (PDF p.137):
|
||||
|
||||
|
|
|
|||
|
|
@ -127,8 +127,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0390-2954-3640-2196-4175",
|
||||
actual_sap=60,
|
||||
expected_sap_resid=+7,
|
||||
expected_pe_resid_kwh_per_m2=-26.3749,
|
||||
expected_co2_resid_tonnes_per_yr=-2.5544,
|
||||
expected_pe_resid_kwh_per_m2=-28.5027,
|
||||
expected_co2_resid_tonnes_per_yr=-2.7481,
|
||||
notes=(
|
||||
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
|
||||
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
|
||||
|
|
@ -141,11 +141,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"53 → 54 (resid -7 → -6). Slice S0380.131 flipped heating-oil "
|
||||
"tariff 7.64 → 5.44 (cert 0240 closed exactly), exposing this "
|
||||
"cert's previously-masked +13 SAP of cascade gaps: residual "
|
||||
"swung -6 → +7. The oil-price bug was netting against an "
|
||||
"opposite-direction gap (cert lodges age F + 360 m² detached "
|
||||
"+ Firebird PCDF — likely fabric or hot-water cascade). PE / "
|
||||
"CO2 residuals unchanged by the unit-price flip; remaining "
|
||||
"SAP residual is a follow-up slice candidate."
|
||||
"swung -6 → +7. Slice S0380.142 re-routed this cert via the "
|
||||
"SAP 10.2 §4 line 7702 cylinder-presence gate ((61)m = 0 since "
|
||||
"the combi feeds a cylinder → not a combi for DHW per Table 3) "
|
||||
"+ Table 3 row 1 primary loss (PCDB Table 322 boiler + cylinder "
|
||||
"→ primary loss applies). The combi-loss removal (-600 kWh/yr) "
|
||||
"exceeded the primary-loss gain → cascade HW fuel dropped "
|
||||
"~650 kWh; PE residual shifted -26.37 → -28.50, CO2 -2.55 → "
|
||||
"-2.75. SAP integer unchanged because the cascade was already "
|
||||
"well above SAP 60 (actual). Remaining residual is a fabric or "
|
||||
"different §4 driver — follow-up slice candidate."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue