From 3581513b7e90dcee60b28c7e020e00e4ad00fc53 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:35:08 +0000 Subject: [PATCH] S0380.202: SAP 10.2 Table 5a note a) second main-system pump gain (70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../worksheet/internal_gains.py | 117 +++++++++++++----- .../rdsap/test_golden_fixtures.py | 15 ++- .../_elmhurst_worksheet_001431_case6.py | 9 ++ .../worksheet/test_section_cascade_pins.py | 25 ++++ 4 files changed, 131 insertions(+), 35 deletions(-) diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 8e6784e1..509ac363 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 847e7d0d..d656440d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -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( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 57a07537..56b91e60 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index c8db9ec1..e00194c6 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -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 # ============================================================================