Slice S0380.161: SAP 10.2 Table 5a warm-air fan gain (SFP × 0.04 × V)

SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans
a) c)" computes the gain as SFP × 0.04 × V (W). Footnote c) sets
the default SFP to 1.5 W/(l/s) when no PCDB warm-air-unit record
is lodged; footnote a) applies the heating-season-only mask
(zero in summer months). Footnote c) further omits the gain when
the dwelling has balanced whole-house mechanical ventilation
(MVHR / MV) — same omission as the Table 4f kWh-side footnote e).

Pre-slice the cascade's `internal_gains_from_cert` only wired the
central-heating-pump row of Table 5a; the warm-air-fan gain helper
(`warm_air_heating_fan_w`) existed but was unwired. The kWh-side
parallel (Table 4f, 136.35 kWh/yr) was wired in S0380.158 — this
slice closes the symmetry on the gain side.

Per-line walk on electric 2 (SAP code 524 = Cat 5 ASHP with
warm-air distribution, V = 227.25 m³, no balanced MV):

  worksheet (70)[Jan] = 13.6350 W
  cascade (70)[Jan]   = 0.0000 W      delta = -13.635 W
  worksheet (98c)[Jan] = 1600.43 kWh
  cascade (98c)[Jan]  = 1608.12 kWh   delta = +7.69 kWh

13.635 W = 1.5 × 0.04 × 227.25 exactly. The -13.6 W winter gain
shortfall propagates through the §7 utilisation cascade and over-
states cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
9426), under-charging cost by ~£2.50 with opposite sign to the
S0380.156-.158 closures.

Fix: new `_any_main_system_has_warm_air_distribution(epc)` +
`_has_balanced_mechanical_ventilation(epc)` predicates in
`internal_gains.py`, mirroring `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES`
+ `_BALANCED_MV_KIND_NAMES` (kept here as siblings so the worksheet
layer stays free of rdsap deps). Orchestrator wires
`warm_air_heating_fan_w(sfp=1.5, dwelling_volume_m3)` into the
heating-season term of `pumps_fans_monthly_w` when warm-air
distribution is present and balanced MV is not.

Closures electric 2:
  ΔSAP_c -0.1087 → -0.0000 EXACT
  Δcost  +£2.50 → -£0.00 EXACT
  ΔCO2   +16.54 → +11.95 (joins lighting-PE deferred cohort)
  ΔPE    +97.69 → +48.66 (joins lighting-PE deferred cohort)

Electric 2 joins the 15-variant lighting-PE deferred cohort
(electric 1 + electric 3/5/6/7/8/9 + solid fuel 5/6/7/8 + solid
fuel 4/9/10/11 + electric 2) where SAP/cost are EXACT but PE/CO2
carry an Elmhurst-vs-spec MONTHLY-factor offset (cohort uses
Table 12 annual factors on the off-peak HW immersion line; spec
mandates Table 12d/12e monthly per the header).

Verbatim spec quote (SAP 10.2 Table 5a row "Warm air heating
system fans a) c)", PDF p.177):
  "Warm air heating system fans a) c)  SFP × 0.04 × V"
  Footnote c): "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 an in-use factor.
    If the heating system is a warm air unit and there is balanced
    whole house mechanical ventilation, the gains for the warm air
    system should not be included."
  Footnote a): "... Set to zero in summer months. ..."

Σ |ΔSAP_c| across 25-variant cohort: 0.18 → 0.07 (~60% reduction).
No regressions on the other 24 variants or any golden fixture —
gate keyed on Table 4a warm-air SAP code frozenset (only electric
2 in the corpus has a code in that set).

Tests: 905 pass (+1), 0 fail. Pyright net-zero (35 → 35).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 23:16:57 +00:00
parent af34ad9846
commit 482ce88b55
3 changed files with 160 additions and 4 deletions

View file

@ -319,10 +319,25 @@ class _CorpusExpectation:
# variants; only the lighting-PE +48.66 / +11.95 CO2 deferred quirk
# remains (same offset as electric 1 + solid fuel 5/6/7/8). Cluster
# Σ|ΔSAP_c| 1.06 → 0.00 in one slice.
#
# Slice S0380.161 closed electric 2 (Cat 5 warm-air HP, code 524) by
# wiring the SAP 10.2 Table 5a row "Warm air heating system fans
# a) c)" (PDF p.177) GAIN side. Pre-slice S0380.158 wired the kWh
# side (136.35 kWh/yr via Table 4f) but the parallel GAIN row was
# never wired, so cascade (70) m = 0 every month vs worksheet 13.6350
# W in heating months (= 1.5 × 0.04 × 227.25 with SFP default 1.5
# W/(l/s) per footnote c). The -13.6 W winter gain shortfall over-
# stated cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
# 9426), under-charging cost by ~£2.50 with opposite sign. New
# `_any_main_system_has_warm_air_distribution` + `_has_balanced_
# mechanical_ventilation` predicates + leaf wiring in the orchestrator.
# Electric 2 SAP -0.1087 → -0.0000 EXACT; joins the lighting-PE
# deferred cohort (CO2 +11.95 / PE +48.66). Cohort Σ|ΔSAP_c|
# 0.18 → 0.07 in one slice.
_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.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604),

View file

@ -59,6 +59,11 @@ _LIQUID_FUEL_WARM_AIR_PUMP_W: Final[float] = 10.0
_WARM_AIR_HEATING_VOLUME_COEFF: Final[float] = 0.04
_PIV_VOLUME_COEFF: Final[float] = 0.12
_BALANCED_MV_NO_HR_VOLUME_COEFF: Final[float] = 0.06
# Table 5a footnote c) default SFP when no PCDB warm-air-unit SFP is
# lodged: "otherwise 1.5 W/(l/s). These values of SFP include an
# in-use factor." Same default as Table 4f footnote e) for the kWh
# side (see `cert_to_inputs._TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S`).
_TABLE_5A_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S: Final[float] = 1.5
_HIU_HOURS_PER_DAY: Final[float] = 24.0
_SUMMER_MONTHS: Final[frozenset[int]] = frozenset({6, 7, 8, 9})
@ -730,6 +735,59 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool:
return False
# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The
# Table 5a "Warm air heating system fans" gain (and Table 4f
# electricity row) fire for these mains:
# - Cat 5 (heat pumps with warm-air distribution): 521, 523-527
# - Cat 9 (warm air NOT heat pump): 501-515, 520
# Mirrors `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES` — kept here as
# a sibling so the worksheet layer does not depend on rdsap. Keep in
# sync manually with the cert_to_inputs constant.
_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 5a footnote c) (PDF p.177) for the "Warm air heating
# system fans" row: "If the heating system is a warm air unit and
# there is balanced whole house mechanical ventilation, the gains for
# the warm air system should not be included."
# Mirrors `cert_to_inputs._BALANCED_MV_KIND_NAMES`. Balanced MV kinds
# = MVHR (balanced with HR) + MV (balanced without HR). MEV, PIV from
# outside, and natural ventilation do NOT trigger the omission.
_BALANCED_MV_KIND_NAMES: Final[frozenset[str]] = frozenset({"MVHR", "MV"})
def _any_main_system_has_warm_air_distribution(epc: EpcPropertyData) -> bool:
"""True iff any lodged main heating system distributes heat as warm
air (Table 4a Cat 5 HPs with warm-air dist. + Cat 9 warm-air not
HP) qualifying for the SAP 10.2 Table 5a "Warm air heating
system fans" gain row.
"""
details = epc.sap_heating.main_heating_details
if not details:
return False
for d in details:
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
return True
return False
def _has_balanced_mechanical_ventilation(epc: EpcPropertyData) -> bool:
"""SAP 10.2 Table 5a footnote c) / Table 4f footnote e) balanced-MV
gate: True when the cert lodges either MVHR or MV (both balanced).
Mirrors `cert_to_inputs._has_balanced_mechanical_ventilation`.
"""
sv = getattr(epc, "sap_ventilation", None)
if sv is None:
return False
name = getattr(sv, "mechanical_ventilation_kind", None)
return name in _BALANCED_MV_KIND_NAMES
def internal_gains_from_cert(
*,
epc: EpcPropertyData,
@ -803,11 +861,29 @@ def internal_gains_from_cert(
)
else:
pump_w = 0.0
# Liquid-fuel + warm-air + PIV + MV + HIU branches default to zero for
# the combi-gas-natural-vent population; future slices will detect them
# SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF
# p.177): SFP × 0.04 × V W, heating-season only per footnote a),
# omitted when balanced whole-house MV is present per footnote c).
# Default SFP 1.5 W/(l/s) per footnote c) — no PCDB warm-air-unit
# SFP lookup yet. Sister to the Table 4f kWh-side wiring in
# `_table_4f_warm_air_heating_fans_kwh` (S0380.158). Cohort
# entry point: heating-systems corpus electric 2 (code 524 ASHP
# warm-air, V=227.25 m³, no MV → 13.6350 W matches worksheet (70)).
if (
_any_main_system_has_warm_air_distribution(epc)
and not _has_balanced_mechanical_ventilation(epc)
):
warm_air_fan_w = warm_air_heating_fan_w(
sfp_w_per_l_per_s=_TABLE_5A_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S,
dwelling_volume_m3=dwelling_volume_m3,
)
else:
warm_air_fan_w = 0.0
# Liquid-fuel + PIV + MV + HIU branches default to zero for the
# combi-gas-natural-vent population; future slices will detect them
# from epc.main_heating_details + epc.mechanical_ventilation.
pumps_fans = pumps_fans_monthly_w(
heating_season_w=pump_w,
heating_season_w=pump_w + warm_air_fan_w,
year_round_w=0.0,
)

View file

@ -575,6 +575,71 @@ def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> No
assert result.total_internal_gains_monthly_w[m] == pytest.approx(expected_73[m], abs=1e-1), f"(73) month {m+1}"
def test_internal_gains_pumps_fans_adds_warm_air_fan_gain_for_cat5_hp_main() -> None:
"""SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans a) c)"
gain = SFP × 0.04 × V (W). Default SFP = 1.5 W/(l/s) per footnote c
when no PCDB warm-air-unit record is lodged. Heating-season mask per
footnote a). Worksheet evidence: heating-systems corpus electric 2
(SAP code 524 ASHP with warm-air distribution, V = 227.25 ,
no balanced MV) lodges (70) = 13.6350 W in heating months
(Jan-May, Oct-Dec), 0 W in summer (Jun-Sep) exactly
1.5 × 0.04 × 227.25 = 13.635.
Sister to S0380.158 which wired the Table 4f KWH side of the same
row (136.35 kWh/yr). This slice wires the gain side. Footnote c)
omission "If the heating system is a warm air unit and there is
balanced whole house mechanical ventilation, the gains for the
warm air system should not be included" parallels Table 4f
footnote e) for the kWh.
"""
# Arrange — Cat 5 warm-air HP (code 524), no balanced MV.
sap_heating = SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30,
heat_emitter_type="",
emitter_temperature="",
sap_main_heating_code=524,
main_heating_category=4, # HP per Table 4a Cat 5
main_heating_control=2401,
central_heating_pump_age_str="Unknown",
),
],
has_fixed_air_conditioning=False,
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=90.0,
low_energy_fixed_lighting_bulbs_count=6,
sap_windows=[],
sap_heating=sap_heating,
)
# Act
result = internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=227.25,
heat_gains_from_water_heating_monthly_kwh=(0.0,) * 12,
overshading=OvershadingCategory.AVERAGE,
)
# Assert — 13.635 W (= 1.5 × 0.04 × 227.25) in heating months,
# zero in summer (Jun-Sep) per footnote a) heating-season mask.
expected_warm_air_fan_w = 1.5 * 0.04 * 227.25 # 13.635
expected = (
expected_warm_air_fan_w, expected_warm_air_fan_w, expected_warm_air_fan_w,
expected_warm_air_fan_w, expected_warm_air_fan_w,
0.0, 0.0, 0.0, 0.0,
expected_warm_air_fan_w, expected_warm_air_fan_w, expected_warm_air_fan_w,
)
for m in range(12):
assert abs(result.pumps_fans_monthly_w[m] - expected[m]) <= 1e-6, (
f"(70) month {m+1} = {result.pumps_fans_monthly_w[m]:.4f}, "
f"expected {expected[m]:.4f}"
)
def test_internal_gains_pumps_fans_is_zero_for_electric_storage_heater_main() -> None:
"""SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
space" — the gain applies only to mains with a water-loop circulation