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