mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
feat(epc): EPC persistence round-trip fidelity + JSONB code columns (Slice 1 #1129)
Relocate EpcPropertyModel + child tables from the dying backend/ tree to
infrastructure/postgres/epc_property_table.py (re-export shim keeps
documents_parser working). Add EpcRepository port + EpcPostgresRepository with
a full reverse mapper (epc_property tables -> EpcPropertyData).
Round-trip test surfaced two fidelity gaps:
1. Union[int,str] SAP code fields were str()-coerced on save, losing the int
(API) vs str (Site Notes) distinction. Now stored as JSONB (type-preserving).
2. The schema was a partial projection. Closed the cheap gaps on the model
(heating shower/bath counts, roof_construction_type, curtain_wall_age,
addendum, mechanical_vent_duct_insulation_level, SAP 10.2 §2 ventilation
fields + a ventilation_present flag). Structural gaps tracked as follow-ups;
renewable_heat_incentive (P0, #1137) excluded from the assertion until landed.
Round-trip passes for RdSAP-Schema-21.0.0 and 21.0.1; pyright strict clean.
Migration inventory for the DB: docs/migrations/epc-property-round-trip-fidelity.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8291f29721
commit
559616d3bb
7 changed files with 882 additions and 655 deletions
|
|
@ -1,659 +1,29 @@
|
||||||
from __future__ import annotations
|
"""Re-export shim.
|
||||||
|
|
||||||
from typing import Optional
|
The EPC persistence models moved to ``infrastructure/postgres/epc_property_table.py``
|
||||||
from sqlmodel import SQLModel, Field
|
as part of the Ara backend rebuild (PRD Hestia-Homes/Model#1128, Slice 1 #1129).
|
||||||
|
This shim keeps the dying ``backend/`` callers working until cut-over. New code must
|
||||||
|
import from ``infrastructure.postgres.epc_property_table`` directly.
|
||||||
|
"""
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from infrastructure.postgres.epc_property_table import (
|
||||||
EpcPropertyData,
|
EpcBuildingPartModel,
|
||||||
EnergyElement,
|
EpcEnergyElementModel,
|
||||||
MainHeatingDetail,
|
EpcFlatDetailsModel,
|
||||||
SapBuildingPart,
|
EpcFloorDimensionModel,
|
||||||
SapFloorDimension,
|
EpcMainHeatingDetailModel,
|
||||||
SapFlatDetails,
|
EpcPropertyEnergyPerformanceModel,
|
||||||
SapWindow,
|
EpcPropertyModel,
|
||||||
|
EpcWindowModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
class EpcPropertyModel(SQLModel, table=True):
|
"EpcBuildingPartModel",
|
||||||
__tablename__ = "epc_property"
|
"EpcEnergyElementModel",
|
||||||
|
"EpcFlatDetailsModel",
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
"EpcFloorDimensionModel",
|
||||||
property_id: Optional[int] = Field(default=None)
|
"EpcMainHeatingDetailModel",
|
||||||
portfolio_id: Optional[int] = Field(default=None)
|
"EpcPropertyEnergyPerformanceModel",
|
||||||
uploaded_file_id: Optional[int] = Field(default=None)
|
"EpcPropertyModel",
|
||||||
|
"EpcWindowModel",
|
||||||
# Identity / admin
|
]
|
||||||
uprn: Optional[int] = Field(default=None)
|
|
||||||
uprn_source: Optional[str] = Field(default=None)
|
|
||||||
report_reference: Optional[str] = Field(default=None)
|
|
||||||
report_type: Optional[str] = Field(default=None)
|
|
||||||
assessment_type: Optional[str] = Field(default=None)
|
|
||||||
sap_version: Optional[float] = Field(default=None)
|
|
||||||
schema_type: Optional[str] = Field(default=None)
|
|
||||||
schema_versions_original: Optional[str] = Field(default=None)
|
|
||||||
status: Optional[str] = Field(default=None)
|
|
||||||
calculation_software_version: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
# Address
|
|
||||||
address_line_1: Optional[str] = Field(default=None)
|
|
||||||
address_line_2: Optional[str] = Field(default=None)
|
|
||||||
post_town: Optional[str] = Field(default=None)
|
|
||||||
postcode: Optional[str] = Field(default=None)
|
|
||||||
region_code: Optional[str] = Field(default=None)
|
|
||||||
country_code: Optional[str] = Field(default=None)
|
|
||||||
language_code: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
# Property description
|
|
||||||
dwelling_type: str
|
|
||||||
property_type: Optional[str] = Field(default=None)
|
|
||||||
built_form: Optional[str] = Field(default=None)
|
|
||||||
tenure: str
|
|
||||||
transaction_type: str
|
|
||||||
inspection_date: str # store as ISO string; cast on read if needed
|
|
||||||
completion_date: Optional[str] = Field(default=None)
|
|
||||||
registration_date: Optional[str] = Field(default=None)
|
|
||||||
total_floor_area_m2: float
|
|
||||||
measurement_type: Optional[int] = Field(default=None)
|
|
||||||
|
|
||||||
# Flags
|
|
||||||
solar_water_heating: bool
|
|
||||||
has_hot_water_cylinder: bool
|
|
||||||
has_fixed_air_conditioning: bool
|
|
||||||
has_conservatory: Optional[bool] = Field(default=None)
|
|
||||||
has_heated_separate_conservatory: Optional[bool] = Field(default=None)
|
|
||||||
conservatory_type: Optional[int] = Field(default=None)
|
|
||||||
|
|
||||||
# Counts
|
|
||||||
door_count: int
|
|
||||||
wet_rooms_count: int
|
|
||||||
extensions_count: int
|
|
||||||
heated_rooms_count: int
|
|
||||||
open_chimneys_count: int
|
|
||||||
habitable_rooms_count: int
|
|
||||||
insulated_door_count: int
|
|
||||||
cfl_fixed_lighting_bulbs_count: int
|
|
||||||
led_fixed_lighting_bulbs_count: int
|
|
||||||
incandescent_fixed_lighting_bulbs_count: int
|
|
||||||
blocked_chimneys_count: Optional[int] = Field(default=None)
|
|
||||||
draughtproofed_door_count: Optional[int] = Field(default=None)
|
|
||||||
energy_rating_average: Optional[int] = Field(default=None)
|
|
||||||
low_energy_fixed_lighting_bulbs_count: Optional[int] = Field(default=None)
|
|
||||||
fixed_lighting_outlets_count: Optional[int] = Field(default=None)
|
|
||||||
low_energy_fixed_lighting_outlets_count: Optional[int] = Field(default=None)
|
|
||||||
number_of_storeys: Optional[int] = Field(default=None)
|
|
||||||
any_unheated_rooms: Optional[bool] = Field(default=None)
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
hydro: Optional[bool] = Field(default=None)
|
|
||||||
photovoltaic_array: Optional[bool] = Field(default=None)
|
|
||||||
waste_water_heat_recovery: Optional[str] = Field(default=None)
|
|
||||||
pressure_test: Optional[int] = Field(default=None)
|
|
||||||
pressure_test_certificate_number: Optional[int] = Field(default=None)
|
|
||||||
percent_draughtproofed: Optional[int] = Field(default=None)
|
|
||||||
insulated_door_u_value: Optional[float] = Field(default=None)
|
|
||||||
multiple_glazed_proportion: Optional[int] = Field(default=None)
|
|
||||||
windows_transmission_u_value: Optional[float] = Field(default=None)
|
|
||||||
windows_transmission_data_source: Optional[int] = Field(default=None)
|
|
||||||
windows_transmission_solar_transmittance: Optional[float] = Field(default=None)
|
|
||||||
|
|
||||||
# Energy source
|
|
||||||
energy_mains_gas: bool
|
|
||||||
energy_meter_type: str
|
|
||||||
energy_pv_battery_count: int
|
|
||||||
energy_wind_turbines_count: int
|
|
||||||
energy_gas_smart_meter_present: bool
|
|
||||||
energy_is_dwelling_export_capable: bool
|
|
||||||
energy_wind_turbines_terrain_type: str
|
|
||||||
energy_electricity_smart_meter_present: bool
|
|
||||||
energy_pv_connection: Optional[str] = Field(default=None)
|
|
||||||
energy_pv_percent_roof_area: Optional[int] = Field(default=None)
|
|
||||||
energy_pv_battery_capacity: Optional[float] = Field(default=None)
|
|
||||||
energy_wind_turbine_hub_height: Optional[float] = Field(default=None)
|
|
||||||
energy_wind_turbine_rotor_diameter: Optional[float] = Field(default=None)
|
|
||||||
|
|
||||||
# Heating config
|
|
||||||
heating_cylinder_size: Optional[str] = Field(default=None)
|
|
||||||
heating_water_heating_code: Optional[int] = Field(default=None)
|
|
||||||
heating_water_heating_fuel: Optional[int] = Field(default=None)
|
|
||||||
heating_immersion_heating_type: Optional[str] = Field(default=None)
|
|
||||||
heating_cylinder_insulation_type: Optional[str] = Field(default=None)
|
|
||||||
heating_cylinder_thermostat: Optional[str] = Field(default=None)
|
|
||||||
heating_secondary_fuel_type: Optional[int] = Field(default=None)
|
|
||||||
heating_secondary_heating_type: Optional[str] = Field(default=None)
|
|
||||||
heating_cylinder_insulation_thickness_mm: Optional[int] = Field(default=None)
|
|
||||||
heating_wwhrs_index_number_1: Optional[int] = Field(default=None)
|
|
||||||
heating_wwhrs_index_number_2: Optional[int] = Field(default=None)
|
|
||||||
heating_shower_outlet_type: Optional[str] = Field(default=None)
|
|
||||||
heating_shower_wwhrs: Optional[int] = Field(default=None)
|
|
||||||
|
|
||||||
# Ventilation
|
|
||||||
ventilation_type: Optional[str] = Field(default=None)
|
|
||||||
ventilation_draught_lobby: Optional[bool] = Field(default=None)
|
|
||||||
ventilation_pressure_test: Optional[str] = Field(default=None)
|
|
||||||
ventilation_open_flues_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_closed_flues_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_boiler_flues_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_other_flues_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_extract_fans_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_passive_vents_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_flueless_gas_fires_count: Optional[int] = Field(default=None)
|
|
||||||
ventilation_in_pcdf_database: Optional[bool] = Field(default=None)
|
|
||||||
mechanical_ventilation: Optional[int] = Field(default=None)
|
|
||||||
mechanical_vent_duct_type: Optional[int] = Field(default=None)
|
|
||||||
mechanical_vent_duct_placement: Optional[int] = Field(default=None)
|
|
||||||
mechanical_vent_duct_insulation: Optional[int] = Field(default=None)
|
|
||||||
mechanical_ventilation_index_number: Optional[int] = Field(default=None)
|
|
||||||
mechanical_vent_measured_installation: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_epc_property_data(
|
|
||||||
cls,
|
|
||||||
data: EpcPropertyData,
|
|
||||||
property_id: Optional[int] = None,
|
|
||||||
portfolio_id: Optional[int] = None,
|
|
||||||
) -> EpcPropertyModel:
|
|
||||||
es = data.sap_energy_source
|
|
||||||
h = data.sap_heating
|
|
||||||
v = data.sap_ventilation
|
|
||||||
shower = h.shower_outlets.shower_outlet if h.shower_outlets else None
|
|
||||||
pv = es.photovoltaic_supply
|
|
||||||
wt = es.wind_turbine_details
|
|
||||||
pvb = es.pv_batteries
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
property_id=property_id,
|
|
||||||
portfolio_id=portfolio_id,
|
|
||||||
uprn=data.uprn,
|
|
||||||
uprn_source=data.uprn_source,
|
|
||||||
report_reference=data.report_reference,
|
|
||||||
report_type=data.report_type,
|
|
||||||
assessment_type=data.assessment_type,
|
|
||||||
sap_version=data.sap_version,
|
|
||||||
schema_type=data.schema_type,
|
|
||||||
schema_versions_original=data.schema_versions_original,
|
|
||||||
status=data.status,
|
|
||||||
calculation_software_version=data.calculation_software_version,
|
|
||||||
address_line_1=data.address_line_1,
|
|
||||||
address_line_2=data.address_line_2,
|
|
||||||
post_town=data.post_town,
|
|
||||||
postcode=data.postcode,
|
|
||||||
region_code=data.region_code,
|
|
||||||
country_code=data.country_code,
|
|
||||||
language_code=data.language_code,
|
|
||||||
dwelling_type=data.dwelling_type,
|
|
||||||
property_type=data.property_type,
|
|
||||||
built_form=data.built_form,
|
|
||||||
tenure=data.tenure,
|
|
||||||
transaction_type=data.transaction_type,
|
|
||||||
inspection_date=data.inspection_date.isoformat(),
|
|
||||||
completion_date=(
|
|
||||||
data.completion_date.isoformat() if data.completion_date else None
|
|
||||||
),
|
|
||||||
registration_date=(
|
|
||||||
data.registration_date.isoformat() if data.registration_date else None
|
|
||||||
),
|
|
||||||
total_floor_area_m2=data.total_floor_area_m2,
|
|
||||||
measurement_type=data.measurement_type,
|
|
||||||
solar_water_heating=data.solar_water_heating,
|
|
||||||
has_hot_water_cylinder=data.has_hot_water_cylinder,
|
|
||||||
has_fixed_air_conditioning=data.has_fixed_air_conditioning,
|
|
||||||
has_conservatory=data.has_conservatory,
|
|
||||||
has_heated_separate_conservatory=data.has_heated_separate_conservatory,
|
|
||||||
conservatory_type=data.conservatory_type,
|
|
||||||
door_count=data.door_count,
|
|
||||||
wet_rooms_count=data.wet_rooms_count,
|
|
||||||
extensions_count=data.extensions_count,
|
|
||||||
heated_rooms_count=data.heated_rooms_count,
|
|
||||||
open_chimneys_count=data.open_chimneys_count,
|
|
||||||
habitable_rooms_count=data.habitable_rooms_count,
|
|
||||||
insulated_door_count=data.insulated_door_count,
|
|
||||||
cfl_fixed_lighting_bulbs_count=data.cfl_fixed_lighting_bulbs_count,
|
|
||||||
led_fixed_lighting_bulbs_count=data.led_fixed_lighting_bulbs_count,
|
|
||||||
incandescent_fixed_lighting_bulbs_count=data.incandescent_fixed_lighting_bulbs_count,
|
|
||||||
blocked_chimneys_count=data.blocked_chimneys_count,
|
|
||||||
draughtproofed_door_count=data.draughtproofed_door_count,
|
|
||||||
energy_rating_average=data.energy_rating_average,
|
|
||||||
low_energy_fixed_lighting_bulbs_count=data.low_energy_fixed_lighting_bulbs_count,
|
|
||||||
fixed_lighting_outlets_count=data.fixed_lighting_outlets_count,
|
|
||||||
low_energy_fixed_lighting_outlets_count=data.low_energy_fixed_lighting_outlets_count,
|
|
||||||
number_of_storeys=data.number_of_storeys,
|
|
||||||
any_unheated_rooms=data.any_unheated_rooms,
|
|
||||||
hydro=data.hydro,
|
|
||||||
photovoltaic_array=data.photovoltaic_array,
|
|
||||||
waste_water_heat_recovery=data.waste_water_heat_recovery,
|
|
||||||
pressure_test=data.pressure_test,
|
|
||||||
pressure_test_certificate_number=data.pressure_test_certificate_number,
|
|
||||||
percent_draughtproofed=data.percent_draughtproofed,
|
|
||||||
insulated_door_u_value=data.insulated_door_u_value,
|
|
||||||
multiple_glazed_proportion=data.multiple_glazed_proportion,
|
|
||||||
windows_transmission_u_value=(
|
|
||||||
data.windows_transmission_details.u_value
|
|
||||||
if data.windows_transmission_details
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
windows_transmission_data_source=(
|
|
||||||
data.windows_transmission_details.data_source
|
|
||||||
if data.windows_transmission_details
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
windows_transmission_solar_transmittance=(
|
|
||||||
data.windows_transmission_details.solar_transmittance
|
|
||||||
if data.windows_transmission_details
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
energy_mains_gas=es.mains_gas,
|
|
||||||
energy_meter_type=str(es.meter_type),
|
|
||||||
energy_pv_battery_count=es.pv_battery_count,
|
|
||||||
energy_wind_turbines_count=es.wind_turbines_count,
|
|
||||||
energy_gas_smart_meter_present=es.gas_smart_meter_present,
|
|
||||||
energy_is_dwelling_export_capable=es.is_dwelling_export_capable,
|
|
||||||
energy_wind_turbines_terrain_type=str(es.wind_turbines_terrain_type),
|
|
||||||
energy_electricity_smart_meter_present=es.electricity_smart_meter_present,
|
|
||||||
energy_pv_connection=(
|
|
||||||
str(es.pv_connection) if es.pv_connection is not None else None
|
|
||||||
),
|
|
||||||
energy_pv_percent_roof_area=(
|
|
||||||
pv.none_or_no_details.percent_roof_area if pv else None
|
|
||||||
),
|
|
||||||
energy_pv_battery_capacity=pvb.pv_battery.battery_capacity if pvb else None,
|
|
||||||
energy_wind_turbine_hub_height=wt.hub_height if wt else None,
|
|
||||||
energy_wind_turbine_rotor_diameter=wt.rotor_diameter if wt else None,
|
|
||||||
heating_cylinder_size=(
|
|
||||||
str(h.cylinder_size) if h.cylinder_size is not None else None
|
|
||||||
),
|
|
||||||
heating_water_heating_code=h.water_heating_code,
|
|
||||||
heating_water_heating_fuel=h.water_heating_fuel,
|
|
||||||
heating_immersion_heating_type=(
|
|
||||||
str(h.immersion_heating_type)
|
|
||||||
if h.immersion_heating_type is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
heating_cylinder_insulation_type=(
|
|
||||||
str(h.cylinder_insulation_type)
|
|
||||||
if h.cylinder_insulation_type is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
heating_cylinder_thermostat=h.cylinder_thermostat,
|
|
||||||
heating_secondary_fuel_type=h.secondary_fuel_type,
|
|
||||||
heating_secondary_heating_type=(
|
|
||||||
str(h.secondary_heating_type)
|
|
||||||
if h.secondary_heating_type is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
heating_cylinder_insulation_thickness_mm=h.cylinder_insulation_thickness_mm,
|
|
||||||
heating_wwhrs_index_number_1=h.instantaneous_wwhrs.wwhrs_index_number1,
|
|
||||||
heating_wwhrs_index_number_2=h.instantaneous_wwhrs.wwhrs_index_number2,
|
|
||||||
heating_shower_outlet_type=(
|
|
||||||
str(shower.shower_outlet_type) if shower else None
|
|
||||||
),
|
|
||||||
heating_shower_wwhrs=shower.shower_wwhrs if shower else None,
|
|
||||||
ventilation_type=v.ventilation_type if v else None,
|
|
||||||
ventilation_draught_lobby=v.draught_lobby if v else None,
|
|
||||||
ventilation_pressure_test=v.pressure_test if v else None,
|
|
||||||
ventilation_open_flues_count=v.open_flues_count if v else None,
|
|
||||||
ventilation_closed_flues_count=v.closed_flues_count if v else None,
|
|
||||||
ventilation_boiler_flues_count=v.boiler_flues_count if v else None,
|
|
||||||
ventilation_other_flues_count=v.other_flues_count if v else None,
|
|
||||||
ventilation_extract_fans_count=v.extract_fans_count if v else None,
|
|
||||||
ventilation_passive_vents_count=v.passive_vents_count if v else None,
|
|
||||||
ventilation_flueless_gas_fires_count=(
|
|
||||||
v.flueless_gas_fires_count if v else None
|
|
||||||
),
|
|
||||||
ventilation_in_pcdf_database=v.ventilation_in_pcdf_database if v else None,
|
|
||||||
mechanical_ventilation=data.mechanical_ventilation,
|
|
||||||
mechanical_vent_duct_type=data.mechanical_vent_duct_type,
|
|
||||||
mechanical_vent_duct_placement=data.mechanical_vent_duct_placement,
|
|
||||||
mechanical_vent_duct_insulation=data.mechanical_vent_duct_insulation,
|
|
||||||
mechanical_ventilation_index_number=data.mechanical_ventilation_index_number,
|
|
||||||
mechanical_vent_measured_installation=data.mechanical_vent_measured_installation,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcPropertyEnergyPerformanceModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_property_energy_performance"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_property_id: int = Field(
|
|
||||||
foreign_key="epc_property.id", nullable=False, unique=True
|
|
||||||
)
|
|
||||||
|
|
||||||
energy_rating_current: Optional[int] = Field(default=None)
|
|
||||||
energy_consumption_current: Optional[int] = Field(default=None)
|
|
||||||
environmental_impact_current: Optional[int] = Field(default=None)
|
|
||||||
heating_cost_current: Optional[float] = Field(default=None)
|
|
||||||
lighting_cost_current: Optional[float] = Field(default=None)
|
|
||||||
hot_water_cost_current: Optional[float] = Field(default=None)
|
|
||||||
co2_emissions_current: Optional[float] = Field(default=None)
|
|
||||||
co2_emissions_current_per_floor_area: Optional[int] = Field(default=None)
|
|
||||||
current_energy_efficiency_band: Optional[str] = Field(default=None)
|
|
||||||
energy_rating_potential: Optional[float] = Field(default=None)
|
|
||||||
energy_consumption_potential: Optional[int] = Field(default=None)
|
|
||||||
environmental_impact_potential: Optional[int] = Field(default=None)
|
|
||||||
heating_cost_potential: Optional[float] = Field(default=None)
|
|
||||||
lighting_cost_potential: Optional[float] = Field(default=None)
|
|
||||||
hot_water_cost_potential: Optional[float] = Field(default=None)
|
|
||||||
co2_emissions_potential: Optional[float] = Field(default=None)
|
|
||||||
potential_energy_efficiency_band: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_epc_property_data(
|
|
||||||
cls, data: EpcPropertyData, epc_property_id: int
|
|
||||||
) -> EpcPropertyEnergyPerformanceModel:
|
|
||||||
return cls(
|
|
||||||
epc_property_id=epc_property_id,
|
|
||||||
energy_rating_current=data.energy_rating_current,
|
|
||||||
energy_consumption_current=data.energy_consumption_current,
|
|
||||||
environmental_impact_current=data.environmental_impact_current,
|
|
||||||
heating_cost_current=data.heating_cost_current,
|
|
||||||
lighting_cost_current=data.lighting_cost_current,
|
|
||||||
hot_water_cost_current=data.hot_water_cost_current,
|
|
||||||
co2_emissions_current=data.co2_emissions_current,
|
|
||||||
co2_emissions_current_per_floor_area=data.co2_emissions_current_per_floor_area,
|
|
||||||
current_energy_efficiency_band=(
|
|
||||||
data.current_energy_efficiency_band.value
|
|
||||||
if data.current_energy_efficiency_band
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
energy_rating_potential=data.energy_rating_potential,
|
|
||||||
energy_consumption_potential=data.energy_consumption_potential,
|
|
||||||
environmental_impact_potential=data.environmental_impact_potential,
|
|
||||||
heating_cost_potential=data.heating_cost_potential,
|
|
||||||
lighting_cost_potential=data.lighting_cost_potential,
|
|
||||||
hot_water_cost_potential=data.hot_water_cost_potential,
|
|
||||||
co2_emissions_potential=data.co2_emissions_potential,
|
|
||||||
potential_energy_efficiency_band=(
|
|
||||||
data.potential_energy_efficiency_band.value
|
|
||||||
if data.potential_energy_efficiency_band
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcFlatDetailsModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_flat_details"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_property_id: int = Field(
|
|
||||||
foreign_key="epc_property.id", nullable=False, unique=True
|
|
||||||
)
|
|
||||||
|
|
||||||
level: int
|
|
||||||
top_storey: str
|
|
||||||
flat_location: int
|
|
||||||
heat_loss_corridor: int
|
|
||||||
storey_count: Optional[int] = Field(default=None)
|
|
||||||
unheated_corridor_length_m: Optional[int] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_domain(
|
|
||||||
cls, flat: SapFlatDetails, epc_property_id: int
|
|
||||||
) -> EpcFlatDetailsModel:
|
|
||||||
return cls(
|
|
||||||
epc_property_id=epc_property_id,
|
|
||||||
level=flat.level,
|
|
||||||
top_storey=flat.top_storey,
|
|
||||||
flat_location=flat.flat_location,
|
|
||||||
heat_loss_corridor=flat.heat_loss_corridor,
|
|
||||||
storey_count=flat.storey_count,
|
|
||||||
unheated_corridor_length_m=flat.unheated_corridor_length_m,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcMainHeatingDetailModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_main_heating_detail"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
|
|
||||||
|
|
||||||
has_fghrs: bool
|
|
||||||
main_fuel_type: str
|
|
||||||
heat_emitter_type: str
|
|
||||||
emitter_temperature: str
|
|
||||||
main_heating_control: str
|
|
||||||
fan_flue_present: Optional[bool] = Field(default=None)
|
|
||||||
boiler_flue_type: Optional[int] = Field(default=None)
|
|
||||||
boiler_ignition_type: Optional[int] = Field(default=None)
|
|
||||||
central_heating_pump_age: Optional[int] = Field(default=None)
|
|
||||||
central_heating_pump_age_str: Optional[str] = Field(default=None)
|
|
||||||
main_heating_index_number: Optional[int] = Field(default=None)
|
|
||||||
sap_main_heating_code: Optional[int] = Field(default=None)
|
|
||||||
main_heating_number: Optional[int] = Field(default=None)
|
|
||||||
main_heating_category: Optional[int] = Field(default=None)
|
|
||||||
main_heating_fraction: Optional[int] = Field(default=None)
|
|
||||||
main_heating_data_source: Optional[int] = Field(default=None)
|
|
||||||
condensing: Optional[bool] = Field(default=None)
|
|
||||||
weather_compensator: Optional[bool] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_domain(
|
|
||||||
cls, detail: MainHeatingDetail, epc_property_id: int
|
|
||||||
) -> EpcMainHeatingDetailModel:
|
|
||||||
return cls(
|
|
||||||
epc_property_id=epc_property_id,
|
|
||||||
has_fghrs=detail.has_fghrs,
|
|
||||||
main_fuel_type=str(detail.main_fuel_type),
|
|
||||||
heat_emitter_type=str(detail.heat_emitter_type),
|
|
||||||
emitter_temperature=str(detail.emitter_temperature),
|
|
||||||
main_heating_control=str(detail.main_heating_control),
|
|
||||||
fan_flue_present=detail.fan_flue_present,
|
|
||||||
boiler_flue_type=detail.boiler_flue_type,
|
|
||||||
boiler_ignition_type=detail.boiler_ignition_type,
|
|
||||||
central_heating_pump_age=detail.central_heating_pump_age,
|
|
||||||
central_heating_pump_age_str=detail.central_heating_pump_age_str,
|
|
||||||
main_heating_index_number=detail.main_heating_index_number,
|
|
||||||
sap_main_heating_code=detail.sap_main_heating_code,
|
|
||||||
main_heating_number=detail.main_heating_number,
|
|
||||||
main_heating_category=detail.main_heating_category,
|
|
||||||
main_heating_fraction=detail.main_heating_fraction,
|
|
||||||
main_heating_data_source=detail.main_heating_data_source,
|
|
||||||
condensing=detail.condensing,
|
|
||||||
weather_compensator=detail.weather_compensator,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcBuildingPartModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_building_part"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
|
|
||||||
|
|
||||||
identifier: str
|
|
||||||
construction_age_band: str
|
|
||||||
wall_construction: str
|
|
||||||
wall_insulation_type: str
|
|
||||||
wall_thickness_measured: bool
|
|
||||||
party_wall_construction: str
|
|
||||||
building_part_number: Optional[int] = Field(default=None)
|
|
||||||
wall_dry_lined: Optional[bool] = Field(default=None)
|
|
||||||
wall_thickness_mm: Optional[int] = Field(default=None)
|
|
||||||
wall_insulation_thickness: Optional[str] = Field(default=None)
|
|
||||||
floor_heat_loss: Optional[int] = Field(default=None)
|
|
||||||
floor_insulation_thickness: Optional[str] = Field(default=None)
|
|
||||||
flat_roof_insulation_thickness: Optional[str] = Field(default=None)
|
|
||||||
floor_type: Optional[str] = Field(default=None)
|
|
||||||
floor_construction_type: Optional[str] = Field(default=None)
|
|
||||||
floor_insulation_type_str: Optional[str] = Field(default=None)
|
|
||||||
floor_u_value_known: Optional[bool] = Field(default=None)
|
|
||||||
roof_construction: Optional[int] = Field(default=None)
|
|
||||||
roof_insulation_location: Optional[str] = Field(default=None)
|
|
||||||
roof_insulation_thickness: Optional[str] = Field(default=None)
|
|
||||||
room_in_roof_floor_area: Optional[float] = Field(default=None)
|
|
||||||
room_in_roof_construction_age_band: Optional[str] = Field(default=None)
|
|
||||||
alt_wall_1_area: Optional[float] = Field(default=None)
|
|
||||||
alt_wall_1_dry_lined: Optional[str] = Field(default=None)
|
|
||||||
alt_wall_1_construction: Optional[int] = Field(default=None)
|
|
||||||
alt_wall_1_insulation_type: Optional[int] = Field(default=None)
|
|
||||||
alt_wall_1_thickness_measured: Optional[str] = Field(default=None)
|
|
||||||
alt_wall_1_insulation_thickness: Optional[str] = Field(default=None)
|
|
||||||
alt_wall_2_area: Optional[float] = Field(default=None)
|
|
||||||
alt_wall_2_dry_lined: Optional[str] = Field(default=None)
|
|
||||||
alt_wall_2_construction: Optional[int] = Field(default=None)
|
|
||||||
alt_wall_2_insulation_type: Optional[int] = Field(default=None)
|
|
||||||
alt_wall_2_thickness_measured: Optional[str] = Field(default=None)
|
|
||||||
alt_wall_2_insulation_thickness: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_domain(
|
|
||||||
cls, part: SapBuildingPart, epc_property_id: int
|
|
||||||
) -> EpcBuildingPartModel:
|
|
||||||
rir = part.sap_room_in_roof
|
|
||||||
aw1 = part.sap_alternative_wall_1
|
|
||||||
aw2 = part.sap_alternative_wall_2
|
|
||||||
return cls(
|
|
||||||
epc_property_id=epc_property_id,
|
|
||||||
identifier=part.identifier.value,
|
|
||||||
construction_age_band=part.construction_age_band,
|
|
||||||
wall_construction=str(part.wall_construction),
|
|
||||||
wall_insulation_type=str(part.wall_insulation_type),
|
|
||||||
wall_thickness_measured=part.wall_thickness_measured,
|
|
||||||
party_wall_construction=str(part.party_wall_construction),
|
|
||||||
building_part_number=part.building_part_number,
|
|
||||||
wall_dry_lined=part.wall_dry_lined,
|
|
||||||
wall_thickness_mm=part.wall_thickness_mm,
|
|
||||||
wall_insulation_thickness=part.wall_insulation_thickness,
|
|
||||||
floor_heat_loss=part.floor_heat_loss,
|
|
||||||
floor_insulation_thickness=part.floor_insulation_thickness,
|
|
||||||
flat_roof_insulation_thickness=(
|
|
||||||
str(part.flat_roof_insulation_thickness)
|
|
||||||
if part.flat_roof_insulation_thickness is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
floor_type=part.floor_type,
|
|
||||||
floor_construction_type=part.floor_construction_type,
|
|
||||||
floor_insulation_type_str=part.floor_insulation_type_str,
|
|
||||||
floor_u_value_known=part.floor_u_value_known,
|
|
||||||
roof_construction=part.roof_construction,
|
|
||||||
roof_insulation_location=(
|
|
||||||
str(part.roof_insulation_location)
|
|
||||||
if part.roof_insulation_location is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
roof_insulation_thickness=(
|
|
||||||
str(part.roof_insulation_thickness)
|
|
||||||
if part.roof_insulation_thickness is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
room_in_roof_floor_area=float(rir.floor_area) if rir else None,
|
|
||||||
room_in_roof_construction_age_band=(
|
|
||||||
rir.construction_age_band if rir else None
|
|
||||||
),
|
|
||||||
alt_wall_1_area=aw1.wall_area if aw1 else None,
|
|
||||||
alt_wall_1_dry_lined=aw1.wall_dry_lined if aw1 else None,
|
|
||||||
alt_wall_1_construction=aw1.wall_construction if aw1 else None,
|
|
||||||
alt_wall_1_insulation_type=aw1.wall_insulation_type if aw1 else None,
|
|
||||||
alt_wall_1_thickness_measured=aw1.wall_thickness_measured if aw1 else None,
|
|
||||||
alt_wall_1_insulation_thickness=(
|
|
||||||
aw1.wall_insulation_thickness if aw1 else None
|
|
||||||
),
|
|
||||||
alt_wall_2_area=aw2.wall_area if aw2 else None,
|
|
||||||
alt_wall_2_dry_lined=aw2.wall_dry_lined if aw2 else None,
|
|
||||||
alt_wall_2_construction=aw2.wall_construction if aw2 else None,
|
|
||||||
alt_wall_2_insulation_type=aw2.wall_insulation_type if aw2 else None,
|
|
||||||
alt_wall_2_thickness_measured=aw2.wall_thickness_measured if aw2 else None,
|
|
||||||
alt_wall_2_insulation_thickness=(
|
|
||||||
aw2.wall_insulation_thickness if aw2 else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcFloorDimensionModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_floor_dimension"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_building_part_id: int = Field(
|
|
||||||
foreign_key="epc_building_part.id", nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
floor: Optional[int] = Field(default=None)
|
|
||||||
room_height_m: float
|
|
||||||
total_floor_area_m2: float
|
|
||||||
party_wall_length_m: float
|
|
||||||
heat_loss_perimeter_m: float
|
|
||||||
floor_insulation: Optional[int] = Field(default=None)
|
|
||||||
floor_construction: Optional[int] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_domain(
|
|
||||||
cls, dim: SapFloorDimension, epc_building_part_id: int
|
|
||||||
) -> EpcFloorDimensionModel:
|
|
||||||
return cls(
|
|
||||||
epc_building_part_id=epc_building_part_id,
|
|
||||||
floor=dim.floor,
|
|
||||||
room_height_m=dim.room_height_m,
|
|
||||||
total_floor_area_m2=dim.total_floor_area_m2,
|
|
||||||
party_wall_length_m=dim.party_wall_length_m,
|
|
||||||
heat_loss_perimeter_m=dim.heat_loss_perimeter_m,
|
|
||||||
floor_insulation=dim.floor_insulation,
|
|
||||||
floor_construction=dim.floor_construction,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcWindowModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_window"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
|
|
||||||
|
|
||||||
frame_material: Optional[str] = Field(default=None)
|
|
||||||
glazing_gap: str
|
|
||||||
orientation: str
|
|
||||||
window_type: str
|
|
||||||
glazing_type: str
|
|
||||||
window_width: float
|
|
||||||
window_height: float
|
|
||||||
draught_proofed: bool
|
|
||||||
window_location: str
|
|
||||||
window_wall_type: str
|
|
||||||
permanent_shutters_present: bool
|
|
||||||
frame_factor: Optional[float] = Field(default=None)
|
|
||||||
permanent_shutters_insulated: Optional[str] = Field(default=None)
|
|
||||||
transmission_u_value: Optional[float] = Field(default=None)
|
|
||||||
transmission_data_source: Optional[str] = Field(default=None)
|
|
||||||
transmission_solar_transmittance: Optional[float] = Field(default=None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_domain(cls, window: SapWindow, epc_property_id: int) -> EpcWindowModel:
|
|
||||||
td = window.window_transmission_details
|
|
||||||
return cls(
|
|
||||||
epc_property_id=epc_property_id,
|
|
||||||
frame_material=window.frame_material,
|
|
||||||
glazing_gap=str(window.glazing_gap),
|
|
||||||
orientation=str(window.orientation),
|
|
||||||
window_type=str(window.window_type),
|
|
||||||
glazing_type=str(window.glazing_type),
|
|
||||||
window_width=window.window_width,
|
|
||||||
window_height=window.window_height,
|
|
||||||
draught_proofed=bool(window.draught_proofed),
|
|
||||||
window_location=str(window.window_location),
|
|
||||||
window_wall_type=str(window.window_wall_type),
|
|
||||||
permanent_shutters_present=bool(window.permanent_shutters_present),
|
|
||||||
frame_factor=window.frame_factor,
|
|
||||||
permanent_shutters_insulated=window.permanent_shutters_insulated,
|
|
||||||
transmission_u_value=td.u_value if td else None,
|
|
||||||
transmission_data_source=td.data_source if td else None,
|
|
||||||
transmission_solar_transmittance=td.solar_transmittance if td else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EpcEnergyElementModel(SQLModel, table=True):
|
|
||||||
__tablename__ = "epc_energy_element"
|
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
|
|
||||||
|
|
||||||
element_type: str # roof | wall | floor | main_heating | window | lighting | hot_water | secondary_heating | main_heating_controls
|
|
||||||
description: str
|
|
||||||
energy_efficiency_rating: int
|
|
||||||
environmental_efficiency_rating: int
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_domain(
|
|
||||||
cls, element: EnergyElement, element_type: str, epc_property_id: int
|
|
||||||
) -> EpcEnergyElementModel:
|
|
||||||
return cls(
|
|
||||||
epc_property_id=epc_property_id,
|
|
||||||
element_type=element_type,
|
|
||||||
description=element.description,
|
|
||||||
energy_efficiency_rating=element.energy_efficiency_rating,
|
|
||||||
environmental_efficiency_rating=element.environmental_efficiency_rating,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
167
docs/migrations/epc-property-round-trip-fidelity.md
Normal file
167
docs/migrations/epc-property-round-trip-fidelity.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# EPC persistence schema gaps — migrations for round-trip fidelity
|
||||||
|
|
||||||
|
**Context:** Slice 1 (Hestia-Homes/Model#1129) of the `ara_first_run` rebuild. The round-trip
|
||||||
|
fidelity test (`EpcPropertyData → epc_property tables → reload → EpcPropertyData`, deep-equality)
|
||||||
|
surfaced that the current `epc_property` schema stores only a **partial, partly type-lossy
|
||||||
|
projection** of the `EpcPropertyData` domain object. This document lists every gap and the
|
||||||
|
migration needed to close it, so the schema (FE-owned for some tables) can be updated.
|
||||||
|
|
||||||
|
We can make the column/table changes on the **SQLModel definitions** in
|
||||||
|
`infrastructure/postgres/epc_property_table.py` directly — tests build their schema from those
|
||||||
|
models via `SQLModel.metadata.create_all`, so they don't need the live DB. The live migrations
|
||||||
|
listed here are what must be applied wherever the physical tables are owned.
|
||||||
|
|
||||||
|
**`epc_cache` relationship:** the raw gov-API JSON response is retained in the `epc_cache` table,
|
||||||
|
so the *source* is always recoverable even where the structured `epc_property` projection is
|
||||||
|
lossy. That makes these gaps "the structured store is incomplete" rather than "data is lost
|
||||||
|
forever" — but the modelling pipeline reads the structured `epc_property`, not the raw cache, so
|
||||||
|
the gaps below still block faithful modelling and must be closed.
|
||||||
|
|
||||||
|
Priority key: **P0** modelling needs it now · **P1** needed soon · **P2** completeness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status after Slice 1 (#1129)
|
||||||
|
|
||||||
|
The round-trip test passes over the persisted projection for RdSAP-Schema-21.0.0 and 21.0.1.
|
||||||
|
The following were **applied on the SQLModel** (`infrastructure/postgres/epc_property_table.py`)
|
||||||
|
and **still require the matching DB migration** wherever the physical tables live:
|
||||||
|
|
||||||
|
- **§1 JSONB** — all `Union` code columns converted (`epc_property`: `heating_cylinder_size`,
|
||||||
|
`heating_immersion_heating_type`, `heating_cylinder_insulation_type`,
|
||||||
|
`heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection`;
|
||||||
|
`epc_main_heating_detail`: `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`,
|
||||||
|
`main_heating_control`; `epc_building_part`: `wall_construction`, `wall_insulation_type`,
|
||||||
|
`party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`,
|
||||||
|
`roof_insulation_thickness`; `epc_window`: `glazing_gap`, `orientation`, `window_type`,
|
||||||
|
`glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`,
|
||||||
|
`permanent_shutters_present`, `transmission_data_source`).
|
||||||
|
- **New scalar columns** — `epc_property`: `heating_number_baths`, `heating_number_baths_wwhrs`,
|
||||||
|
`heating_electric_shower_count`, `heating_mixer_shower_count`,
|
||||||
|
`mechanical_vent_duct_insulation_level`, `addendum_stone_walls`, `addendum_system_build`,
|
||||||
|
`addendum_numbers` (JSONB), `ventilation_present`, `ventilation_sheltered_sides`,
|
||||||
|
`ventilation_has_suspended_timber_floor`, `ventilation_suspended_timber_floor_sealed`,
|
||||||
|
`ventilation_has_draught_lobby`, `ventilation_air_permeability_ap4_m3_h_m2`,
|
||||||
|
`ventilation_mechanical_ventilation_kind`; `epc_building_part`: `roof_construction_type`,
|
||||||
|
`curtain_wall_age`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Type fidelity — convert `Union[int, str]` code columns to JSONB
|
||||||
|
|
||||||
|
These columns hold SAP/RdSAP categorical codes that are **`int` from the gov API** and **`str`
|
||||||
|
from Site Notes** (`Union[int, str]` in the domain). The forward mapper currently coerces them
|
||||||
|
with `str(...)` (and `bool(...)` for two window flags), so an API `int` of `26` is stored as
|
||||||
|
`"26"` and cannot be recovered. Convert each to **JSONB** and drop the `str()`/`bool()` coercion
|
||||||
|
in the forward mapper so the Python type round-trips exactly (JSON scalars preserve `int` vs
|
||||||
|
`str` vs `bool` vs `null`). **P0** — these feed the SAP10 calculator's int-keyed dispatch.
|
||||||
|
|
||||||
|
| Table | Columns |
|
||||||
|
|---|---|
|
||||||
|
| `epc_property` | `heating_cylinder_size`, `heating_immersion_heating_type`, `heating_cylinder_insulation_type`, `heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection` |
|
||||||
|
| `epc_main_heating_detail` | `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`, `main_heating_control` |
|
||||||
|
| `epc_building_part` | `wall_construction`, `wall_insulation_type`, `party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`, `roof_insulation_thickness` |
|
||||||
|
| `epc_window` | `glazing_gap`, `orientation`, `window_type`, `glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`, `permanent_shutters_present` |
|
||||||
|
|
||||||
|
(`energy_meter_type` and `energy_wind_turbines_terrain_type` are `str` in the domain — leave as
|
||||||
|
`TEXT`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Not stored at all — new tables
|
||||||
|
|
||||||
|
### 2.1 `epc_renewable_heat_incentive` — **P0**
|
||||||
|
Maps `EpcPropertyData.renewable_heat_incentive` (`RenewableHeatIncentive`). Carries the **baseline
|
||||||
|
space-heating and hot-water kWh** that EPC Energy Derivation consumes — the single most important
|
||||||
|
gap. One row per `epc_property`.
|
||||||
|
|
||||||
|
| Column | Type | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `epc_property_id` | FK → `epc_property.id`, unique | |
|
||||||
|
| `space_heating_kwh` | float | `space_heating_kwh` |
|
||||||
|
| `water_heating_kwh` | float | `water_heating_kwh` |
|
||||||
|
| `impact_of_loft_insulation_kwh` | float, null | `impact_of_loft_insulation_kwh` |
|
||||||
|
| `impact_of_cavity_insulation_kwh` | float, null | `impact_of_cavity_insulation_kwh` |
|
||||||
|
| `impact_of_solid_wall_insulation_kwh` | float, null | `impact_of_solid_wall_insulation_kwh` |
|
||||||
|
|
||||||
|
### 2.2 `epc_room_in_roof` (+ `epc_room_in_roof_surface`) — **P1**
|
||||||
|
`SapBuildingPart.sap_room_in_roof` (`SapRoomInRoof`) is currently flattened to just
|
||||||
|
`room_in_roof_floor_area` + `room_in_roof_construction_age_band` on `epc_building_part`, dropping
|
||||||
|
the Type-2 geometry and the Detailed-measurement surfaces. Replace with a child table of
|
||||||
|
`epc_building_part`:
|
||||||
|
|
||||||
|
`epc_room_in_roof`: `epc_building_part_id` (FK, unique), `floor_area`, `construction_age_band`,
|
||||||
|
`common_wall_length_m`, `common_wall_height_m`, `gable_1_length_m`, `gable_1_height_m`,
|
||||||
|
`gable_2_length_m`, `gable_2_height_m`.
|
||||||
|
|
||||||
|
`epc_room_in_roof_surface` (0..n per RIR, from `detailed_surfaces: List[SapRoomInRoofSurface]`):
|
||||||
|
`epc_room_in_roof_id` (FK), `kind`, `area_m2`, `insulation_thickness_mm` (null),
|
||||||
|
`insulation_type` (null), `u_value` (null).
|
||||||
|
|
||||||
|
### 2.3 `epc_photovoltaic_array` — **P1**
|
||||||
|
`SapEnergySource.photovoltaic_arrays: List[PhotovoltaicArray]` (measured PV) is not stored at all
|
||||||
|
— only the `percent_roof_area` fallback is. One row per array: `epc_property_id` (FK),
|
||||||
|
`peak_power`, `pitch`, `orientation`, `overshading`.
|
||||||
|
|
||||||
|
### 2.4 `epc_roof_window` — **P2**
|
||||||
|
`EpcPropertyData.sap_roof_windows: List[SapRoofWindow]` not stored. One row per roof window:
|
||||||
|
`epc_property_id` (FK), `area_m2`, `u_value_raw`, `orientation`, `pitch_deg`, `g_perpendicular`,
|
||||||
|
`frame_factor`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Not stored at all — new columns
|
||||||
|
|
||||||
|
### 3.1 `epc_property` additions
|
||||||
|
| Column | Type | Source | Pri |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `addendum_stone_walls` | bool, null | `addendum.stone_walls` | P2 |
|
||||||
|
| `addendum_system_build` | bool, null | `addendum.system_build` | P2 |
|
||||||
|
| `addendum_numbers` | JSONB, null | `addendum.addendum_numbers` (`List[int]`) | P2 |
|
||||||
|
| `lzc_energy_sources` | JSONB, null | `lzc_energy_sources` (`List[int]`) | P2 |
|
||||||
|
| `solar_hw_collector_orientation` | text, null | `solar_hw_collector_orientation` | P1 |
|
||||||
|
| `solar_hw_collector_pitch_deg` | int, null | `solar_hw_collector_pitch_deg` | P1 |
|
||||||
|
| `solar_hw_overshading` | text, null | `solar_hw_overshading` | P1 |
|
||||||
|
| `extract_fans_count` | int, null | top-level `extract_fans_count` (distinct from the `ventilation_*` one) | P2 |
|
||||||
|
| `mechanical_vent_duct_insulation_level` | int, null | `mechanical_vent_duct_insulation_level` | P2 |
|
||||||
|
|
||||||
|
### 3.2 `epc_building_part` additions
|
||||||
|
| Column | Type | Source | Pri |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `roof_construction_type` | text, null | `roof_construction_type` (Site-Notes str) | P1 |
|
||||||
|
| `curtain_wall_age` | text, null | `curtain_wall_age` (RdSAP §5.18) | P1 |
|
||||||
|
| `alt_wall_1_u_value` | float, null | `sap_alternative_wall_1.u_value` | P1 |
|
||||||
|
| `alt_wall_1_thickness_mm` | int, null | `sap_alternative_wall_1.wall_thickness_mm` | P1 |
|
||||||
|
| `alt_wall_2_u_value` | float, null | `sap_alternative_wall_2.u_value` | P1 |
|
||||||
|
| `alt_wall_2_thickness_mm` | int, null | `sap_alternative_wall_2.wall_thickness_mm` | P1 |
|
||||||
|
|
||||||
|
### 3.3 `epc_floor_dimension` additions
|
||||||
|
| Column | Type | Source | Pri |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `is_exposed_floor` | bool, default false | `SapFloorDimension.is_exposed_floor` | P1 |
|
||||||
|
| `is_above_partially_heated_space` | bool, default false | `SapFloorDimension.is_above_partially_heated_space` | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mapper-only gaps (no schema change required)
|
||||||
|
|
||||||
|
The table can already hold these; the **save mapper** simply doesn't write them. Fix in the
|
||||||
|
forward mapper, not the DB:
|
||||||
|
|
||||||
|
- **`air_tightness`** (`EnergyElement`) — `epc_energy_element.element_type` is a free string, so add
|
||||||
|
an `"air_tightness"` element type to the save loop. **P1.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scope note
|
||||||
|
|
||||||
|
Slice 1 (#1129) asserts faithful round-trip over the **projection the schema is meant to store**,
|
||||||
|
after applying §1 (JSONB) and the straightforward §3/§4 additions on the SQLModel. The structural
|
||||||
|
new tables in §2 (RHI, room-in-roof, PV arrays, roof windows) are tracked as their own follow-up
|
||||||
|
issues — `epc_renewable_heat_incentive` (§2.1) first, as it unblocks EPC Energy Derivation. Each
|
||||||
|
gap above should become a checkbox on the relevant issue so nothing is silently dropped.
|
||||||
0
repositories/epc/__init__.py
Normal file
0
repositories/epc/__init__.py
Normal file
608
repositories/epc/epc_postgres_repository.py
Normal file
608
repositories/epc/epc_postgres_repository.py
Normal file
|
|
@ -0,0 +1,608 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional, TypeVar
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
Addendum,
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EnergyElement,
|
||||||
|
EpcPropertyData,
|
||||||
|
InstantaneousWwhrs,
|
||||||
|
MainHeatingDetail,
|
||||||
|
PhotovoltaicSupply,
|
||||||
|
PhotovoltaicSupplyNoneOrNoDetails,
|
||||||
|
PvBatteries,
|
||||||
|
PvBattery,
|
||||||
|
SapAlternativeWall,
|
||||||
|
SapBuildingPart,
|
||||||
|
SapEnergySource,
|
||||||
|
SapFlatDetails,
|
||||||
|
SapFloorDimension,
|
||||||
|
SapHeating,
|
||||||
|
SapRoomInRoof,
|
||||||
|
SapVentilation,
|
||||||
|
SapWindow,
|
||||||
|
ShowerOutlet,
|
||||||
|
ShowerOutlets,
|
||||||
|
WindowsTransmissionDetails,
|
||||||
|
WindowTransmissionDetails,
|
||||||
|
WindTurbineDetails,
|
||||||
|
)
|
||||||
|
from infrastructure.postgres.epc_property_table import (
|
||||||
|
EpcBuildingPartModel,
|
||||||
|
EpcEnergyElementModel,
|
||||||
|
EpcFlatDetailsModel,
|
||||||
|
EpcFloorDimensionModel,
|
||||||
|
EpcMainHeatingDetailModel,
|
||||||
|
EpcPropertyEnergyPerformanceModel,
|
||||||
|
EpcPropertyModel,
|
||||||
|
EpcWindowModel,
|
||||||
|
)
|
||||||
|
from repositories.epc.epc_repository import EpcRepository
|
||||||
|
from utilities.private import private
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def _require(value: Optional[_T], field: str) -> _T:
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"epc_property row is missing required field {field!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class EpcPostgresRepository(EpcRepository):
|
||||||
|
"""Maps EpcPropertyData to/from the epc_property parent row + child tables.
|
||||||
|
|
||||||
|
Round-trip fidelity over the persisted projection is pinned by the Slice-1
|
||||||
|
round-trip test (Hestia-Homes/Model#1129). Fields the schema does not yet
|
||||||
|
store (see docs/migrations/epc-property-round-trip-fidelity.md §2) reconstruct
|
||||||
|
as their dataclass defaults — tracked as follow-up migrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
data: EpcPropertyData,
|
||||||
|
property_id: Optional[int] = None,
|
||||||
|
portfolio_id: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
parent = EpcPropertyModel.from_epc_property_data(
|
||||||
|
data, property_id=property_id, portfolio_id=portfolio_id
|
||||||
|
)
|
||||||
|
self._session.add(parent)
|
||||||
|
self._session.flush()
|
||||||
|
epc_property_id = _require(parent.id, "id")
|
||||||
|
|
||||||
|
self._session.add(
|
||||||
|
EpcPropertyEnergyPerformanceModel.from_epc_property_data(
|
||||||
|
data, epc_property_id=epc_property_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for detail in data.sap_heating.main_heating_details:
|
||||||
|
self._session.add(
|
||||||
|
EpcMainHeatingDetailModel.from_domain(detail, epc_property_id)
|
||||||
|
)
|
||||||
|
for part in data.sap_building_parts:
|
||||||
|
bp = EpcBuildingPartModel.from_domain(part, epc_property_id)
|
||||||
|
self._session.add(bp)
|
||||||
|
self._session.flush()
|
||||||
|
bp_id = _require(bp.id, "epc_building_part.id")
|
||||||
|
for dim in part.sap_floor_dimensions:
|
||||||
|
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 element_type, elements in (
|
||||||
|
("roof", data.roofs),
|
||||||
|
("wall", data.walls),
|
||||||
|
("floor", data.floors),
|
||||||
|
("main_heating", data.main_heating),
|
||||||
|
):
|
||||||
|
for el in elements:
|
||||||
|
self._session.add(
|
||||||
|
EpcEnergyElementModel.from_domain(el, element_type, epc_property_id)
|
||||||
|
)
|
||||||
|
for el, element_type in (
|
||||||
|
(data.window, "window"),
|
||||||
|
(data.lighting, "lighting"),
|
||||||
|
(data.hot_water, "hot_water"),
|
||||||
|
(data.secondary_heating, "secondary_heating"),
|
||||||
|
(data.main_heating_controls, "main_heating_controls"),
|
||||||
|
):
|
||||||
|
if el is not None:
|
||||||
|
self._session.add(
|
||||||
|
EpcEnergyElementModel.from_domain(el, element_type, epc_property_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.sap_flat_details is not None:
|
||||||
|
self._session.add(
|
||||||
|
EpcFlatDetailsModel.from_domain(data.sap_flat_details, epc_property_id)
|
||||||
|
)
|
||||||
|
return epc_property_id
|
||||||
|
|
||||||
|
def get(self, epc_property_id: int) -> EpcPropertyData:
|
||||||
|
p = self._session.get(EpcPropertyModel, epc_property_id)
|
||||||
|
if p is None:
|
||||||
|
raise ValueError(f"epc_property {epc_property_id} not found")
|
||||||
|
perf = self._session.exec(
|
||||||
|
select(EpcPropertyEnergyPerformanceModel).where(
|
||||||
|
EpcPropertyEnergyPerformanceModel.epc_property_id == epc_property_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
elements = list(
|
||||||
|
self._session.exec(
|
||||||
|
select(EpcEnergyElementModel)
|
||||||
|
.where(EpcEnergyElementModel.epc_property_id == epc_property_id)
|
||||||
|
.order_by(EpcEnergyElementModel.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
heating_rows = list(
|
||||||
|
self._session.exec(
|
||||||
|
select(EpcMainHeatingDetailModel)
|
||||||
|
.where(EpcMainHeatingDetailModel.epc_property_id == epc_property_id)
|
||||||
|
.order_by(EpcMainHeatingDetailModel.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
part_rows = list(
|
||||||
|
self._session.exec(
|
||||||
|
select(EpcBuildingPartModel)
|
||||||
|
.where(EpcBuildingPartModel.epc_property_id == epc_property_id)
|
||||||
|
.order_by(EpcBuildingPartModel.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
flat_row = self._session.exec(
|
||||||
|
select(EpcFlatDetailsModel).where(
|
||||||
|
EpcFlatDetailsModel.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]
|
||||||
|
|
||||||
|
def _single(element_type: str) -> Optional[EnergyElement]:
|
||||||
|
found = _elements(element_type)
|
||||||
|
return found[0] if found else None
|
||||||
|
|
||||||
|
return EpcPropertyData(
|
||||||
|
dwelling_type=p.dwelling_type,
|
||||||
|
inspection_date=date.fromisoformat(p.inspection_date),
|
||||||
|
tenure=p.tenure,
|
||||||
|
transaction_type=p.transaction_type,
|
||||||
|
address_line_1=_require(p.address_line_1, "address_line_1"),
|
||||||
|
postcode=_require(p.postcode, "postcode"),
|
||||||
|
post_town=_require(p.post_town, "post_town"),
|
||||||
|
roofs=_elements("roof"),
|
||||||
|
walls=_elements("wall"),
|
||||||
|
floors=_elements("floor"),
|
||||||
|
main_heating=_elements("main_heating"),
|
||||||
|
door_count=p.door_count,
|
||||||
|
sap_heating=self._to_sap_heating(p, heating_rows),
|
||||||
|
sap_windows=[self._to_window(w) for w in self._windows(epc_property_id)],
|
||||||
|
sap_energy_source=self._to_energy_source(p),
|
||||||
|
sap_building_parts=[self._to_building_part(bp) for bp in part_rows],
|
||||||
|
solar_water_heating=p.solar_water_heating,
|
||||||
|
has_hot_water_cylinder=p.has_hot_water_cylinder,
|
||||||
|
has_fixed_air_conditioning=p.has_fixed_air_conditioning,
|
||||||
|
wet_rooms_count=p.wet_rooms_count,
|
||||||
|
extensions_count=p.extensions_count,
|
||||||
|
heated_rooms_count=p.heated_rooms_count,
|
||||||
|
open_chimneys_count=p.open_chimneys_count,
|
||||||
|
habitable_rooms_count=p.habitable_rooms_count,
|
||||||
|
insulated_door_count=p.insulated_door_count,
|
||||||
|
cfl_fixed_lighting_bulbs_count=p.cfl_fixed_lighting_bulbs_count,
|
||||||
|
led_fixed_lighting_bulbs_count=p.led_fixed_lighting_bulbs_count,
|
||||||
|
incandescent_fixed_lighting_bulbs_count=p.incandescent_fixed_lighting_bulbs_count,
|
||||||
|
total_floor_area_m2=p.total_floor_area_m2,
|
||||||
|
assessment_type=p.assessment_type,
|
||||||
|
sap_version=p.sap_version,
|
||||||
|
uprn=p.uprn,
|
||||||
|
status=p.status,
|
||||||
|
window=_single("window"),
|
||||||
|
lighting=_single("lighting"),
|
||||||
|
hot_water=_single("hot_water"),
|
||||||
|
secondary_heating=_single("secondary_heating"),
|
||||||
|
main_heating_controls=_single("main_heating_controls"),
|
||||||
|
schema_type=p.schema_type,
|
||||||
|
schema_versions_original=p.schema_versions_original,
|
||||||
|
report_type=p.report_type,
|
||||||
|
report_reference=p.report_reference,
|
||||||
|
uprn_source=p.uprn_source,
|
||||||
|
address_line_2=p.address_line_2,
|
||||||
|
region_code=p.region_code,
|
||||||
|
country_code=p.country_code,
|
||||||
|
built_form=p.built_form,
|
||||||
|
property_type=p.property_type,
|
||||||
|
pressure_test=p.pressure_test,
|
||||||
|
language_code=p.language_code,
|
||||||
|
completion_date=(
|
||||||
|
date.fromisoformat(p.completion_date) if p.completion_date else None
|
||||||
|
),
|
||||||
|
registration_date=(
|
||||||
|
date.fromisoformat(p.registration_date)
|
||||||
|
if p.registration_date
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
measurement_type=p.measurement_type,
|
||||||
|
conservatory_type=p.conservatory_type,
|
||||||
|
has_conservatory=p.has_conservatory,
|
||||||
|
has_heated_separate_conservatory=p.has_heated_separate_conservatory,
|
||||||
|
blocked_chimneys_count=p.blocked_chimneys_count,
|
||||||
|
energy_rating_average=p.energy_rating_average,
|
||||||
|
current_energy_efficiency_band=(
|
||||||
|
Epc(perf.current_energy_efficiency_band)
|
||||||
|
if perf and perf.current_energy_efficiency_band
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
environmental_impact_current=(
|
||||||
|
perf.environmental_impact_current if perf else None
|
||||||
|
),
|
||||||
|
heating_cost_current=perf.heating_cost_current if perf else None,
|
||||||
|
co2_emissions_current=perf.co2_emissions_current if perf else None,
|
||||||
|
energy_consumption_current=(
|
||||||
|
perf.energy_consumption_current if perf else None
|
||||||
|
),
|
||||||
|
energy_rating_current=perf.energy_rating_current if perf else None,
|
||||||
|
lighting_cost_current=perf.lighting_cost_current if perf else None,
|
||||||
|
hot_water_cost_current=perf.hot_water_cost_current if perf else None,
|
||||||
|
insulated_door_u_value=p.insulated_door_u_value,
|
||||||
|
mechanical_ventilation=p.mechanical_ventilation,
|
||||||
|
percent_draughtproofed=p.percent_draughtproofed,
|
||||||
|
heating_cost_potential=perf.heating_cost_potential if perf else None,
|
||||||
|
co2_emissions_potential=perf.co2_emissions_potential if perf else None,
|
||||||
|
energy_consumption_potential=(
|
||||||
|
perf.energy_consumption_potential if perf else None
|
||||||
|
),
|
||||||
|
energy_rating_potential=perf.energy_rating_potential if perf else None,
|
||||||
|
lighting_cost_potential=perf.lighting_cost_potential if perf else None,
|
||||||
|
hot_water_cost_potential=perf.hot_water_cost_potential if perf else None,
|
||||||
|
environmental_impact_potential=(
|
||||||
|
perf.environmental_impact_potential if perf else None
|
||||||
|
),
|
||||||
|
potential_energy_efficiency_band=(
|
||||||
|
Epc(perf.potential_energy_efficiency_band)
|
||||||
|
if perf and perf.potential_energy_efficiency_band
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
draughtproofed_door_count=p.draughtproofed_door_count,
|
||||||
|
mechanical_vent_duct_type=p.mechanical_vent_duct_type,
|
||||||
|
windows_transmission_details=(
|
||||||
|
WindowsTransmissionDetails(
|
||||||
|
u_value=p.windows_transmission_u_value,
|
||||||
|
data_source=_require(
|
||||||
|
p.windows_transmission_data_source,
|
||||||
|
"windows_transmission_data_source",
|
||||||
|
),
|
||||||
|
solar_transmittance=_require(
|
||||||
|
p.windows_transmission_solar_transmittance,
|
||||||
|
"windows_transmission_solar_transmittance",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if p.windows_transmission_u_value is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
multiple_glazed_proportion=p.multiple_glazed_proportion,
|
||||||
|
calculation_software_version=p.calculation_software_version,
|
||||||
|
mechanical_vent_duct_placement=p.mechanical_vent_duct_placement,
|
||||||
|
mechanical_vent_duct_insulation=p.mechanical_vent_duct_insulation,
|
||||||
|
pressure_test_certificate_number=p.pressure_test_certificate_number,
|
||||||
|
mechanical_ventilation_index_number=p.mechanical_ventilation_index_number,
|
||||||
|
mechanical_vent_measured_installation=p.mechanical_vent_measured_installation,
|
||||||
|
co2_emissions_current_per_floor_area=(
|
||||||
|
perf.co2_emissions_current_per_floor_area if perf else None
|
||||||
|
),
|
||||||
|
low_energy_fixed_lighting_bulbs_count=p.low_energy_fixed_lighting_bulbs_count,
|
||||||
|
sap_flat_details=(
|
||||||
|
self._to_flat_details(flat_row) if flat_row is not None else None
|
||||||
|
),
|
||||||
|
fixed_lighting_outlets_count=p.fixed_lighting_outlets_count,
|
||||||
|
low_energy_fixed_lighting_outlets_count=p.low_energy_fixed_lighting_outlets_count,
|
||||||
|
sap_ventilation=self._to_ventilation(p),
|
||||||
|
number_of_storeys=p.number_of_storeys,
|
||||||
|
any_unheated_rooms=p.any_unheated_rooms,
|
||||||
|
waste_water_heat_recovery=p.waste_water_heat_recovery,
|
||||||
|
hydro=p.hydro,
|
||||||
|
photovoltaic_array=p.photovoltaic_array,
|
||||||
|
mechanical_vent_duct_insulation_level=p.mechanical_vent_duct_insulation_level,
|
||||||
|
addendum=(
|
||||||
|
Addendum(
|
||||||
|
stone_walls=p.addendum_stone_walls,
|
||||||
|
system_build=p.addendum_system_build,
|
||||||
|
addendum_numbers=p.addendum_numbers,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
p.addendum_stone_walls is not None
|
||||||
|
or p.addendum_system_build is not None
|
||||||
|
or p.addendum_numbers is not None
|
||||||
|
)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _windows(self, epc_property_id: int) -> list[EpcWindowModel]:
|
||||||
|
return list(
|
||||||
|
self._session.exec(
|
||||||
|
select(EpcWindowModel)
|
||||||
|
.where(EpcWindowModel.epc_property_id == epc_property_id)
|
||||||
|
.order_by(EpcWindowModel.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_energy_element(self, e: EpcEnergyElementModel) -> EnergyElement:
|
||||||
|
return EnergyElement(
|
||||||
|
description=e.description,
|
||||||
|
energy_efficiency_rating=e.energy_efficiency_rating,
|
||||||
|
environmental_efficiency_rating=e.environmental_efficiency_rating,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_sap_heating(
|
||||||
|
self, p: EpcPropertyModel, heating_rows: list[EpcMainHeatingDetailModel]
|
||||||
|
) -> SapHeating:
|
||||||
|
shower_outlets = (
|
||||||
|
ShowerOutlets(
|
||||||
|
shower_outlet=ShowerOutlet(
|
||||||
|
shower_outlet_type=p.heating_shower_outlet_type,
|
||||||
|
shower_wwhrs=p.heating_shower_wwhrs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if p.heating_shower_outlet_type is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return SapHeating(
|
||||||
|
instantaneous_wwhrs=InstantaneousWwhrs(
|
||||||
|
wwhrs_index_number1=p.heating_wwhrs_index_number_1,
|
||||||
|
wwhrs_index_number2=p.heating_wwhrs_index_number_2,
|
||||||
|
),
|
||||||
|
main_heating_details=[self._to_main_heating(m) for m in heating_rows],
|
||||||
|
has_fixed_air_conditioning=p.has_fixed_air_conditioning,
|
||||||
|
cylinder_size=p.heating_cylinder_size,
|
||||||
|
water_heating_code=p.heating_water_heating_code,
|
||||||
|
water_heating_fuel=p.heating_water_heating_fuel,
|
||||||
|
immersion_heating_type=p.heating_immersion_heating_type,
|
||||||
|
shower_outlets=shower_outlets,
|
||||||
|
cylinder_insulation_type=p.heating_cylinder_insulation_type,
|
||||||
|
cylinder_thermostat=p.heating_cylinder_thermostat,
|
||||||
|
secondary_fuel_type=p.heating_secondary_fuel_type,
|
||||||
|
secondary_heating_type=p.heating_secondary_heating_type,
|
||||||
|
cylinder_insulation_thickness_mm=p.heating_cylinder_insulation_thickness_mm,
|
||||||
|
number_baths=p.heating_number_baths,
|
||||||
|
number_baths_wwhrs=p.heating_number_baths_wwhrs,
|
||||||
|
electric_shower_count=p.heating_electric_shower_count,
|
||||||
|
mixer_shower_count=p.heating_mixer_shower_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_main_heating(self, m: EpcMainHeatingDetailModel) -> MainHeatingDetail:
|
||||||
|
return MainHeatingDetail(
|
||||||
|
has_fghrs=m.has_fghrs,
|
||||||
|
main_fuel_type=m.main_fuel_type,
|
||||||
|
heat_emitter_type=m.heat_emitter_type,
|
||||||
|
emitter_temperature=m.emitter_temperature,
|
||||||
|
main_heating_control=m.main_heating_control,
|
||||||
|
fan_flue_present=m.fan_flue_present,
|
||||||
|
boiler_flue_type=m.boiler_flue_type,
|
||||||
|
boiler_ignition_type=m.boiler_ignition_type,
|
||||||
|
central_heating_pump_age=m.central_heating_pump_age,
|
||||||
|
central_heating_pump_age_str=m.central_heating_pump_age_str,
|
||||||
|
main_heating_index_number=m.main_heating_index_number,
|
||||||
|
sap_main_heating_code=m.sap_main_heating_code,
|
||||||
|
main_heating_number=m.main_heating_number,
|
||||||
|
main_heating_category=m.main_heating_category,
|
||||||
|
main_heating_fraction=m.main_heating_fraction,
|
||||||
|
main_heating_data_source=m.main_heating_data_source,
|
||||||
|
condensing=m.condensing,
|
||||||
|
weather_compensator=m.weather_compensator,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_window(self, w: EpcWindowModel) -> SapWindow:
|
||||||
|
return SapWindow(
|
||||||
|
frame_material=w.frame_material,
|
||||||
|
glazing_gap=w.glazing_gap,
|
||||||
|
orientation=w.orientation,
|
||||||
|
window_type=w.window_type,
|
||||||
|
glazing_type=w.glazing_type,
|
||||||
|
window_width=w.window_width,
|
||||||
|
window_height=w.window_height,
|
||||||
|
draught_proofed=w.draught_proofed,
|
||||||
|
window_location=w.window_location,
|
||||||
|
window_wall_type=w.window_wall_type,
|
||||||
|
permanent_shutters_present=w.permanent_shutters_present,
|
||||||
|
frame_factor=w.frame_factor,
|
||||||
|
window_transmission_details=(
|
||||||
|
WindowTransmissionDetails(
|
||||||
|
u_value=w.transmission_u_value,
|
||||||
|
data_source=_require(
|
||||||
|
w.transmission_data_source, "window.transmission_data_source"
|
||||||
|
),
|
||||||
|
solar_transmittance=_require(
|
||||||
|
w.transmission_solar_transmittance,
|
||||||
|
"window.transmission_solar_transmittance",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if w.transmission_u_value is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
permanent_shutters_insulated=w.permanent_shutters_insulated,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_building_part(self, bp: EpcBuildingPartModel) -> SapBuildingPart:
|
||||||
|
floor_rows = list(
|
||||||
|
self._session.exec(
|
||||||
|
select(EpcFloorDimensionModel)
|
||||||
|
.where(EpcFloorDimensionModel.epc_building_part_id == bp.id)
|
||||||
|
.order_by(EpcFloorDimensionModel.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return SapBuildingPart(
|
||||||
|
identifier=BuildingPartIdentifier(bp.identifier),
|
||||||
|
construction_age_band=bp.construction_age_band,
|
||||||
|
wall_construction=bp.wall_construction,
|
||||||
|
wall_insulation_type=bp.wall_insulation_type,
|
||||||
|
wall_thickness_measured=bp.wall_thickness_measured,
|
||||||
|
party_wall_construction=bp.party_wall_construction,
|
||||||
|
sap_floor_dimensions=[self._to_floor_dimension(f) for f in floor_rows],
|
||||||
|
building_part_number=bp.building_part_number,
|
||||||
|
wall_dry_lined=bp.wall_dry_lined,
|
||||||
|
wall_thickness_mm=bp.wall_thickness_mm,
|
||||||
|
wall_insulation_thickness=bp.wall_insulation_thickness,
|
||||||
|
sap_alternative_wall_1=self._to_alt_wall(bp, 1),
|
||||||
|
sap_alternative_wall_2=self._to_alt_wall(bp, 2),
|
||||||
|
floor_heat_loss=bp.floor_heat_loss,
|
||||||
|
floor_insulation_thickness=bp.floor_insulation_thickness,
|
||||||
|
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
|
||||||
|
floor_type=bp.floor_type,
|
||||||
|
floor_construction_type=bp.floor_construction_type,
|
||||||
|
floor_insulation_type_str=bp.floor_insulation_type_str,
|
||||||
|
floor_u_value_known=bp.floor_u_value_known,
|
||||||
|
roof_construction=bp.roof_construction,
|
||||||
|
roof_construction_type=bp.roof_construction_type,
|
||||||
|
curtain_wall_age=bp.curtain_wall_age,
|
||||||
|
roof_insulation_location=bp.roof_insulation_location,
|
||||||
|
roof_insulation_thickness=bp.roof_insulation_thickness,
|
||||||
|
sap_room_in_roof=(
|
||||||
|
SapRoomInRoof(
|
||||||
|
floor_area=bp.room_in_roof_floor_area,
|
||||||
|
construction_age_band=_require(
|
||||||
|
bp.room_in_roof_construction_age_band,
|
||||||
|
"room_in_roof_construction_age_band",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if bp.room_in_roof_floor_area is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_alt_wall(
|
||||||
|
self, bp: EpcBuildingPartModel, n: int
|
||||||
|
) -> Optional[SapAlternativeWall]:
|
||||||
|
area = bp.alt_wall_1_area if n == 1 else bp.alt_wall_2_area
|
||||||
|
if area is None:
|
||||||
|
return None
|
||||||
|
dry_lined = bp.alt_wall_1_dry_lined if n == 1 else bp.alt_wall_2_dry_lined
|
||||||
|
construction = (
|
||||||
|
bp.alt_wall_1_construction if n == 1 else bp.alt_wall_2_construction
|
||||||
|
)
|
||||||
|
insulation_type = (
|
||||||
|
bp.alt_wall_1_insulation_type if n == 1 else bp.alt_wall_2_insulation_type
|
||||||
|
)
|
||||||
|
thickness_measured = (
|
||||||
|
bp.alt_wall_1_thickness_measured
|
||||||
|
if n == 1
|
||||||
|
else bp.alt_wall_2_thickness_measured
|
||||||
|
)
|
||||||
|
insulation_thickness = (
|
||||||
|
bp.alt_wall_1_insulation_thickness
|
||||||
|
if n == 1
|
||||||
|
else bp.alt_wall_2_insulation_thickness
|
||||||
|
)
|
||||||
|
return SapAlternativeWall(
|
||||||
|
wall_area=area,
|
||||||
|
wall_dry_lined=_require(dry_lined, f"alt_wall_{n}_dry_lined"),
|
||||||
|
wall_construction=_require(construction, f"alt_wall_{n}_construction"),
|
||||||
|
wall_insulation_type=_require(
|
||||||
|
insulation_type, f"alt_wall_{n}_insulation_type"
|
||||||
|
),
|
||||||
|
wall_thickness_measured=_require(
|
||||||
|
thickness_measured, f"alt_wall_{n}_thickness_measured"
|
||||||
|
),
|
||||||
|
wall_insulation_thickness=insulation_thickness,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_floor_dimension(self, f: EpcFloorDimensionModel) -> SapFloorDimension:
|
||||||
|
return SapFloorDimension(
|
||||||
|
room_height_m=f.room_height_m,
|
||||||
|
total_floor_area_m2=f.total_floor_area_m2,
|
||||||
|
party_wall_length_m=f.party_wall_length_m,
|
||||||
|
heat_loss_perimeter_m=f.heat_loss_perimeter_m,
|
||||||
|
floor=f.floor,
|
||||||
|
floor_insulation=f.floor_insulation,
|
||||||
|
floor_construction=f.floor_construction,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_energy_source(self, p: EpcPropertyModel) -> SapEnergySource:
|
||||||
|
return SapEnergySource(
|
||||||
|
mains_gas=p.energy_mains_gas,
|
||||||
|
meter_type=p.energy_meter_type,
|
||||||
|
pv_battery_count=p.energy_pv_battery_count,
|
||||||
|
wind_turbines_count=p.energy_wind_turbines_count,
|
||||||
|
gas_smart_meter_present=p.energy_gas_smart_meter_present,
|
||||||
|
is_dwelling_export_capable=p.energy_is_dwelling_export_capable,
|
||||||
|
wind_turbines_terrain_type=p.energy_wind_turbines_terrain_type,
|
||||||
|
electricity_smart_meter_present=p.energy_electricity_smart_meter_present,
|
||||||
|
pv_connection=p.energy_pv_connection,
|
||||||
|
photovoltaic_supply=(
|
||||||
|
PhotovoltaicSupply(
|
||||||
|
none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails(
|
||||||
|
percent_roof_area=p.energy_pv_percent_roof_area
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if p.energy_pv_percent_roof_area is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
wind_turbine_details=(
|
||||||
|
WindTurbineDetails(
|
||||||
|
hub_height=p.energy_wind_turbine_hub_height,
|
||||||
|
rotor_diameter=_require(
|
||||||
|
p.energy_wind_turbine_rotor_diameter,
|
||||||
|
"energy_wind_turbine_rotor_diameter",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if p.energy_wind_turbine_hub_height is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
pv_batteries=(
|
||||||
|
PvBatteries(
|
||||||
|
pv_battery=PvBattery(battery_capacity=p.energy_pv_battery_capacity)
|
||||||
|
)
|
||||||
|
if p.energy_pv_battery_capacity is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_ventilation(self, p: EpcPropertyModel) -> Optional[SapVentilation]:
|
||||||
|
if not p.ventilation_present:
|
||||||
|
return None
|
||||||
|
return SapVentilation(
|
||||||
|
ventilation_type=p.ventilation_type,
|
||||||
|
draught_lobby=p.ventilation_draught_lobby,
|
||||||
|
pressure_test=p.ventilation_pressure_test,
|
||||||
|
open_flues_count=p.ventilation_open_flues_count,
|
||||||
|
closed_flues_count=p.ventilation_closed_flues_count,
|
||||||
|
boiler_flues_count=p.ventilation_boiler_flues_count,
|
||||||
|
other_flues_count=p.ventilation_other_flues_count,
|
||||||
|
extract_fans_count=p.ventilation_extract_fans_count,
|
||||||
|
passive_vents_count=p.ventilation_passive_vents_count,
|
||||||
|
flueless_gas_fires_count=p.ventilation_flueless_gas_fires_count,
|
||||||
|
ventilation_in_pcdf_database=p.ventilation_in_pcdf_database,
|
||||||
|
sheltered_sides=p.ventilation_sheltered_sides,
|
||||||
|
has_suspended_timber_floor=p.ventilation_has_suspended_timber_floor,
|
||||||
|
suspended_timber_floor_sealed=p.ventilation_suspended_timber_floor_sealed,
|
||||||
|
has_draught_lobby=p.ventilation_has_draught_lobby,
|
||||||
|
air_permeability_ap4_m3_h_m2=p.ventilation_air_permeability_ap4_m3_h_m2,
|
||||||
|
mechanical_ventilation_kind=p.ventilation_mechanical_ventilation_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
@private
|
||||||
|
def _to_flat_details(self, f: EpcFlatDetailsModel) -> SapFlatDetails:
|
||||||
|
return SapFlatDetails(
|
||||||
|
level=f.level,
|
||||||
|
top_storey=f.top_storey,
|
||||||
|
flat_location=f.flat_location,
|
||||||
|
heat_loss_corridor=f.heat_loss_corridor,
|
||||||
|
storey_count=f.storey_count,
|
||||||
|
unheated_corridor_length_m=f.unheated_corridor_length_m,
|
||||||
|
)
|
||||||
26
repositories/epc/epc_repository.py
Normal file
26
repositories/epc/epc_repository.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
|
||||||
|
|
||||||
|
class EpcRepository(ABC):
|
||||||
|
"""Persists and loads the structured EPC Property Data slice.
|
||||||
|
|
||||||
|
`save` writes the `EpcPropertyData` to the `epc_property` parent row and its
|
||||||
|
child tables; `get` reconstructs the persisted projection back into an
|
||||||
|
`EpcPropertyData`. Round-trip fidelity over that projection is pinned by the
|
||||||
|
Slice-1 round-trip test (Hestia-Homes/Model#1129).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
data: EpcPropertyData,
|
||||||
|
property_id: int | None = None,
|
||||||
|
portfolio_id: int | None = None,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, epc_property_id: int) -> EpcPropertyData: ...
|
||||||
0
tests/repositories/epc/__init__.py
Normal file
0
tests/repositories/epc/__init__.py
Normal file
56
tests/repositories/epc/test_epc_round_trip.py
Normal file
56
tests/repositories/epc/test_epc_round_trip.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Persistence round-trip fidelity for EPC Property Data (Slice 1, #1129).
|
||||||
|
|
||||||
|
The load-bearing risk of the ara_first_run rebuild: an EpcPropertyData mapped to
|
||||||
|
the epc_property tables, saved, reloaded and mapped back must reconstruct the
|
||||||
|
original object exactly. A failure here is either a missing column (a migration
|
||||||
|
the FE repo must make) or a mapper gap — either way we want it to fail loudly,
|
||||||
|
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
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import Engine
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
|
||||||
|
|
||||||
|
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_epc(schema_dir: str) -> EpcPropertyData:
|
||||||
|
raw: dict[str, Any] = json.loads(
|
||||||
|
(_JSON_SAMPLES / schema_dir / "epc.json").read_text()
|
||||||
|
)
|
||||||
|
return EpcPropertyDataMapper.from_api_response(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"schema_dir",
|
||||||
|
["RdSAP-Schema-21.0.0", "RdSAP-Schema-21.0.1"],
|
||||||
|
)
|
||||||
|
def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> None:
|
||||||
|
# Arrange
|
||||||
|
original = _load_epc(schema_dir)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
epc_property_id = EpcPostgresRepository(session).save(original)
|
||||||
|
session.commit()
|
||||||
|
with Session(db_engine) as session:
|
||||||
|
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
|
||||||
Loading…
Add table
Reference in a new issue