diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 17437f6f..895e4ab3 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -370,7 +370,12 @@ class SapEnergySource: wind_turbines_terrain_type: str # int in API, str (e.g. "Suburban") in site notes electricity_smart_meter_present: bool - pv_connection: Optional[Union[int, str]] = None # int from API; str from site notes + # gov-API enum (int): 0 = no PV, 1 = PV present but NOT connected to the + # dwelling's own electricity meter (communal / separately metered), 2 = PV + # connected to the dwelling's meter. Per RdSAP 10 §11.1 / SAP 10.2 Appendix M, + # PV is credited to the dwelling's SAP only when connected (== 2); see + # `_pv_connected_to_dwelling_meter` in cert_to_inputs. str from site notes. + pv_connection: Optional[Union[int, str]] = None photovoltaic_supply: Optional[PhotovoltaicSupply] = None photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None wind_turbine_details: Optional[WindTurbineDetails] = None 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/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index f92a4995..d3fae7e2 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -267,6 +267,7 @@ def make_minimal_sap10_epc( sap_heating: Optional[SapHeating] = None, photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None, photovoltaic_supply_percent_roof_area: Optional[int] = None, + pv_connection: Optional[int] = None, mains_gas: bool = True, electricity_smart_meter_present: bool = False, gas_smart_meter_present: bool = False, @@ -308,6 +309,7 @@ def make_minimal_sap10_epc( sap_energy_source=SapEnergySource( mains_gas=mains_gas, meter_type="Single", + pv_connection=pv_connection, pv_battery_count=pv_battery_count, wind_turbines_count=wind_turbines_count, gas_smart_meter_present=gas_smart_meter_present, 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) 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 2adeea02..b643bf0d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -40,6 +40,7 @@ from domain.sap10_ml.tests._fixtures import ( make_floor_dimension, make_main_heating_detail, make_minimal_sap10_epc, + make_pv_array, make_sap_heating, make_window, ) @@ -8387,3 +8388,38 @@ def test_heat_pump_water_scop_not_applied_to_separate_immersion_dhw() -> None: # heat pump or a gas boiler (the HP water SCOP does not apply to it). assert hp_fuel > 0.0 assert abs(hp_fuel - boiler_fuel) <= 1e-6 + + +def test_pv_credited_only_when_connected_to_dwelling_meter_per_pv_connection() -> None: + # RdSAP 10 §11.1 / SAP 10.2 Appendix M: PV-generated electricity is + # included in a dwelling's assessment ONLY IF the array is connected to + # the dwelling's own electricity meter; an unconnected (communal / + # separately-metered) array contributes nothing to the dwelling's energy + # cost, CO2 or primary energy. The gov-API `sap_energy_source.pv_connection` + # enum encodes this: 0 = no PV, 1 = present but NOT connected, 2 = connected. + # + # Validated on the RdSAP-21.0.1 corpus (57 PV certs): every pv_connection=1 + # cert reconciles BETTER without the credit (MAE 4.48 -> 1.22, 0/5 need it), + # while pv_connection=2 certs need it (MAE 0.98 vs 10.29 without). Accredited + # Elmhurst proof: an identical dwelling rates SAP 87 with "Connected to + # Dwelling = Yes" (credit -£167) vs SAP 74 with "No" (credit £0). + array = [make_pv_array(peak_power=3.0)] + + def _gen(pv_connection: int) -> float: + epc = make_minimal_sap10_epc( + dwelling_type="Mid-terrace house", + total_floor_area_m2=70.0, + habitable_rooms_count=3, + country_code="ENG", + photovoltaic_arrays=array, + is_dwelling_export_capable=True, + pv_connection=pv_connection, + ) + return cert_to_inputs(epc).pv_generation_kwh_per_yr + + # pv_connection=2 (connected to the dwelling's meter) → PV serves the + # dwelling and is credited. + assert _gen(2) > 0.0 + # pv_connection=1 (present but NOT connected to the dwelling's meter) → + # the array contributes nothing to this dwelling's SAP. + assert _gen(1) == 0.0 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 496ddd31..cc57d28d 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -193,7 +193,7 @@ _CORPUS = Path( # within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst # stress worksheet (simulated case 46): closed its last ventilation residual # (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst). -_MIN_WITHIN_HALF_SAP = 0.73 +_MIN_WITHIN_HALF_SAP = 0.74 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, @@ -238,7 +238,17 @@ _MIN_WITHIN_HALF_SAP = 0.73 # already computes 74. roof_insulation_location="ND" ⟺ party ceiling separates # the corpus classes with zero disagreement (all 190 party flats lodge "ND"); # the 4 mid/ground-floor flats this exposes all move toward lodged, 0 away. -_MAX_SAP_MAE = 0.762 +# Then 0.761 -> 0.740 (within-0.5 73.6% -> 74.1%) via the PV dwelling-meter +# connection gate (RdSAP 10 §11.1 / SAP 10.2 Appendix M): PV is credited to the +# dwelling only when gov-API `pv_connection == 2` ("connected to the dwelling's +# meter"); == 1 ("present but NOT connected" — communal / separately metered) +# now contributes zero to cost/CO2/PE. All 5 pv_connection=1 PV certs move +# inside ±0.5 (e.g. 100051118081 +6.5 -> +0.5); pv_connection=2 certs (52) keep +# their credit (corpus MAE 0.98 with vs 10.29 without). Khalim's Elmhurst proof: +# an identical dwelling rates SAP 87 with "Connected to Dwelling = Yes" (credit +# -£167) vs SAP 74 with "No" (credit £0). Enum decoded empirically: 0 = no PV, +# 1 = not connected, 2 = connected (the gov-API does not expose it elsewhere). +_MAX_SAP_MAE = 0.740 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current