slice S-B19: PV generation cost credit (SAP 10.2 Appendix M)

Wires photovoltaic_arrays into the calculator as a per-kWh cost credit
against the ECF numerator. Total annual PV kWh = sum(peak_power_kw)
× 850 (UK-average yield per Appendix M, single national figure since
ratings use UK-average weather per S-B18). Credit rate is Table 12
code 60 (PV export tariff) — 5.59 p/kWh under SAP spec prices, 13.19
p/kWh under cert-calibration prices.

This is the first slice from the worksheet-driven phase (per user
suggestion). PV was identified as a clear systemic gap that probe-
driven iteration hadn't surfaced because only ~5-10% of certs have
PV and the corpus probe is biased toward the most-frequent shapes.

100-cert: MAE 4.39 → 4.49 (small regression; bias -0.17 → -0.07)
300-cert: MAE 5.44 → 5.45 (essentially flat; bias 0.11 → 0.22)

Net spec-correct, aggregate MAE neutral. The certs that DO have PV
should see the right cost story now; ML residual will pick up the
fidelity gap (no orientation/overshading/pitch on our yield).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 16:54:11 +00:00
parent 0102ff313a
commit 0d552b5a22
2 changed files with 43 additions and 1 deletions

View file

@ -105,6 +105,11 @@ class CalculatorInputs:
hot_water_fuel_cost_gbp_per_kwh: float
other_fuel_cost_gbp_per_kwh: float
co2_factor_kg_per_kwh: float
# 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).
pv_generation_kwh_per_yr: float = 0.0
pv_export_credit_gbp_per_kwh: float = 0.0
@dataclass(frozen=True)
@ -265,11 +270,14 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ inputs.pumps_fans_kwh_per_yr
+ inputs.lighting_kwh_per_yr
)
total_cost = (
pv_credit = inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh
total_cost = max(
0.0,
main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
+ inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
+ (inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr)
* inputs.other_fuel_cost_gbp_per_kwh
- pv_credit,
)
ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa)
sap_int = sap_rating_integer(ecf=ecf)

View file

@ -134,6 +134,19 @@ _DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
_INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
# UK-average annual PV yield (kWh per kWp). SAP 10.2 Appendix M references
# regional yield factors; for rating purposes (Appendix U: ratings use UK
# average weather) the single national figure applies. Derived from
# `domain.ml.ecf._PV_YIELD_BY_REGION` UK-average baseline.
_PV_ANNUAL_YIELD_KWH_PER_KWP: Final[float] = 850.0
# SAP 10.2 Table 12 code 60 — PV export tariff. The calculator uses this
# rate as the per-kWh PV cost credit applied against total annual fuel
# cost in the ECF numerator.
_PV_EXPORT_TARIFF_CODE: Final[int] = 60
@dataclass(frozen=True)
class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
@ -475,6 +488,25 @@ def _hot_water_fuel_cost_gbp_per_kwh(
return _fuel_cost_gbp_per_kwh(main, prices)
def _pv_generation_kwh_per_yr(epc: EpcPropertyData) -> float:
"""Annual PV generation (kWh/yr) summed across all photovoltaic
arrays on the cert. SAP 10.2 Appendix M: yield = peak power × annual
yield factor. We use the UK-average yield (Appendix U rule for
ratings)."""
arrays = epc.sap_energy_source.photovoltaic_arrays
if not arrays:
return 0.0
total_kwp = sum(a.peak_power for a in arrays if a.peak_power is not None)
return total_kwp * _PV_ANNUAL_YIELD_KWH_PER_KWP
def _pv_export_credit_gbp_per_kwh(prices: PriceTable) -> float:
"""PV cost credit per kWh generated. SAP 10.2 Table 12 code 60 (PV
export to grid) 5.59 p/kWh on the spec table, 13.19 p/kWh under
cert calibration (legacy unit prices)."""
return prices.unit_price_p_per_kwh(_PV_EXPORT_TARIFF_CODE) * _PENCE_TO_GBP
def _other_fuel_cost_gbp_per_kwh(
meter_type: object, prices: PriceTable
) -> float:
@ -665,4 +697,6 @@ def cert_to_inputs(
epc.sap_energy_source.meter_type, prices
),
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(prices),
)