From 4fb9b853dc789035251541c2c740924b01f59c58 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 13 Jun 2026 23:15:32 +0000 Subject: [PATCH] fix(ventilation): apply Table 4g note 3 in-use factor to index-less MEV SFP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 33 +++++++++- .../rdsap/test_cert_to_inputs.py | 65 ++++++++++++++++--- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 698c5e54..d293c193 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index c088628d..ed9d43ab 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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