mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
fff6ef3352
commit
b050348927
6 changed files with 137 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue