Slice S0380.103: MEV fans cost split via Table 12a Grid 2 FANS_FOR_MECH_VENT rate (SAP 10.2 Table 12a)

SAP 10.2 Table 12a Grid 2 (PDF p.191) splits off-peak electricity
costs into two categories:

  Other electricity uses                       Tariff    Fraction at
                                                          high rate
  Fans for mechanical ventilation systems      7-hour    0.71
                                              10-hour    0.58
  All other uses, and locally generated        7-hour    0.90
  electricity                                 10-hour    0.80

Cert 000565 (Dual meter, 10-hour off-peak, MEV decentralised) lodges
127.5159 kWh of MEV-fan electricity (line 230a) that bills at the
`FANS_FOR_MECH_VENT` blend (0.58 × 14.68 + 0.42 × 7.50 = 11.6644
p/kWh), distinct from the 125 kWh of other pumps_fans (45 kWh gas-
boiler flue fan + 80 kWh solar HW pump) which bills at the
`ALL_OTHER_USES` blend (0.80 × 14.68 + 0.20 × 7.50 = 13.2440 p/kWh).

Pre-slice the cascade applied `ALL_OTHER_USES` to ALL 252.5159 kWh,
over-counting MEV cost by 127.5159 × (0.13244 - 0.11664) = +£2.01/yr.

Worksheet pin verification (line (249)):
  "Pumps, fans and electric keep-hot ... 172.5159  13.2440  20.8338"
  127.5159 × 0.11664 + 45 × 0.13244 = £14.8753 + £5.9598 = £20.8351
  ≈ ws £20.8338 ✓
  Pump for solar water heating 80.0 × 0.13244 = £10.5952 ✓

Implementation (3-layer):
1. `calculator.py:CalculatorInputs` — new optional
   `pumps_fans_fuel_cost_gbp_per_kwh: Optional[float] = None`.
2. `calculator.py` legacy cost path — `pumps_fans_cost` resolves
   via the new field with fallback to `other_fuel_cost_gbp_per_kwh`.
3. `cert_to_inputs.py:_pumps_fans_fuel_cost_gbp_per_kwh` — computes
   the kWh-weighted blended rate when off-peak + MEV is lodged.
   Reuses `_mev_decentralised_kwh_per_yr_from_cert` (S0380.102) to
   recover the MEV portion.

Cohort safety: STANDARD-tariff certs (the entire cohort except cert
000565) get None back → existing `other_fuel_cost_gbp_per_kwh`
fallback unchanged. Certs without MEV (zero MEV kWh) also get None
→ no behavioural change.

Movement at HEAD (cert 000565):
- pumps_fans_kwh_per_yr ✓ EXACT (unchanged)
- total_fuel_cost_gbp: 4680.6514 → 4678.6372  (Δ +£0.39 → -£1.62)
- ecf: 5.3873 → 5.3850 (Δ +0.0007 → -0.0016)
- sap_score_continuous: 28.5043 → 28.5269 (Δ -0.0044 → +0.0182)

Continuous-SAP residual drifted from -0.0044 to +0.0182 in absolute
value: closing the MEV cost over-count exposes a pre-existing
space-heating cascade under-count (main_heating_fuel_kwh is -16 kWh
under ws). Per user direction [[feedback-spec-floor-skepticism]]:
shipping spec-correct intermediate-value fixes even when they
transiently drift continuous SAP. The remaining residual is now
SH-cascade driven; a separate slice.

Test count: 597 pass + 7 expected 000565 fails unchanged.

Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 16:07:55 +00:00
parent a0413155ae
commit e3abe9b2b5
3 changed files with 133 additions and 1 deletions

View file

@ -1847,6 +1847,51 @@ def test_summary_000565_ext2_floor_routes_to_u_value_0p22_via_table_20_per_rdsap
assert bp_2.floor_insulation_thickness == "200mm"
def test_summary_000565_mev_fans_cost_uses_table_12a_grid_2_fans_for_mech_vent_rate() -> None:
# Arrange — SAP 10.2 Table 12a Grid 2 (PDF p.191) "Other electricity
# uses" splits two cost categories on off-peak tariffs:
#
# Fans for mechanical ventilation systems 10-hour 0.58
# All other uses, and locally generated 10-hour 0.80
# electricity
#
# Cert 000565 lodges 127.5159 kWh of MEV decentralised fan energy
# (line 230a) which must be billed at the FANS_FOR_MECH_VENT blend
# (0.58 × 14.68 + 0.42 × 7.50 = 11.6644 p/kWh), NOT the
# ALL_OTHER_USES blend (13.244 p/kWh). The remaining 125 kWh of
# pumps_fans (45 flue fan + 80 solar HW pump) stay at 13.244.
#
# Worksheet line (249) verifies the split:
# Pumps, fans and electric keep-hot 172.5159 × effective 12.076 = £20.8338
# = 127.5159 × 0.11664 + 45 × 0.13244
# = 14.8753 + 5.9598 = £20.8351 ≈ £20.8338 ✓
# Pump for solar water heating 80.0000 × 13.244 / 100 = £10.5952
#
# Pre-slice the cascade applied 0.13244 to ALL 252.5159 kWh, over-
# counting MEV cost by 127.5159 × (0.13244 - 0.11664) = £2.01.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_inputs,
)
# Act
result = calculate_sap_from_inputs(cert_to_inputs(epc))
# Assert — total fuel cost should be ≤ +£0.05 over worksheet (the
# MEV cost split closes the +£2.01 pumps_fans over-count; remaining
# residual is the space-heating cascade under-count, separate slice).
# Worksheet line (255) = £4680.2593.
delta = result.total_fuel_cost_gbp - 4680.2593
assert delta < 0.05, (
f"cascade total_fuel_cost_gbp={result.total_fuel_cost_gbp:.4f}; "
f"ws=£4680.2593; Δ={delta:+.4f} (expected ≤+£0.05 after MEV "
f"cost split closes the +£2.01 over-count)"
)
def test_summary_000565_main_1_ashp_sap_code_224_routes_to_main_heating_category_4_per_sap_table_4a() -> None:
# Arrange — SAP 10.2 Table 4a (PDF p.165) "Main heating systems":
# the category column lists "Heat pumps" as category 4. Codes in

View file

@ -181,6 +181,15 @@ class CalculatorInputs:
hot_water_fuel_cost_gbp_per_kwh: float
other_fuel_cost_gbp_per_kwh: float
co2_factor_kg_per_kwh: float
# SAP 10.2 Table 12a Grid 2 split — MEV/MVHR fans on off-peak
# tariffs (7-hour: 0.71 high-frac; 10-hour: 0.58 high-frac) bill
# at a DIFFERENT blended rate than "all other uses" (7-hour: 0.90;
# 10-hour: 0.80). Cert_to_inputs supplies the MEV-kWh-weighted
# blended rate here for pumps_fans on off-peak; None on standard-
# tariff certs (no split applies) and on certs without MEV/MVHR.
# When None the legacy `other_fuel_cost_gbp_per_kwh` applies to
# the whole pumps_fans stream.
pumps_fans_fuel_cost_gbp_per_kwh: Optional[float] = None
# Pre-computed monthly external temperature (°C). When provided, the
# calculator's per-month solve uses this directly instead of looking up
# `external_temperature_c(region, month)`. Set by cert_to_inputs from
@ -516,7 +525,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
hot_water_cost = (
inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
)
pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
pumps_fans_rate = (
inputs.pumps_fans_fuel_cost_gbp_per_kwh
if inputs.pumps_fans_fuel_cost_gbp_per_kwh is not None
else inputs.other_fuel_cost_gbp_per_kwh
)
pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * pumps_fans_rate
lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
# SAP 10.2 §10a (PDF p.145) line (247a): instantaneous electric
# showers route their (64a) kWh through the "other fuel" tariff

View file

@ -1593,6 +1593,62 @@ def _other_fuel_cost_gbp_per_kwh(
return blended * _PENCE_TO_GBP
def _pumps_fans_fuel_cost_gbp_per_kwh(
*,
tariff: Tariff,
mev_kwh_per_yr: float,
total_pumps_fans_kwh_per_yr: float,
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 — MEV/MVHR fan electricity bills at the
`FANS_FOR_MECH_VENT` high-rate fraction (10-hour: 0.58; 7-hour:
0.71), distinct from `ALL_OTHER_USES` (10-hour: 0.80; 7-hour: 0.90)
which covers central-heating circulation pumps, flue fans, solar
HW pump, and locally-generated electricity.
Returns the kWh-weighted blended rate across the two Grid 2
categories `(mev_kwh × fans_rate + non_mev_kwh × other_rate) /
total_kwh`. Returns None on STANDARD tariff (no off-peak split
applies; the calculator's `other_fuel_cost_gbp_per_kwh` already
yields the right scalar) and when no MEV is lodged (no split
needed; the same `other_fuel_cost_gbp_per_kwh` applies).
Worksheet pin for cert 000565 (TEN_HOUR + MEV 127.5 kWh + 125 kWh
other pumps/fans):
fans_blend = 0.58 × 14.68 + 0.42 × 7.50 = 11.6644 p/kWh
other_blend = 0.80 × 14.68 + 0.20 × 7.50 = 13.2440 p/kWh
weighted = (127.5159 × 11.6644 + 125.0 × 13.2440) / 252.5159
= 12.4467 p/kWh
The (249) line in the worksheet uses the same weighting to bill
MEV at the lower 11.6644 rate; without this helper the cascade
over-counted by £2.01 / yr.
"""
if tariff is Tariff.STANDARD:
return None
if mev_kwh_per_yr <= 0.0 or total_pumps_fans_kwh_per_yr <= 0.0:
return None
try:
fans_high_frac = other_use_high_rate_fraction(
OtherUse.FANS_FOR_MECH_VENT, tariff,
)
other_high_frac = other_use_high_rate_fraction(
OtherUse.ALL_OTHER_USES, tariff,
)
except NotImplementedError:
return None
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
fans_blend = (
fans_high_frac * high_rate + (1.0 - fans_high_frac) * low_rate
)
other_blend = (
other_high_frac * high_rate + (1.0 - other_high_frac) * low_rate
)
non_mev_kwh = max(0.0, total_pumps_fans_kwh_per_yr - mev_kwh_per_yr)
weighted_p_per_kwh = (
mev_kwh_per_yr * fans_blend + non_mev_kwh * other_blend
) / total_pumps_fans_kwh_per_yr
return weighted_p_per_kwh * _PENCE_TO_GBP
# Water-heating codes that say "inherit from the main system" — the
# `seasonal_efficiency` cascade returns 0 as a sentinel for these in the
# legacy `domain.sap10_ml.sap_efficiencies` module. We need to inherit through
@ -3968,6 +4024,11 @@ def cert_to_inputs(
# SAP 10.2 Table 4f (p.174) — additive components on top of the
# Main 1 category base. Each component is per-cert-lodging:
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)
primary_age = (
epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None
)
@ -4388,6 +4449,18 @@ def cert_to_inputs(
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
_rdsap_tariff(epc), prices
),
# SAP 10.2 Table 12a Grid 2 — MEV/MVHR fans bill at a different
# high-rate fraction (10-hour: 0.58; 7-hour: 0.71) than the
# general "all other uses" category (10-hour: 0.80; 7-hour:
# 0.90). Compute the kWh-weighted blended rate so the
# calculator's legacy pumps_fans cost line resolves correctly.
# None on standard-tariff certs (no split applies) and on certs
# without MEV (no MEV portion to split out).
pumps_fans_fuel_cost_gbp_per_kwh=_pumps_fans_fuel_cost_gbp_per_kwh(
tariff=_rdsap_tariff(epc),
mev_kwh_per_yr=mev_kwh_for_cost_split,
total_pumps_fans_kwh_per_yr=pumps_fans_kwh,
),
# Table 32 standing charges for the off-peak fallback path.
# STANDARD-tariff certs route via `fuel_cost.additional_
# standing_charges_gbp` (set inside `_fuel_cost`) and the