From 273e9c7bb09909cff179b543ea1828a71d901284 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 16:59:56 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.106:=20MEV=20fans=20PE=20split=20?= =?UTF-8?q?via=20Table=2012a=20Grid=202=20+=20Table=2012e=20(SAP=2010.2=20?= =?UTF-8?q?=C2=A710a=20/=20=C2=A710c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PE-side mirror of S0380.103 (cost) + S0380.105 (CO2). Completes the MEV cascade trifecta for off-peak tariff certs. Cert 000565 worksheet line (281): Pumps, fans and electric keep-hot 252.5159 1.5239 383.3796 (281) The displayed factor (1.5239) is the ALL_OTHER_USES Table 12e Σ days-weighted blend; the displayed product (383.3796) is the kWh- weighted blend across the two Grid 2 categories: F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 1.51268 kWh/kWh F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 1.52391 kWh/kWh F_eff = (127.5159 × 1.51268 + 125.0 × 1.52391) / 252.5159 = 1.51824 kWh/kWh PE = 252.5159 × 1.51824 = 383.3796 kWh/yr ✓ Pre-slice the cascade applied 1.52391 to ALL 252.5159 kWh → 384.81 → +1.43 over ws. SAP 10.2 Table 12a Grid 2 (PDF p.191) — same dispatch as Slice S0380.105 — splits the off-peak high-rate fraction by end-use between `FANS_FOR_MECH_VENT` and `ALL_OTHER_USES`. SAP 10.2 Table 12e (PDF p.195) verbatim header: "Where electricity is the fuel used, the relevant set of factors in the table below should be used to calculate the monthly primary energy instead the annual average factor given in Table 12." The Grid 2 high-rate fraction blends Table 12e high-rate × low- rate codes per `F_blended = high_frac × F_high + (1 − high_frac) × F_low`. MEV fans bill at the lower 0.58 high_frac → lower PE factor on the higher-PE high-rate code 34. Identical structural fix as the .105 CO2 slice; the only delta is the underlying Table 12 column. 2-layer fix: 1. New helper `_pumps_fans_primary_factor` in cert_to_inputs.py — mirror of `_pumps_fans_co2_factor_kg_per_kwh`. Returns kWh- weighted blend of FANS_FOR_MECH_VENT + ALL_OTHER_USES factors. Falls back to ALL_OTHER_USES rate on STANDARD / no-MEV certs. 2. Call site at line 4640 wires `mev_kwh_for_cost_split` + `pumps_fans_kwh` through the helper. Movement at HEAD `8a3aaf7a` → post-slice (cert 000565): | Pin | Pre | Post | |--------------------------------|-----------:|-----------:| | pumps_fans_primary_factor | 1.52391 | 1.51824 | | pumps_fans_pe_kwh_per_yr | 384.8122 | 383.3797 | ✓ EXACT vs ws (281) | primary_energy_kwh_per_yr | 62228.4896 | 62227.0570 | | primary_energy_kwh_per_m2 | 194.5187 | 194.5143 | No effect on sap_score_continuous (ECF is cost-based, not PE-based), ecf, or any of the 7 currently-failing 000565 pins. The total PE residual remains dominated by an unrelated SH cascade PE factor gap (cascade 170 kWh/m² vs ws 135.6 — separate slice). Cohort safety: STANDARD-tariff and no-MEV certs return the existing ALL_OTHER_USES rate (helper falls through). No-MEV certs return the same rate (mev_kwh_per_yr=0 short-circuit). Pyright net-zero per touched file (45 baseline → 45 post-change). Test count: 605 pass + 7 expected 000565 fails → **606 pass + 7 expected 000565 fails** (new test_summary_000565_mev_fans_pe_factor_uses_table_12a_grid_2_ fans_for_mech_vent_split GREEN; 7 known 000565 fails set unchanged). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 50 ++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 66 ++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) 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 c6430f0a..14e56b6c 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1943,6 +1943,56 @@ def test_summary_000565_mev_fans_co2_factor_uses_table_12a_grid_2_fans_for_mech_ ) +def test_summary_000565_mev_fans_pe_factor_uses_table_12a_grid_2_fans_for_mech_vent_split() -> None: + # Arrange — SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12e + # (PDF p.195) — PE-side mirror of the cost split (S0380.103) and + # CO2 split (S0380.105). The Table 12a Grid 2 high-rate fractions + # on TEN_HOUR are: + # + # Fans for mechanical ventilation systems high_frac = 0.58 + # All other uses, and locally generated high_frac = 0.80 + # electricity + # + # Table 12e codes for TEN_HOUR are 34 (high) + 33 (low). Days- + # weighted Σ(F_m × N_m) / Σ N_m over the 12 months yields: + # + # F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 1.51268 kWh/kWh + # F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 1.52391 kWh/kWh + # + # Cert 000565 splits pumps_fans into 127.5159 kWh MEV + 125 kWh + # non-MEV (45 flue fan + 80 solar HW pump). kWh-weighted blend: + # + # F_eff = (127.5159 × 1.51268 + 125 × 1.52391) / 252.5159 + # = 1.51824 kWh/kWh + # + # Worksheet line (281): + # Pumps, fans and electric keep-hot 252.5159 × 1.5239 = 383.3796 + # (display rounds factor to 1.5239 but the product is the + # kWh-weighted MEV-split total of 383.3796) + # + # Pre-slice the cascade applied 1.52391 to ALL 252.5159 kWh → + # 384.81 → +1.43 over ws. + 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.rdsap.cert_to_inputs import cert_to_inputs + + # Act + inputs = cert_to_inputs(epc) + + # Assert — pumps_fans PE factor equals the worksheet line (281) + # effective rate (within 1e-4 / kWh). + expected_factor = 383.3796 / 252.5159 + actual = inputs.pumps_fans_primary_factor + assert actual is not None + assert abs(actual - expected_factor) <= 1e-4, ( + f"cascade pumps_fans_primary_factor={actual:.6f}; " + f"ws (281) effective={expected_factor:.6f}; " + f"Δ={actual - expected_factor:+.6f} " + f"(expected MEV-split kWh-weighted blend post-S0380.106)" + ) + + 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/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0c934312..71cca2cb 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1977,6 +1977,58 @@ def _other_use_co2_factor_kg_per_kwh( return high_frac * high_factor + (1.0 - high_frac) * low_factor +def _pumps_fans_primary_factor( + *, + tariff: Tariff, + mev_kwh_per_yr: float, + total_pumps_fans_kwh_per_yr: float, + monthly_kwh: tuple[float, ...], +) -> Optional[float]: + """SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12e (PDF p.195) — + PE-side mirror of `_pumps_fans_co2_factor_kg_per_kwh` (Slice + S0380.105) and `_pumps_fans_fuel_cost_gbp_per_kwh` (Slice + S0380.103). + + MEV/MVHR-fan electricity bills at the `FANS_FOR_MECH_VENT` high- + rate fraction (10-hour: 0.58; 7-hour: 0.71) on dual-rate tariffs, + while the remaining pumps_fans portion uses `ALL_OTHER_USES` + (10-hour: 0.80; 7-hour: 0.90). Returns the kWh-weighted blend of + the two PE factors. + + Returns the existing `_other_use_primary_factor(ALL_OTHER_USES, + ...)` rate on STANDARD tariff (no Grid 2 split — Table 12e code 30 + monthly cascade only), and when no MEV is lodged. + + Worksheet pin for cert 000565 (TEN_HOUR + MEV 127.5159 kWh + 125 + kWh other pumps/fans): + F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 1.51268 kWh/kWh + F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 1.52391 kWh/kWh + F_eff = (127.5159 × 1.51268 + 125.0 × 1.52391) / 252.5159 + = 1.51824 kWh/kWh + Worksheet line (281): 252.5159 × 1.51824 = 383.3796 kWh/yr; pre- + slice the cascade applied 1.52391 to all pumps_fans → 384.81 → + +1.43 over ws. + """ + other_factor = _other_use_primary_factor( + OtherUse.ALL_OTHER_USES, tariff, monthly_kwh, + ) + if ( + tariff is Tariff.STANDARD + or mev_kwh_per_yr <= 0.0 + or total_pumps_fans_kwh_per_yr <= 0.0 + ): + return other_factor + fans_factor = _other_use_primary_factor( + OtherUse.FANS_FOR_MECH_VENT, tariff, monthly_kwh, + ) + if fans_factor is None or other_factor is None: + return other_factor + non_mev_kwh = max(0.0, total_pumps_fans_kwh_per_yr - mev_kwh_per_yr) + return ( + mev_kwh_per_yr * fans_factor + non_mev_kwh * other_factor + ) / total_pumps_fans_kwh_per_yr + + def _other_use_primary_factor( other_use: OtherUse, tariff: Tariff, @@ -4637,9 +4689,17 @@ def cert_to_inputs( ), # PE-side mirror of the Grid 2 dual-rate CO2 blend above — # Table 12a Grid 2 (p.191) + Table 12e (p.195). - pumps_fans_primary_factor=_other_use_primary_factor( - OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), - _days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH), + # + # MEV/MVHR-fan kWh route through `FANS_FOR_MECH_VENT` (lower + # high-rate fraction → lower PE factor on a higher-PE high- + # rate code) instead of `ALL_OTHER_USES`. Slice S0380.106 + # weights the two streams by their lodged kWh portions — + # mirror of the cost-side (.103) + CO2-side (.105) splits. + pumps_fans_primary_factor=_pumps_fans_primary_factor( + tariff=_rdsap_tariff(epc), + mev_kwh_per_yr=mev_kwh_for_cost_split, + total_pumps_fans_kwh_per_yr=pumps_fans_kwh, + monthly_kwh=_days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH), ), lighting_primary_factor=_other_use_primary_factor( OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh,