From b0503489270b73207ff0578fbaa3c741c104ca41 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 16 May 2026 16:00:25 +0000 Subject: [PATCH] slice 10.5: PhotovoltaicArray on SAP10 schema + EpcPropertyData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP10 EPCs with measured PV carry photovoltaic_supply as a nested list of arrays (peak_power, pitch, orientation, overshading) rather than the legacy unmeasured wrapper {none_or_no_details: {percent_roof_area: N}}. The schema-21 dataclasses now accept both shapes via Union[PhotovoltaicSupply, List[List[PhotovoltaicArray]]], and from_dict._coerce now dispatches list values onto list type variants of multi-type Unions. EpcPropertyData.SapEnergySource gains photovoltaic_arrays: Optional[List[PhotovoltaicArray]] — populated when the measured shape is present, otherwise None. The legacy photovoltaic_supply field is preserved for the fallback case. Both schema-21.0.0 and 21.0.1 mappers dispatch via the new _map_schema_21_pv helper. Unblocks Slice 11 (PV feature aggregation in EpcMlTransform). Co-Authored-By: Claude Opus 4.7 --- datatypes/epc/domain/epc_property_data.py | 14 +++++ datatypes/epc/domain/mapper.py | 63 +++++++++++++------ .../domain/tests/test_from_rdsap_schema.py | 40 ++++++++++++ datatypes/epc/schema/helpers.py | 6 ++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 17 ++++- datatypes/epc/schema/rdsap_schema_21_0_1.py | 17 ++++- 6 files changed, 137 insertions(+), 20 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 4d9b4e24..9a261768 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -137,6 +137,19 @@ class PhotovoltaicSupply: none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails +@dataclass +class PhotovoltaicArray: + """One measured PV array: peak power (kW), pitch, orientation (SAP octant + 1-8), and overshading code. Populated on EpcPropertyData when the EPC has + measured PV configuration; `photovoltaic_supply` carries the fallback + `percent_roof_area` estimate when the surveyor could not confirm details. + """ + peak_power: float + pitch: int + orientation: int + overshading: int + + @dataclass class SapEnergySource: mains_gas: bool @@ -150,6 +163,7 @@ class SapEnergySource: pv_connection: Optional[Union[int, str]] = None # int from API; str from site notes photovoltaic_supply: Optional[PhotovoltaicSupply] = None + photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None wind_turbine_details: Optional[WindTurbineDetails] = None pv_batteries: Optional[PvBatteries] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 18cefa35..98a36e2c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -7,6 +7,7 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, InstantaneousWwhrs, MainHeatingDetail, + PhotovoltaicArray, PhotovoltaicSupply, PhotovoltaicSupplyNoneOrNoDetails, PvBatteries, @@ -84,6 +85,44 @@ AnyRdSapSchema = Union[ ] +def _map_schema_21_pv( + es_pv_supply: Any, +) -> tuple[Optional[PhotovoltaicSupply], Optional[List[PhotovoltaicArray]]]: + """Dispatch on the polymorphic schema-21 ``photovoltaic_supply`` field. + + Schema-21 EPCs carry one of two shapes under the same JSON key: + - the legacy wrapper dict ``{"none_or_no_details": {"percent_roof_area": N}}`` + when PV is absent or the surveyor logged only roof-coverage, + - a nested list ``[[{peak_power, pitch, orientation, overshading}, ...], ...]`` + when measured-array detail is available. + + Returns ``(supply, arrays)`` — exactly one half is populated; the other is + None. With no PV data at all, both are None. + """ + if isinstance(es_pv_supply, list): + flattened = [ + PhotovoltaicArray( + peak_power=array.peak_power, + pitch=array.pitch, + orientation=array.orientation, + overshading=array.overshading, + ) + for inner_list in es_pv_supply + for array in inner_list + ] + return None, (flattened or None) + if es_pv_supply is None: + return None, None + return ( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es_pv_supply.none_or_no_details.percent_roof_area, + ) + ), + None, + ) + + class EpcPropertyDataMapper: @staticmethod @@ -1028,6 +1067,7 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_21_0_0(schema: RdSapSchema21_0_0) -> EpcPropertyData: es = schema.sap_energy_source + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) return EpcPropertyData( uprn=schema.uprn, assessment_type=schema.assessment_type, @@ -1155,15 +1195,8 @@ class EpcPropertyDataMapper: electricity_smart_meter_present=es.electricity_smart_meter_present == "true", pv_connection=es.pv_connection, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - if es.photovoltaic_supply - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, wind_turbine_details=( WindTurbineDetails( hub_height=es.wind_turbine_details.hub_height, @@ -1276,6 +1309,7 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_21_0_1(schema: RdSapSchema21_0_1) -> EpcPropertyData: es = schema.sap_energy_source + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) return EpcPropertyData( # General uprn=schema.uprn, @@ -1411,15 +1445,8 @@ class EpcPropertyDataMapper: electricity_smart_meter_present=es.electricity_smart_meter_present == "true", pv_connection=es.pv_connection, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - if es.photovoltaic_supply - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, wind_turbine_details=( WindTurbineDetails( hub_height=es.wind_turbine_details.hub_height, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index a9d0119c..a20b5fb7 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -267,6 +267,46 @@ class TestFromRdSapSchema21_0_0: assert rhi.impact_of_cavity_insulation_kwh == -122.0 assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0 + def test_photovoltaic_arrays_none_when_unmeasured( + self, result: EpcPropertyData + ) -> None: + # Arrange — fixture has the unmeasured-PV shape + # (photovoltaic_supply.none_or_no_details.percent_roof_area = 0) + + # Act + es = result.sap_energy_source + + # Assert + assert es.photovoltaic_arrays is None + assert es.photovoltaic_supply is not None + + def test_photovoltaic_arrays_populated_when_measured(self) -> None: + # Arrange — load the schema-21.0.0 fixture and override + # sap_energy_source.photovoltaic_supply with the modern list-of-arrays + # shape carried by SAP10 EPCs with measured PV. + data = load("21_0_0.json") + data["sap_energy_source"]["photovoltaic_supply"] = [ + [{"pitch": 2, "peak_power": 2.04, "orientation": 4, "overshading": 1}], + [{"pitch": 2, "peak_power": 1.86, "orientation": 8, "overshading": 2}], + ] + schema = from_dict(RdSapSchema21_0_0, data) + + # Act + result = EpcPropertyDataMapper.from_rdsap_schema_21_0_0(schema) + + # Assert + arrays = result.sap_energy_source.photovoltaic_arrays + assert arrays is not None + assert len(arrays) == 2 + assert arrays[0].peak_power == 2.04 + assert arrays[0].pitch == 2 + assert arrays[0].orientation == 4 + assert arrays[0].overshading == 1 + assert arrays[1].peak_power == 1.86 + assert arrays[1].orientation == 8 + # photovoltaic_supply is None when the measured shape is present + assert result.sap_energy_source.photovoltaic_supply is None + # --------------------------------------------------------------------------- # Schema 21.0.1 (most comprehensive — full field coverage) diff --git a/datatypes/epc/schema/helpers.py b/datatypes/epc/schema/helpers.py index 22f132d2..c4b98135 100644 --- a/datatypes/epc/schema/helpers.py +++ b/datatypes/epc/schema/helpers.py @@ -59,6 +59,12 @@ def _coerce(value: Any, hint: Any) -> Any: for arg in non_none_args: if dataclasses.is_dataclass(arg) and isinstance(value, dict): return _from_dict_impl(arg, value) + # Then try list types — covers Union[Dataclass, list[...]] polymorphism + # where a single JSON key can carry either a wrapper dict or a list of items. + if isinstance(value, list): + for arg in non_none_args: + if typing.get_origin(arg) is list: + return _coerce(value, arg) # All remaining args are primitives — return value as-is return value diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index eee00cb8..bbebb33a 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -99,13 +99,28 @@ class PhotovoltaicSupply: none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails +@dataclass +class PhotovoltaicArray: + """Measured-PV array (peak_power, pitch, orientation, overshading). + + Modern SAP10 EPCs with measured PV carry `photovoltaic_supply` as a nested + list (`list[list[PhotovoltaicArray]]`) rather than the legacy wrapper dict + `PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply + accepts either shape. + """ + peak_power: float + pitch: int + orientation: int + overshading: int + + @dataclass class SapEnergySource: mains_gas: str meter_type: int pv_connection: int pv_battery_count: int - photovoltaic_supply: PhotovoltaicSupply + photovoltaic_supply: Union[PhotovoltaicSupply, List[List[PhotovoltaicArray]]] wind_turbines_count: int wind_turbine_details: WindTurbineDetails gas_smart_meter_present: str diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 9b3dbd1d..85d532c4 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -100,13 +100,28 @@ class PhotovoltaicSupply: none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails +@dataclass +class PhotovoltaicArray: + """Measured-PV array (peak_power, pitch, orientation, overshading). + + Modern SAP10 EPCs with measured PV carry `photovoltaic_supply` as a nested + list (`list[list[PhotovoltaicArray]]`) rather than the legacy wrapper dict + `PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply + accepts either shape. + """ + peak_power: float + pitch: int + orientation: int + overshading: int + + @dataclass class SapEnergySource: mains_gas: str meter_type: int pv_connection: int pv_battery_count: int - photovoltaic_supply: PhotovoltaicSupply + photovoltaic_supply: Union[PhotovoltaicSupply, List[List[PhotovoltaicArray]]] wind_turbines_count: int wind_turbine_details: WindTurbineDetails gas_smart_meter_present: str