feat: persist solar PV arrays through the EPC round-trip 🟩

Add EpcPhotovoltaicArrayModel (epc_photovoltaic_array child table) and wire
save / delete / read so sap_energy_source.photovoltaic_arrays survives
load->save->load in order. Threaded through both the single get() and the
bulk _for_properties() paths via _compose -> _to_energy_source. Column
names match the FE migration (feature/epc-pv-and-floor-heatloss-schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-26 16:47:37 +00:00
parent 1041c8ed0e
commit 09ba84edf9
2 changed files with 79 additions and 2 deletions

View file

@ -10,6 +10,7 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
EnergyElement,
MainHeatingDetail,
PhotovoltaicArray,
RenewableHeatIncentive,
SapBuildingPart,
SapFloorDimension,
@ -778,6 +779,37 @@ class EpcWindowModel(SQLModel, table=True):
)
class EpcPhotovoltaicArrayModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
# List position on `sap_energy_source.photovoltaic_arrays`. Persisted so the
# array order is recoverable for round-trip equality (a dwelling can carry
# several arrays at different pitch/orientation).
array_index: int
# kWp as a float (e.g. 3.24). pitch / overshading / orientation are SAP
# enumeration CODES (small categorical ints), not physical degrees.
peak_power: float
pitch: int
overshading: int
orientation: Optional[int] = Field(default=None)
@classmethod
def from_domain(
cls, array: PhotovoltaicArray, array_index: int, epc_property_id: int
) -> EpcPhotovoltaicArrayModel:
return cls(
epc_property_id=epc_property_id,
array_index=array_index,
peak_power=array.peak_power,
pitch=array.pitch,
overshading=array.overshading,
orientation=array.orientation,
)
class EpcEnergyElementModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride]

View file

@ -14,6 +14,7 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
InstantaneousWwhrs,
MainHeatingDetail,
PhotovoltaicArray,
PhotovoltaicSupply,
PhotovoltaicSupplyNoneOrNoDetails,
PvBatteries,
@ -41,6 +42,7 @@ from infrastructure.postgres.epc_property_table import (
EpcFlatDetailsModel,
EpcFloorDimensionModel,
EpcMainHeatingDetailModel,
EpcPhotovoltaicArrayModel,
EpcPropertyEnergyPerformanceModel,
EpcPropertyModel,
EpcRenewableHeatIncentiveModel,
@ -140,6 +142,10 @@ class EpcPostgresRepository(EpcRepository):
self._session.add(EpcFloorDimensionModel.from_domain(dim, bp_id))
for window in data.sap_windows:
self._session.add(EpcWindowModel.from_domain(window, epc_property_id))
for index, array in enumerate(data.sap_energy_source.photovoltaic_arrays or []):
self._session.add(
EpcPhotovoltaicArrayModel.from_domain(array, index, epc_property_id)
)
for element_type, elements in (
("roof", data.roofs),
@ -211,6 +217,7 @@ class EpcPostgresRepository(EpcRepository):
EpcMainHeatingDetailModel,
EpcBuildingPartModel,
EpcWindowModel,
EpcPhotovoltaicArrayModel,
EpcFlatDetailsModel,
EpcRenewableHeatIncentiveModel,
):
@ -327,6 +334,13 @@ class EpcPostgresRepository(EpcRepository):
.order_by(EpcWindowModel.id) # type: ignore[arg-type]
).all()
)
pv_arrays_by = _group_by_epc(
self._session.exec(
select(EpcPhotovoltaicArrayModel)
.where(col(EpcPhotovoltaicArrayModel.epc_property_id).in_(epc_ids))
.order_by(EpcPhotovoltaicArrayModel.array_index) # type: ignore[arg-type]
).all()
)
part_ids = [
bp.id
for parts in parts_by.values()
@ -346,6 +360,7 @@ class EpcPostgresRepository(EpcRepository):
part_rows=parts_by.get(epc_id, []),
floor_dims_by_part=floor_dims_by_part,
window_rows=windows_by.get(epc_id, []),
pv_array_rows=pv_arrays_by.get(epc_id, []),
flat_row=flat_by.get(epc_id),
rhi_row=rhi_by.get(epc_id),
)
@ -407,6 +422,7 @@ class EpcPostgresRepository(EpcRepository):
)
).first()
window_rows = self._windows(epc_property_id)
pv_array_rows = self._pv_arrays(epc_property_id)
floor_dims_by_part = self._floor_dims_by_part(
[bp.id for bp in part_rows if bp.id is not None]
)
@ -418,6 +434,7 @@ class EpcPostgresRepository(EpcRepository):
part_rows=part_rows,
floor_dims_by_part=floor_dims_by_part,
window_rows=window_rows,
pv_array_rows=pv_array_rows,
flat_row=flat_row,
rhi_row=rhi_row,
)
@ -432,6 +449,7 @@ class EpcPostgresRepository(EpcRepository):
part_rows: list[EpcBuildingPartModel],
floor_dims_by_part: dict[int, list[EpcFloorDimensionModel]],
window_rows: list[EpcWindowModel],
pv_array_rows: list[EpcPhotovoltaicArrayModel],
flat_row: Optional[EpcFlatDetailsModel],
rhi_row: Optional[EpcRenewableHeatIncentiveModel],
) -> EpcPropertyData:
@ -457,7 +475,7 @@ class EpcPostgresRepository(EpcRepository):
door_count=p.door_count,
sap_heating=self._to_sap_heating(p, heating_rows),
sap_windows=[self._to_window(w) for w in window_rows],
sap_energy_source=self._to_energy_source(p),
sap_energy_source=self._to_energy_source(p, pv_array_rows),
sap_building_parts=[
self._to_building_part(
bp, floor_dims_by_part.get(bp.id, []) if bp.id is not None else []
@ -622,6 +640,18 @@ class EpcPostgresRepository(EpcRepository):
).all()
)
@private
def _pv_arrays(
self, epc_property_id: int
) -> list[EpcPhotovoltaicArrayModel]:
return list(
self._session.exec(
select(EpcPhotovoltaicArrayModel)
.where(EpcPhotovoltaicArrayModel.epc_property_id == epc_property_id)
.order_by(EpcPhotovoltaicArrayModel.array_index) # type: ignore[arg-type]
).all()
)
@private
def _to_energy_element(self, e: EpcEnergyElementModel) -> EnergyElement:
return EnergyElement(
@ -817,8 +847,23 @@ class EpcPostgresRepository(EpcRepository):
)
@private
def _to_energy_source(self, p: EpcPropertyModel) -> SapEnergySource:
def _to_energy_source(
self, p: EpcPropertyModel, pv_array_rows: list[EpcPhotovoltaicArrayModel]
) -> SapEnergySource:
return SapEnergySource(
photovoltaic_arrays=(
[
PhotovoltaicArray(
peak_power=a.peak_power,
pitch=a.pitch,
overshading=a.overshading,
orientation=a.orientation,
)
for a in sorted(pv_array_rows, key=lambda a: a.array_index)
]
if pv_array_rows
else None
),
mains_gas=p.energy_mains_gas,
meter_type=p.energy_meter_type,
pv_battery_count=p.energy_pv_battery_count,