mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.152: SAP 10.2 Table 3 — primary loss for solid-fuel back-boilers
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:
"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)."
The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:
- WHC=901/902/914 (HW from main heating system) + wet boiler +
cylinder → primary loss applies (back-boiler / wet boiler heats
cylinder via primary loop).
- WHC=903 (HW from a separate electric immersion / secondary) → no
primary loss even when the main is a wet boiler.
Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.
Worksheet evidence on the 001431 corpus (all age G, same cylinder):
- solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply
- solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply
- solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip
- solid fuel 4..11 (633/636 non-boilers, WHC=903): skip
The fix:
- `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
parameter (default None for back-compat with synthetic tests).
- New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
+ `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
- Call site `_primary_loss_override` passes
`epc.sap_heating.water_heating_code`.
Heating-systems corpus impact:
- solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
PE -918.6 → -214.3 kWh/yr
- solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
PE -1241.7 → -754.1 kWh/yr
- All other variants: unchanged
SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).
Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fb173cdf3f
commit
d4f6ff0f2f
3 changed files with 132 additions and 3 deletions
|
|
@ -241,8 +241,8 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
# cost / CO2 / PE all route via the correct Table 32 fuel code.
|
||||
# Remaining residuals are likely heating-system efficiency or
|
||||
# control-type gaps — separate slices.
|
||||
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.7654, expected_cost_resid_gbp=-63.7195, expected_co2_resid_kg=+120.3433, expected_pe_resid_kwh=-1241.7357),
|
||||
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3086, expected_cost_resid_gbp=-30.1525, expected_co2_resid_kg=-327.2043, expected_pe_resid_kwh=-918.6312),
|
||||
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.0649, expected_cost_resid_gbp=-47.5795, expected_co2_resid_kg=+295.4889, expected_pe_resid_kwh=-754.0879),
|
||||
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+0.2968, expected_cost_resid_gbp=-6.8392, expected_co2_resid_kg=-74.2162, expected_pe_resid_kwh=-214.2510),
|
||||
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762),
|
||||
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604),
|
||||
|
|
|
|||
|
|
@ -4006,6 +4006,7 @@ def _primary_loss_applies(
|
|||
main: Optional[MainHeatingDetail],
|
||||
cylinder_present: bool,
|
||||
hp_record: Optional[HeatPumpRecord],
|
||||
water_heating_code: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""SAP 10.2 Table 3 (PDF p.160) zero-loss configurations — primary
|
||||
loss only fires when a cylinder is present AND the lodgement falls
|
||||
|
|
@ -4074,6 +4075,25 @@ def _primary_loss_applies(
|
|||
and code not in _TABLE_4B_COMBI_OR_CPSU_CODES
|
||||
):
|
||||
return True
|
||||
# Table 4a solid-fuel + electric boilers (codes 151-161 / 191-196):
|
||||
# the spec rule applies to ANY heat generator connected to a cylinder
|
||||
# via primary pipework — not just Table 4b gas/oil boilers. The
|
||||
# discriminator is the cert's `water_heating_code`: 901 / 902 / 914
|
||||
# (HW from main heating) means the back-boiler / electric boiler
|
||||
# feeds the cylinder through a primary loop and the loss applies.
|
||||
# WHC=903 (HW from a separate electric immersion) means the cylinder
|
||||
# isn't on the boiler's primary loop and no loss applies. Cohort
|
||||
# evidence (1431 corpus, age G, cylinder thermostat lodged):
|
||||
# - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply
|
||||
# - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply
|
||||
# - solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip
|
||||
# - solid fuel 4..11 (codes 633/636 non-boiler, WHC=903): skip
|
||||
if (
|
||||
code is not None
|
||||
and _is_wet_boiler_main(main)
|
||||
and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -4400,7 +4420,12 @@ def _primary_loss_override(
|
|||
hp_record: Optional[HeatPumpRecord] = None
|
||||
if main is not None and main.main_heating_index_number is not None:
|
||||
hp_record = heat_pump_record(main.main_heating_index_number)
|
||||
if not _primary_loss_applies(main, cylinder_present, hp_record):
|
||||
if not _primary_loss_applies(
|
||||
main,
|
||||
cylinder_present,
|
||||
hp_record,
|
||||
water_heating_code=epc.sap_heating.water_heating_code,
|
||||
):
|
||||
return None
|
||||
return primary_loss_monthly_kwh(
|
||||
pipework_insulation_fraction=_pipework_insulation_fraction_table_3(
|
||||
|
|
|
|||
|
|
@ -3771,6 +3771,110 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi
|
|||
)
|
||||
|
||||
|
||||
def test_sap_table_3_primary_loss_applies_to_solid_fuel_back_boiler_with_cylinder_and_whc_901() -> None:
|
||||
"""SAP 10.2 Table 3 (PDF p.160) — primary circuit loss applies when
|
||||
hot water is heated by a heat generator (e.g. boiler) connected to
|
||||
a hot water storage vessel via primary pipework. The spec doesn't
|
||||
restrict the rule to Table 4b gas/oil boilers — Table 4a solid-fuel
|
||||
boilers (codes 151-161: manual/auto-feed boilers, range cookers,
|
||||
closed room heaters with back-boiler, open fires with back-boiler)
|
||||
also feed cylinders via primary pipework when WHC=901 (HW from main
|
||||
heating).
|
||||
|
||||
The discriminator is the lodged `water_heating_code`:
|
||||
- WHC=901/902/914 (HW from main heating) + wet boiler + cylinder
|
||||
→ primary loss applies (the back-boiler's primary loop incurs
|
||||
the standing loss).
|
||||
- WHC=903 (HW from a separate immersion or secondary system) →
|
||||
no primary loss, even if the main is a wet boiler — the
|
||||
cylinder isn't connected to the boiler's primary loop.
|
||||
|
||||
Worksheet evidence across the 001431 corpus (all age G, same
|
||||
cylinder + cylinder thermostat lodged):
|
||||
- solid fuel 2 (code 158, WHC=901): (59) ≈ 505 kWh/yr (winter only)
|
||||
- solid fuel 3 (code 160, WHC=901): (59) ≈ 643 kWh/yr (year-round)
|
||||
- solid fuel 5 (code 153, WHC=903): (59) = 0 (separate immersion)
|
||||
|
||||
Pre-slice `_primary_loss_applies` only covered Table 4b codes
|
||||
101-141 (gas/oil) — Table 4a solid-fuel boiler codes 151-161 fell
|
||||
through and primary loss silently went to zero, leaving the §5 (72)
|
||||
water-heating internal gain ~74 W lower than the worksheet for
|
||||
every WHC=901 solid-fuel back-boiler variant. Knock-on: SH demand
|
||||
~+330 kWh/yr (less internal gain → more SH needed) → ~+2.3% SAP
|
||||
over-shoot pattern documented in the Cluster B audit (electric 5
|
||||
has the same +2.3% pattern but a separate cause).
|
||||
"""
|
||||
# Arrange — solid fuel 2 corpus variant: Table 4a code 158
|
||||
# (anthracite closed room heater with back-boiler) + 110 L cylinder +
|
||||
# cylinder thermostat Yes + WHC=901. No PCDB index 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_sf2 = (
|
||||
Path(__file__).parents[4]
|
||||
/ "sap worksheets/heating systems examples/solid fuel 2"
|
||||
)
|
||||
summary_pdf = next(corpus_sf2.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 == 158
|
||||
assert epc.sap_heating.water_heating_code == 901
|
||||
|
||||
# Act — drive §4 (45..65) via the cascade helper. Cascade post-slice
|
||||
# should now apply primary loss (Table 4a solid-fuel boiler + WHC=901
|
||||
# + cylinder → loss applies).
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=0.65,
|
||||
is_instantaneous=False,
|
||||
primary_age="G",
|
||||
pcdb_record=None,
|
||||
)
|
||||
assert wh_result is not None
|
||||
|
||||
# Assert — primary loss must be non-zero (the worksheet's (59) sums
|
||||
# to ~505 kWh/yr for SF2). The cascade uses (h=3, h=3) per
|
||||
# `_separately_timed_dhw=True` so the cascade output is the
|
||||
# year-round Table 3 formula = 31×14×(0.0245×3 + 0.0263) ≈ 43.3 kWh
|
||||
# per 31-day month ≈ 510 kWh/yr — within ~5 kWh of the worksheet.
|
||||
annual_primary = sum(wh_result.primary_loss_monthly_kwh)
|
||||
assert annual_primary > 400.0, (
|
||||
f"solid fuel 2 (Table 4a code 158, WHC=901) primary loss "
|
||||
f"annual = {annual_primary:.2f} kWh/yr; pre-slice the cascade "
|
||||
f"returned 0 because `_primary_loss_applies` only covered "
|
||||
f"Table 4b codes. Per SAP 10.2 Table 3 the spec rule applies "
|
||||
f"to any boiler connected to a cylinder via primary pipework."
|
||||
)
|
||||
|
||||
|
||||
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