fix(ventilation): apply Table 4g default SFP to index-less MEV fan electricity

Completes the MEV fan-electricity thread. The PCDB-index slice closed the
9 MEV certs carrying a Table 322 record; the other 11 (mostly gas houses)
lodge mechanical_ventilation=2 with NO PCDB index, so
`_mev_decentralised_kwh_per_yr_from_cert` returned 0 and billed no fan
running cost — a tight +2.2 SAP over-rate (signed +1.23, median +2.19).

SAP 10.2 §2.6.3 / Table 4g note 1 (PDF p.176) prescribes a DEFAULT
specific fan power of 0.8 W/(l/s) for an MEV system whose fans are not in
the PCDB, used directly as SFPav in the §5 Table 4f (230a) formula
(SFPav × 1.22 × V). Restructure the helper: when no Table 322 record
resolves, fall back to the default for a mechanical-extract system
(`mechanical_ventilation_kind == EXTRACT_OR_PIV_OUTSIDE`); natural /
balanced (MVHR / MV) systems still contribute nothing.

Index-less extract cohort closed +1.23 -> +0.18 signed (each gains
~1.1 SAP of fan electricity). This is a spec-correct fix that improves
the aggregate but is a HEADLINE TRADE-OFF: within-2.0 83.6% -> 84.6%,
within-1.0 70.08% -> 70.19%, mean|err| 1.232 -> 1.224, but within-0.5
55.12% -> 54.90% (-2) — the fan energy is only ~half each cert's
over-rate, so the cohort lands at ~+1.0 (still outside 0.5) while two
borderline certs with offsetting errors cross out. Applied uniformly per
the determinism principle ([[feedback_software_no_special_handling]]):
the unmasked residual (~+1.0 on gas-house MEV) is the next lead.

1 AAA test (default SFP 0.8 × 1.22 × V for index-less MEV, 0 for
natural). Goldens + full calc/epc regression green (000565 MEV uses its
resolvable PCDB record, unaffected); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-09 09:00:54 +00:00
parent 01ebc9ac1e
commit e6dda705f4
2 changed files with 70 additions and 4 deletions

View file

@ -566,6 +566,12 @@ _MV_DUCT_TYPE_RIGID: Final[int] = 2
# 5, 6 = through-wall (use no-duct IUF independent of duct type)
_MEV_THROUGH_WALL_CONFIG_CODES: Final[frozenset[int]] = frozenset({5, 6})
# SAP 10.2 Table 4g (PDF p.176) / §2.6.3 note 1 — default specific fan
# power (W per litre/sec) for an MEV system whose fan(s) are not in the
# PCDB. Used directly as the IUF-adjusted SFPav in the (230a) formula
# (SFPav × 1.22 × V) when no Table 322 record resolves.
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S: Final[float] = 0.8
def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
"""Compose the SAP 10.2 §5 Table 4f line (230a) MEV decentralised
@ -601,11 +607,28 @@ def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
decentralised fixture; the rule above fits cert 000565 alone.
"""
pcdf_id = epc.mechanical_ventilation_index_number
if pcdf_id is None:
return 0.0
record = decentralised_mev_record(pcdf_id)
record = decentralised_mev_record(pcdf_id) if pcdf_id is not None else None
if record is None:
return 0.0
# No PCDB Table 322 record (index absent, or lodged index not in
# the table). For a mechanical-EXTRACT system, SAP 10.2 §2.6.3 /
# Table 4g note 1 prescribes the default SFP (0.8 W/(l/s)) used
# directly as SFPav. Natural / balanced (MVHR / MV) systems
# contribute no (230a) decentralised-MEV fan electricity here —
# the gate mirrors `_has_balanced_mechanical_ventilation`'s
# MEV / PIV-from-outside vs balanced split. Closes the +2.2 SAP
# over-rate on the index-less MEV cohort (mostly gas houses), which
# previously billed zero fan electricity.
sv = epc.sap_ventilation
if (
sv is None
or sv.mechanical_ventilation_kind
!= MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE.name
):
return 0.0
return mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S,
dwelling_volume_m3=dimensions_from_cert(epc).volume_m3,
)
iuf_record = mv_in_use_factors_record(_MEV_DECENTRALISED_SYSTEM_TYPE)
if iuf_record is None:
return 0.0

View file

@ -1032,6 +1032,49 @@ def test_ventilation_from_cert_routes_mev_decentralised_to_extract_or_piv_outsid
assert abs(w25 - expected) <= 1e-4
def test_index_less_mev_uses_table_4g_default_sfp_for_fan_electricity() -> None:
# Arrange — an MEV system with NO PCDB record (index absent / not in
# Table 322). SAP 10.2 §2.6.3 / Table 4g note 1 prescribes a default
# specific fan power of 0.8 W/(l/s), used directly as the SFPav in the
# §5 Table 4f line (230a) `SFPav × 1.22 × V`. Without it the cascade
# billed ZERO fan electricity for index-less MEV (the +2.2 SAP
# over-rate on the index-less MEV cohort, mostly gas houses). A natural
# dwelling contributes nothing.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage]
_mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage]
dimensions_from_cert,
)
base = _typical_semi_detached_epc()
mev_no_index = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4,
region_code="1", sap_building_parts=base.sap_building_parts,
sap_windows=base.sap_windows, sap_heating=base.sap_heating,
sap_ventilation=SapVentilation(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
),
)
natural = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4,
region_code="1", sap_building_parts=base.sap_building_parts,
sap_windows=base.sap_windows, sap_heating=base.sap_heating,
sap_ventilation=SapVentilation(extract_fans_count=0),
)
# Act
mev_kwh = _mev_decentralised_kwh_per_yr_from_cert(mev_no_index)
natural_kwh = _mev_decentralised_kwh_per_yr_from_cert(natural)
# Assert — default SFP 0.8 × 1.22 × V for the index-less MEV; zero for
# the naturally-ventilated dwelling.
volume = dimensions_from_cert(mev_no_index).volume_m3
expected = _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * 1.22 * volume
assert abs(mev_kwh - expected) <= 1e-6
assert mev_kwh > 0.0
assert natural_kwh == 0.0
def test_open_chimneys_raise_infiltration_ach() -> None:
# Arrange — Direction check: chimneys add Table 2.1 volume to the
# infiltration calc, so an otherwise identical dwelling with 2 open