Slice S0380.149: Table 4f — circulation pump dispatch by pump age + wet-boiler gate

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 09:14:11 +00:00
parent 1b1f45b679
commit 35ea664db8
5 changed files with 245 additions and 50 deletions

View file

@ -220,20 +220,20 @@ class _CorpusExpectation:
# for codes 401/402) remains the open driver of those SAP residuals. # for codes 401/402) remains the open driver of those SAP residuals.
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _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='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 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 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=-1.4255, expected_cost_resid_gbp=+32.8452, expected_co2_resid_kg=+61.9651, expected_pe_resid_kwh=+535.1955), _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.1698, expected_cost_resid_gbp=+3.9118, expected_co2_resid_kg=+7.8972, expected_pe_resid_kwh=+103.4643), _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.2044, expected_cost_resid_gbp=+4.7098, expected_co2_resid_kg=+9.6362, expected_pe_resid_kwh=+112.7064), _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.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896), _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.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145), _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='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 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.1485, expected_cost_resid_gbp=+3.4232, expected_co2_resid_kg=-22.0838, expected_pe_resid_kwh=+67.4561), _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.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.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.5872, expected_cost_resid_gbp=-13.5304, expected_co2_resid_kg=-39.2997, expected_pe_resid_kwh=-120.1551), _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.0288, expected_cost_resid_gbp=+0.6418, expected_co2_resid_kg=-37.3200, expected_pe_resid_kwh=+41.8245), _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 # Slice S0380.133 unblocked 10 solid-fuel variants by routing the
# Elmhurst §14.0 "Main Heating EES Code" through the new # Elmhurst §14.0 "Main Heating EES Code" through the new
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the # `_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. # cost / CO2 / PE all route via the correct Table 32 fuel code.
# Remaining residuals are likely heating-system efficiency or # Remaining residuals are likely heating-system efficiency or
# control-type gaps — separate slices. # 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 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.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), _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.2919, expected_cost_resid_gbp=+6.7262, expected_co2_resid_kg=-68.4116, expected_pe_resid_kwh=+89.7782), _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.1655, expected_cost_resid_gbp=+3.8136, expected_co2_resid_kg=-44.3197, expected_pe_resid_kwh=+92.8384), _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.0281, expected_cost_resid_gbp=-0.6473, expected_co2_resid_kg=+0.6642, expected_pe_resid_kwh=+44.7851), _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.0994, expected_cost_resid_gbp=-2.3310, expected_co2_resid_kg=-75.1034, expected_pe_resid_kwh=+16.7917), _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.0804, expected_cost_resid_gbp=+1.8511, expected_co2_resid_kg=+18.0444, expected_pe_resid_kwh=+45.1812), _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.1956, expected_cost_resid_gbp=+4.5065, expected_co2_resid_kg=+19.6820, expected_pe_resid_kwh=+92.8981), _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.1605, expected_cost_resid_gbp=+3.6988, expected_co2_resid_kg=+17.7916, expected_pe_resid_kwh=+66.5227), _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.2633, expected_cost_resid_gbp=+6.0671, expected_co2_resid_kg=+23.5398, expected_pe_resid_kwh=+104.1723), _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),
) )

View file

@ -3965,13 +3965,24 @@ def _elmhurst_pump_age_int(age_str: Optional[str]) -> Optional[int]:
mapper's `MainHeatingDetail.central_heating_pump_age` field. The mapper's `MainHeatingDetail.central_heating_pump_age` field. The
cascade reads the str field (`_str` suffix) via internal_gains.py; cascade reads the str field (`_str` suffix) via internal_gains.py;
the int dual-encoding exists purely for cross-mapper field 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: if age_str is None:
return None return None
s = age_str.strip().lower() s = age_str.strip().lower()
if s in ("", "unknown"): if s in ("", "unknown"):
return 0 return 0
if "pre 2013" in s: if "pre 2013" in s or "2012 or earlier" in s:
return 1 return 1
return 2 return 2

View file

@ -210,21 +210,80 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13
_PENCE_TO_GBP: Final[float] = 0.01 _PENCE_TO_GBP: Final[float] = 0.01
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _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 # SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump
# heating system category. The Elmhurst gas-combi cohort lodges 115 # rows. Keyed on RdSAP API `central_heating_pump_age` enum:
# (230c central heating pump, post-2013 install) + 45 (230e main # 0 = Unknown → 115 kWh/yr (Table 4f "Circulation pump, unknown date")
# heating flue fan, balanced/condensing) = 160 kWh/yr. Heat pumps, # 1 = Pre 2013 → 165 kWh/yr (Table 4f "Circulation pump, 2012 or earlier")
# warm-air, oil/biomass, electric storage etc. use different rows # 2 = 2013 or later→ 41 kWh/yr (Table 4f "Circulation pump, 2013 or later")
# (Table 4f spec lines 7905-8076) — deferred until a fixture exercises. # Elmhurst-path certs route here via `_elmhurst_pump_age_int` (mapper)
_PUMPS_FANS_KWH_BY_MAIN_CATEGORY: Final[dict[int, float]] = { # which recognises both "Pre 2013" and "2012 or earlier" variants.
2: 160.0, # Gas-fired boilers (115 pump + 45 flue fan) _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE: Final[dict[int, float]] = {
4: 0.0, # Heat pumps — circulation pump + fans already in COP 0: 115.0,
# per SAP 10.2 Table 4f. Worksheet line (249) shows 1: 165.0,
# 0 kWh on cert 0380 (HP ASHP). Without this explicit 2: 41.0,
# entry HP certs fell through to the 130 kWh/yr DEFAULT
# and over-billed £17/yr at electricity rate.
} }
# 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 # 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 # 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 _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: def _table_4f_additive_components(epc: EpcPropertyData) -> float:
"""Sum the SAP 10.2 Table 4f line items that the base """Sum the SAP 10.2 Table 4f line items that the base
`_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` lookup doesn't already cover — `_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 = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None 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) main_fuel = _main_fuel_code(main)
pumps_fans_kwh = _PUMPS_FANS_KWH_BY_MAIN_CATEGORY.get( # SAP 10.2 Table 4f (p.174) — Main 1 circulation pump (per
main_category if main_category is not None else -1, # `central_heating_pump_age`) + Main 1 gas-boiler flue fan (45
_DEFAULT_PUMPS_FANS_KWH_PER_YR, # 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) pumps_fans_kwh += _table_4f_additive_components(epc)
# Track the MEV/MVHR-fan portion separately so the cost cascade can # 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 # apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on

View file

@ -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: 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 """SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and
other auxiliary uses" row "Liquid fuel boiler flue fan and fuel other auxiliary uses" row "Liquid fuel boiler flue fan and fuel

View file

@ -75,8 +75,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0240-0200-5706-2365-8010", cert_number="0240-0200-5706-2365-8010",
actual_sap=73, actual_sap=73,
expected_sap_resid=-1, expected_sap_resid=-1,
expected_pe_resid_kwh_per_m2=+2.5225, expected_pe_resid_kwh_per_m2=+2.1847,
expected_co2_resid_tonnes_per_yr=+0.1395, expected_co2_resid_tonnes_per_yr=+0.1333,
notes=( notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "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_" "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", cert_number="0390-2954-3640-2196-4175",
actual_sap=60, actual_sap=60,
expected_sap_resid=+7, expected_sap_resid=+7,
expected_pe_resid_kwh_per_m2=-28.0830, expected_pe_resid_kwh_per_m2=-28.2719,
expected_co2_resid_tonnes_per_yr=-2.7342, expected_co2_resid_tonnes_per_yr=-2.7404,
notes=( notes=(
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " "(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", cert_number="6035-7729-2309-0879-2296",
actual_sap=70, actual_sap=70,
expected_sap_resid=-6, expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=+47.2928, expected_pe_resid_kwh_per_m2=+46.4156,
expected_co2_resid_tonnes_per_yr=+1.0779, expected_co2_resid_tonnes_per_yr=+1.0677,
notes=( notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"Slice 59 per-bp window apportionment tightens all 3 " "Slice 59 per-bp window apportionment tightens all 3 "