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,