fix(tariff): include MVHR fan electricity in the off-peak Grid 2 fan split

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), distinct from "All other uses"
(0.90 / 0.80) which covers circulation pumps, flue fans and the solar HW pump.

The cost-split mech-vent kWh (`mev_kwh_for_cost_split`) only summed the
decentralised-MEV (230b) fans, not the (230a) MVHR fan electricity — even
though the total pumps/fans bucket adds both. So an MVHR dwelling on an
off-peak tariff billed its fan electricity at the 0.90/0.80 "all other uses"
rate instead of 0.71/0.58. The comment already said "MEV/MVHR-fan portion";
only the MEV term was wired when MVHR landed. Fixed to mirror both
mechanical-ventilation fan terms summed into the total.

Worksheet-proven on simulated case 50 (000565 semi + MVHR Vent Axia + dual
electric immersion, Unknown meter -> 7-hour via the §12 dual-immersion
trigger): the fan bucket (315.64 kWh, 100% MVHR per worksheet line 230a) was
billing at 14.311 p/kWh (0.90) vs Elmhurst's 12.451 p/kWh (0.71) — +£5.87/yr,
-0.23 SAP. After the fix our existing-dwelling rating reconciles to Elmhurst
EXACTLY: SAP value 38.8426 (=Elmhurst 38.8426 -> 39), total cost £1317.0116
(=Elmhurst £1317.0116 to the penny).

Same `mev_kwh_for_cost_split` feeds the CO2 + PE cascades, so all three split
consistently. 0 corpus impact (all 3 corpus MVHR certs are standard tariff);
gauge unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4.

Pin: test_mvhr_fan_electricity_bills_at_grid2_fan_fraction_on_off_peak.
pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 22:16:58 +00:00
parent eea5d3a5a8
commit cd5113abf2
2 changed files with 80 additions and 4 deletions

View file

@ -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

View file

@ -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