Slice S0380.158: SAP 10.2 Table 4f warm-air heating system fans

SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) — verbatim:

  Warm air heating system fans e)        SFP × 0.4 × V

  e) SFP is the specific fan power from the database record for the
     warm air unit if applicable; otherwise 1.5 W/(l/s). These values
     of SFP include the in-use factor.
     If the heating system is a warm air unit and there is balanced
     whole house mechanical ventilation, the electricity for warm
     air circulation should not be included in addition to the
     electricity for mechanical ventilation. However it is included
     for a warm air system and MEV or PIV from outside.
     V is the volume of the dwelling in m³.

Per Table 4a (PDF p.165-166), warm-air systems are:
  - Category 5: heat pumps with warm-air distribution (codes 521,
    523, 524 electric; 525, 526, 527 gas-fired)
  - Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
    fired; 512-514 liquid-fired; 515 Electricaire electric)

Pre-slice the cascade's `_table_4f_additive_components` docstring
explicitly listed "(230b) Warm-air heating fans + (230c) for warm-
air pump" as "Not yet wired" — every Cat 5 / Cat 9 warm-air corpus
variant resolved `pumps_fans_kwh_per_yr` to 0. For electric 2 (code
524 Cat 5 air-source warm-air HP, no MV, V = 227.25 m³), the P960
worksheet block 11a (249) lodges 136.35 kWh × 13.67 p/kWh = £18.64
where the cascade computed 0.

New `_TABLE_4A_WARM_AIR_SAP_CODES` frozenset (22 codes) + leaf helper
`_table_4f_warm_air_heating_fans_kwh(main, dwelling_volume_m3,
has_balanced_mv)` wired at the orchestrator pumps_fans summation
alongside the existing circulation-pump and gas-flue-fan helpers.
Footnote-e balanced-MV omission reads `epc.sap_ventilation.
mechanical_ventilation_kind` via the new
`_has_balanced_mechanical_ventilation` predicate (returns True for
MVHR / MV; False for MEV / PIV / NATURAL).

Per-line walk evidence: cascade `pumps_fans_kwh_per_yr` = 0.0000 vs
worksheet (249) = 136.3500 = 1.5 × 0.4 × 227.25 exactly. Default SFP
from footnote e matches; PCDB warm-air-unit SFP lookup deferred
until a fixture exercises it.

Closures electric 2:
  pumps_fans_kwh_per_yr: 0 → 136.35 (EXACT match to worksheet)
  ΔSAP +0.7002 → −0.1087 (residual swung past worksheet — the +0.70
    pre-slice was an under-counted-fan offset; spec-correct fix lands
    just past zero, exposing a small upstream SH cascade gap likely
    in the Cat 5 warm-air HP Table 4a SH efficiency or Table 9c MIT
    cascade for warm-air mains — follow-up slice)
  Δcost −£16.14 → +£2.50
  ΔCO2 −2.37 → +16.54 kg
  ΔPE −108.58 → +97.69 kWh

No regressions on the other 24 cohort variants — the warm-air-code
gate fires only when `sap_main_heating_code` is in the new frozenset
and only electric 2 has a warm-air SAP code in the corpus. Extended
handover suite: 902 pass / 0 fail (was 901 — +1 from the new AAA
test). Pyright net-zero (43 → 43).

Σ |ΔSAP_c| across the 25-variant cohort: 2.87 → 2.30 (~20%
reduction from this slice).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 21:56:03 +00:00 committed by Jun-te Kim
parent 8507728237
commit 4291193ba8
3 changed files with 220 additions and 5 deletions

View file

@ -255,10 +255,33 @@ class _CorpusExpectation:
# independent of main heating → main-heating-specific DHW rules do
# not apply). No regressions on other variants — only electric 2 has
# the (Cat 4 HP + WHC=903 + cylinder) combination in the corpus.
#
# Slice S0380.158 wired the SAP 10.2 Table 4f (PDF p.174) row "Warm
# air heating system fans" = SFP × 0.4 × V (footnote e default SFP =
# 1.5 W/(l/s) when no PCDB warm-air-unit record). Pre-slice the
# cascade's `_table_4f_additive_components` docstring listed warm-air
# fans as "Not yet wired" — every Cat 5 / Cat 9 warm-air main
# resolved `pumps_fans_kwh_per_yr` to 0 even though the spec rule has
# been in place since SAP 2012. For electric 2 (code 524 Cat 5
# air-source warm-air HP, no MV, V = 227.25 m³): 1.5 × 0.4 × 227.25 =
# 136.35 kWh — matches worksheet block 11a (249) "Pumps, fans and
# electric keep-hot" line exactly. Footnote-e balanced-MV omission
# applies when `mechanical_ventilation_kind` is MVHR or MV (electric
# 2 lodges no MV → fans included). New `_TABLE_4A_WARM_AIR_SAP_CODES`
# frozenset (22 codes: 501-515, 520-521, 523-527). Cascade closures
# electric 2: SAP +0.7002 → 0.1087, cost £16.14 → +£2.50, CO2
# 2.37 → +16.54 kg, PE 108.58 → +97.69 kWh. The cascade now
# overshoots cost / CO2 / PE because the +136 kWh of warm-air fan
# electricity is being charged at the full 18-hour high rate; SAP
# under-shoots by 0.11 because the cost residual is still slightly
# off. Remaining gap likely a small upstream SH-demand divergence
# (cascade SH demand +57 kWh vs worksheet — Cat 5 specific). No
# regressions on the other 24 variants — gate keyed on the new
# warm-air-code frozenset and only electric 2 has a code in that set.
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=-0.0240, expected_cost_resid_gbp=+0.5536, expected_co2_resid_kg=+7.3267, expected_pe_resid_kwh=+36.3435),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.7002, expected_cost_resid_gbp=-16.1353, expected_co2_resid_kg=-2.3729, expected_pe_resid_kwh=-108.5828),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.1215, expected_cost_resid_gbp=-2.8003, expected_co2_resid_kg=+6.7227, expected_pe_resid_kwh=-5.9859),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.1759, expected_cost_resid_gbp=+27.0929, expected_co2_resid_kg=+62.7232, expected_pe_resid_kwh=+438.0333),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.3225, expected_pe_resid_kwh=+0.1603),

View file

@ -307,6 +307,37 @@ _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH: Final[float] = 100.0
_TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2: Final[float] = 3.0
# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. Two
# spec categories distribute heat as ducted air:
# - Category 5: heat pumps with warm-air distribution (codes 521,
# 523, 524 electric SH; 525, 526, 527 gas-fired).
# - Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
# fired; 512-514 liquid-fired; 515 Electricaire electric).
# These systems share the Table 4f "Warm air heating system fans" row
# (the fan electricity is air-side, distinct from the wet-system
# circulation pump and the gas-boiler flue fan).
_TABLE_4A_WARM_AIR_SAP_CODES: Final[frozenset[int]] = frozenset({
501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 520,
512, 513, 514, 515,
521, 523, 524, 525, 526, 527,
})
# SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans" =
# SFP × 0.4 × V (kWh/yr). Footnote e):
# "SFP is the specific fan power from the database record for the
# warm air unit if applicable; otherwise 1.5 W/(l/s). These values
# of SFP include the in-use factor."
_TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S: Final[float] = 1.5
_TABLE_4F_WARM_AIR_FAN_VOLUME_FACTOR: Final[float] = 0.4
# Footnote e) — the warm-air fan electricity is omitted when the
# dwelling also has balanced whole-house mechanical ventilation,
# because the MV system's fans displace the warm-air circulation
# fans. Balanced kinds = MVHR + MV. Extract-only / PIV-from-outside
# / natural ventilation kinds do NOT trigger the omission.
_BALANCED_MV_KIND_NAMES: Final[frozenset[str]] = frozenset({"MVHR", "MV"})
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.
@ -357,6 +388,52 @@ def _table_4f_main_1_gas_boiler_flue_fan_kwh(
return 0.0
def _has_balanced_mechanical_ventilation(epc: EpcPropertyData) -> bool:
"""SAP 10.2 Table 4f footnote e) balanced-MV gate: True when the
cert lodges either MVHR (balanced with heat recovery) or MV
(balanced without heat recovery). False for MEV / PIV-from-outside
/ natural footnote e) explicitly INCLUDES the warm-air fan kWh
for "a warm air system and MEV or PIV from outside".
"""
sv = epc.sap_ventilation
if sv is None:
return False
name = sv.mechanical_ventilation_kind
return name in _BALANCED_MV_KIND_NAMES
def _table_4f_warm_air_heating_fans_kwh(
main: Optional[MainHeatingDetail],
dwelling_volume_m3: float,
has_balanced_mv: bool,
) -> float:
"""SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system
fans" = SFP × 0.4 × V per footnote e). Default SFP = 1.5 W/(l/s)
when the cert has no PCDB warm-air-unit record. Suppressed when
the dwelling lodges balanced whole-house MV per the footnote-e
omission rule.
Fires for the Table 4a Cat 5 (heat pumps with warm-air
distribution) and Cat 9 (warm air NOT heat pump) sub-rows see
`_TABLE_4A_WARM_AIR_SAP_CODES`. Cohort entry point is the
heating-systems corpus 001431 electric 2 variant (code 524
air-source warm-air HP, no MV, V = 227.25 1.5 × 0.4 × 227.25
= 136.35 kWh, matching the P960 worksheet (249) line exactly).
"""
if main is None:
return 0.0
code = main.sap_main_heating_code
if code is None or code not in _TABLE_4A_WARM_AIR_SAP_CODES:
return 0.0
if has_balanced_mv:
return 0.0
return (
_TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S
* _TABLE_4F_WARM_AIR_FAN_VOLUME_FACTOR
* dwelling_volume_m3
)
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 —
@ -378,10 +455,18 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
schema doesn't carry the lodged value. TODO: parse the
Elmhurst §16 aperture area into the schema.
Warm-air heating fans (Table 4f row "Warm air heating system fans"
= SFP × 0.4 × V) live in a sibling helper
`_table_4f_warm_air_heating_fans_kwh` because they require the
dwelling volume from `dimensions_from_cert(epc)`, not just the
EPC payload see the orchestrator pumps_fans summation.
Not yet wired:
- (230f) Combi keep-hot 600 / 900 kWh per Table 4f when the
cert lodges keep-hot on the gas combi.
- (230b) Warm-air heating fans + (230c) for warm-air pump.
- (230c) Warm-air system pump (Cat 9 sub-row for systems with a
separate warm-air circulation pump cohort doesn't exercise
it yet).
- (230h) WWHRS pump.
"""
total = 0.0
@ -5033,12 +5118,20 @@ def cert_to_inputs(
main_fuel = _main_fuel_code(main)
# 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).
# kWh when fan_flue_present + gas fuel) + Main 1 warm-air heating
# fans (SFP × 0.4 × V for Cat 5 / Cat 9 warm-air mains, suppressed
# under balanced MV per footnote e). HP wet mains (cat 4) return 0
# for the circulation-pump branch. 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)
+ _table_4f_warm_air_heating_fans_kwh(
main=main,
dwelling_volume_m3=dim.volume_m3,
has_balanced_mv=_has_balanced_mechanical_ventilation(epc),
)
)
pumps_fans_kwh += _table_4f_additive_components(epc)
# Track the MEV/MVHR-fan portion separately so the cost cascade can

View file

@ -4408,6 +4408,105 @@ def test_sap_table_3_primary_loss_skipped_for_whc_903_electric_immersion_with_he
)
def test_sap_table_4f_warm_air_heating_system_fans_kwh_for_cat5_heat_pump() -> None:
"""SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) verbatim:
Warm air heating system fans e) SFP × 0.4 × V
e) SFP is the specific fan power from the database record for
the warm air unit if applicable; otherwise 1.5 W/(l/s).
These values of SFP include the in-use factor.
If the heating system is a warm air unit and there is
balanced whole house mechanical ventilation, the electricity
for warm air circulation should not be included in addition
to the electricity for mechanical ventilation. However it is
included for a warm air system and MEV or PIV from outside.
V is the volume of the dwelling in .
Per Table 4a, warm-air systems are Category 5 (heat pumps with
warm air distribution, codes 521/523/524/525/526/527) and Category
9 (warm air systems NOT heat pump, codes 501-515 + 520).
For electric 2 (sap_main_heating_code=524 Cat 5 air-source warm-air
HP, no PCDB record, no MVHR / MV, 110 L cylinder, V=227.25 ):
worksheet block 11a (249) "Pumps, fans and electric keep-hot"
lodges 136.35 kWh × 13.67 p/kWh = £18.64 exactly 1.5 × 0.4 ×
227.25 = 136.35 kWh per the default SFP formula.
Pre-slice the cascade's `_table_4f_additive_components` docstring
explicitly listed "(230b) Warm-air heating fans + (230c) for
warm-air pump" as "Not yet wired" — the cert's pumps_fans_kwh_per_yr
resolved to 0 for every warm-air corpus variant.
"""
# Arrange — electric 2 corpus variant: code 524 + no MVHR + 90 m²
# × 2.525 m = 227.25 m³ dwelling volume per worksheet line (5).
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
corpus_electric_2 = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples/electric 2"
)
summary_pdf = next(corpus_electric_2.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)
main = epc.sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 524
sv = epc.sap_ventilation
assert sv is not None
# MV kind defaults to NATURAL when the cert lodges no MV system —
# confirms the worksheet (249) value comes from the warm-air fan
# line alone (not blocked by the footnote-e balanced-MV gate).
assert sv.mechanical_ventilation_kind in (None, "NATURAL")
# Act — drive the full cert→CalculatorInputs cascade. Pre-slice
# `pumps_fans_kwh_per_yr` resolves to 0 because the Cat 5 warm-air
# HP main has no Table 4f circulation pump (HP exemption) and no
# gas-flue fan, and `_table_4f_additive_components` doesn't yet
# cover the warm-air fan row.
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — pumps_fans_kwh_per_yr must equal the worksheet (249)
# value 136.35 kWh/yr = 1.5 × 0.4 × 227.25 per Table 4f.
expected_kwh = 1.5 * 0.4 * 227.25 # = 136.35
assert abs(inputs.pumps_fans_kwh_per_yr - expected_kwh) <= 0.01, (
f"electric 2 (Cat 5 warm-air ASHP code 524, no MVHR, V=227.25 "
f"m³) pumps_fans_kwh_per_yr = {inputs.pumps_fans_kwh_per_yr:.4f}; "
f"want {expected_kwh:.4f} per SAP 10.2 Table 4f 'Warm air "
f"heating system fans' = SFP × 0.4 × V with default SFP = 1.5 "
f"W/(l/s) per footnote e). Pre-slice the cascade's Table 4f "
f"additive-components helper listed warm-air fans as 'Not yet "
f"wired' so every warm-air corpus variant fell back to 0 kWh."
)
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: