fix(pv): credit PV only when connected to the dwelling's meter 🟩

Gate PV generation/credit in cert_to_inputs on gov-API pv_connection:
credit only when ==2 ('connected'); ==1 ('present but not connected to the
dwelling's meter') contributes zero to the dwelling's cost/CO2/PE per
RdSAP 10 §11.1 / SAP 10.2 Appendix M. Non-int (None / site-notes str) keeps
the credit-if-array behaviour, so the Elmhurst/Summary + synthetic paths are
unchanged (no regression).

Corpus: all 5 pv_connection=1 PV certs move inside ±0.5 (e.g. 100051118081
+6.5→+0.5); MAE 0.760→0.740, within-0.5 73.8→74.3%, no regression
(pv_connection=2 certs keep their credit).

Also corrects a now-load-bearing latent bug: the solar-recommendation
overlay tagged recommended arrays pv_connection=1 ('not connected') — which
the new gate would zero. A new install connects to the dwelling's meter, so
it must be 2; pinned by the overlay test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-29 13:40:51 +00:00
parent 775232b4e7
commit 8606cab5f0
3 changed files with 60 additions and 4 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)