S0380.202: SAP 10.2 Table 5a note a) second main-system pump gain (70)

The §5 (70) internal-gains mirror of S0380.201's Table 4f (230c). SAP
10.2 Table 5a note a) (PDF p.177) verbatim: "Where there are two main
heating systems serving different parts of the dwelling, assume each has
its own circulation pump and therefore include two figures from this
table. ... Where two main systems serve the same space a single pump is
assumed."

Simulated case 6 (dual oil, 51% radiators + 49% underfloor) lodges Main
1 "2013 or later" (3 W) + Main 2 unknown date (7 W) → worksheet (70) =
10 W in the 8 heating months. The cascade billed a single Main 1 pump
(3 W). New `_second_main_central_heating_pump_gain_w` adds the second
main's gain (at its own pump-age bucket), gated on a lodged
main_heating_fraction > 0 — the same genuine-second-space-heating-main
test as S0380.201, so DHW-only second mains (cert 000565 Main 2 combi via
WHC 914, fraction 0) keep a single pump (70)=3. Refactored the per-detail
pump predicate (`_main_detail_has_central_heating_pump`) and date bucket
(`_pump_date_category_for_detail`) out of the orchestrator.

Re-pin: golden 0240 (dual-main oil combi, both unknown date) (70) 7 → 14
W; the extra internal gain lowers space-heating demand → SAP cont 72.18 →
72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 +0.1385 →
+0.1269 (both closer to zero). Validated against the case-6 worksheet.

This closes the (70) leg of case 6's space-demand gap. Remaining for full
case-6 closure: roof fabric (37) +1.176 W/K (room-in-roof shell) and HW
(216) Eq-D1 water efficiency −1.6%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 14:35:08 +00:00
parent 4ed691603f
commit 3581513b7e
4 changed files with 131 additions and 35 deletions

View file

@ -27,9 +27,13 @@ from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from math import cos, exp, pi
from typing import Final
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
SapWindow,
)
def _decimal_window_area_2dp(width: float, height: float) -> float:
@ -634,15 +638,15 @@ def _daylight_factor_from_cert(
return 52.2 * g_l * g_l - 9.94 * g_l + 1.433
def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
"""Map first main-heating detail's central_heating_pump_age_str to a
def _pump_date_category_for_detail(
detail: Optional[MainHeatingDetail],
) -> PumpDateCategory:
"""Map a `MainHeatingDetail`'s central_heating_pump_age_str to a
Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown"
/ None on each `MainHeatingDetail` (nested under `epc.sap_heating`)."""
sap_heating = getattr(epc, "sap_heating", None)
details = getattr(sap_heating, "main_heating_details", None) or []
/ None on each detail."""
age_str = ""
if details:
age_str = (details[0].central_heating_pump_age_str or "").lower()
if detail is not None:
age_str = (detail.central_heating_pump_age_str or "").lower()
if "post" in age_str or "2013 or later" in age_str:
return PumpDateCategory.NEW_2013_OR_LATER
if "pre" in age_str or "2012" in age_str:
@ -650,6 +654,14 @@ def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
return PumpDateCategory.UNKNOWN
def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
"""Table 5a date bucket for Main 1 (the dwelling's first circulation
pump). Delegates to `_pump_date_category_for_detail`."""
sap_heating = getattr(epc, "sap_heating", None)
details = getattr(sap_heating, "main_heating_details", None) or []
return _pump_date_category_for_detail(details[0] if details else None)
# SAP 10.2 Table 5a Note a) (PDF p.177): "Not applicable for electric
# heat pumps from database." The pump GAIN (worksheet line 70) is
# omitted only for HP-category systems. Where the cert lodges a
@ -730,33 +742,69 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool:
details = epc.sap_heating.main_heating_details
if not details:
return False
for d in details:
if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY:
# PCDB Table 362 record → pump electricity AND gain are
# embedded in COP (Appendix N1.2.1); no separate gain row.
if d.main_heating_index_number is not None:
continue
# Cat 5 warm-air HP (codes 521/523-527) → no water pump.
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
continue
# Cat 4 HP, Table 4a default cascade → apply Table 5a
# pump gain per Appendix N3.1.
return True
code = d.sap_main_heating_code
if code is not None and any(
code in r for r in _WET_BOILER_SAP_CODE_RANGES
):
return True
return any(_main_detail_has_central_heating_pump(d) for d in details)
def _main_detail_has_central_heating_pump(d: MainHeatingDetail) -> bool:
"""Whether a single `MainHeatingDetail` carries a Table 5a central-
heating-pump gain the per-detail core of
`_any_main_system_has_central_heating_pump` (see that docstring for
the wet-main identification + HP rules)."""
if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY:
# PCDB Table 362 record → pump electricity AND gain are
# embedded in COP (Appendix N1.2.1); no separate gain row.
if d.main_heating_index_number is not None:
return True
if d.main_heating_category in {1, 2}:
return True
if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES:
return True
return False
# Cat 5 warm-air HP (codes 521/523-527) → no water pump.
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
return False
# Cat 4 HP, Table 4a default cascade → apply Table 5a
# pump gain per Appendix N3.1.
return True
code = d.sap_main_heating_code
if code is not None and any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
return True
if d.main_heating_index_number is not None:
return True
if d.main_heating_category in {1, 2}:
return True
if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES:
return True
return False
def _second_main_central_heating_pump_gain_w(epc: EpcPropertyData) -> float:
"""SAP 10.2 Table 5a note a) (PDF p.177): "Where there are two main
heating systems serving different parts of the dwelling, assume each
has its own circulation pump and therefore include two figures from
this table. ... Where two main systems serve the same space a single
pump is assumed."
Returns the SECOND main system's central-heating-pump gain (W,
heating-season) when a genuine second SPACE-heating main is lodged
detected by `main_heating_fraction > 0`, the same gate
`cert_to_inputs` uses to split §9a space-heating demand and to add
the Table 4f note c) second circulation pump (S0380.201). Excludes
DHW-only second mains (fraction 0, e.g. cert 000565 Main 2 combi via
WHC 914). The gain uses the SECOND main's own pump-age bucket — for
simulated case 6 (dual oil, Main 2 unknown date) that is 7 W, giving
worksheet (70) = 3 (Main 1) + 7 (Main 2) = 10.
"""
details = epc.sap_heating.main_heating_details
if len(details) < 2:
return 0.0
second = details[1]
fraction = second.main_heating_fraction
if fraction is None or fraction <= 0:
return 0.0
if not _main_detail_has_central_heating_pump(second):
return 0.0
return central_heating_pump_w(
date_category=_pump_date_category_for_detail(second)
)
# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The
# Table 5a "Warm air heating system fans" gain (and Table 4f
# electricity row) fire for these mains:
@ -881,6 +929,11 @@ def internal_gains_from_cert(
pump_w = central_heating_pump_w(
date_category=_pump_date_category_from_cert(epc)
)
# SAP 10.2 Table 5a note a) — a second main heating system serving
# a different part of the dwelling has its own circulation pump
# (two figures from the table). Simulated case 6 (dual oil, rads +
# underfloor) → Main 1 3 W + Main 2 7 W = worksheet (70) 10 W.
pump_w += _second_main_central_heating_pump_gain_w(epc)
else:
pump_w = 0.0
# SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF

View file

@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-1,
expected_pe_resid_kwh_per_m2=+2.8092,
expected_co2_resid_tonnes_per_yr=+0.1385,
expected_pe_resid_kwh_per_m2=+2.5812,
expected_co2_resid_tonnes_per_yr=+0.1269,
notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
@ -154,7 +154,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"+0.1385. The lodged 73 carries Elmhurst's own rounding/"
"residual (this cert is API-only with no worksheet); the "
"worksheet-backed case 6 is the spec authority for the "
"archetype per [[feedback-worksheet-not-api-reference]]."
"archetype per [[feedback-worksheet-not-api-reference]]. "
"Slice S0380.202 added the SECOND main's central-heating-pump "
"GAIN per SAP 10.2 Table 5a note a) (PDF p.177) \"two main "
"heating systems serving different parts ... include two "
"figures\" — the §5 (70) mirror of S0380.201's Table 4f (230c). "
"Both Main 1 + Main 2 unknown-date → (70) 7 → 14 W. The extra "
"internal gain lowers space-heating demand → SAP cont 72.18 → "
"72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 "
"+0.1385 → +0.1269 (both closer to zero). Validated against "
"case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2)."
),
),
_GoldenExpectation(

View file

@ -77,6 +77,15 @@ LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13
# wired in `_table_4f_additive_components`).
LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0
# Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only
# (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair
# per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2
# (unknown date → 7 W). The pre-S0380.202 cascade billed a single Main 1
# pump (3 W).
LINE_70_PUMPS_FANS_GAINS_W: Final[tuple[float, ...]] = (
10.0, 10.0, 10.0, 10.0, 10.0, 0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0,
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the

View file

@ -303,6 +303,31 @@ def test_section_4f_pumps_fans_case6_match_pdf() -> None:
)
def test_section_5_pumps_fans_gains_case6_match_pdf() -> None:
"""(70) pumps/fans internal-gain pin for simulated case 6. The dual oil
boiler serves different parts (51% radiators + 49% underfloor), so SAP
10.2 Table 5a note a) ("Where there are two main heating systems serving
different parts of the dwelling, assume each has its own circulation
pump and therefore include two figures from this table") bills TWO
central-heating-pump gains: Main 1 "2013 or later" (3 W) + Main 2
unknown date (7 W) = 10 W in the 8 heating months. The pre-S0380.202
cascade billed a single Main 1 pump (3 W)."""
# Arrange
epc = _w001431_case6.build_epc()
# Act
ig = internal_gains_section_from_cert(epc)
# Assert
assert ig is not None
for m in range(12):
_pin(
ig.pumps_fans_monthly_w[m],
_w001431_case6.LINE_70_PUMPS_FANS_GAINS_W[m],
f"§5 (70) case6 month {m + 1}",
)
# ============================================================================
# §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples
# ============================================================================