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 # ============================================================================