diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index cb33c153..0cf9fcf8 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -849,6 +849,18 @@ _TARIFF_HIGH_LOW_RATES_P_PER_KWH: Final[dict[Tariff, tuple[float, float]]] = { } +# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2 +# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the +# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into +# distinct Table 12d profiles; 18-hour (38/40) and 24-hour (35) fall +# through to standard code 30 monthly factors in Table 12d itself, so +# no dual-rate split applies for them. +_TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12: Final[dict[Tariff, tuple[int, int]]] = { + Tariff.SEVEN_HOUR: (32, 31), # 7-hour high, 7-hour low + Tariff.TEN_HOUR: (34, 33), # 10-hour high, 10-hour low +} + + def _table_12a_system_for_main( main: Optional[MainHeatingDetail], ) -> Optional[Table12aSystem]: @@ -1401,6 +1413,58 @@ def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: return co2_factor_kg_per_kwh(_main_fuel_code(main)) +def _main_heating_co2_factor_kg_per_kwh( + main: Optional[MainHeatingDetail], + tariff: Tariff, + main_fuel_monthly_kwh: tuple[float, ...], +) -> float: + """SAP 10.2 Table 12a Grid 1 (SH) + Table 12d (p.194) dual-rate + monthly CO2 factor for electric mains on off-peak tariffs. + + Mirrors `_space_heating_fuel_cost_gbp_per_kwh` on the CO2 side — + cert 000565 worksheet line 261 shows the spec calculation: + + Main heating CO2 = high_frac × Σ(F_m × CO2^high_m) / Σ F_m × F_total + + (1 - high_frac) × Σ(F_m × CO2^low_m) / Σ F_m × F_total + + blended as a single effective factor × annual fuel for the + calculator's energy-rating output. For TEN_HOUR + ASHP_OTHER (Grid 1 + high_frac=0.6) the worksheet blends Table 12d code 34 (10h high) + and code 33 (10h low) over the cert's main_1_fuel_monthly_kwh + profile → 0.6 × 0.1581 + 0.4 × 0.1460 = 0.1533 kg/kWh, vs the pre- + S0380.65 flat Table 12 code-30 annual factor 0.136 that hid ~579 + kg/yr of HP CO2 on a winter-peaked load. + + Fallback to `_co2_factor_kg_per_kwh` (annual Table 12) for: + - non-electric mains (gas, oil, LPG — pass-through) + - STANDARD tariff (no dual-rate routing per RdSAP 10 §12) + - mains without a Table 12a Grid 1 row yet (storage heaters, direct- + acting electric — TODO mirrors the cost-helper coverage gap) + - tariffs without a Table 12d split (EIGHTEEN_HOUR, TWENTY_FOUR_HOUR + — both fall through to code 30 monthly factors in the table) + - zero-fuel cases (sum monthly_kwh == 0 → effective factor None; + annual factor is the safe degenerate value) + """ + if not _is_electric_main(main) or tariff is Tariff.STANDARD: + return _co2_factor_kg_per_kwh(main) + system = _table_12a_system_for_main(main) + if system is None: + return _co2_factor_kg_per_kwh(main) + try: + high_frac = space_heating_high_rate_fraction(system, tariff) + except NotImplementedError: + return _co2_factor_kg_per_kwh(main) + codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) + if codes is None: + return _co2_factor_kg_per_kwh(main) + high_code, low_code = codes + high_factor = _effective_monthly_co2_factor(main_fuel_monthly_kwh, high_code) + low_factor = _effective_monthly_co2_factor(main_fuel_monthly_kwh, low_code) + if high_factor is None or low_factor is None: + return _co2_factor_kg_per_kwh(main) + return high_frac * high_factor + (1.0 - high_frac) * low_factor + + def _int_or_none(value: object) -> Optional[int]: return value if isinstance(value, int) else None @@ -3476,7 +3540,15 @@ def cert_to_inputs( # annual Table 12 value. None → calculator falls back to the global # `co2_factor_kg_per_kwh`. Secondary heating defaults to standard # electricity per RdSAP §A.2.2 (portable electric heater). - main_heating_co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), + # Main heating routes through `_main_heating_co2_factor_kg_per_kwh` + # so electric mains on off-peak tariffs blend Table 12a Grid 1 SH + # high-rate fraction × Table 12d high-rate monthly factors with + # the matching low-rate pair (mirror of the cost-side dual-rate + # split landed in Slice S0380.61). + main_heating_co2_factor_kg_per_kwh=_main_heating_co2_factor_kg_per_kwh( + main, _rdsap_tariff(epc), + energy_requirements_result.main_1_fuel_monthly_kwh, + ), secondary_heating_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( energy_requirements_result.secondary_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, 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 c8683cb4..d8d04759 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -851,6 +851,97 @@ def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None: assert abs(inputs.other_fuel_cost_gbp_per_kwh - 0.14311) < 1e-5 +def test_dual_meter_ashp_main_heating_co2_factor_applies_table_12a_grid_1_split() -> None: + # Arrange — RdSAP 10 §12 page 62 Rule 1: HP without PCDB record → + # TEN_HOUR tariff. Table 12a Grid 1 (SH) ASHP_OTHER + TEN_HOUR = + # 0.6 high-rate fraction. Table 12d (CO2) high-rate code 34 (10h + # high) + low-rate code 33 (10h low) monthly factors blend by + # main_1_fuel_monthly_kwh profile per Slice S0380.65. Pre-S0380.65 + # the cascade applied annual-flat code 30 factor 0.136 to all + # electric main heating, masking ~579 kg/yr of CO2 on cert 000565. + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=3, + region_code="1", + dwelling_type="Detached bungalow", + sap_building_parts=[ + make_building_part( + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=_TYPICAL_TFA_M2, floor=0, + ), + ], + ), + ], + sap_heating=make_sap_heating( + water_heating_fuel=29, + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, main_fuel_type=29, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=4, sap_main_heating_code=224, + ), + ], + ), + ) + epc.sap_energy_source.meter_type = 1 # type: ignore[assignment] # Dual → §12 Rule 1 → TEN_HOUR + + # Act + inputs = cert_to_inputs(epc) + + # Assert — winter-peaked HP fuel profile weights higher-CO2 winter + # months; the dual-rate Table 12a + Table 12d blend must land + # materially above the pre-S0380.65 annual-flat 0.136. (The exact + # value depends on the cascade's main_1_fuel_monthly_kwh profile; + # cert 000565 worksheet line 261 lands at 0.1533 for its real + # geometry.) + annual_flat = 0.136 + factor = inputs.main_heating_co2_factor_kg_per_kwh + assert factor is not None and factor > annual_flat + 0.005, ( + f"expected dual-rate blend > {annual_flat + 0.005:.4f}; " + f"got {factor}" + ) + + +def test_standard_meter_ashp_main_heating_co2_factor_falls_back_to_annual_table_12() -> None: + # Arrange — same ASHP, but meter_type=2 (Standard) → no §12 + # routing → no Table 12a Grid 1 split → annual Table 12 code-30 + # factor 0.136 (pass-through). Pre- and post-S0380.65 behave + # identically for STANDARD tariff. + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=3, + region_code="1", + dwelling_type="Detached bungalow", + sap_building_parts=[ + make_building_part( + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=_TYPICAL_TFA_M2, floor=0, + ), + ], + ), + ], + sap_heating=make_sap_heating( + water_heating_fuel=29, + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, main_fuel_type=29, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=4, sap_main_heating_code=224, + ), + ], + ), + ) + epc.sap_energy_source.meter_type = 2 # type: ignore[assignment] # Standard + + # Act + inputs = cert_to_inputs(epc) + + # Assert — annual flat 0.136 (Table 12 code 30). + assert inputs.main_heating_co2_factor_kg_per_kwh == 0.136 + + def test_standard_meter_keeps_electric_costs_on_standard_rate() -> None: # Arrange — same all-electric dwelling but meter_type=1 (Standard); # space heating + HW should now bill at the standard rate, not E7.