From 42ed38f77d86e70cb4ba29ce16f049fb170f734c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 19:01:38 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.47:=20wire=20=CE=B2-split=20into?= =?UTF-8?q?=20cost=20cascade=20per=20SAP=2010.2=20Appendix=20M1=20=C2=A76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix M1 §6 (p.94): "When calculating the fuel cost benefits ... apply the normal import electricity price to PV energy used within the dwelling and the 'electricity sold to grid, PV' price from Table 12 to the energy exported." Adds the third leg of the β-factor split (PE was S0380.45, CO2 was S0380.46). Now uniform across all three cascades: PE → IMPORT PEF × E_dw + EXPORT PEF × E_ex CO2 → IMPORT CO2 × E_dw + EXPORT CO2 × E_ex Cost → IMPORT £ × E_dw + EXPORT £ × E_ex Mechanism: - `worksheet/fuel_cost.py`: optional `pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` + `pv_dwelling_import_price_gbp_per_kwh` keyword args; when all three are set, split the credit; otherwise fall back to legacy single-rate-EXPORT (preserves synthetic test constructions). - `rdsap/cert_to_inputs.py`: new `_pv_dwelling_import_price_gbp_per_kwh` helper that pulls Table 32 code 30 (standard electricity = 13.19 p/kWh) for standard tariff; off-peak branch uses `prices.e7_low_rate_p_per_kwh` as the natural extension point when the first off-peak PV cert lands (currently short-circuited by the `Tariff != STANDARD` guard at line 2710). - `calculator.py`: new `pv_dwelling_import_price_gbp_per_kwh` field on `CalculatorInputs` with synthetic-fallback split logic mirroring the precomputed-fuel_cost path. Maintains the cross-cascade architecture documented in the prior handover. Cohort impact: **none**. Per ADR-0010 RdSAP10 amendment, Table 32 collapses code 30 (standard electricity import) and code 60 (electricity sold to grid, PV) to the SAME 13.19 p/kWh rate. So the β-split's E_dw × 13.19 + E_ex × 13.19 == E_total × 13.19, matching the legacy single-rate credit at 1e-4 — 763 pass + 0 fail across the full chain test suite (Elmhurst U985, cohort-1 ASHP, cohort-2 38-cert sweep, 15-cert golden fixtures). The β-split shape is now in place for the off-peak case (where weighted Table 12a high/low rates would diverge) and any future amendment that splits import/export prices. Pyright net-zero on touched files (34 errors before, 34 after — all pre-existing). --- domain/sap10_calculator/calculator.py | 31 ++++++++++++- .../sap10_calculator/rdsap/cert_to_inputs.py | 44 +++++++++++++++++++ .../sap10_calculator/worksheet/fuel_cost.py | 29 ++++++++++-- 3 files changed, 100 insertions(+), 4 deletions(-) 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,