diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index c7218cd8..ae4862a2 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1572,6 +1572,91 @@ def _main_heating_primary_factor( return high_frac * high_factor + (1.0 - high_frac) * low_factor +def _other_use_co2_factor_kg_per_kwh( + other_use: OtherUse, + tariff: Tariff, + monthly_kwh: tuple[float, ...], +) -> Optional[float]: + """SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194) + dual-rate monthly CO2 factor for "other electricity uses" (lighting, + pumps + fans, electric shower, etc.). + + Per Table 12d header (p.194): "Where electricity is the fuel used, + the relevant set of factors in the table below should be used to + calculate the monthly CO2 emissions INSTEAD of the annual average + factor given in Table 12." For STANDARD tariff this means single + Table 12d code 30 monthly factors weighted by the end-use's profile. + For Grid-2-eligible off-peak tariffs (SEVEN_HOUR / TEN_HOUR) the + Grid 2 ALL_OTHER_USES / FANS_FOR_MECH_VENT high-rate fraction + blends Table 12d high-rate × low-rate codes per: + + F_blended = high_frac × F_high + (1 − high_frac) × F_low + + Grid 2 doesn't list EIGHTEEN_HOUR / TWENTY_FOUR_HOUR rows; those + tariffs fall through to single-code-30 monthly. + + Mirrors `_main_heating_co2_factor_kg_per_kwh` for the Grid 2 + end-uses. Returns None when the cascade can't form a factor (zero + monthly kWh in every month); callers fall back to the annual + `_STANDARD_ELECTRICITY_FUEL_CODE` Table 12 value.""" + if tariff is Tariff.STANDARD: + return _effective_monthly_co2_factor( + monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + try: + high_frac = other_use_high_rate_fraction(other_use, tariff) + except NotImplementedError: + return _effective_monthly_co2_factor( + monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) + if codes is None: + return _effective_monthly_co2_factor( + monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + high_code, low_code = codes + high_factor = _effective_monthly_co2_factor(monthly_kwh, high_code) + low_factor = _effective_monthly_co2_factor(monthly_kwh, low_code) + if high_factor is None or low_factor is None: + return None + return high_frac * high_factor + (1.0 - high_frac) * low_factor + + +def _other_use_primary_factor( + other_use: OtherUse, + tariff: Tariff, + monthly_kwh: tuple[float, ...], +) -> Optional[float]: + """SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12e (PDF p.195) + dual-rate monthly PE factor for "other electricity uses" — PE-side + mirror of `_other_use_co2_factor_kg_per_kwh`. Same dispatch shape: + STANDARD tariff → code 30 monthly cascade; SEVEN_HOUR / TEN_HOUR → + Grid 2 ALL_OTHER_USES / FANS_FOR_MECH_VENT blend; EIGHTEEN_HOUR / + TWENTY_FOUR_HOUR fall through to single-code-30. Returns None for + the zero-monthly-kWh degenerate case.""" + if tariff is Tariff.STANDARD: + return _effective_monthly_pe_factor( + monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + try: + high_frac = other_use_high_rate_fraction(other_use, tariff) + except NotImplementedError: + return _effective_monthly_pe_factor( + monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) + if codes is None: + return _effective_monthly_pe_factor( + monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + high_code, low_code = codes + high_factor = _effective_monthly_pe_factor(monthly_kwh, high_code) + low_factor = _effective_monthly_pe_factor(monthly_kwh, low_code) + if high_factor is None or low_factor is None: + return None + return high_frac * high_factor + (1.0 - high_frac) * low_factor + + def _hot_water_co2_factor_kg_per_kwh( epc: EpcPropertyData, hw_monthly_kwh: tuple[float, ...], @@ -4067,19 +4152,25 @@ def cert_to_inputs( epc, wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12, ), - pumps_fans_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + # SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps, + # lighting, and the electric-shower end-use all bill via the + # "All other uses" row → on off-peak tariffs blend the high / + # 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), - _STANDARD_ELECTRICITY_FUEL_CODE, ), - lighting_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( - lighting_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + lighting_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh( + OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh, ), electric_shower_kwh_per_yr=( wh_result.electric_shower_kwh_per_yr if wh_result is not None else 0.0 ), - electric_shower_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + electric_shower_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh( + OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12, - _STANDARD_ELECTRICITY_FUEL_CODE, ), pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(), @@ -4140,16 +4231,18 @@ def cert_to_inputs( secondary_heating_primary_factor=_secondary_heating_primary_factor( epc, energy_requirements_result.secondary_fuel_monthly_kwh, ), - pumps_fans_primary_factor=_effective_monthly_pe_factor( + # 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), - _STANDARD_ELECTRICITY_FUEL_CODE, ), - lighting_primary_factor=_effective_monthly_pe_factor( - lighting_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + lighting_primary_factor=_other_use_primary_factor( + OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh, ), - electric_shower_primary_factor=_effective_monthly_pe_factor( + electric_shower_primary_factor=_other_use_primary_factor( + OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12, - _STANDARD_ELECTRICITY_FUEL_CODE, ), fuel_cost=_fuel_cost( epc=epc, diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 97c0cc83..d7dd235c 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -2090,6 +2090,58 @@ def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_ ) +def test_lighting_co2_factor_blends_table_12a_grid_2_with_table_12d_dual_rate_on_off_peak_certs() -> None: + """SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194) — + "other electricity uses" (lighting, pumps + fans, electric shower) on + an off-peak tariff blend the dual-rate Table 12d high/low monthly CO2 + factors per the Grid 2 ALL_OTHER_USES high-rate fraction. From the + spec text on p.194: + + "Where electricity is the fuel used, the relevant set of factors + in the table below should be used to calculate the monthly CO2 + emissions INSTEAD of the annual average factor given in + Table 12." + + And Table 12a Grid 2 (PDF p.191) "Other electricity uses" row + "All other uses" × 10-hour tariff = 0.80 high-rate fraction. + + Cert 000565 is on a Dual meter routed via §12 Rule 3 (heat pump + main → TEN_HOUR). The lighting CO2 factor must blend Table 12d + code 34 (10h high) and code 33 (10h low) monthly factors weighted + by the L11 lighting profile, NOT use code 30 alone (Slice S0380.65 + landed this for main_heating; lighting / pumps_fans / electric_ + shower were still on the code-30-only path). + + Pre-S0380.82 cert 000565 cascade: lighting factor 0.1443 (code 30 + monthly × L11 profile). Post: 0.1469 (Grid 2 blend) — pushes the + cohort CO2 residual from −8.92 kg/yr toward zero on the lighting + + pumps_fans + electric_shower trio. + """ + # Arrange — mapper-driven cohort fixture (Dual meter / TEN_HOUR + # tariff, heat-pump main). + from domain.sap10_calculator.worksheet.tests import ( + _elmhurst_worksheet_000565 as _w000565, + ) + epc = _w000565.build_epc() + + # Act + inputs = cert_to_inputs(epc) + + # Assert — lighting CO2 factor lifted above the code-30-only baseline + # by the Grid 2 dual-rate blend. Pre-S0380.82 value 0.1443; post-fix + # ≥ 0.146 per the 0.80-weighted code 34 + 0.20-weighted code 33 + # cascade. + pre_fix_baseline = 0.1444 # code 30 monthly × L11 profile + factor = inputs.lighting_co2_factor_kg_per_kwh + assert factor is not None and factor > pre_fix_baseline + 0.001, ( + f"lighting_co2_factor_kg_per_kwh = {factor!r}; expected dual-rate " + f"Grid 2 blend > {pre_fix_baseline + 0.001:.4f} per SAP 10.2 " + f"Table 12a Grid 2 (p.191) + Table 12d (p.194). The cascade was " + f"applying code 30 alone — must now blend code 34 (10h high) and " + f"code 33 (10h low) at the 0.80 / 0.20 split." + ) + + def test_rdsap_10_table_32_prices_charge_mains_gas_hot_water_at_3p48_per_kwh() -> None: """RdSAP 10 Specification §19.1 (PDF page 80-81) — the §10a fuel-cost block uses RdSAP 10 Table 32 (PDF page 95) prices, NOT SAP 10.2