From 2a9999bdf6e83aba8eec322038aa05150c28531e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 19:02:30 +0000 Subject: [PATCH] slice S-B22: primary energy in SapResult + Table 12 PEF column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/domain/src/domain/sap/calculator.py | 27 ++++++++++++ .../src/domain/sap/rdsap/cert_to_inputs.py | 11 ++++- .../domain/src/domain/sap/tables/table_12.py | 41 +++++++++++++++++++ .../src/ml_training_data/sap_parity_probe.py | 8 ++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index d5a7c6a8..39f47d92 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -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, ) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 91968326..d5bc6383 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 ) diff --git a/packages/domain/src/domain/sap/tables/table_12.py b/packages/domain/src/domain/sap/tables/table_12.py index 398b1a79..32d5acb1 100644 --- a/packages/domain/src/domain/sap/tables/table_12.py +++ b/packages/domain/src/domain/sap/tables/table_12.py @@ -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 diff --git a/services/ml_training_data/src/ml_training_data/sap_parity_probe.py b/services/ml_training_data/src/ml_training_data/sap_parity_probe.py index ccf8c4b3..a03e6e2c 100644 --- a/services/ml_training_data/src/ml_training_data/sap_parity_probe.py +++ b/services/ml_training_data/src/ml_training_data/sap_parity_probe.py @@ -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}"})