diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 03525a8e..d19f4359 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -234,6 +234,14 @@ class CalculatorInputs: # SAP 10.2 Table 12 code 60 ("electricity sold to grid, PV") PE # factor = 0.501. Applied to E_PV,ex when split is set. pv_export_primary_factor: float = 0.501 + # SAP 10.2 Appendix M1 §6 (p.94) — IMPORT price for onsite-consumed + # PV generation. cert_to_inputs supplies this from Table 12a (standard + # tariff or weighted off-peak per the dwelling's meter); synthetic + # constructions leave it None to fall back to the legacy single-rate + # credit at the EXPORT price. When set, the calculator's synthetic + # cost fallback (the `fuel_cost is _ZERO` branch) credits onsite kWh + # at this IMPORT price and exported kWh at `pv_export_credit_gbp_per_kwh`. + pv_dwelling_import_price_gbp_per_kwh: Optional[float] = None # SAP 10.2 Appendix M1 §7 — per-cert CO2 factors for the PV split. # The dwelling factor is the effective monthly Table 12d IMPORT # factor (Σ(E_PV,dw,m × CO2_30,m) / Σ(E_PV,dw,m)); the exported @@ -459,7 +467,28 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: lighting_cost = fuel_cost_result.lighting_cost_gbp pv_credit = -fuel_cost_result.pv_credit_gbp else: - pv_credit = inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh + # SAP 10.2 Appendix M1 §6 — synthetic-path β-split credit. When + # cert_to_inputs supplies the split (E_PV,dw + E_PV,ex + dwelling + # IMPORT price) credit onsite kWh at IMPORT and exported kWh at + # EXPORT; otherwise fall through to the legacy single-rate credit + # at the EXPORT price (preserves unit-test fixtures that lodge + # only `pv_generation_kwh_per_yr` + `pv_export_credit_gbp_per_kwh`). + if ( + inputs.pv_dwelling_kwh_per_yr is not None + and inputs.pv_exported_kwh_per_yr is not None + and inputs.pv_dwelling_import_price_gbp_per_kwh is not None + ): + pv_credit = ( + inputs.pv_dwelling_kwh_per_yr + * inputs.pv_dwelling_import_price_gbp_per_kwh + + inputs.pv_exported_kwh_per_yr + * inputs.pv_export_credit_gbp_per_kwh + ) + else: + pv_credit = ( + inputs.pv_generation_kwh_per_yr + * inputs.pv_export_credit_gbp_per_kwh + ) main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh secondary_heating_cost = ( secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index f304dde7..eff7e95d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1057,6 +1057,29 @@ def _pv_export_credit_gbp_per_kwh() -> float: return table_32_unit_price_p_per_kwh(_PV_EXPORT_TARIFF_CODE) * _PENCE_TO_GBP +def _pv_dwelling_import_price_gbp_per_kwh( + meter_type: object, prices: PriceTable +) -> float: + """PV dwelling-consumption price per kWh per SAP 10.2 Appendix M1 §6 + (p.94): "apply the normal import electricity price to PV energy used + within the dwelling". Onsite-consumed PV displaces grid IMPORTS, so + it bills at the standard electricity import tariff (Table 32 code 30 + under the RdSAP10 amendment per ADR-0010 §10 = 13.19 p/kWh — the + same rate `_fuel_cost`'s `other_uses_p_per_kwh` already pays for + lighting/pumps/fans, and crucially the same rate Table 32 code 60 + pays for the EXPORT credit. In Table 32 these collapse to a single + 13.19 p value, so the IMPORT/EXPORT split is mathematically + equivalent to the legacy single-rate-EXPORT credit — but the + distinction matters when an off-peak tariff lands: §6 then directs + a weighted Table 12a high/low rate, deferred until the first off- + peak cost cert ships.""" + if _is_off_peak_meter(meter_type, fuel_is_electric=True): + # Off-peak weighted Table 12a rate (deferred — `_fuel_cost` + # short-circuits Tariff != STANDARD before reaching this path). + return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP + return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP + + def _other_fuel_cost_gbp_per_kwh( meter_type: object, prices: PriceTable ) -> float: @@ -2682,6 +2705,9 @@ def _fuel_cost( lighting_kwh: float, cooling_kwh: float, climate: "int | PostcodeClimate", + prices: PriceTable, + pv_dwelling_kwh_per_yr: Optional[float], + pv_exported_kwh_per_yr: Optional[float], electric_shower_kwh: float = 0.0, ) -> FuelCostResult: """SAP10.2 §10a fuel-cost precompute — produce a `FuelCostResult` from @@ -2783,6 +2809,18 @@ def _fuel_cost( additional_standing_charges_gbp=standing, appendix_q_saved_gbp=0.0, appendix_q_used_gbp=0.0, + # SAP 10.2 Appendix M1 §6 (p.94): split the PV credit per the β- + # factor — onsite kWh bills at the dwelling IMPORT tariff (Table + # 12a standard / off-peak low), exported kWh keeps the EXPORT + # tariff (Table 32 code 60). None fall-through preserves the + # legacy single-rate path for synthetic test constructions. + pv_dwelling_kwh_per_yr=pv_dwelling_kwh_per_yr, + pv_exported_kwh_per_yr=pv_exported_kwh_per_yr, + pv_dwelling_import_price_gbp_per_kwh=( + _pv_dwelling_import_price_gbp_per_kwh( + epc.sap_energy_source.meter_type, prices + ) + ), ) @@ -3202,6 +3240,9 @@ def cert_to_inputs( ), pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(), + pv_dwelling_import_price_gbp_per_kwh=_pv_dwelling_import_price_gbp_per_kwh( + epc.sap_energy_source.meter_type, prices + ), # SAP 10.2 Appendix M1 §3-4 PV split — the cascade applies # IMPORT PEF (Table 12) to the onsite portion and EXPORT PEF # (Table 12 code 60 = 0.501) to the exported portion per §8. @@ -3261,6 +3302,9 @@ def cert_to_inputs( lighting_kwh=lighting_kwh, cooling_kwh=energy_requirements_result.cooling_fuel_kwh_per_yr, climate=climate, + prices=prices, + pv_dwelling_kwh_per_yr=pv_split.epv_dwelling_kwh_per_yr, + pv_exported_kwh_per_yr=pv_split.epv_exported_kwh_per_yr, ), ) diff --git a/domain/sap10_calculator/worksheet/fuel_cost.py b/domain/sap10_calculator/worksheet/fuel_cost.py index 8c2e9827..19b048e0 100644 --- a/domain/sap10_calculator/worksheet/fuel_cost.py +++ b/domain/sap10_calculator/worksheet/fuel_cost.py @@ -13,7 +13,7 @@ Reference: SAP 10.2 specification (14-03-2025) §10a (lines 8044-8084). from __future__ import annotations from dataclasses import dataclass -from typing import NamedTuple +from typing import NamedTuple, Optional class _OffPeakSplit(NamedTuple): @@ -141,6 +141,9 @@ def fuel_cost( additional_standing_charges_gbp: float, appendix_q_saved_gbp: float, appendix_q_used_gbp: float, + pv_dwelling_kwh_per_yr: Optional[float] = None, + pv_exported_kwh_per_yr: Optional[float] = None, + pv_dwelling_import_price_gbp_per_kwh: Optional[float] = None, ) -> FuelCostResult: """SAP 10.2 §10a orchestrator — produce (240)..(255) line refs. @@ -149,7 +152,17 @@ def fuel_cost( tariff callers pass high_rate_fraction=1.0 so (240d) collapses to zero. (240e) "other fuel" cost stays zero in the off-peak split form — populated only when the spec routes a row through the single-rate - column (deferred until a non-electric off-peak cert lands).""" + column (deferred until a non-electric off-peak cert lands). + + PV credit per Appendix M1 §6 (p.94): onsite-consumed generation + (E_PV,dw) bills at the dwelling IMPORT price (Table 12a standard or + weighted off-peak); exported generation (E_PV,ex) bills at the + EXPORT price (Table 12a code 60 = "electricity sold to grid, PV"). + When `pv_dwelling_kwh_per_yr`, `pv_exported_kwh_per_yr`, AND + `pv_dwelling_import_price_gbp_per_kwh` are all supplied, the credit + splits accordingly; otherwise it falls back to the legacy single- + rate path that credits ALL generation at the EXPORT price (used by + synthetic CalculatorInputs constructions in unit tests).""" main_1 = _split( main_1_kwh_per_yr, main_1_high_rate_gbp_per_kwh, @@ -179,7 +192,17 @@ def fuel_cost( lighting_cost = lighting_kwh_per_yr * other_uses_gbp_per_kwh cooling_cost = cooling_kwh_per_yr * other_uses_gbp_per_kwh instant_shower_cost = instant_shower_kwh_per_yr * instant_shower_gbp_per_kwh - pv_credit = -pv_generation_kwh_per_yr * pv_export_credit_gbp_per_kwh + if ( + pv_dwelling_kwh_per_yr is not None + and pv_exported_kwh_per_yr is not None + and pv_dwelling_import_price_gbp_per_kwh is not None + ): + pv_credit = -( + pv_dwelling_kwh_per_yr * pv_dwelling_import_price_gbp_per_kwh + + pv_exported_kwh_per_yr * pv_export_credit_gbp_per_kwh + ) + else: + pv_credit = -pv_generation_kwh_per_yr * pv_export_credit_gbp_per_kwh total = max( 0.0,