diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 3c63f46d..3fc4c625 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -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, 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 6175a0b2..80a5d6f8 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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, diff --git a/packages/domain/src/domain/sap/tables/table_12.py b/packages/domain/src/domain/sap/tables/table_12.py index 63b6af14..78c55488 100644 --- a/packages/domain/src/domain/sap/tables/table_12.py +++ b/packages/domain/src/domain/sap/tables/table_12.py @@ -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,