From 9ee3821138720fb0ee76c0e9a5f6a870b1d1dec1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 14 Jun 2026 08:48:38 +0000 Subject: [PATCH] fix(pv): zero exported PV when dwelling is not export-capable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 16 ++++++++++ .../rdsap/test_cert_to_inputs.py | 32 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 15 +++++---- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9c6cc278..12f5b17e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index a862c238..dbb817bb 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 378b6b99..13e9f6e6 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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