slice 10.5: PhotovoltaicArray on SAP10 schema + EpcPropertyData

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-16 16:00:25 +00:00
parent fff6ef3352
commit b050348927
6 changed files with 137 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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