slice S-B22: primary energy in SapResult + Table 12 PEF column

Wires SAP 10.2 Table 12 "Primary energy factor" column into Table 12
helpers and onto CalculatorInputs as three per-end-use factors (space
heating, hot water, other). calculate_sap_from_inputs now emits
primary_energy_kwh_per_yr and primary_energy_kwh_per_m2 on SapResult,
matching the cert's `energy_consumption_current` field (PEUI).

Triggered by a decomposition that revealed I'd been comparing our
delivered energy to the cert's primary energy — apples to oranges.
With proper primary-energy comparison the actual finding is:

300-cert primary-energy diff (cert calibration prices):
  energy MAE: 57.3 kWh/m²
  energy bias: +51.6 (we over-predict by ~50%)
  energy P50: +49.5

This is a much bigger systemic bug than the SAP MAE 5.34 suggested.
Closing it requires investigating either (a) demand model
over-prediction, (b) HW losses, (c) PEF values per fuel, or (d) cert
reporting convention differences. Targeted for the next context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 19:02:30 +00:00
parent 7786a6e9b7
commit 2a9999bdf6
4 changed files with 86 additions and 1 deletions

View file

@ -105,6 +105,13 @@ class CalculatorInputs:
hot_water_fuel_cost_gbp_per_kwh: float
other_fuel_cost_gbp_per_kwh: float
co2_factor_kg_per_kwh: float
# Primary energy factors per end-use (Table 12 "Primary energy factor"
# column). Used by §14 to derive the cert's `energy_consumption_current`
# (which is PRIMARY energy per m²). For a single-fuel dwelling all
# 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)
# 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).
@ -153,6 +160,8 @@ class SapResult:
hot_water_kwh_per_yr: float
pumps_fans_kwh_per_yr: float
lighting_kwh_per_yr: float
primary_energy_kwh_per_yr: float
primary_energy_kwh_per_m2: float
monthly: tuple[MonthlyEntry, ...]
@ -306,6 +315,22 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
sap_cont = sap_rating(ecf=ecf)
co2 = delivered_fuel_kwh * inputs.co2_factor_kg_per_kwh
primary_energy_kwh = (
main_fuel_kwh * inputs.space_heating_primary_factor
+ secondary_fuel_kwh * inputs.space_heating_primary_factor
+ inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor
+ (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).
primary_energy_kwh = max(
0.0,
primary_energy_kwh - inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor,
)
primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0
return SapResult(
sap_score=sap_int,
sap_score_continuous=sap_cont,
@ -318,6 +343,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
hot_water_kwh_per_yr=inputs.hot_water_kwh_per_yr,
pumps_fans_kwh_per_yr=inputs.pumps_fans_kwh_per_yr,
lighting_kwh_per_yr=inputs.lighting_kwh_per_yr,
primary_energy_kwh_per_yr=primary_energy_kwh,
primary_energy_kwh_per_m2=primary_energy_per_m2,
monthly=monthly,
)

View file

@ -53,7 +53,11 @@ from domain.ml.sap_efficiencies import (
water_heating_efficiency as _legacy_water_heating_efficiency,
)
from domain.sap.calculator import CalculatorInputs, WindowInput
from domain.sap.tables.table_12 import co2_factor_kg_per_kwh, unit_price_p_per_kwh
from domain.sap.tables.table_12 import (
co2_factor_kg_per_kwh,
primary_energy_factor,
unit_price_p_per_kwh,
)
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
@ -806,4 +810,9 @@ def cert_to_inputs(
secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh(
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices
),
space_heating_primary_factor=primary_energy_factor(main_fuel),
hot_water_primary_factor=primary_energy_factor(
epc.sap_heating.water_heating_fuel or main_fuel
),
other_primary_factor=primary_energy_factor(30), # standard electricity
)

View file

@ -132,6 +132,47 @@ def unit_price_p_per_kwh(fuel_code: int | None) -> float:
return _DEFAULT_P_PER_KWH
# SAP 10.2 Table 12 "Primary energy factor" column. The cert's
# `energy_consumption_current` field (PEUI) is delivered energy times
# this factor per fuel, summed across end-uses, divided by TFA.
PRIMARY_ENERGY_FACTOR: Final[dict[int, float]] = {
# Gas
1: 1.130,
2: 1.141, 3: 1.141, 5: 1.133, 9: 1.163,
7: 1.286,
# Liquid
4: 1.180,
71: 1.180, 73: 1.180, 75: 1.136, 76: 1.472,
# Solid
11: 1.064, 15: 1.064, 12: 1.261, 20: 1.046,
22: 1.325, 23: 1.325, 21: 1.046, 10: 1.049,
# Electricity — all grid tariffs same PEF.
30: 1.501, 31: 1.501, 32: 1.501, 33: 1.501, 34: 1.501, 35: 1.501,
38: 1.501, 40: 1.501, 39: 1.501, 60: 0.501, 36: 0.501,
# Heat networks (sample — main values; less common)
51: 1.130, 52: 1.141, 53: 1.180, 54: 1.064, 55: 1.180,
56: 1.180, 57: 1.180, 58: 1.180,
41: 1.501, 42: 0.063, 43: 1.037, 44: 1.286,
45: 0.051, 46: 0.051, 47: 0.063, 48: 1.501, 49: 1.501,
50: 0.0,
}
_DEFAULT_PEF: Final[float] = 1.130 # mains gas baseline
def primary_energy_factor(fuel_code: int | None) -> float:
"""Primary energy factor for the given fuel code, accepting either
Table 12 code or gov API enum (translated). Unknown mains gas
(1.13)."""
if fuel_code is None:
return _DEFAULT_PEF
if fuel_code in PRIMARY_ENERGY_FACTOR:
return PRIMARY_ENERGY_FACTOR[fuel_code]
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
if translated is not None and translated in PRIMARY_ENERGY_FACTOR:
return PRIMARY_ENERGY_FACTOR[translated]
return _DEFAULT_PEF
def co2_factor_kg_per_kwh(fuel_code: int | None) -> float:
"""CO2 emission factor (kg CO2e/kWh) for the given fuel code, with
the same accept-either-API-or-Table-12-code translation as

View file

@ -74,6 +74,7 @@ def main(argv: list[str] | None = None) -> None:
epc = EpcPropertyDataMapper.from_api_response(document)
inputs = cert_to_inputs(epc, prices=prices)
result = calculate_sap_from_inputs(inputs)
cert_primary = epc.energy_consumption_current
results.append({
"cert": cn,
"actual": actual,
@ -83,6 +84,13 @@ def main(argv: list[str] | None = None) -> None:
"tfa": epc.total_floor_area_m2,
"ext": epc.extensions_count,
"dwelling": epc.dwelling_type,
"our_primary_kwh_m2": round(result.primary_energy_kwh_per_m2, 1),
"cert_primary_kwh_m2": cert_primary,
"primary_resid": (
round(result.primary_energy_kwh_per_m2 - cert_primary, 1)
if cert_primary is not None
else None
),
})
except Exception as e: # noqa: BLE001 — exploratory probe
errors.append({"cert": cn, "actual": actual, "error": f"{type(e).__name__}: {e}"})