mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.146: Table 3 primary loss — Table 4b non-PCDB regular boilers with cylinder
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 ..."
A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a cylinder is in neither zero-loss list, so primary loss must
apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies`
only covered PCDB Table 322 records (S0380.142) — when the cert lodges
a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil
boiler") with no PCDB index and no `main_heating_category` lodgement,
primary loss silently fell through to zero.
This slice extends the Elmhurst-path fallback in `_primary_loss_applies`
to fire when `sap_main_heating_code` is in the Table 4b code range
(101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3:
Combi codes: 103, 104, 107, 108, 112, 113, 118, 128, 129, 130
CPSU codes: 120, 121, 122, 123
Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 ×
[0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat +
separately timed DHW → h=3 winter & summer per Table 3 split). Annual
sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual.
Cascade impact on heating-systems corpus:
- oil 1 SAP residual +2.66 → +1.76 (Δ -0.90)
cost -£61.24 → -£40.60 (Δ +£20.64)
CO2 -242.27 → -129.22 (Δ +113.05 kg/yr)
PE -1050.49 → -590.02 (Δ +460.47 kWh/yr)
Only the oil 1 variant moves — every other cascade-OK variant either
already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/
2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil
codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for
free by the same dispatch when their certs surface in future cohorts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1636cfbc83
commit
bd193e06fc
3 changed files with 149 additions and 2 deletions
|
|
@ -229,7 +229,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
|
||||
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
|
||||
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),
|
||||
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919),
|
||||
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+1.7621, expected_cost_resid_gbp=-40.6035, expected_co2_resid_kg=-129.2211, expected_pe_resid_kwh=-590.0236),
|
||||
_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),
|
||||
|
|
|
|||
|
|
@ -3767,6 +3767,28 @@ def _heat_pump_extended_heating_days_per_month(
|
|||
return None
|
||||
|
||||
|
||||
# SAP 10.2 Table 4b (PDF p.168) sub-rows that are explicitly combi or
|
||||
# CPSU boilers — i.e. on the Table 3 zero-loss list ("Combi boiler ...
|
||||
# CPSU ..."). Every other Table 4b code (101-141) is a regular or
|
||||
# back-boiler / range-cooker boiler that incurs primary circuit loss
|
||||
# when feeding a hot-water cylinder.
|
||||
#
|
||||
# Combi codes:
|
||||
# 103, 104 — combi gas 1998+ (non-condensing / condensing)
|
||||
# 107, 108 — combi gas 1998+ permanent pilot
|
||||
# 112, 113 — combi gas pre-1998 fan-assisted flue
|
||||
# 118 — combi gas pre-1998 balanced/open flue
|
||||
# 128, 129, 130 — combi oil (pre-1998 / 1998+ / condensing)
|
||||
# CPSU codes:
|
||||
# 120, 121, 122, 123 — CPSU gas (auto/permanent × non/condensing)
|
||||
_TABLE_4B_COMBI_OR_CPSU_CODES: Final[frozenset[int]] = frozenset({
|
||||
103, 104, 107, 108, 112, 113, 118,
|
||||
120, 121, 122, 123,
|
||||
128, 129, 130,
|
||||
})
|
||||
_TABLE_4B_CODE_RANGE: Final[range] = range(101, 142)
|
||||
|
||||
|
||||
def _primary_loss_applies(
|
||||
main: Optional[MainHeatingDetail],
|
||||
cylinder_present: bool,
|
||||
|
|
@ -3780,7 +3802,7 @@ 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 three paths:
|
||||
cohort coverage we model four 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
|
||||
|
|
@ -3793,6 +3815,15 @@ def _primary_loss_applies(
|
|||
leaves `main_heating_category=None`, so the cascade dispatch
|
||||
falls through to this branch instead of the boiler-category
|
||||
branch above).
|
||||
- Table 4b non-PCDB boiler (sap_main_heating_code 101-141)
|
||||
with cylinder, when main_heating_category is not lodged:
|
||||
primary loss applies UNLESS the code is on the Table 3 zero
|
||||
list (combi sub-rows + CPSU sub-rows per
|
||||
`_TABLE_4B_COMBI_OR_CPSU_CODES`). Mirror of the PCDB Table
|
||||
322 branch — Elmhurst's heating-systems corpus leaves
|
||||
`main_heating_category=None` for Table 4b oil 1 (code 127
|
||||
"Condensing oil boiler" + 110 L cylinder), so the boiler-
|
||||
category branch above misses it; this branch picks it up.
|
||||
"""
|
||||
if not cylinder_present:
|
||||
return False
|
||||
|
|
@ -3818,6 +3849,18 @@ def _primary_loss_applies(
|
|||
if main.main_heating_index_number is not None:
|
||||
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
|
||||
return True
|
||||
# Elmhurst-path fallback for Table 4b non-PCDB boilers: a lodged
|
||||
# `sap_main_heating_code` in the 101-141 gas/liquid-fuel-boiler
|
||||
# range that is NOT a combi or CPSU sub-row is a regular / back-
|
||||
# boiler / range-cooker boiler — primary loss applies per Table 3
|
||||
# row 1 (boiler + cylinder via primary pipework).
|
||||
code = main.sap_main_heating_code
|
||||
if (
|
||||
code is not None
|
||||
and code in _TABLE_4B_CODE_RANGE
|
||||
and code not in _TABLE_4B_COMBI_OR_CPSU_CODES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3536,3 +3536,107 @@ def test_rdsap_10_table_32_prices_charge_mains_gas_hot_water_at_3p48_per_kwh() -
|
|||
f"{inputs.hot_water_fuel_cost_gbp_per_kwh!r}, expected 0.0348 per "
|
||||
f"RdSAP 10 Table 32 mains gas (§19.1 amendment, ADR-0010)"
|
||||
)
|
||||
|
||||
|
||||
def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_with_cylinder() -> None:
|
||||
"""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 Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
|
||||
feeding a hot-water cylinder is in neither zero-loss list, so
|
||||
primary loss must apply. Pre-slice the Elmhurst-path fallback in
|
||||
`_primary_loss_applies` only covered PCDB Table 322 records — when
|
||||
the cert lodges a Table 4b code (e.g. oil 1 sap_main_heating_code
|
||||
= 127 "Condensing oil boiler") with no PCDB index and no
|
||||
`main_heating_category` lodgement, primary loss silently fell back
|
||||
to zero.
|
||||
|
||||
Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform =
|
||||
14 × [0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder
|
||||
thermostat + separately timed DHW → h=3 winter & summer per Table
|
||||
3 split). Annual sum = 365 × 1.3972 ≈ 510 kWh/yr.
|
||||
"""
|
||||
# Arrange — oil 1 corpus variant: Table 4b code 127 (condensing
|
||||
# oil boiler) + 110 L cylinder + cylinder thermostat Yes. No
|
||||
# PCDB index lodged; main_heating_category None per Elmhurst
|
||||
# mapper. Same property shape as pcdb 1 fixture (cert 001431).
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
|
||||
corpus_oil_1 = (
|
||||
Path(__file__).parents[4]
|
||||
/ "sap worksheets/heating systems examples/oil 1"
|
||||
)
|
||||
summary_pdf = next(corpus_oil_1.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)
|
||||
|
||||
# Pre-conditions reaffirm the diagnostic shape: cylinder lodged,
|
||||
# Table 4b regular oil boiler code 127, no PCDB index, no category.
|
||||
main = epc.sap_heating.main_heating_details[0]
|
||||
assert epc.has_hot_water_cylinder is True
|
||||
assert main.sap_main_heating_code == 127
|
||||
assert main.main_heating_index_number is None
|
||||
assert main.main_heating_category is None
|
||||
|
||||
# Act — drive §4 (45..65) via the cascade helper. Primary loss is
|
||||
# NOT applied today (pre-slice) because the Table 4b code path is
|
||||
# missing in `_primary_loss_applies`.
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=0.84,
|
||||
is_instantaneous=False,
|
||||
primary_age="G",
|
||||
pcdb_record=None,
|
||||
)
|
||||
assert wh_result is not None
|
||||
|
||||
# Assert — (59)m annual ≈ 510 kWh per Table 3 + the daily-rate
|
||||
# back-solve above. Worksheet oil 1 line (59) sum = 7 × 43.3132
|
||||
# + 4 × 41.9160 + 1 × 39.1216 ≈ 509.98 kWh/yr.
|
||||
expected_primary_annual = 509.98
|
||||
got_primary_annual = sum(wh_result.primary_loss_monthly_kwh)
|
||||
assert abs(got_primary_annual - expected_primary_annual) < 1.0, (
|
||||
f"oil 1 primary loss annual: got {got_primary_annual!r}, "
|
||||
f"want {expected_primary_annual!r} per SAP 10.2 Table 3 "
|
||||
f"(Table 4b regular oil boiler + cylinder + uninsulated "
|
||||
f"primary pipework + cylinder thermostat + separately timed "
|
||||
f"DHW)"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue