diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 400dab5d..f8fb45ec 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 8309e86a..47366741 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9abb8723..fe925db1 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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