from __future__ import annotations from collections.abc import Sequence from datetime import date from typing import Optional, Protocol, TypeVar from sqlmodel import Session, col, delete, 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, RenewableHeatIncentive, 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, EpcRenewableHeatIncentiveModel, 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 _HasEpcPropertyId(Protocol): epc_property_id: int _RowT = TypeVar("_RowT", bound=_HasEpcPropertyId) def _group_by_epc(rows: Sequence[_RowT]) -> dict[int, list[_RowT]]: grouped: dict[int, list[_RowT]] = {} for row in rows: grouped.setdefault(row.epc_property_id, []).append(row) return grouped 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: # Idempotent on property_id: a re-run replaces the property's EPC graph # rather than duplicating it (ADR-0012). Anonymous saves (no property_id) # always insert. if property_id is not None: self._delete_for_property(property_id) 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) ) 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 _delete_for_property(self, property_id: int) -> None: """Remove the property's existing EPC graph (parent + child tables) so a re-save replaces rather than duplicates (ADR-0012).""" epc_ids = [ i for i in self._session.exec( select(EpcPropertyModel.id).where( EpcPropertyModel.property_id == property_id ) ).all() if i is not None ] if not epc_ids: return part_ids = [ i for i in self._session.exec( select(EpcBuildingPartModel.id).where( col(EpcBuildingPartModel.epc_property_id).in_(epc_ids) ) ).all() if i is not None ] if part_ids: self._session.exec( # type: ignore[call-overload] delete(EpcFloorDimensionModel).where( col(EpcFloorDimensionModel.epc_building_part_id).in_(part_ids) ) ) for child in ( EpcPropertyEnergyPerformanceModel, EpcEnergyElementModel, EpcMainHeatingDetailModel, EpcBuildingPartModel, EpcWindowModel, EpcFlatDetailsModel, EpcRenewableHeatIncentiveModel, ): self._session.exec( # type: ignore[call-overload] delete(child).where(col(child.epc_property_id).in_(epc_ids)) ) self._session.exec( # type: ignore[call-overload] delete(EpcPropertyModel).where(col(EpcPropertyModel.id).in_(epc_ids)) ) def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]: row = self._session.exec( select(EpcPropertyModel) .where(EpcPropertyModel.property_id == property_id) .order_by(EpcPropertyModel.id) # type: ignore[arg-type] ).first() if row is None or row.id is None: return None return self.get(row.id) def get_for_properties( self, property_ids: list[int] ) -> dict[int, EpcPropertyData]: """Bulk-hydrate a batch's EPCs in a handful of per-table IN queries (ADR-0012), not N x per-property. Load-whole per ADR-0002.""" if not property_ids: return {} parents = self._session.exec( select(EpcPropertyModel) .where(col(EpcPropertyModel.property_id).in_(property_ids)) .order_by(EpcPropertyModel.id) # type: ignore[arg-type] ).all() parent_by_property: dict[int, EpcPropertyModel] = {} for parent in parents: if parent.property_id is not None and parent.id is not None: parent_by_property.setdefault(parent.property_id, parent) epc_ids = [p.id for p in parent_by_property.values() if p.id is not None] if not epc_ids: return {} perf_by = { r.epc_property_id: r for r in self._session.exec( select(EpcPropertyEnergyPerformanceModel).where( col(EpcPropertyEnergyPerformanceModel.epc_property_id).in_(epc_ids) ) ).all() } flat_by = { r.epc_property_id: r for r in self._session.exec( select(EpcFlatDetailsModel).where( col(EpcFlatDetailsModel.epc_property_id).in_(epc_ids) ) ).all() } rhi_by = { r.epc_property_id: r for r in self._session.exec( select(EpcRenewableHeatIncentiveModel).where( col(EpcRenewableHeatIncentiveModel.epc_property_id).in_(epc_ids) ) ).all() } elements_by = _group_by_epc( self._session.exec( select(EpcEnergyElementModel) .where(col(EpcEnergyElementModel.epc_property_id).in_(epc_ids)) .order_by(EpcEnergyElementModel.id) # type: ignore[arg-type] ).all() ) heating_by = _group_by_epc( self._session.exec( select(EpcMainHeatingDetailModel) .where(col(EpcMainHeatingDetailModel.epc_property_id).in_(epc_ids)) .order_by(EpcMainHeatingDetailModel.id) # type: ignore[arg-type] ).all() ) parts_by = _group_by_epc( self._session.exec( select(EpcBuildingPartModel) .where(col(EpcBuildingPartModel.epc_property_id).in_(epc_ids)) .order_by(EpcBuildingPartModel.id) # type: ignore[arg-type] ).all() ) windows_by = _group_by_epc( self._session.exec( select(EpcWindowModel) .where(col(EpcWindowModel.epc_property_id).in_(epc_ids)) .order_by(EpcWindowModel.id) # type: ignore[arg-type] ).all() ) part_ids = [ bp.id for parts in parts_by.values() for bp in parts if bp.id is not None ] floor_dims_by_part = self._floor_dims_by_part(part_ids) result: dict[int, EpcPropertyData] = {} for property_id, parent in parent_by_property.items(): epc_id = _require(parent.id, "id") result[property_id] = self._compose( p=parent, perf=perf_by.get(epc_id), elements=elements_by.get(epc_id, []), heating_rows=heating_by.get(epc_id, []), part_rows=parts_by.get(epc_id, []), floor_dims_by_part=floor_dims_by_part, window_rows=windows_by.get(epc_id, []), flat_row=flat_by.get(epc_id), rhi_row=rhi_by.get(epc_id), ) return result def _floor_dims_by_part( self, part_ids: list[int] ) -> dict[int, list[EpcFloorDimensionModel]]: if not part_ids: return {} rows = self._session.exec( select(EpcFloorDimensionModel) .where(col(EpcFloorDimensionModel.epc_building_part_id).in_(part_ids)) .order_by(EpcFloorDimensionModel.id) # type: ignore[arg-type] ).all() grouped: dict[int, list[EpcFloorDimensionModel]] = {} for row in rows: grouped.setdefault(row.epc_building_part_id, []).append(row) return grouped 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() rhi_row = self._session.exec( select(EpcRenewableHeatIncentiveModel).where( EpcRenewableHeatIncentiveModel.epc_property_id == epc_property_id ) ).first() window_rows = self._windows(epc_property_id) floor_dims_by_part = self._floor_dims_by_part( [bp.id for bp in part_rows if bp.id is not None] ) return self._compose( p=p, perf=perf, elements=elements, heating_rows=heating_rows, part_rows=part_rows, floor_dims_by_part=floor_dims_by_part, window_rows=window_rows, flat_row=flat_row, rhi_row=rhi_row, ) def _compose( self, *, p: EpcPropertyModel, perf: Optional[EpcPropertyEnergyPerformanceModel], elements: list[EpcEnergyElementModel], heating_rows: list[EpcMainHeatingDetailModel], part_rows: list[EpcBuildingPartModel], floor_dims_by_part: dict[int, list[EpcFloorDimensionModel]], window_rows: list[EpcWindowModel], flat_row: Optional[EpcFlatDetailsModel], rhi_row: Optional[EpcRenewableHeatIncentiveModel], ) -> EpcPropertyData: 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 window_rows], sap_energy_source=self._to_energy_source(p), sap_building_parts=[ self._to_building_part( bp, floor_dims_by_part.get(bp.id, []) if bp.id is not None else [] ) 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, 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( 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, floor_rows: list[EpcFloorDimensionModel] ) -> SapBuildingPart: 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, )