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