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.
_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),
)

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

View file

@ -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

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:
"""SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and
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",
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 "