S0380.233: PV self-consumption credited at Table 12a weighted import rate

SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "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. In the case of the former, use a weighted average of high and
low rates (Table 12a)."

`_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak
LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling
credit. PV self-consumption displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), which on an off-peak tariff
bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low
rate. On simulated case 19 the worksheet (252)/(269) credits
PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we
credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every
off-peak PV cert.

Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same
ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32
code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass);
off-peak returns the weighted high/low blend. Call sites now pass the
resolved `_rdsap_tariff(epc)`. The now-unused
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller)
is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 22:57:40 +00:00
parent 212b0c92ab
commit d4a8c02b54
2 changed files with 42 additions and 43 deletions

View file

@ -2082,23 +2082,6 @@ def _off_peak_low_rate_gbp_per_kwh(tariff: Tariff) -> float:
return low * _PENCE_TO_GBP
def _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type: object) -> float:
"""Off-peak low-rate £/kWh for callsites that detect off-peak via the
`_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is
treated as off-peak for electric end-uses; see _is_off_peak_meter
docstring). When the meter resolves to a known off-peak tariff
(codes 1/4/5), bills at that tariff's Table 32 low rate; when the
meter resolves to STANDARD (codes 2 = Single, 3 = Unknown), falls
back to the SEVEN_HOUR rate (5.50, Table 32 code 31). Codifies the
heuristic that pre-S0380.138 was baked into the literal
`prices.e7_low_rate_p_per_kwh` constant."""
tariff = tariff_from_meter_type(meter_type)
if tariff is Tariff.STANDARD:
_high, low = _tariff_high_low_rates_p_per_kwh(Tariff.SEVEN_HOUR)
return low * _PENCE_TO_GBP
return _off_peak_low_rate_gbp_per_kwh(tariff)
# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2
# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the
# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into
@ -2660,30 +2643,25 @@ def _pv_export_credit_gbp_per_kwh() -> float:
def _pv_dwelling_import_price_gbp_per_kwh(
meter_type: object, prices: PriceTable
tariff: Tariff, 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).
# Routes through the meter-heuristic helper so an Unknown-meter
# cert (code 3 = "treat as off-peak for electric end-uses" per
# _is_off_peak_meter) falls back to the SEVEN_HOUR low rate
# rather than raising on STANDARD.
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP
(PDF p.94, lines 5510-5513): "apply the normal import electricity
price to PV energy used within the dwelling In the case of the
former, use a weighted average of high and low rates (Table 12a)."
Onsite-consumed PV displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), so it bills at the same
Table 12a Grid 2 ALL_OTHER_USES rate `_other_fuel_cost_gbp_per_kwh`
derives a STANDARD-tariff dwelling pays the flat Table 32 code 30
13.19 p/kWh (unchanged from the legacy single-rate path), while an
off-peak dwelling pays the weighted high/low blend (7-hour:
0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh, matching worksheet
(252)/(269) "PV used in dwelling" on case 19).
Pre-S0380.233 the off-peak branch returned the bare low rate
(5.50 p/kWh), under-crediting onsite PV on every off-peak cert."""
return _other_fuel_cost_gbp_per_kwh(tariff, prices)
def _other_fuel_cost_gbp_per_kwh(
@ -6137,9 +6115,7 @@ def _fuel_cost(
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
)
_pv_dwelling_import_price_gbp_per_kwh(_rdsap_tariff(epc), prices)
),
)
@ -6976,7 +6952,7 @@ 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
_rdsap_tariff(epc), prices
),
# SAP 10.2 Appendix M1 §3-4 PV split — the cascade applies
# IMPORT PEF (Table 12) to the onsite portion and EXPORT PEF

View file

@ -61,6 +61,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
_main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage]
_other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_pv_dwelling_import_price_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_primary_loss_applies, # pyright: ignore[reportPrivateUsage]
_rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage]
@ -1852,6 +1853,28 @@ def test_pv_eligible_demand_excludes_low_rate_main_space_heating() -> None:
assert abs(included[m] - (base[m] + secondary[m] + main_1[m])) <= 1e-9
def test_pv_dwelling_import_price_blends_high_low_on_off_peak() -> None:
# Arrange — SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): PV
# used in the dwelling is credited at "a weighted average of high and
# low rates (Table 12a)". On a 7-hour tariff the ALL_OTHER_USES blend
# is 0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh (worksheet case 19
# (252) "PV used in dwelling" = 14.3110). STANDARD tariff has no
# split → flat Table 32 code 30 = 13.19 p/kWh (unchanged).
from domain.sap10_calculator.tables.table_12a import Tariff
# Act
off_peak = _pv_dwelling_import_price_gbp_per_kwh(
Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
standard = _pv_dwelling_import_price_gbp_per_kwh(
Tariff.STANDARD, SAP_10_2_SPEC_PRICES
)
# Assert
assert abs(off_peak - 0.14311) <= 1e-6
assert abs(standard - 0.1319) <= 1e-6
def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None:
# Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter
# as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2