Slice S0380.47: wire β-split into cost cascade per SAP 10.2 Appendix M1 §6

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).
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 19:01:38 +00:00
parent cbc6d5dbc8
commit 42ed38f77d
3 changed files with 100 additions and 4 deletions

View file

@ -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

View file

@ -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,
),
)

View file

@ -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,