From e6dda705f4711d31b05578bc746ba36cb9967ec4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 9 Jun 2026 09:00:54 +0000 Subject: [PATCH] fix(ventilation): apply Table 4g default SFP to index-less MEV fan electricity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 31 +++++++++++-- .../rdsap/test_cert_to_inputs.py | 43 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 31709822..15f14149 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 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 b10fb12f..516a5e9e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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