From 963db2ae230cf861172b89f44b235a1e0f74eb0b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:51:13 +0000 Subject: [PATCH] S0380.201: SAP 10.2 Table 4f note c) second main-system circulation pump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simulated case 6 (P960-0001-001431, dual oil boiler 51% rads + 49% underfloor) worksheet (231) = 356 = (230c) central-heating pump 156 + (230d) oil boiler pump 200. (230c) decomposes per SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main heating systems include two figures from this table" — Main 1 41 kWh (pump age "2013 or later") + Main 2 115 kWh (pump age unknown). The cascade summed only Main 1's circulation pump, giving (231) = 241. cert_to_inputs now adds the second main's circulation pump, gated on a lodged main_heating_fraction > 0 (a genuine second SPACE-heating main — the same test §9a uses to split space-heating demand). This excludes DHW-only second mains (cert 000565 Main 2 = gas combi via WHC 914, fraction 0); without the gate 000565's worksheet pins regressed +115 kWh. Re-pin: golden 0240 (dual-main oil combi, API-only, no worksheet) gains its Main 2 pump too (pumps_fans 315 → 430). Spec-correct per note c and validated by the case-6 worksheet; SAP cont 72.55 → 72.18 (integer 73 → 72, resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → +0.1385. The lodged 73 carries Elmhurst's own residual; the worksheet- backed case 6 is the spec authority for the archetype. Note: the boiler-interlock −5pp per-main determination the prior handover flagged as the priority is already implemented (S0380.141 cylinder-thermostat path + S0380.177 room-thermostat path) — case 6 already produces (206)=79 / (207)=84 exactly, and 0240 is a combi with no cylinder so correctly unpenalised. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 21 +++++++++++++++ .../rdsap/test_golden_fixtures.py | 26 ++++++++++++++++--- .../_elmhurst_worksheet_001431_case6.py | 8 ++++++ .../worksheet/test_section_cascade_pins.py | 25 ++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index db29fa0a..68c91620 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5924,6 +5924,27 @@ def cert_to_inputs( has_balanced_mv=_has_balanced_mechanical_ventilation(epc), ) ) + # SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main + # heating systems include two figures from this table." A genuine + # second SPACE-heating main therefore contributes its own circulation + # pump alongside Main 1's. The "second main heating system" test is the + # same one §9a uses to split space-heating demand: a lodged + # `main_heating_fraction > 0`. This excludes DHW-only second mains + # (e.g. cert 000565 Main 2 = gas combi via WHC 914, fraction 0 — water + # heating only, no space-heating circulation pump). Simulated case 6 + # (dual oil boiler, 51% rads + 49% underfloor) lodges Main 1 "2013 or + # later" (41 kWh) + Main 2 unknown-date (115 kWh) → worksheet (230c) + # central-heating pump = 41 + 115 = 156. The Main 2 oil-boiler aux + # (230d) is already summed in `_table_4f_additive_components`; this + # adds only the circulation pump. + _pumps_main_details = ( + epc.sap_heating.main_heating_details if epc.sap_heating else [] + ) + if len(_pumps_main_details) >= 2: + _pumps_main_2 = _pumps_main_details[1] + _pumps_main_2_fraction = _pumps_main_2.main_heating_fraction + if _pumps_main_2_fraction is not None and _pumps_main_2_fraction > 0: + pumps_fans_kwh += _table_4f_circulation_pump_kwh(_pumps_main_2) pumps_fans_kwh += _table_4f_additive_components(epc) # Track the MEV/MVHR-fan portion separately so the cost cascade can # apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 03f5fe93..847e7d0d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -82,9 +82,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0240-0200-5706-2365-8010", actual_sap=73, - expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+1.9459, - expected_co2_resid_tonnes_per_yr=+0.1226, + expected_sap_resid=-1, + expected_pe_resid_kwh_per_m2=+2.8092, + expected_co2_resid_tonnes_per_yr=+0.1385, 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_" @@ -136,7 +136,25 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "45°-inclined solar gains. Validated against the simulated-" "case-6 worksheet ((27a) U_eff 2.1062). The inclined solar " "gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 " - "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226." + "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226. " + "Slice S0380.201 added the SECOND main heating system's " + "circulation pump per SAP 10.2 Table 4f note c) (PDF p.175) " + "\"Where there are two main heating systems include two " + "figures from this table\" — gated on a lodged " + "main_heating_fraction > 0 (a genuine second SPACE-heating " + "main, excluding DHW-only second mains). This cert is dual-" + "main oil combi 51%/49%; Main 2 pump_age unknown (115 kWh) " + "joins Main 1's 115 → cascade pumps_fans 315 → 430 (+115 " + "kWh/yr). The fix was validated against the simulated-case-6 " + "worksheet, whose (231) = 356 decomposes as (230c) central-" + "heating pump 156 (= Main 1 41 + Main 2 115) + (230d) oil " + "boiler pump 200 — proving the two-pump treatment is spec-" + "correct. Cascade SAP cont 72.55 → 72.18 (integer 73 → 72, " + "resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → " + "+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]]." ), ), _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 94b6d7e8..57a07537 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -69,6 +69,14 @@ LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408 LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375 LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13 +# Worksheet (231) "Total electricity for the above, kWh/year" (Block 1). +# Decomposes as (230c) central heating pump 156 + (230d) oil boiler pump +# 200. (230c) = 41 (Main 1 circ pump, "2013 or later") + 115 (Main 2 circ +# pump, unknown date) — the two-main-system circulation-pump pair per +# SAP 10.2 Table 4f note c. (230d) = 2 × 100 oil-boiler aux (already +# wired in `_table_4f_additive_components`). +LINE_231_PUMPS_FANS_KWH: Final[float] = 356.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 396247f6..c8db9ec1 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -278,6 +278,31 @@ def test_section_3_roof_windows_case6_match_pdf() -> None: ) +def test_section_4f_pumps_fans_case6_match_pdf() -> None: + """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler + detached dwelling. Worksheet (231) = 356 = (230c) central heating + pump 156 + (230d) oil boiler pump 200. (230c) is itself the two- + main-system circulation-pump pair per SAP 10.2 Table 4f note c + ("Where there are two main heating systems include two figures from + this table"): Main 1 41 kWh (pump age "2013 or later") + Main 2 115 + kWh (pump age unknown). The pre-S0380.201 cascade summed only Main 1's + circulation pump (41) and gave (231) = 241.""" + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + + # Arrange + epc = _w001431_case6.build_epc() + + # Act + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert + _pin( + result.pumps_fans_kwh_per_yr, + _w001431_case6.LINE_231_PUMPS_FANS_KWH, + "§4f (231) case6", + ) + + # ============================================================================ # §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples # ============================================================================