Slice S0380.65: SAP 10.2 Table 12d + Table 12a Grid 1 dual-rate CO2 factor for electric mains on off-peak

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 09:14:56 +00:00 committed by Jun-te Kim
parent 2725ff505b
commit 99c8c148f1
2 changed files with 164 additions and 1 deletions

View file

@ -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,

View file

@ -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.