From 99c8c148f16731ae3157615f10e2ae509af4d5cf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 09:14:56 +0000 Subject: [PATCH] Slice S0380.65: SAP 10.2 Table 12d + Table 12a Grid 1 dual-rate CO2 factor for electric mains on off-peak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-S0380.65 the cascade applied the annual-flat SAP 10.2 Table 12 code-30 CO2 factor (0.136 kg/kWh) to all electric main heating fuel, ignoring both the Table 12d monthly variation AND the Table 12a Grid 1 (SH) high-rate fraction. For winter-peaked heat-pump loads on an off-peak tariff this hid ~600 kg/yr of CO2 — cert 000565 worksheet line 261: Main 1 (high-rate cost) 20826.4764 kWh × 0.1581 = 3293.6388 kg Main 1 (low-rate cost) 13884.3176 kWh × 0.1460 = 2027.2261 kg ───────────── ──────────── blended 34710.7941 × 0.1533 = 5320.87 kg Cascade impact on cert 000565: - co2_kg_per_yr 5823.16 → 6427.86 (Δ −624 → Δ −20) - main_heating_co2_factor 0.1360 → 0.1533 (matches spec line 261) - sap_score 29 EXACT (unchanged — CO2 doesn't enter the energy-rating cost factor) Spec basis: - SAP 10.2 Table 12a Grid 1 (p.191): SH high-rate fraction by `Table12aSystem` × tariff. ASHP_OTHER + TEN_HOUR = 0.6 high (Slice S0380.61 already exercises this on the cost side). - SAP 10.2 Table 12d (p.194): monthly CO2 factors by electricity fuel code. 7-hour split = codes 32 (high) / 31 (low); 10-hour split = codes 34 (high) / 33 (low). 18-hour (38/40) and 24-hour (35) fall through to code-30 monthly factors in the table — no dual-rate split applies for those tariffs. - RdSAP 10 §12 page 62 (Slice S0380.60): meter_type=Dual + HP without PCDB record → Rule 1 → TEN_HOUR tariff. Implementation: - New `_TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12` dict — mirror of the existing `_TARIFF_HIGH_LOW_RATES_P_PER_KWH` cost-rates dict. Only SEVEN_HOUR + TEN_HOUR have a Table 12d split entry. - New `_main_heating_co2_factor_kg_per_kwh(main, tariff, monthly_kwh)` helper — mirror of `_space_heating_fuel_cost_gbp_per_kwh` on the CO2 side. Falls back to the annual `_co2_factor_kg_per_kwh` for non-electric mains, STANDARD tariff, mains without a Table 12a Grid 1 row yet (storage / direct-acting electric — TODO matches cost-helper coverage gap), tariffs without a Table 12d split, and zero-fuel degenerate cases. - Wire helper into `CalculatorInputs.main_heating_co2_factor_kg_per_kwh` using the `energy_requirements_result.main_1_fuel_monthly_kwh` profile already precomputed in `cert_to_inputs`. Tests: - `test_dual_meter_ashp_main_heating_co2_factor_applies_table_12a _grid_1_split` — minimal ASHP code 224 + meter_type=1 cert asserts the effective factor exceeds the pre-S0380.65 annual flat (0.136 + 0.005) per spec. - `test_standard_meter_ashp_main_heating_co2_factor_falls_back_ to_annual_table_12` — meter_type=2 (Standard) pass-through pin at 0.136. Locks in non-regression for non-dual-meter certs. Test suite: 480 pass + 9 expected 000565 cascade-gap fails (was 478/9 pre-S0380.65). Pyright net-zero on both touched files (cert_to_inputs.py 34/34; test_cert_to_inputs.py 11/11). Co-Authored-By: Claude Opus 4.7 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 74 ++++++++++++++- .../rdsap/tests/test_cert_to_inputs.py | 91 +++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) 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.