From 35ea664db8d9e70aaebfc1e65190b761f2cffbc5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 09:14:11 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.149:=20Table=204f=20=E2=80=94=20c?= =?UTF-8?q?irculation=20pump=20dispatch=20by=20pump=20age=20+=20wet-boiler?= =?UTF-8?q?=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other auxiliary uses" — Heating system circulation pump rows: Circulation pump, 2013 or later 41 kWh/yr Circulation pump, 2012 or earlier 165 kWh/yr Circulation pump, unknown date 115 kWh/yr Pre-slice the cascade hardcoded `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY[2] = 160 kWh/yr` (115 Unknown CH + 45 gas flue fan) for category=2 gas boilers and fell through to `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130` for any other category. Both shortcuts ignored the per-cert `central_heating_pump_age` lodging AND incorrectly applied circulation pump electricity to dry electric storage / direct-acting / room heater systems (no primary water loop). Implementation: - Mapper: `_elmhurst_pump_age_int` now recognises both "Pre 2013" and "2012 or earlier" string forms as the SAP10 enum 1 (Pre 2013). Pre-slice "2012 or earlier" silently returned 2 (2013 or later) on the entire oil corpus, mis-applying the 41 kWh post-2013 circulation pump to certs that lodge "2012 or earlier" via Elmhurst Summary §14 "Heat pump age". - New `_is_wet_boiler_main(main)` gate: identifies wet-boiler systems by Table 4a/4b code range (101-141 gas/oil, 151-161 solid fuel, 191-196 electric boilers), PCDB Table 322 record, or category ∈ {1, 2} fallback. Heat pumps (cat 4) return False per Table 4f note "Not applicable for electric heat pumps from database". Electric storage / direct / room heater codes (401-499, 601-699) return False — they have no primary loop. - New `_table_4f_circulation_pump_kwh(main)` dispatches on `central_heating_pump_age`: None / 0 → 115 kWh (Unknown date) 1 → 165 kWh (Pre 2013 / 2012 or earlier) 2 → 41 kWh (2013 or later) - New `_table_4f_main_1_gas_boiler_flue_fan_kwh(main)` extracts the gas-flue-fan 45 kWh logic from the old category dispatch. Gated on `_is_wet_boiler_main` + gas fuel + fan_flue_present. - Remove `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` and `_DEFAULT_PUMPS_FANS_KWH_PER_YR` constants (the new helpers replace both). Worksheet evidence for the wet-boiler gate: electric 1 (code 191 electric boiler): ws (230c) = 41 kWh ✓ electric 5 (code 402 electric storage): ws (231) = 0 kWh ✗ solid fuel 2 (code 158 anthracite): ws (230c) = 41 kWh ✓ solid fuel 9 (code 636 wood stove): ws (231) = 0 kWh ✗ oil 1 (code 127 condensing oil): ws (230c) = 165 kWh ✓ oil pcdb 3 (PCDB 18573): ws (230c) = 41 kWh ✓ Cascade impact across heating-systems corpus (vs S0380.148 state): | Variant | SAP Δ | Cause | |----------------|--------------|-------| | oil 1 | +0.60→+0.40 | 165 + 100 = 265 ≡ worksheet exact | | oil pcdb 1/2 | -0.15→+0.36 | 41 + 100 = 141 ≡ ws exact | | oil pcdb 3 | +0.59→+0.39 | same | | pcdb 1 | -0.03→+0.50 | 41 + 100 = 141 ≡ ws (was over) | | electric 1 | -0.06→+0.45 | 41 (wet electric boiler) | | electric 3-9 | -0.1..-1.4→ | 0 (dry storage/UFH) | | | +0.5..+0.6 | was 130 default; now 0 | | solid fuel 2-8 | various | 41 (boilers) — partial closures | | solid fuel 9-11| -0.2→+0.5 | 0 (room heaters) — was 130 | Re-pins reflect spec-correct application. Per [[feedback-software-no-special-handling]]: pre-slice near-zero pins were masking pre-existing offsetting cascade gaps; spec correctness unmasks them. Golden fixtures impact: - cert 0240 (dual oil combi, pump_age=0 Unknown): PE +2.52→+2.18 - cert 0390 (Firebird PCDF oil, pump_age=0): PE -28.08→-28.27 - cert 6035 (gas combi, pump_age=2 post-2013): PE +47.29→+46.42 Cert 6035 closer to zero (post-2013 41 kWh < pre-slice 115 unknown). Cert 0240/0390 small shifts from removing the gas-cat-2 hardcoded 160 path for oil mains. Tests: - test_sap_table_4f_circulation_pump_dispatches_per_central_heating_ pump_age — asserts oil 1 inputs.pumps_fans_kwh_per_yr == 265 (165 Pre 2013 + 100 liquid fuel) ± 1.0. - test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_ 100_kwh (S0380.148) still passes. Extended handover suite: 892 pass, 0 fail. Pyright net-improved (removed unused `main_category` variable, file 33→32 errors). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 44 ++--- datatypes/epc/domain/mapper.py | 15 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 151 +++++++++++++++--- .../rdsap/tests/test_cert_to_inputs.py | 73 +++++++++ .../rdsap/tests/test_golden_fixtures.py | 12 +- 5 files changed, 245 insertions(+), 50 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index d33c2a20..27bbd496 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -220,20 +220,20 @@ class _CorpusExpectation: # for codes 401/402) remains the open driver of those SAP residuals. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017), - _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789), + _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+0.4522, expected_cost_resid_gbp=-10.4203, expected_co2_resid_kg=-4.3334, expected_pe_resid_kwh=-40.1603), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1842, expected_cost_resid_gbp=+4.2439, expected_co2_resid_kg=+38.7768, expected_pe_resid_kwh=+392.8379), - _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0874, expected_cost_resid_gbp=+2.0136, expected_co2_resid_kg=+4.2088, expected_pe_resid_kwh=+81.9107), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.4255, expected_cost_resid_gbp=+32.8452, expected_co2_resid_kg=+61.9651, expected_pe_resid_kwh=+535.1955), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.1698, expected_cost_resid_gbp=+3.9118, expected_co2_resid_kg=+7.8972, expected_pe_resid_kwh=+103.4643), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-0.2044, expected_cost_resid_gbp=+4.7098, expected_co2_resid_kg=+9.6362, expected_pe_resid_kwh=+112.7064), - _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896), - _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145), + _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.6568, expected_cost_resid_gbp=-15.1334, expected_co2_resid_kg=-13.8238, expected_pe_resid_kwh=-114.7533), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.6813, expected_cost_resid_gbp=+15.6982, expected_co2_resid_kg=+43.9325, expected_pe_resid_kwh=+338.5315), + _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.5744, expected_cost_resid_gbp=-13.2352, expected_co2_resid_kg=-10.1354, expected_pe_resid_kwh=-93.1997), + _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.5398, expected_cost_resid_gbp=-12.4372, expected_co2_resid_kg=-8.3964, expected_pe_resid_kwh=-83.9576), + _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.4874, expected_cost_resid_gbp=-11.2307, expected_co2_resid_kg=-6.4095, expected_pe_resid_kwh=-70.5744), + _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.6261, expected_cost_resid_gbp=-14.4253, expected_co2_resid_kg=-12.3507, expected_pe_resid_kwh=-105.2495), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023), - _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+0.6045, expected_cost_resid_gbp=-13.9307, expected_co2_resid_kg=-41.4921, expected_pe_resid_kwh=-124.2355), - _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=-0.1485, expected_cost_resid_gbp=+3.4232, expected_co2_resid_kg=-22.0838, expected_pe_resid_kwh=+67.4561), - _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=-0.1485, expected_cost_resid_gbp=+3.4232, expected_co2_resid_kg=-22.0838, expected_pe_resid_kwh=+67.4561), - _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.5872, expected_cost_resid_gbp=-13.5304, expected_co2_resid_kg=-39.2997, expected_pe_resid_kwh=-120.1551), - _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=-0.0288, expected_cost_resid_gbp=+0.6418, expected_co2_resid_kg=-37.3200, expected_pe_resid_kwh=+41.8245), + _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+0.4042, expected_cost_resid_gbp=-9.3142, expected_co2_resid_kg=-36.6371, expected_pe_resid_kwh=-71.2875), + _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.3609, expected_cost_resid_gbp=-8.3159, expected_co2_resid_kg=-34.4292, expected_pe_resid_kwh=-67.1831), + _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.3609, expected_cost_resid_gbp=-8.3159, expected_co2_resid_kg=-34.4292, expected_pe_resid_kwh=-67.1831), + _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.3869, expected_cost_resid_gbp=-8.9139, expected_co2_resid_kg=-34.4447, expected_pe_resid_kwh=-67.2071), + _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.5018, expected_cost_resid_gbp=-11.0973, expected_co2_resid_kg=-49.6654, expected_pe_resid_kwh=-92.8147), # Slice S0380.133 unblocked 10 solid-fuel variants by routing the # Elmhurst §14.0 "Main Heating EES Code" through the new # `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the @@ -241,16 +241,16 @@ _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.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624), - _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), - _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.2919, expected_cost_resid_gbp=+6.7262, expected_co2_resid_kg=-68.4116, expected_pe_resid_kwh=+89.7782), - _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.1655, expected_cost_resid_gbp=+3.8136, expected_co2_resid_kg=-44.3197, expected_pe_resid_kwh=+92.8384), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0281, expected_cost_resid_gbp=-0.6473, expected_co2_resid_kg=+0.6642, expected_pe_resid_kwh=+44.7851), - _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0994, expected_cost_resid_gbp=-2.3310, expected_co2_resid_kg=-75.1034, expected_pe_resid_kwh=+16.7917), - _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.0804, expected_cost_resid_gbp=+1.8511, expected_co2_resid_kg=+18.0444, expected_pe_resid_kwh=+45.1812), - _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.1956, expected_cost_resid_gbp=+4.5065, expected_co2_resid_kg=+19.6820, expected_pe_resid_kwh=+92.8981), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.1605, expected_cost_resid_gbp=+3.6988, expected_co2_resid_kg=+17.7916, expected_pe_resid_kwh=+66.5227), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.2633, expected_cost_resid_gbp=+6.0671, expected_co2_resid_kg=+23.5398, expected_pe_resid_kwh=+104.1723), + _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+3.1478, expected_cost_resid_gbp=-72.5305, expected_co2_resid_kg=+41.5584, expected_pe_resid_kwh=-1346.0016), + _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.8310, expected_cost_resid_gbp=-42.1903, expected_co2_resid_kg=-441.0048, expected_pe_resid_kwh=-1069.2375), + _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.4523, expected_cost_resid_gbp=-10.4208, expected_co2_resid_kg=-86.4442, expected_pe_resid_kwh=-106.8858), + _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.3440, expected_cost_resid_gbp=-7.9255, expected_co2_resid_kg=-56.6651, expected_pe_resid_kwh=-41.8008), + _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.5376, expected_cost_resid_gbp=-12.3864, expected_co2_resid_kg=-11.6812, expected_pe_resid_kwh=-89.8541), + _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.6029, expected_cost_resid_gbp=-14.0701, expected_co2_resid_kg=-87.4488, expected_pe_resid_kwh=-117.8475), + _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+0.4291, expected_cost_resid_gbp=-9.8880, expected_co2_resid_kg=+5.6990, expected_pe_resid_kwh=-89.4580), + _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+0.5486, expected_cost_resid_gbp=-12.6405, expected_co2_resid_kg=+1.6494, expected_pe_resid_kwh=-103.7659), + _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+0.5837, expected_cost_resid_gbp=-13.4482, expected_co2_resid_kg=-0.2410, expected_pe_resid_kwh=-130.1413), + _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+0.4809, expected_cost_resid_gbp=-11.0799, expected_co2_resid_kg=+5.5072, expected_pe_resid_kwh=-92.4917), ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0878f2ad..937f8e62 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3965,13 +3965,24 @@ def _elmhurst_pump_age_int(age_str: Optional[str]) -> Optional[int]: mapper's `MainHeatingDetail.central_heating_pump_age` field. The cascade reads the str field (`_str` suffix) via internal_gains.py; the int dual-encoding exists purely for cross-mapper field - parity. "Unknown" → 0, "Pre 2013" → 1, modern post-2013 → 2.""" + parity. "Unknown" → 0, "Pre 2013" / "2012 or earlier" → 1, modern + post-2013 / "2013 or later" → 2. + + Elmhurst Summary §14 lodges the pump age string verbatim in one of + two equivalent forms: "Pre 2013" or "2012 or earlier" (semantically + identical — both predate the 2013 Ecodesign circulation-pump + threshold per SAP 10.2 Table 4f). Pre-S0380.149 only "Pre 2013" + was recognised and "2012 or earlier" silently returned 2 (2013 or + later), misclassifying the oil corpus pump_age and leading the + Table 4f circulation pump dispatch to pick 41 kWh (2013+) instead + of 165 kWh (Pre 2013). + """ if age_str is None: return None s = age_str.strip().lower() if s in ("", "unknown"): return 0 - if "pre 2013" in s: + if "pre 2013" in s or "2012 or earlier" in s: return 1 return 2 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d7d9421a..b89c03bd 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -210,21 +210,80 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13 _PENCE_TO_GBP: Final[float] = 0.01 _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 -_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0 -# SAP10.2 Table 4f cascade — annual pumps + fans electricity by main -# heating system category. The Elmhurst gas-combi cohort lodges 115 -# (230c central heating pump, post-2013 install) + 45 (230e main -# heating flue fan, balanced/condensing) = 160 kWh/yr. Heat pumps, -# warm-air, oil/biomass, electric storage etc. use different rows -# (Table 4f spec lines 7905-8076) — deferred until a fixture exercises. -_PUMPS_FANS_KWH_BY_MAIN_CATEGORY: Final[dict[int, float]] = { - 2: 160.0, # Gas-fired boilers (115 pump + 45 flue fan) - 4: 0.0, # Heat pumps — circulation pump + fans already in COP - # per SAP 10.2 Table 4f. Worksheet line (249) shows - # 0 kWh on cert 0380 (HP ASHP). Without this explicit - # entry HP certs fell through to the 130 kWh/yr DEFAULT - # and over-billed £17/yr at electricity rate. + +# SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump +# rows. Keyed on RdSAP API `central_heating_pump_age` enum: +# 0 = Unknown → 115 kWh/yr (Table 4f "Circulation pump, unknown date") +# 1 = Pre 2013 → 165 kWh/yr (Table 4f "Circulation pump, 2012 or earlier") +# 2 = 2013 or later→ 41 kWh/yr (Table 4f "Circulation pump, 2013 or later") +# Elmhurst-path certs route here via `_elmhurst_pump_age_int` (mapper) +# which recognises both "Pre 2013" and "2012 or earlier" variants. +_TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE: Final[dict[int, float]] = { + 0: 115.0, + 1: 165.0, + 2: 41.0, } +# Default circulation pump kWh when pump_age is None (no lodging at +# all) — Table 4f doesn't have a "missing" row; the SAP convention is +# to use the unknown-date value. +_TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT: Final[float] = 115.0 + +# Heat pumps from PCDB include circulation pump electricity in COP per +# Table 4f note: "Not applicable for electric heat pumps from +# database." Cat 4 (heat pump) → 0 kWh circulation pump. +_HP_MAIN_HEATING_CATEGORY: Final[int] = 4 + +# Wet-boiler SAP main_heating_code ranges (Table 4a + Table 4b). The +# Table 4f "Circulation pump" rows apply to systems with a primary +# water loop — i.e. boilers driving radiators / wet underfloor / +# convectors. Dry electric storage heaters (401-499), room heaters +# (601-699), and electric direct-acting / warm-air (501-515, 691+) +# have NO circulation pump per worksheet evidence: +# +# - electric 1 (code 191 electric boiler): ws (230c) = 41 kWh ✓ +# - electric 5 (code 402 electric storage): ws (231) = 0 kWh ✗ +# - solid fuel 2 (code 158 boiler): ws (230c) = 41 kWh ✓ +# - solid fuel 9 (code 636 room heater): ws (231) = 0 kWh ✗ +# +# Code ranges: +# 101-141 Gas/oil boilers (Table 4b) +# 151-161 Solid fuel boilers (Table 4a) +# 191-196 Electric boilers (Table 4a) +_WET_BOILER_CODE_RANGES: Final[tuple[range, ...]] = ( + range(101, 142), # Gas/oil boilers + range(151, 162), # Solid fuel boilers + range(191, 197), # Electric boilers +) + + +def _is_wet_boiler_main(main: Optional[MainHeatingDetail]) -> bool: + """Whether `main` is a wet boiler system (has a water-loop + circulation pump per Table 4f). Identifies by Table 4a/4b code + when lodged; falls back to PCDB Table 322 (gas/oil boiler) record + when the cert lodges an index number; finally falls back to + `main_heating_category` ∈ {1, 2} ("central heating" — conventionally + wet). Heat pumps (cat 4) return False here (Table 4f note "Not + applicable for electric heat pumps from database"). + """ + if main is None: + return False + if main.main_heating_category == _HP_MAIN_HEATING_CATEGORY: + return False + code = main.sap_main_heating_code + if code is not None: + return any(code in r for r in _WET_BOILER_CODE_RANGES) + # No SAP code lodged. Try PCDB Table 322 (gas/oil boiler) record — + # the Elmhurst-path cohort certs (e.g. oil pcdb 1/2/3, pcdb 1) + # lodge `main_heating_index_number` but no Table 4b code, and a + # Table 322 record is sufficient evidence the main is a wet boiler. + if main.main_heating_index_number is not None: + if gas_oil_boiler_record(main.main_heating_index_number) is not None: + return True + # Final fallback — RdSAP categories 1/2 = central heating (without/ + # with separate HW); both imply a wet primary loop. The gas-API + # cohort lodges cat=2 with no code and routed via this branch + # pre-S0380.149's refactor. + return main.main_heating_category in {1, 2} # SAP 10.2 Table 4f (page 174) — flue fan kWh for a gas-fired boiler # with fan-assisted flue (row "Gas boiler – flue fan"). Liquid-fuel @@ -247,6 +306,56 @@ _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH: Final[float] = 100.0 _TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2: Final[float] = 3.0 +def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float: + """SAP 10.2 Table 4f (PDF p.174) — Main 1 circulation pump kWh + based on `central_heating_pump_age` lodging. + + Heat-pump mains (category 4) return 0 per Table 4f note "Not + applicable for electric heat pumps from database" — the HP's COP + already accounts for pump electricity internally. Dry electric + storage / direct-acting / room heaters also return 0 (no primary + water loop, no pump) — see `_is_wet_boiler_main`. + + For wet boiler mains the dispatch reads the pump_age int enum: + 0 / None → 115 kWh (Unknown date) + 1 → 165 kWh (Pre 2013 / 2012 or earlier) + 2 → 41 kWh (2013 or later) + """ + if not _is_wet_boiler_main(main): + return 0.0 + assert main is not None # _is_wet_boiler_main guards None + age = main.central_heating_pump_age + if age is None: + return _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT + return _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE.get( + age, _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT + ) + + +def _table_4f_main_1_gas_boiler_flue_fan_kwh( + main: Optional[MainHeatingDetail], +) -> float: + """SAP 10.2 Table 4f (PDF p.174) row "Gas boiler – flue fan (if + fan assisted flue)": 45 kWh/yr. + + Fires only when Main 1 is a wet gas-fuelled boiler with a + fan-assisted flue. Heat pumps (cat 4) and electric mains return + 0 — different Table 4f rows govern (HPs subsumed in COP; electric + mains have no flue). Liquid fuel mains have their own 100 kWh + row, applied via `_table_4f_additive_components`. + """ + if not _is_wet_boiler_main(main): + return 0.0 + assert main is not None # _is_wet_boiler_main guards None + fuel = main.main_fuel_type + # Gas fuel codes per Table 32 + their RdSAP API equivalents (same + # set the Main 2 branch in _table_4f_additive_components uses). + fuel_is_gas = isinstance(fuel, int) and fuel in {1, 2, 3, 5, 7, 9, 26, 27} + if fuel_is_gas and main.fan_flue_present: + return _TABLE_4F_GAS_FLUE_FAN_KWH + return 0.0 + + def _table_4f_additive_components(epc: EpcPropertyData) -> float: """Sum the SAP 10.2 Table 4f line items that the base `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` lookup doesn't already cover — @@ -4512,14 +4621,16 @@ def cert_to_inputs( main = _first_main_heating(epc) main_code = main.sap_main_heating_code if main is not None else None - main_category = main.main_heating_category if main is not None else None main_fuel = _main_fuel_code(main) - pumps_fans_kwh = _PUMPS_FANS_KWH_BY_MAIN_CATEGORY.get( - main_category if main_category is not None else -1, - _DEFAULT_PUMPS_FANS_KWH_PER_YR, + # SAP 10.2 Table 4f (p.174) — Main 1 circulation pump (per + # `central_heating_pump_age`) + Main 1 gas-boiler flue fan (45 + # kWh when fan_flue_present + gas fuel). HP mains (cat 4) return + # 0 for both. Additive components add MEV, Main 2 flue fan, + # solar HW pump, and Main 1/2 liquid fuel boiler aux (100 kWh). + pumps_fans_kwh = ( + _table_4f_circulation_pump_kwh(main) + + _table_4f_main_1_gas_boiler_flue_fan_kwh(main) ) - # SAP 10.2 Table 4f (p.174) — additive components on top of the - # Main 1 category base. Each component is per-cert-lodging: 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/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index afea5bd4..c4498024 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -3642,6 +3642,79 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi ) +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: + + Circulation pump, 2013 or later 41 kWh/yr + Circulation pump, 2012 or earlier 165 kWh/yr + Circulation pump, unknown date 115 kWh/yr + + Pre-slice the cascade hardcoded gas-category=2 → 160 kWh/yr + (115 Unknown CH pump + 45 gas flue fan) and fell through to + `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130` for any other category + (including Elmhurst-path oil certs with `main_heating_category=None`). + Both shortcuts ignored the per-cert `central_heating_pump_age` + lodging. + + For oil 1 + oil pcdb 3 (Elmhurst Summary lodges "Heat pump age: + 2012 or earlier" which the mapper normalises to pump_age=1): + worksheet (230c) = 165 kWh/yr. The cascade should now dispatch + on pump_age int per the spec rows. + """ + # Arrange — oil 1 corpus variant (Table 4b code 127, no PCDB, + # cylinder, central_heating_pump_age_str = "2012 or earlier"). + import re + import subprocess + from pathlib import Path + + from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor + from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + corpus_dir = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/oil 1" + ) + summary_pdf = next(corpus_dir.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) + + # Act — full cascade. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — Worksheet (231) = (230c) 165 (Pre 2013 circulation + # pump) + (230d) 100 (liquid fuel boiler aux from S0380.148) = + # 265 kWh/yr. Cascade should match. + expected_kwh = 265.0 + got_kwh = inputs.pumps_fans_kwh_per_yr + assert abs(got_kwh - expected_kwh) < 1.0, ( + f"oil 1 pumps_fans annual: got {got_kwh!r}, " + f"expected {expected_kwh!r} per SAP 10.2 Table 4f " + f"(Pre 2013 circulation pump 165 + liquid fuel boiler aux 100)" + ) + + def test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_100_kwh() -> None: """SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other auxiliary uses" row "Liquid fuel boiler – flue fan and fuel diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index de7baff1..53f1636a 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -75,8 +75,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.5225, - expected_co2_resid_tonnes_per_yr=+0.1395, + expected_pe_resid_kwh_per_m2=+2.1847, + expected_co2_resid_tonnes_per_yr=+0.1333, 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_" @@ -143,8 +143,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2954-3640-2196-4175", actual_sap=60, expected_sap_resid=+7, - expected_pe_resid_kwh_per_m2=-28.0830, - expected_co2_resid_tonnes_per_yr=-2.7342, + expected_pe_resid_kwh_per_m2=-28.2719, + expected_co2_resid_tonnes_per_yr=-2.7404, notes=( "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " @@ -178,8 +178,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+47.2928, - expected_co2_resid_tonnes_per_yr=+1.0779, + expected_pe_resid_kwh_per_m2=+46.4156, + expected_co2_resid_tonnes_per_yr=+1.0677, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 "