diff --git a/docs/migrations/epc-property-round-trip-fidelity.md b/docs/migrations/epc-property-round-trip-fidelity.md index e7e23c02..d9ed6557 100644 --- a/docs/migrations/epc-property-round-trip-fidelity.md +++ b/docs/migrations/epc-property-round-trip-fidelity.md @@ -44,11 +44,14 @@ and **still require the matching DB migration** wherever the physical tables liv `ventilation_has_draught_lobby`, `ventilation_air_permeability_ap4_m3_h_m2`, `ventilation_mechanical_ventilation_kind`; `epc_building_part`: `roof_construction_type`, `curtain_wall_age`. +- **§2.1 `epc_renewable_heat_incentive` table** (#1137) — now created on the SQLModel and wired + into save/get; the round-trip test asserts **full deep-equality** (no exclusion). DB migration + still required. -**Still open (follow-up issues):** §2.1 `epc_renewable_heat_incentive` (P0, #1137 — excluded from the -slice-1 assertion via `dataclasses.replace(..., renewable_heat_incentive=None)` until landed), and -the remaining §2 structural tables (room-in-roof detail, PV arrays, roof windows) + §3 nested-wall -fields (`SapAlternativeWall.u_value`/`wall_thickness_mm`) + `SapFloorDimension` exposed-floor flags. +**Still open (follow-up issues):** the remaining §2 structural tables (room-in-roof detail, PV +arrays, roof windows) + §3 nested-wall fields (`SapAlternativeWall.u_value`/`wall_thickness_mm`) + +`SapFloorDimension` exposed-floor flags — none populated in the 21.0.0/21.0.1 fixtures, so latent +until a richer fixture exercises them. --- diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index deee192c..539628bd 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -9,6 +9,7 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, EnergyElement, MainHeatingDetail, + RenewableHeatIncentive, SapBuildingPart, SapFloorDimension, SapFlatDetails, @@ -413,6 +414,34 @@ class EpcPropertyEnergyPerformanceModel(SQLModel, table=True): ) +class EpcRenewableHeatIncentiveModel(SQLModel, table=True): + __tablename__: ClassVar[str] = "epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride] + + id: Optional[int] = Field(default=None, primary_key=True) + epc_property_id: int = Field( + foreign_key="epc_property.id", nullable=False, unique=True + ) + + space_heating_kwh: float + water_heating_kwh: float + impact_of_loft_insulation_kwh: Optional[float] = Field(default=None) + impact_of_cavity_insulation_kwh: Optional[float] = Field(default=None) + impact_of_solid_wall_insulation_kwh: Optional[float] = Field(default=None) + + @classmethod + def from_domain( + cls, rhi: RenewableHeatIncentive, epc_property_id: int + ) -> EpcRenewableHeatIncentiveModel: + return cls( + epc_property_id=epc_property_id, + space_heating_kwh=rhi.space_heating_kwh, + water_heating_kwh=rhi.water_heating_kwh, + impact_of_loft_insulation_kwh=rhi.impact_of_loft_insulation_kwh, + impact_of_cavity_insulation_kwh=rhi.impact_of_cavity_insulation_kwh, + impact_of_solid_wall_insulation_kwh=rhi.impact_of_solid_wall_insulation_kwh, + ) + + class EpcFlatDetailsModel(SQLModel, table=True): __tablename__: ClassVar[str] = "epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride] diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 02dc49b9..52873dce 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -17,6 +17,7 @@ from datatypes.epc.domain.epc_property_data import ( PhotovoltaicSupplyNoneOrNoDetails, PvBatteries, PvBattery, + RenewableHeatIncentive, SapAlternativeWall, SapBuildingPart, SapEnergySource, @@ -40,6 +41,7 @@ from infrastructure.postgres.epc_property_table import ( EpcMainHeatingDetailModel, EpcPropertyEnergyPerformanceModel, EpcPropertyModel, + EpcRenewableHeatIncentiveModel, EpcWindowModel, ) from repositories.epc.epc_repository import EpcRepository @@ -124,6 +126,12 @@ class EpcPostgresRepository(EpcRepository): self._session.add( EpcFlatDetailsModel.from_domain(data.sap_flat_details, epc_property_id) ) + if data.renewable_heat_incentive is not None: + self._session.add( + EpcRenewableHeatIncentiveModel.from_domain( + data.renewable_heat_incentive, epc_property_id + ) + ) return epc_property_id def get(self, epc_property_id: int) -> EpcPropertyData: @@ -161,6 +169,11 @@ class EpcPostgresRepository(EpcRepository): EpcFlatDetailsModel.epc_property_id == epc_property_id ) ).first() + rhi_row = self._session.exec( + select(EpcRenewableHeatIncentiveModel).where( + EpcRenewableHeatIncentiveModel.epc_property_id == epc_property_id + ) + ).first() def _elements(element_type: str) -> list[EnergyElement]: return [self._to_energy_element(e) for e in elements if e.element_type == element_type] @@ -308,6 +321,17 @@ class EpcPostgresRepository(EpcRepository): waste_water_heat_recovery=p.waste_water_heat_recovery, hydro=p.hydro, photovoltaic_array=p.photovoltaic_array, + renewable_heat_incentive=( + RenewableHeatIncentive( + space_heating_kwh=rhi_row.space_heating_kwh, + water_heating_kwh=rhi_row.water_heating_kwh, + impact_of_loft_insulation_kwh=rhi_row.impact_of_loft_insulation_kwh, + impact_of_cavity_insulation_kwh=rhi_row.impact_of_cavity_insulation_kwh, + impact_of_solid_wall_insulation_kwh=rhi_row.impact_of_solid_wall_insulation_kwh, + ) + if rhi_row is not None + else None + ), mechanical_vent_duct_insulation_level=p.mechanical_vent_duct_insulation_level, addendum=( Addendum( diff --git a/tests/repositories/epc/test_epc_round_trip.py b/tests/repositories/epc/test_epc_round_trip.py index 064891bd..192027f7 100644 --- a/tests/repositories/epc/test_epc_round_trip.py +++ b/tests/repositories/epc/test_epc_round_trip.py @@ -9,7 +9,6 @@ inside First Run, rather than be deferred to a later Refresh. from __future__ import annotations -import dataclasses import json from pathlib import Path from typing import Any @@ -48,9 +47,4 @@ def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> No reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert - # Slice 1 pins round-trip fidelity over the persisted projection. The only - # field not yet stored is `renewable_heat_incentive` (the P0 structural gap - # tracked in #1137 — a new table); exclude it here and drop this `replace` - # once that table lands. - projected = dataclasses.replace(original, renewable_heat_incentive=None) - assert reloaded == projected + assert reloaded == original