fix(pv): zero exported PV when dwelling is not export-capable

SAP 10.2 Appendix M1 (PDF p.94): "EPV,ex,m = 0 if the PV system is not
connected to an export-capable meter." The cascade computed the β-split
export stream regardless of `is_dwelling_export_capable`, so a non-export-
capable dwelling was credited the full PV export — in the §10a COST it
credits at the Table 32 import rate (13.19 p/kWh), which dominates the rating.
On 7 Wybourn Terrace S2 5BJ the PE (144 vs lodged 151) and CO2 (27 vs 29)
already matched, yet the phantom export cost credit pulled SAP from ~73 to
92.1 (+19). Zero `epv_exported_monthly_kwh` after the Appendix-G4 diverter
adjustment when not export-capable; the onsite (EPV,dw) consumption and the
diverter HW reduction are unchanged.

Not-export-capable PV cohort (corpus, 4 certs): 7 Wybourn +19.1 -> +6.5,
4 Lime Ave +11.1 -> +0.4, 8 Hatherleigh +7.6 -> -0.2, Flat 5 ~-0.4. Gauge
66.1% -> 66.9%, MAE 1.124 -> 1.039. Floor 0.64 -> 0.65 / ceiling 1.18 -> 1.08.
Worksheet harness 47/47 0 diverge (Summary certs carry export-capable meters).
1 AAA test, pyright net-zero. Found by auditing the worst over-rater without a
worksheet: PE/CO2-match + cost-miss localised it to the PV export credit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 08:48:38 +00:00
parent 94275d07cc
commit 9ee3821138
3 changed files with 56 additions and 7 deletions

View file

@ -7612,6 +7612,22 @@ def cert_to_inputs(
epv_exported_monthly_kwh=adjusted_export_monthly_kwh,
)
# SAP 10.2 Appendix M1 (PDF p.94): "EPV,ex,m = 0 if the PV system is not
# connected to an export-capable meter." A non-export-capable dwelling
# earns no export payment — only the onsite β consumption (EPV,dw)
# offsets demand. Zero the exported stream so the §10a cost, CO2 and PE
# export credits all drop out; the dwelling (onsite) portion and any
# diverter HW reduction above are unchanged. (Without this the cascade
# credited the full export — e.g. cert at 7 Wybourn Terrace S2 5BJ
# over-rated +19 SAP: PE/CO2 matched the lodged figures but the export
# cost credit alone pulled the rating from ~73 to 92.)
if not epc.sap_energy_source.is_dwelling_export_capable:
pv_split = PhotovoltaicSplit(
beta_monthly=pv_split.beta_monthly,
epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh,
epv_exported_monthly_kwh=(0.0,) * 12,
)
# SAP 10.2 §12.4.4 overrides — when summer immersion applies (back-
# boiler combo + cylinder + WHC from main heating), the HW cost /
# CO2 / PE factors are kWh-weighted blends of the winter boiler fuel

View file

@ -1226,6 +1226,38 @@ def test_pv_export_credit_input_reports_rdsap10_table_32_rate() -> None:
assert abs(inputs.pv_export_credit_gbp_per_kwh - 0.1319) <= 1e-6
def test_pv_not_export_capable_zeroes_exported_kwh() -> None:
# Arrange — two identical PV dwellings (same array), one connected to an
# export-capable meter and one not. SAP 10.2 Appendix M1 (PDF p.94):
# "EPV,ex,m = 0 if the PV system is not connected to an export-capable
# meter." A non-export-capable dwelling earns no export — only the
# onsite β consumption offsets demand — so its exported kWh must be 0,
# while the export-capable twin exports a positive amount. The onsite
# (dwelling) portion is identical for both.
capable = _typical_semi_detached_epc()
capable.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=3.0, pitch=2, orientation=5, overshading=1),
]
capable.sap_energy_source.is_dwelling_export_capable = True
not_capable = _typical_semi_detached_epc()
not_capable.sap_energy_source.photovoltaic_arrays = [
PhotovoltaicArray(peak_power=3.0, pitch=2, orientation=5, overshading=1),
]
not_capable.sap_energy_source.is_dwelling_export_capable = False
# Act
cap_inputs = cert_to_inputs(capable)
nocap_inputs = cert_to_inputs(not_capable)
# Assert — exported kWh zeroed when not export-capable; onsite unchanged.
assert (cap_inputs.pv_exported_kwh_per_yr or 0.0) > 0.0
assert (nocap_inputs.pv_exported_kwh_per_yr or 0.0) == 0.0
assert abs(
(cap_inputs.pv_dwelling_kwh_per_yr or 0.0)
- (nocap_inputs.pv_dwelling_kwh_per_yr or 0.0)
) <= 1e-9
def test_pv_generation_differentiates_arrays_by_orientation() -> None:
# Arrange — two single-array PVs at the same kWp / pitch / overshading,
# one facing South and one facing North. Per SAP10.2 Appendix M

View file

@ -41,11 +41,12 @@ _CORPUS = Path(
)
# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips).
# Current: SAP within-0.5 = 66.1%, SAP MAE = 1.124 (RdSAP 10 §10.5 present-
# but-unsized cylinder -> Table 28 Normal 110 L default, this slice — a
# correctness fix: 7 certs that silently dropped storage loss + Table 13;
# marginal on the headline. Prior slices: Table 12a code-408 0.20 storage
# high-rate fraction; heat-network Table 4c(3) flat-rate charging factor).
# Current: SAP within-0.5 = 66.9%, SAP MAE = 1.039 (SAP 10.2 Appendix M1
# EPV,ex=0 for non-export-capable PV, this slice: 7 Wybourn +19.1 -> +6.5,
# 4 Lime Ave +11.1 -> +0.4, 8 Hatherleigh +7.6 -> -0.2 — PE/CO2 matched
# lodged but the export cost credit alone was over-rating). Prior slices:
# unsized-cylinder Table 28 110 L; Table 12a code-408 0.20 storage fraction;
# heat-network Table 4c(3) flat-rate charging factor.
# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below).
# PE MAE = 14.7 kWh/m2/yr (signed +9.1).
#
@ -66,8 +67,8 @@ _CORPUS = Path(
# energy were 5% high; actual SAP bias is +0.145).
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
_MIN_WITHIN_HALF_SAP = 0.64
_MAX_SAP_MAE = 1.18
_MIN_WITHIN_HALF_SAP = 0.65
_MAX_SAP_MAE = 1.08
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current