From 09ba84edf93beda97d5a05669701ccefabab2a9a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 16:47:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20persist=20solar=20PV=20arrays=20through?= =?UTF-8?q?=20the=20EPC=20round-trip=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- infrastructure/postgres/epc_property_table.py | 32 ++++++++++++ repositories/epc/epc_postgres_repository.py | 49 ++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index 50a1b393..a9ab5108 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -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] diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 1390e790..ffd746af 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -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,