diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index f5bf6e10..f97a5752 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -49,9 +49,13 @@ _BATTERY_CAPACITY_KWH = 5.0 # Watts → kilowatts for peak-power. _WATTS_PER_KW = 1000.0 # The dwelling's PV connects to its own meter (the after-cert §19 "Connected to -# the dwelling's meter: Yes"). Non-load-bearing for the SAP cascade; carried for -# fidelity. 1 = connected, the modal install case. -_PV_CONNECTED_TO_DWELLING = 1 +# the dwelling's meter: Yes"). LOAD-BEARING: `cert_to_inputs` credits PV to the +# dwelling's SAP only when `pv_connection == 2` ("connected"); value 1 means +# "present but NOT connected" and zeroes the credit. gov-API enum (corpus- and +# Elmhurst-validated): 0 = no PV, 1 = not connected, 2 = connected (the modal +# install case, 52 vs 5 on the RdSAP-21.0.1 corpus). A newly-installed +# recommended array is connected to the dwelling's own meter. +_PV_CONNECTED_TO_DWELLING = 2 # A roof plane within this many degrees of due north (0°/360°, Google compass # convention) is dropped: it generates little and is not worth panelling. The diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7520a440..1cdfd98e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3156,6 +3156,42 @@ def _pv_array_generation_kwh_per_yr( return _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * s * z +# gov-API `sap_energy_source.pv_connection` enum (RdSAP 10 §11.1 / +# SAP 10.2 Appendix M — "PV is included in the dwelling's assessment only +# if connected to the dwelling's electricity meter"): +# 0 = no PV +# 1 = PV present but NOT connected to the dwelling's own meter +# 2 = PV connected to the dwelling's own meter +# Validated on the RdSAP-21.0.1 corpus (57 PV certs): pv_connection=1 certs +# reconcile to the lodged SAP only WITHOUT a credit (MAE 4.48→1.22, 0/5 need +# it); pv_connection=2 certs need it (MAE 0.98 vs 10.29 without). Accredited +# Elmhurst proof: identical dwelling = SAP 87 connected vs SAP 74 not. +_PV_CONNECTION_CONNECTED_TO_DWELLING_METER: Final[int] = 2 + + +def _pv_connected_to_dwelling_meter(epc: EpcPropertyData) -> bool: + """Whether a lodged PV array may be credited to this dwelling's SAP, i.e. + whether it is connected to the dwelling's own electricity meter. + + Keyed on the gov-API integer `pv_connection`: only value 2 ("connected") + earns a credit; value 1 ("present but not connected" — a communal / + separately-metered array) contributes nothing to the dwelling's energy + cost, CO2 or primary energy, per RdSAP 10 §11.1 / SAP 10.2 Appendix M. + + A non-integer `pv_connection` (None, or the site-notes `str` form which + does not yet capture the connection flag) is NOT a determinate + "not connected" signal, so it preserves the existing credit-if-array + behaviour — no regression on the Elmhurst/Summary path or synthetic + CalculatorInputs. The Elmhurst extractor parses "Connected to the + dwelling's meter" today only as a parse stop-token; capturing its value + is a follow-up that would let this gate apply to that path too. + """ + pv_connection = epc.sap_energy_source.pv_connection + if isinstance(pv_connection, int): + return pv_connection == _PV_CONNECTION_CONNECTED_TO_DWELLING_METER + return True + + def _pv_generation_kwh_per_yr( epc: EpcPropertyData, climate: "int | PostcodeClimate", @@ -3170,7 +3206,13 @@ def _pv_generation_kwh_per_yr( roof area" PV figure (no detailed kWp): synthesize a single PV array with kWp = 0.12 × PV area, South orientation, 30° pitch, Modest overshading. + + Returns 0 when the array is not connected to the dwelling's own meter + (`_pv_connected_to_dwelling_meter` — gov-API `pv_connection=1`), per + RdSAP 10 §11.1 / SAP 10.2 Appendix M. """ + if not _pv_connected_to_dwelling_meter(epc): + return 0.0 arrays = epc.sap_energy_source.photovoltaic_arrays if not arrays: arrays = _synthesize_pv_arrays_from_percent_roof_area(epc) @@ -3217,7 +3259,13 @@ def _pv_monthly_generation_kwh( ) -> tuple[float, ...]: """SAP 10.2 Appendix M1 §2 (p.92) — monthly E_PV summed across all PV arrays. Annual sum matches `_pv_generation_kwh_per_yr` to - float precision.""" + float precision. + + Returns all-zero when the array is not connected to the dwelling's own + meter (`_pv_connected_to_dwelling_meter`), so the §10a cost split and the + CO2 / PE cascades all see no PV — mirroring the annual helper's gate.""" + if not _pv_connected_to_dwelling_meter(epc): + return (0.0,) * 12 arrays = epc.sap_energy_source.photovoltaic_arrays if not arrays: arrays = _synthesize_pv_arrays_from_percent_roof_area(epc) diff --git a/tests/domain/modelling/test_solar_recommendation.py b/tests/domain/modelling/test_solar_recommendation.py index f858a78b..43337ab5 100644 --- a/tests/domain/modelling/test_solar_recommendation.py +++ b/tests/domain/modelling/test_solar_recommendation.py @@ -89,6 +89,10 @@ def test_each_option_overlay_installs_per_segment_arrays_and_ensures_export() -> assert overlay is not None assert overlay.is_dwelling_export_capable is True assert overlay.pv_diverter_present is True + # A newly-installed recommended array is connected to the dwelling's own + # meter, so it must be tagged pv_connection=2 ("connected") — the value + # the SAP cascade credits. (1 = present-but-not-connected → zero credit.) + assert overlay.pv_connection == 2 arrays = overlay.photovoltaic_arrays assert arrays is not None and len(arrays) >= 1 assert all(isinstance(a, PhotovoltaicArray) for a in arrays)