mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
559616d3bb
commit
3e1d3acfbf
4 changed files with 61 additions and 11 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue