fix(ventilation): apply Table 4g note 3 in-use factor to index-less MEV SFP

The no-PCDB MEV fan-electricity path fed the SAP 10.2 Table 4g default SFP
(0.8 W/(l/s)) directly as SFPav. But Table 4g note 3 (PDF p.176) is explicit:
the default SFP values "are to be multiplied by the appropriate in-use factor
for default data from the PCDB" — PCDB Table 329 system_type 10 ("default
data, used when SFP is taken from Table 4g rather than the PCDB"), IUF 2.5
(duct-agnostic per note 2). Table 4h, which previously held these factors, is
retired ("no longer used – data now stored in the PCDB").

Omitting the IUF under-billed the index-less MEV fan electricity by 2.5x
(SFPav 0.8 instead of 0.8 x 2.5 = 2.0), so cost was too low and the cohort
over-rated. This is distinct from the with-index path, which already applies
the tested-product system_type-2 "no scheme" IUF (~1.45) per fan.

Index-less gas-house MEV cohort: +1.37 median -> -0.18 (12% -> 92% within 0.5),
no overshoot — the missing IUF was exactly the over-rate. API gauge 67.7% ->
68.4% within-0.5 (mean|err| 0.992 -> 0.986, signed +0.031 -> +0.006).
Worksheet harness 47/47, 0 divergers (Summary-path MEV certs carry a PCDB
index or are natural, so unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-13 23:15:32 +00:00
parent 5b2cf5edc7
commit 4fb9b853dc
2 changed files with 86 additions and 12 deletions

View file

@ -566,6 +566,11 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
_MEV_KITCHEN_FAN_CONFIG_CODES: Final[frozenset[int]] = frozenset({1, 3, 5})
# PCDB Table 329 / 322 system_type=2 = decentralised MEV.
_MEV_DECENTRALISED_SYSTEM_TYPE: Final[int] = 2
# PCDB Table 329 system_type=10 = "default data" (PCDF Spec §A.20) — the
# in-use-factor row used when the SFP is taken from SAP 10.2 Table 4g
# rather than a specific PCDB product. Table 4g note 3 (PDF p.176)
# requires the default SFP to be multiplied by this IUF (2.5, duct-agnostic).
_MEV_DEFAULT_DATA_SYSTEM_TYPE: Final[int] = 10
# Elmhurst "Duct Type" cascade integer: 1=Flexible, 2=Rigid (per
# `_ELMHURST_DUCT_TYPE_TO_INT` in datatypes.epc.domain.mapper).
_MV_DUCT_TYPE_FLEXIBLE: Final[int] = 1
@ -578,11 +583,25 @@ _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.
# PCDB. This is the RAW SFP: Table 4g note 3 requires it to be multiplied
# by the "default data" in-use factor (Table 329 system_type 10) before
# use as the SFPav in the (230a) formula (SFPav × 1.22 × V).
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S: Final[float] = 0.8
def _mev_default_data_iuf() -> float:
"""SAP 10.2 Table 4g note 3 (PDF p.176) in-use factor for default MEV
data PCDB Table 329 system_type 10 (IUF 2.5, identical across rigid/
flexible/no-duct columns per Table 4g note 2 "applies to both rigid and
flexible ducting"). Falls back to 1.0 if the Table 329 record is
unavailable (ETL bootstrap), preserving the pre-fix raw-SFP behaviour
rather than zeroing fan electricity."""
record = mv_in_use_factors_record(_MEV_DEFAULT_DATA_SYSTEM_TYPE)
if record is None or record.sfp_iuf_rigid_no_scheme is None:
return 1.0
return record.sfp_iuf_rigid_no_scheme
def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
"""Compose the SAP 10.2 §5 Table 4f line (230a) MEV decentralised
annual electricity contribution from PCDB Tables 322 (per-fan SFP
@ -635,8 +654,16 @@ def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
!= MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE.name
):
return 0.0
# SAP 10.2 Table 4g note 3 (PDF p.176): the default SFP "[is] to be
# multiplied by the appropriate in-use factor for default data from
# the PCDB" (Table 329 system_type 10, IUF 2.5). Omitting it
# under-billed the index-less MEV fan electricity by 2.5x → +1.3 SAP
# over-rate on the no-PCDB MEV cohort (mostly gas houses). Distinct
# from the with-index path below, which applies the tested-product
# system_type-2 "no scheme" IUF (~1.45) per fan.
sfp_av = _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * _mev_default_data_iuf()
return mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S,
sfp_av_w_per_l_per_s=sfp_av,
dwelling_volume_m3=dimensions_from_cert(epc).volume_m3,
)
iuf_record = mv_in_use_factors_record(_MEV_DECENTRALISED_SYSTEM_TYPE)

View file

@ -59,6 +59,10 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
_mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage]
_mev_default_data_iuf, # pyright: ignore[reportPrivateUsage]
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage]
dimensions_from_cert,
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
@ -90,6 +94,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
ventilation_from_cert,
)
from domain.sap10_calculator.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record
from domain.sap10_calculator.worksheet.mev import mev_decentralised_kwh_per_yr
from tests.domain.sap10_calculator.worksheet import _elmhurst_worksheet_000477 as _w000477
from domain.sap10_calculator.worksheet.water_heating import (
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
@ -1436,12 +1441,12 @@ def test_corridor_flat_assumes_draught_lobby_present_zeroing_line_13() -> None:
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.
# Table 322). SAP 10.2 §2.6.3 / Table 4g gives a default specific fan
# power of 0.8 W/(l/s); Table 4g note 3 (PDF p.176) requires multiplying
# it by the default-data in-use factor (Table 329 system_type 10, IUF
# 2.5) before use as the SFPav in the §5 Table 4f line (230a)
# `SFPav × 1.22 × V`. So the effective SFPav is 0.8 × 2.5 = 2.0. 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]
@ -1468,10 +1473,10 @@ def test_index_less_mev_uses_table_4g_default_sfp_for_fan_electricity() -> None:
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.
# Assert — IUF-adjusted SFPav (0.8 × 2.5) × 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
expected = _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * 2.5 * 1.22 * volume
assert abs(mev_kwh - expected) <= 1e-6
assert mev_kwh > 0.0
assert natural_kwh == 0.0
@ -7306,3 +7311,45 @@ def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> No
# Act / Assert
assert _heat_network_standing_charge_gbp(epc, main) is None
def test_mev_default_data_iuf_is_table_329_system_type_10_value() -> None:
# Arrange / Act — SAP 10.2 Table 4g note 3 (PDF p.176) directs the
# default SFP to "the appropriate in-use factor for default data from
# the PCDB" = Table 329 system_type 10, which lodges IUF 2.5 (identical
# across rigid/flexible/no-duct columns).
iuf = _mev_default_data_iuf()
# Assert
assert abs(iuf - 2.5) <= 1e-9
def test_index_less_mev_applies_table_4g_note_3_default_data_iuf() -> None:
# Arrange — an MEV (mechanical extract) dwelling with NO PCDB index.
# SAP 10.2 Table 4g gives the default SFP 0.8 W/(l/s), and note 3
# requires multiplying it by the default-data in-use factor (2.5) before
# use as SFPav in the (230a) fan-electricity formula. Before this fix
# the raw 0.8 was used directly, under-billing fan electricity by 2.5x
# and over-rating the index-less MEV cohort by ~+1.3 SAP.
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part()],
sap_ventilation=SapVentilation(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE",
),
)
# Act
fan_kwh = _mev_decentralised_kwh_per_yr_from_cert(epc)
volume_m3 = dimensions_from_cert(epc).volume_m3
expected = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * 2.5,
dwelling_volume_m3=volume_m3,
)
# Assert — fan electricity follows the IUF-adjusted SFPav (2.0), i.e.
# 2.5x the raw-0.8 value, not the raw default.
assert fan_kwh > 0.0
assert abs(fan_kwh - expected) <= 1e-9