feat(epc): persist renewable_heat_incentive — full round-trip equality (#1137)

Add epc_renewable_heat_incentive table (space_heating_kwh, water_heating_kwh +
the three insulation-impact kWh fields), wired into EpcPostgresRepository
save/get. This is the P0 gap: RenewableHeatIncentive carries the baseline
space-heating/hot-water kWh that EPC Energy Derivation consumes.

The round-trip test now asserts full deep-equality (dropped the
renewable_heat_incentive exclusion) and passes for RdSAP 21.0.0 + 21.0.1.
DB migration for the new table documented in
docs/migrations/epc-property-round-trip-fidelity.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 19:30:18 +00:00
parent 5f0a3b8f65
commit 311d1e751a
4 changed files with 61 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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