Slice S0380.82: Table 12a Grid 2 dual-rate CO2 + PE for pumps/lighting/shower on off-peak certs

Per SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d / 12e (PDF p.194-195):

  Table 12a Grid 2 row "All other uses" (lighting + pumps + locally
  generated electricity + ... ) × tariff column:
      SEVEN_HOUR  →  0.90 high-rate fraction
      TEN_HOUR    →  0.80 high-rate fraction

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

Identical wording on Table 12e (p.195) for primary energy. The cascade
must therefore blend Table 12d / 12e high-rate × low-rate codes for the
end-uses billing through Grid 2 ALL_OTHER_USES — code 31/32 on 7-hour
and code 33/34 on 10-hour — weighted by each end-use's monthly kWh
profile.

S0380.65 landed this for `main_heating_co2_factor` via Grid 1 SH. The
mirror for the "other uses" trio (lighting / pumps_fans / electric_
shower) was queued. This slice closes it.

Implementation:
- New `_other_use_co2_factor_kg_per_kwh(other_use, tariff, monthly_kwh)`
  helper mirrors `_main_heating_co2_factor_kg_per_kwh` but dispatches
  through `other_use_high_rate_fraction(OtherUse.ALL_OTHER_USES,
  tariff)`. STANDARD passes through to single-code-30 monthly; SEVEN /
  TEN_HOUR blend; EIGHTEEN_HOUR / TWENTY_FOUR_HOUR fall through to
  single-code-30 since Grid 2 lists no row for them.
- `_other_use_primary_factor(...)` is the PE-side mirror via Table 12e.
- Wired into `CalculatorInputs.{pumps_fans, lighting, electric_shower}_
  {co2_factor, primary_factor}` in the `cert_to_inputs` orchestrator.

Cert 000565 movement at HEAD (this commit):
  lighting_co2_factor_kg_per_kwh       0.1443 → 0.1483 (Δ +0.0040)
  pumps_fans_co2_factor_kg_per_kwh     0.1387 → 0.1427 (Δ +0.0040)
  electric_shower_co2_factor_kg_per_kwh 0.1391 → 0.1431 (Δ +0.0040)
  → CO2 residual Δ−8.92 → Δ−3.08 kg/yr (65% closed)

Cohort impact: STANDARD-tariff certs pass through the single-code-30
monthly cascade (identical to the previous `_effective_monthly_co2_
factor(..., _STANDARD_ELECTRICITY_FUEL_CODE)` call). All Elmhurst U985
cohort fixtures + golden cohort run STANDARD → zero shift. Cert 000565
is the only off-peak fixture; its CO2 closes by 5.9 kg/yr.

Test baseline: 554 pass + 8 expected `test_sap_result_pin[000565-*]`
fails (was 553 + 8 at S0380.81; one new test pinning the spec rule).
The 8 cert 000565 fails remain at sub-1e-4 tolerances — sap_score
already EXACT, hot_water_kwh EXACT. CO2 residual closer but not yet
< 1e-4 since lighting +2.2 kWh and pumps_fans +2.5 kWh sub-spec
residuals leak into CO2 too. Closes when those land.

Pyright net-zero on touched files (45 errors, matches baseline).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 22:42:27 +00:00 committed by Jun-te Kim
parent 8626c5a932
commit 2d9cb995e6
2 changed files with 157 additions and 12 deletions

View file

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

View file

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