mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
a2a4b6824a
commit
8843df1b46
3 changed files with 220 additions and 5 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 m³ → 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
|
||||
|
|
|
|||
|
|
@ -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 m³.
|
||||
|
||||
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 m³):
|
||||
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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue