from __future__ import annotations from collections.abc import Sequence from datetime import date, datetime 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, EpcSource 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 def _as_date(value: object) -> date: """Normalise an ``epc_property`` date column value to a ``date``. The FE-owned date columns (``inspection_date`` / ``completion_date`` / ``registration_date``) are Postgres ``timestamp``s even though the SQLModel mirror types them ``str`` (it stores the writer's ``isoformat()`` string). So a read hands back a ``datetime``, while a value still in flight may be the ISO string — accept both. """ if isinstance(value, datetime): return value.date() if isinstance(value, date): return value if isinstance(value, str): return date.fromisoformat(value) raise TypeError(f"unexpected inspection_date value: {value!r}") 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, source: EpcSource = "lodged", ) -> int: # Idempotent on (property_id, source): a re-run replaces the property's # EPC graph for THAT source rather than duplicating it (ADR-0012), and a # predicted save leaves the lodged one intact, and vice versa (ADR-0031). # Anonymous saves (no property_id) always insert. if property_id is not None: self._delete_for_property(property_id, source) parent = EpcPropertyModel.from_epc_property_data( data, property_id=property_id, portfolio_id=portfolio_id, source=source ) 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, source: EpcSource) -> None: """Remove the property's existing EPC graph for `source` (parent + child tables) so a re-save replaces rather than duplicates (ADR-0012), without disturbing the other source's slot (ADR-0031).""" epc_ids = [ i for i in self._session.exec( select(EpcPropertyModel.id) .where(EpcPropertyModel.property_id == property_id) .where(EpcPropertyModel.source == source) ).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]: return self._get_for_property(property_id, source="lodged") def get_predicted_for_property( self, property_id: int ) -> Optional[EpcPropertyData]: return self._get_for_property(property_id, source="predicted") def _get_for_property( self, property_id: int, source: EpcSource ) -> Optional[EpcPropertyData]: row = self._session.exec( select(EpcPropertyModel) .where(EpcPropertyModel.property_id == property_id) .where(EpcPropertyModel.source == source) .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 LODGED EPCs, keyed by property_id.""" return self._for_properties(property_ids, source="lodged") def get_predicted_for_properties( self, property_ids: list[int] ) -> dict[int, EpcPropertyData]: """Bulk-hydrate a batch's PREDICTED EPCs (ADR-0031), keyed by property_id.""" return self._for_properties(property_ids, source="predicted") def _for_properties( self, property_ids: list[int], source: EpcSource ) -> dict[int, EpcPropertyData]: """Bulk-hydrate a batch's EPCs of one `source` 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)) .where(EpcPropertyModel.source == source) .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=_as_date(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=( _as_date(p.completion_date) if p.completion_date else None ), registration_date=( _as_date(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, )