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 f8fb45ec..c6430f0a 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1892,6 +1892,57 @@ def test_summary_000565_mev_fans_cost_uses_table_12a_grid_2_fans_for_mech_vent_r ) +def test_summary_000565_mev_fans_co2_factor_uses_table_12a_grid_2_fans_for_mech_vent_split() -> None: + # Arrange — SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d + # (PDF p.194) — CO2-side mirror of the cost split landed in + # S0380.103. 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 12d codes for TEN_HOUR are 34 (high) + 33 (low). Days- + # weighted Σ(F_m × N_m) / Σ N_m over the 12 months of code 30 + # uniform-per-day proxy yields: + # + # F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh + # F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/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 × 0.13872 + 125 × 0.14116) / 252.5159 + # = 0.13993 kg/kWh + # + # Worksheet line (267) verifies the split: + # Pumps, fans and electric keep-hot 252.5159 × 0.1412 = 35.3349 + # (display rounds factor to 0.1412 but the product is the + # kWh-weighted MEV-split total of 35.3349) + # + # Pre-slice the cascade applied 0.14116 to ALL 252.5159 kWh → + # 35.6457 kg/yr → +0.31 over ws. With the MEV-aware split the + # cascade lands on 35.3349 kg/yr. + 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 CO2 factor equals the worksheet line (267) + # effective rate (within 1e-4 / kWh). + expected_factor = 35.3349 / 252.5159 + actual = inputs.pumps_fans_co2_factor_kg_per_kwh + assert actual is not None + assert abs(actual - expected_factor) <= 1e-4, ( + f"cascade pumps_fans_co2_factor={actual:.6f}; " + f"ws (267) effective={expected_factor:.6f}; Δ={actual - expected_factor:+.6f} " + f"(expected MEV-split kWh-weighted blend post-S0380.105)" + ) + + 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 fe925db1..0c934312 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1872,6 +1872,61 @@ def _main_heating_primary_factor( return high_frac * high_factor + (1.0 - high_frac) * low_factor +def _pumps_fans_co2_factor_kg_per_kwh( + *, + 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 12d (PDF p.194) — + CO2-side mirror of `_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 (central-heating circulation + pumps, flue fans, solar HW pumps, electric keep-hot) bills at + `ALL_OTHER_USES` (10-hour: 0.80; 7-hour: 0.90). The two Grid 2 + categories blend Table 12d high/low-rate codes at different ratios + → two distinct effective CO2 factors. Returns the kWh-weighted + blend across the two streams. + + Returns the existing `_other_use_co2_factor_kg_per_kwh( + ALL_OTHER_USES, ...)` rate on STANDARD tariff (no Grid 2 split + applies — Table 12d code 30 monthly cascade only), and when no MEV + is lodged (no split needed). + + 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 = 0.13872 kg/kWh + F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh + F_eff = (127.5159 × 0.13872 + 125.0 × 0.14116) / 252.5159 + = 0.13993 kg/kWh + Worksheet line (267): 252.5159 × 0.13993 = 35.3349 kg/yr; pre-slice + the cascade applied 0.14116 to all pumps_fans → 35.6457 → +0.31 + over ws. + """ + other_factor = _other_use_co2_factor_kg_per_kwh( + 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_co2_factor_kg_per_kwh( + 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_co2_factor_kg_per_kwh( other_use: OtherUse, tariff: Tariff, @@ -4499,9 +4554,17 @@ def cert_to_inputs( # low Table 12d codes per the Grid 2 fraction. STANDARD tariff # passes through to single-code-30 monthly. Mirrors the main- # heating Grid 1 split landed in S0380.65. - pumps_fans_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh( - 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 CO2 factor on a high-carbon high- + # rate code) instead of `ALL_OTHER_USES`. Slice S0380.105 + # weights the two streams by their lodged kWh portions — + # mirror of the cost-side S0380.103 split. + pumps_fans_co2_factor_kg_per_kwh=_pumps_fans_co2_factor_kg_per_kwh( + 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_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh( OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh,