diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 8905d0b6..779790a9 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7318,10 +7318,21 @@ def cert_to_inputs( pumps_fans_kwh += _table_4f_circulation_pump_kwh(_pumps_main_2) pumps_fans_kwh += _table_4f_additive_components(epc) # Track the MEV/MVHR-fan portion separately so the cost cascade can - # apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on - # 10-hour) instead of `ALL_OTHER_USES` (0.80) — see - # `_pumps_fans_fuel_cost_gbp_per_kwh`. Zero when no MEV is lodged. - mev_kwh_for_cost_split = _mev_decentralised_kwh_per_yr_from_cert(epc) + # apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.71 high-frac on + # 7-hour, 0.58 on 10-hour) instead of `ALL_OTHER_USES` (0.90 / 0.80) + # — see `_pumps_fans_fuel_cost_gbp_per_kwh`. Must mirror the + # mechanical-ventilation fan terms summed into the total pumps/fans + # at `_table_4f_additive_components` (230b decentralised MEV/extract) + # + the (230a) MVHR fan: both are "Fans for mechanical ventilation + # systems" in Grid 2, while flue fans / circulation pumps / solar HW + # pump are "All other uses". The MVHR term was omitted when MVHR + # landed, so an MVHR dwelling on off-peak billed its fan electricity + # at 0.90 instead of 0.71 (case 50: +£5.87/yr, -0.23 SAP). Zero when + # no mechanical-ventilation fan is lodged. + mev_kwh_for_cost_split = ( + _mev_decentralised_kwh_per_yr_from_cert(epc) + + _mvhr_fan_kwh_per_yr_from_cert(epc) + ) primary_age = _dwelling_age_band(epc) # SAP 10.2 Appendix D2.1: if the cert lodges a PCDB index number that 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 e7169dfe..4af6d3d0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -3876,6 +3876,71 @@ def test_mvhr_system_values_apply_pcdb_wet_room_point_and_table_329_iufs() -> No assert abs(fan_kwh - expected_fan_kwh) <= 1e-6 +def test_mvhr_fan_electricity_bills_at_grid2_fan_fraction_on_off_peak() -> None: + # Arrange — an MVHR dwelling on an off-peak tariff. SAP 10.2 Table 12a + # Grid 2 (PDF p.191) bills "Fans for mechanical ventilation systems" at + # 0.71 (7-hour) / 0.58 (10-hour) high-rate fraction, distinct from "All + # other uses" (0.90 / 0.80) which covers circulation pumps + flue fans. + # The MVHR fan electricity (230a) was summed into the pumps/fans total + # but NOT into the cost-split mech-vent kWh, so on off-peak it billed at + # the 0.90/0.80 "all other uses" rate. Worksheet-proven on simulated + # case 50 (000565 + MVHR + dual immersion): +£5.87/yr, -0.23 SAP. + import dataclasses + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _mvhr_fan_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] + _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] + ) + from domain.sap10_calculator.tables.table_12a import ( + OtherUse, + Tariff, + other_use_high_rate_fraction, + ) + from tests.domain.sap10_calculator.worksheet import ( + _elmhurst_worksheet_000565 as _w000565, + ) + + base = _w000565.build_epc() + assert base.sap_ventilation is not None + epc = dataclasses.replace( + base, + mechanical_ventilation_index_number=500140, + wet_rooms_count=2, + mechanical_vent_duct_type=2, # rigid + sap_ventilation=dataclasses.replace( + base.sap_ventilation, mechanical_ventilation_kind="MVHR" + ), + sap_energy_source=dataclasses.replace( + base.sap_energy_source, meter_type="1" # off-peak (10-hour) + ), + ) + + # Act + inputs = cert_to_inputs(epc) + rate = inputs.pumps_fans_fuel_cost_gbp_per_kwh + + # Assert — the resolved rate is the kWh-weighted blend of the MVHR fan + # portion at the FANS_FOR_MECH_VENT fraction and the remaining pumps at + # ALL_OTHER_USES. The presence of the MVHR fan in the split makes it + # strictly cheaper than billing the whole bucket at ALL_OTHER_USES. + tariff = Tariff.TEN_HOUR + high, low = _tariff_high_low_rates_p_per_kwh(tariff) + fan_frac = other_use_high_rate_fraction(OtherUse.FANS_FOR_MECH_VENT, tariff) + other_frac = other_use_high_rate_fraction(OtherUse.ALL_OTHER_USES, tariff) + fan_blend = fan_frac * high + (1.0 - fan_frac) * low + other_blend = other_frac * high + (1.0 - other_frac) * low + fan_kwh = _mvhr_fan_kwh_per_yr_from_cert(epc) + total_kwh = inputs.pumps_fans_kwh_per_yr + non_fan_kwh = total_kwh - fan_kwh + expected = ( + (fan_kwh * fan_blend + non_fan_kwh * other_blend) / total_kwh / 100.0 + ) + assert rate is not None + assert fan_kwh > 0.0 + assert abs(rate - expected) <= 1e-12 + assert rate < other_blend / 100.0 # regression guard: fan portion is in the split + + def test_mvhr_system_values_fall_back_to_table_4g_defaults_without_pcdb_index() -> None: # Arrange — an MVHR cert lodged with NO PCDF index (corpus cert # "Flat 1"). SAP 10.2 Table 4g (PDF p.176): default raw efficiency