Slice 33: §13a Primary Energy — Table 12e monthly cascade wiring

Adds Table 12e (p.195) monthly PE factors for electricity to
`tables/table_12.py` + `pe_monthly_factors_kwh_per_kwh(fuel_code)`
helper. Mirrors slice 32's CO2 cascade — same spec text, same
shape: electricity end-uses use Σ(kWh_m × PE_m); non-electricity
fuels keep the annual Table 12 / RdSAP10 Table 32 (p.95) factor.

Calculator now consumes per-end-use PE factors on `CalculatorInputs`
(`secondary_heating_primary_factor`, `pumps_fans_primary_factor`,
`lighting_primary_factor`, `electric_shower_primary_factor`). Defaults
to None → fall back to the global `space_heating_primary_factor` /
`other_primary_factor` (synthetic path). Fixes the stale 1.969 default
to RdSAP10 Table 32 standard-electricity PE = 1.501.

`_effective_monthly_factor(monthly_kwh, monthly_factors)` generalises
the slice-32 weighting helper; `_effective_monthly_co2_factor` and the
new `_effective_monthly_pe_factor` are thin wrappers over it.

Includes the electric-shower kWh in the PE total — closes the audit
loop opened by slice 30 (electric shower had fuel cost + CO2 but no PE
contribution).

§13a cascade pins NOT added — §13a appears only in the Demand-SAP
block (postcode climate); our cascade pins live against the Rating-SAP
block (UK-average climate). The Demand-SAP postcode cascade is a
separate scope, intentionally deferred. The calculator's existing
`primary_energy_kwh_per_yr` SapResult output now uses the spec-correct
PE factors but stays UK-average climate.

Verification (000474):
  pumps_fans  effective PE factor = 1.5128 (PDF: 1.5128 ✓)
  lighting    effective PE factor = 1.5338 (PDF: 1.5338 ✓)
  pumps_fans  PE = 242.0480 kWh (PDF: 242.0480 ✓)
  lighting    PE = 214.6527 kWh (PDF: 214.6527 ✓)

Wider regression: 1490/1490 PASS — zero failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 08:36:07 +00:00
parent fc1b009bf9
commit 729229ed61
3 changed files with 147 additions and 16 deletions

View file

@ -190,7 +190,20 @@ class CalculatorInputs:
# three collapse to the same value.
space_heating_primary_factor: float = 1.0
hot_water_primary_factor: float = 1.0
other_primary_factor: float = 1.969 # standard-electricity PEF (SAP 10.2)
# Standard-electricity PE factor per RdSAP10 Table 32 (p.95) / SAP10.2
# Table 12 = 1.501. Table 12e (p.195) provides monthly overrides — see
# the per-end-use PE factor fields below for the monthly cascade.
other_primary_factor: float = 1.501
# Per-end-use effective PE factors. For electricity end-uses with known
# monthly kWh distribution, cert_to_inputs computes the days-weighted
# Table 12e factor Σ(kWh_m × PE_m) / Σ(kWh_m). Gas end-uses keep the
# annual Table 12 factor. None → calculator falls back to the global
# `space_heating_primary_factor` / `hot_water_primary_factor` /
# `other_primary_factor` (legacy synthetic path).
secondary_heating_primary_factor: Optional[float] = None
pumps_fans_primary_factor: Optional[float] = None
lighting_primary_factor: Optional[float] = None
electric_shower_primary_factor: Optional[float] = None
# Generation offsets — applied as a cost credit against the ECF
# numerator. SAP 10.2 Appendix M: PV self-consumption + export
# collapse to a single credit at the export rate (Table 12 code 60).
@ -460,16 +473,44 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ electric_shower_co2
)
# Per-end-use effective PE factors. Same shape as the CO2 cascade:
# electricity end-uses use Table 12e (p.195) monthly factors weighted
# by per-month kWh; gas end-uses use the annual Table 12 / Table 32
# PE factor. Defaults fall back to the legacy single-factor path so
# synthetic CalculatorInputs constructions keep working.
secondary_primary_factor = (
inputs.secondary_heating_primary_factor
if inputs.secondary_heating_primary_factor is not None
else inputs.space_heating_primary_factor
)
pumps_fans_primary_factor = (
inputs.pumps_fans_primary_factor
if inputs.pumps_fans_primary_factor is not None
else inputs.other_primary_factor
)
lighting_primary_factor = (
inputs.lighting_primary_factor
if inputs.lighting_primary_factor is not None
else inputs.other_primary_factor
)
electric_shower_primary_factor = (
inputs.electric_shower_primary_factor
if inputs.electric_shower_primary_factor is not None
else inputs.other_primary_factor
)
space_heating_primary_kwh = (
main_fuel_kwh + secondary_fuel_kwh
) * inputs.space_heating_primary_factor
main_fuel_kwh * inputs.space_heating_primary_factor
+ secondary_fuel_kwh * secondary_primary_factor
)
hot_water_primary_kwh = inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor
other_primary_kwh = (
inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr
) * inputs.other_primary_factor
# PV offsets primary energy at the same PEF (Appendix M: export PEF =
# standard-electricity PEF for ratings, since the displaced grid kWh
# would have been imported electricity).
inputs.pumps_fans_kwh_per_yr * pumps_fans_primary_factor
+ inputs.lighting_kwh_per_yr * lighting_primary_factor
+ inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor
)
# PV offsets primary energy at the export PEF (Table 32 code 60 =
# 0.501 — half the import PEF since exported kWh isn't subject to the
# full grid-loss multiplier).
pv_primary_offset_kwh = inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor
primary_energy_kwh = max(
0.0,

View file

@ -58,6 +58,7 @@ from domain.sap.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap.tables.table_12 import (
co2_monthly_factors_kg_per_kwh,
co2_factor_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
primary_energy_factor,
unit_price_p_per_kwh,
)
@ -728,15 +729,16 @@ def _water_efficiency_with_category_inherit(
def _effective_monthly_co2_factor(
monthly_kwh: tuple[float, ...], fuel_code: int
def _effective_monthly_factor(
monthly_kwh: tuple[float, ...],
monthly_factors: Optional[tuple[float, ...]],
) -> Optional[float]:
"""SAP 10.2 Table 12d (p.194): for electricity end-uses, the effective
annual CO2 factor is Σ(kWh_m × CO2_m) / Σ(kWh_m). Returns None for non-
electricity fuels or when total kWh is zero (caller falls back to the
annual Table 12 factor). Used to translate monthly Table 12d cascade
into the calculator's annual × factor shape without restructuring."""
monthly_factors = co2_monthly_factors_kg_per_kwh(fuel_code)
"""Days-weighted effective annual factor = Σ(kWh_m × factor_m) / Σ kWh_m.
Used to translate SAP 10.2 Table 12d (CO2) and Table 12e (PE) monthly
cascades into the calculator's annual × factor shape. Returns None
when factors are None (non-electricity fuel caller falls back to the
annual Table 12 factor) or when total kWh is zero."""
if monthly_factors is None:
return None
total_kwh = sum(monthly_kwh)
@ -745,6 +747,26 @@ def _effective_monthly_co2_factor(
return sum(k * f for k, f in zip(monthly_kwh, monthly_factors)) / total_kwh
def _effective_monthly_co2_factor(
monthly_kwh: tuple[float, ...], fuel_code: int
) -> Optional[float]:
"""SAP 10.2 Table 12d (p.194) monthly CO2 cascade. Thin wrapper over
`_effective_monthly_factor` for the CO2 lookup."""
return _effective_monthly_factor(
monthly_kwh, co2_monthly_factors_kg_per_kwh(fuel_code)
)
def _effective_monthly_pe_factor(
monthly_kwh: tuple[float, ...], fuel_code: int
) -> Optional[float]:
"""SAP 10.2 Table 12e (p.195) monthly PE cascade. Thin wrapper over
`_effective_monthly_factor` for the PE lookup."""
return _effective_monthly_factor(
monthly_kwh, pe_monthly_factors_kwh_per_kwh(fuel_code)
)
def _days_in_month_proportioned(
annual_kwh: float, days_in_month: tuple[int, ...]
) -> tuple[float, ...]:
@ -2019,6 +2041,26 @@ def cert_to_inputs(
epc.sap_heating.water_heating_fuel or main_fuel
),
other_primary_factor=primary_energy_factor(30), # standard electricity
# SAP 10.2 Table 12e (p.195) per-end-use effective PE factors. Same
# shape as the Table 12d CO2 cascade: electricity end-uses use the
# monthly factors weighted by per-month kWh; gas end-uses pass
# through the annual Table 12 / Table 32 PE factor. Secondary
# defaults to standard electricity per RdSAP §A.2.2.
secondary_heating_primary_factor=_effective_monthly_pe_factor(
energy_requirements_result.secondary_fuel_monthly_kwh,
_STANDARD_ELECTRICITY_FUEL_CODE,
),
pumps_fans_primary_factor=_effective_monthly_pe_factor(
_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,
),
electric_shower_primary_factor=_effective_monthly_pe_factor(
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,
main=main,

View file

@ -127,6 +127,54 @@ def co2_monthly_factors_kg_per_kwh(fuel_code: int | None) -> Optional[tuple[floa
return None
# SAP 10.2 Table 12e (p.195) — monthly variation in PE (primary energy)
# emission factors for electricity. Spec text: "Where electricity is the
# fuel used, the relevant set of factors in the table below should be
# used to calculate the monthly primary energy instead the annual average
# factor given in Table 12." Same shape as Table 12d (CO2): electricity
# end-uses use Σ(kWh_m × PE_m); gas/non-electricity fuels keep the
# annual Table 12 PE factor.
PE_FACTOR_MONTHLY: Final[dict[int, tuple[float, ...]]] = {
# Standard tariff
30: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# 7-hour tariff
32: (1.635, 1.626, 1.600, 1.562, 1.518, 1.471, 1.440, 1.443, 1.479, 1.535, 1.591, 1.637),
31: (1.521, 1.512, 1.488, 1.453, 1.411, 1.368, 1.339, 1.342, 1.376, 1.428, 1.480, 1.522),
# 10-hour tariff
34: (1.625, 1.615, 1.590, 1.552, 1.507, 1.462, 1.430, 1.433, 1.470, 1.525, 1.580, 1.626),
33: (1.571, 1.561, 1.537, 1.500, 1.457, 1.413, 1.382, 1.386, 1.421, 1.474, 1.528, 1.572),
# 18-hour tariff (matches standard tariff)
38: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
40: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# 24-hour heating tariff
35: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Electricity sold to grid (PV) — note (i): deducted, low PE factor
60: (0.715, 0.697, 0.645, 0.567, 0.478, 0.389, 0.330, 0.336, 0.405, 0.513, 0.623, 0.718),
# Electricity sold to grid, other
36: (0.602, 0.593, 0.568, 0.530, 0.487, 0.441, 0.410, 0.413, 0.449, 0.504, 0.558, 0.604),
# Electricity, any tariff
39: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Heat from electric heat pump
41: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Low-grade heat recovered from process
49: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
# Electricity for pumping in distribution network
50: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
}
def pe_monthly_factors_kwh_per_kwh(
fuel_code: int | None,
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Table 12e (p.195) monthly PE factors for electricity. Returns
None for non-electricity fuels (use the annual `primary_energy_factor`)."""
if fuel_code is None:
return None
if fuel_code in PE_FACTOR_MONTHLY:
return PE_FACTOR_MONTHLY[fuel_code]
return None
CO2_KG_PER_KWH: Final[dict[int, float]] = {
# Gas fuels
1: 0.210,