city_retrofit/hub/city_model_structure/city.py
2023-08-07 12:32:33 -04:00

526 lines
16 KiB
Python

"""
City module
SPDX - License - Identifier: LGPL - 3.0 - or -later
Copyright © 2022 Concordia CERC group
Project Coder Guille Gutierrez guillermo.gutierrezmorote@concordia.ca
Code contributors: Peter Yefi peteryefi@gmail.com
"""
from __future__ import annotations
import bz2
import copy
import logging
import math
import pickle
import sys
import pathlib
from pathlib import Path
from typing import List, Union
import pyproj
from pandas import DataFrame
from pyproj import Transformer
from hub.city_model_structure.building import Building
from hub.city_model_structure.buildings_cluster import BuildingsCluster
from hub.city_model_structure.city_object import CityObject
from hub.city_model_structure.city_objects_cluster import CityObjectsCluster
from hub.city_model_structure.iot.station import Station
from hub.city_model_structure.level_of_detail import LevelOfDetail
from hub.city_model_structure.parts_consisting_building import PartsConsistingBuilding
from hub.helpers.geometry_helper import GeometryHelper
from hub.helpers.location import Location
import hub.helpers.constants as cte
class City:
"""
City class
"""
def __init__(self, lower_corner, upper_corner, srs_name):
self._name = None
self._lower_corner = lower_corner
self._upper_corner = upper_corner
self._buildings = []
self._srs_name = srs_name
self._location = None
self._country_code = None
self._climate_reference_city = None
self._climate_file = None
self._latitude = None
self._longitude = None
self._time_zone = None
self._buildings_clusters = None
self._parts_consisting_buildings = None
self._city_objects_clusters = None
self._city_objects = None
self._energy_systems = None
self._fuels = None
self._stations = []
self._level_of_detail = LevelOfDetail()
self._city_objects_dictionary = {}
self._city_objects_alias_dictionary = {}
self._energy_systems_connection_table = None
self._generic_energy_systems = None
def _get_location(self) -> Location:
if self._location is None:
gps = pyproj.CRS('EPSG:4326') # LatLon with WGS84 datum used by GPS units and Google Earth
try:
if self._srs_name in GeometryHelper.srs_transformations:
self._srs_name = GeometryHelper.srs_transformations[self._srs_name]
input_reference = pyproj.CRS(self.srs_name) # Projected coordinate system from input data
except pyproj.exceptions.CRSError as err:
logging.error('Invalid projection reference system, please check the input data.')
raise pyproj.exceptions.CRSError from err
transformer = Transformer.from_crs(input_reference, gps)
coordinates = transformer.transform(self.lower_corner[0], self.lower_corner[1])
self._location = GeometryHelper.get_location(coordinates[0], coordinates[1])
return self._location
@property
def country_code(self):
"""
Get city country code
:return: str
"""
return self._get_location().country
@property
def region_code(self):
"""
Get city region name
:return: str
"""
return self._get_location().region_code
@property
def location(self) -> Location:
"""
Get city location
:return: Location
"""
return self._get_location().city
@property
def name(self):
"""
Get city name
:return: str
"""
if self._name is None:
return self._get_location().city
return self._name
@property
def climate_reference_city(self) -> Union[None, str]:
"""
Get the name for the climatic information reference city
:return: None or str
"""
if self._climate_reference_city is None:
self._climate_reference_city = self._get_location().city
return self._climate_reference_city
@climate_reference_city.setter
def climate_reference_city(self, value):
"""
Set the name for the climatic information reference city
:param value: str
"""
self._climate_reference_city = str(value)
@property
def climate_file(self) -> Union[None, Path]:
"""
Get the climate file full path
:return: None or Path
"""
return self._climate_file
@climate_file.setter
def climate_file(self, value):
"""
Set the climate file full path
:param value: Path
"""
if value is not None:
self._climate_file = Path(value)
@property
def city_objects(self) -> Union[List[CityObject], None]:
"""
Get the city objects belonging to the city
:return: None or [CityObject]
"""
if self._city_objects is None:
if self.city_objects_clusters is None:
self._city_objects = []
else:
self._city_objects = self.city_objects_clusters
if self.buildings is not None:
for building in self.buildings:
self._city_objects.append(building)
return self._city_objects
@property
def buildings(self) -> Union[List[Building], None]:
"""
Get the buildings belonging to the city
:return: None or [Building]
"""
return self._buildings
@property
def lower_corner(self) -> List[float]:
"""
Get city lower corner
:return: [x,y,z]
"""
return self._lower_corner
@property
def upper_corner(self) -> List[float]:
"""
Get city upper corner
:return: [x,y,z]
"""
return self._upper_corner
def city_object(self, name) -> Union[CityObject, None]:
"""
Retrieve the city CityObject with the given name
:param name:str
:return: None or CityObject
"""
if name in self._city_objects_dictionary:
return self.buildings[self._city_objects_dictionary[name]]
return None
def building_alias(self, alias) -> list[Building | list[Building]] | None:
"""
Retrieve the city CityObject with the given alias alias
:alert: Building alias is not guaranteed to be unique
:param alias:str
:return: None or [CityObject]
"""
if alias in self._city_objects_alias_dictionary:
return [self.buildings[i] for i in self._city_objects_alias_dictionary[alias]]
return None
def add_building_alias(self, building, alias):
"""
Add an alias to the building
"""
building_index = self._city_objects_dictionary[building.name]
if alias in self._city_objects_alias_dictionary:
self._city_objects_alias_dictionary[alias].append(building_index)
else:
self._city_objects_alias_dictionary[alias] = [building_index]
def add_city_object(self, new_city_object):
"""
Add a CityObject to the city
:param new_city_object:CityObject
:return: None or not implemented error
"""
if new_city_object.type == 'building':
if self._buildings is None:
self._buildings = []
self._buildings.append(new_city_object)
self._city_objects_dictionary[new_city_object.name] = len(self._buildings) - 1
if new_city_object.aliases is not None:
for alias in new_city_object.aliases:
if alias in self._city_objects_alias_dictionary:
self._city_objects_alias_dictionary[alias].append(len(self._buildings) - 1)
else:
self._city_objects_alias_dictionary[alias] = [len(self._buildings) - 1]
elif new_city_object.type == 'energy_system':
if self._energy_systems is None:
self._energy_systems = []
self._energy_systems.append(new_city_object)
else:
raise NotImplementedError(new_city_object.type)
def remove_city_object(self, city_object):
"""
Remove a CityObject from the city
:param city_object:CityObject
:return: None
"""
if city_object.type != 'building':
raise NotImplementedError(city_object.type)
if not self._buildings:
logging.warning('impossible to remove city_object, the city is empty\n')
else:
if city_object in self._buildings:
self._buildings.remove(city_object)
# regenerate hash map
self._city_objects_dictionary = {}
self._city_objects_alias_dictionary = {}
for i, _building in enumerate(self._buildings):
self._city_objects_dictionary[_building.name] = i
for alias in _building.aliases:
if alias in self._city_objects_alias_dictionary:
self._city_objects_alias_dictionary[alias].append(i)
else:
self._city_objects_alias_dictionary[alias] = [i]
@property
def srs_name(self) -> Union[None, str]:
"""
Get city srs name
:return: None or str
"""
return self._srs_name
@name.setter
def name(self, value):
"""
Set city name
:param value:str
"""
if value is not None:
self._name = str(value)
@staticmethod
def load(city_filename) -> City:
"""
Load a city saved with city.save(city_filename)
:param city_filename: city filename
:return: City
"""
if sys.platform == 'win32':
pathlib.PosixPath = pathlib.WindowsPath
elif sys.platform == 'linux':
pathlib.WindowsPath = pathlib.PosixPath
with open(city_filename, 'rb') as file:
return pickle.load(file)
def save(self, city_filename):
"""
Save a city into the given filename
:param city_filename: destination city filename
:return: None
"""
with open(city_filename, 'wb') as file:
pickle.dump(self, file)
def save_compressed(self, city_filename):
"""
Save a city into the given filename
:param city_filename: destination city filename
:return: None
"""
with bz2.BZ2File(city_filename, 'wb') as file:
pickle.dump(self, file)
def region(self, center, radius) -> City:
"""
Get a region from the city
:param center: specific point in space [x, y, z]
:param radius: distance to center of the sphere selected in meters
:return: selected_region_city
"""
selected_region_lower_corner = [center[0] - radius, center[1] - radius, center[2] - radius]
selected_region_upper_corner = [center[0] + radius, center[1] + radius, center[2] + radius]
selected_region_city = City(selected_region_lower_corner, selected_region_upper_corner, srs_name=self.srs_name)
selected_region_city.climate_file = self.climate_file
# selected_region_city.climate_reference_city = self.climate_reference_city
for city_object in self.city_objects:
location = city_object.centroid
if location is not None:
distance = math.sqrt(math.pow(location[0] - center[0], 2) + math.pow(location[1] - center[1], 2)
+ math.pow(location[2] - center[2], 2))
if distance < radius:
selected_region_city.add_city_object(city_object)
return selected_region_city
@property
def latitude(self) -> Union[None, float]:
"""
Get city latitude in degrees
:return: None or float
"""
return self._latitude
@latitude.setter
def latitude(self, value):
"""
Set city latitude in degrees
:parameter value: float
"""
if value is not None:
self._latitude = float(value)
@property
def longitude(self) -> Union[None, float]:
"""
Get city longitude in degrees
:return: None or float
"""
return self._longitude
@longitude.setter
def longitude(self, value):
"""
Set city longitude in degrees
:parameter value: float
"""
if value is not None:
self._longitude = float(value)
@property
def time_zone(self) -> Union[None, float]:
"""
Get city time_zone
:return: None or float
"""
return self._time_zone
@time_zone.setter
def time_zone(self, value):
"""
Set city time_zone
:parameter value: float
"""
if value is not None:
self._time_zone = float(value)
@property
def buildings_clusters(self) -> Union[List[BuildingsCluster], None]:
"""
Get buildings clusters belonging to the city
:return: None or [BuildingsCluster]
"""
return self._buildings_clusters
@property
def parts_consisting_buildings(self) -> Union[List[PartsConsistingBuilding], None]:
"""
Get parts consisting buildings belonging to the city
:return: None or [PartsConsistingBuilding]
"""
return self._parts_consisting_buildings
@property
def stations(self) -> [Station]:
"""
Get the sensors stations belonging to the city
:return: [Station]
"""
return self._stations
@property
def city_objects_clusters(self) -> Union[List[CityObjectsCluster], None]:
"""
Get city objects clusters belonging to the city
:return: None or [CityObjectsCluster]
"""
if self.buildings_clusters is None:
self._city_objects_clusters = []
else:
self._city_objects_clusters = self.buildings_clusters
if self.parts_consisting_buildings is not None:
self._city_objects_clusters.append(self.parts_consisting_buildings)
return self._city_objects_clusters
def add_city_objects_cluster(self, new_city_objects_cluster):
"""
Add a CityObject to the city
:param new_city_objects_cluster:CityObjectsCluster
:return: None or NotImplementedError
"""
if new_city_objects_cluster.type == 'buildings':
if self._buildings_clusters is None:
self._buildings_clusters = []
self._buildings_clusters.append(new_city_objects_cluster)
elif new_city_objects_cluster.type == 'building_parts':
if self._parts_consisting_buildings is None:
self._parts_consisting_buildings = []
self._parts_consisting_buildings.append(new_city_objects_cluster)
else:
raise NotImplementedError
@property
def copy(self) -> City:
"""
Get a copy of the current city
"""
return copy.deepcopy(self)
def merge(self, city) -> City:
"""
Return a merged city combining the current city and the given one
:return: City
"""
merged_city = self.copy
for building in city.buildings:
if merged_city.city_object(building.name) is None:
# building is new so added to the city
merged_city.add_city_object(copy.deepcopy(building))
else:
# keep the one with less radiation
parameter_city_building_total_radiation = 0
for surface in building.surfaces:
if surface.global_irradiance:
parameter_city_building_total_radiation += surface.global_irradiance[cte.YEAR][0]
merged_city_building_total_radiation = 0
for surface in merged_city.city_object(building.name).surfaces:
if surface.global_irradiance:
merged_city_building_total_radiation += surface.global_irradiance[cte.YEAR][0]
if merged_city_building_total_radiation == 0:
merged_city.remove_city_object(merged_city.city_object(building.name))
merged_city.add_city_object(building)
elif merged_city_building_total_radiation > parameter_city_building_total_radiation > 0:
merged_city.remove_city_object(merged_city.city_object(building.name))
merged_city.add_city_object(building)
return merged_city
@property
def level_of_detail(self) -> LevelOfDetail:
"""
Get level of detail of different aspects of the city: geometry, construction and usage
:return: LevelOfDetail
"""
return self._level_of_detail
@property
def energy_systems_connection_table(self) -> Union[None, DataFrame]:
"""
Get energy systems connection table which includes at least two columns: energy_system_type and associated_building
and may also include dimensioned_energy_system and connection_building_to_dimensioned_energy_system
:return: DataFrame
"""
return self._energy_systems_connection_table
@energy_systems_connection_table.setter
def energy_systems_connection_table(self, value):
"""
Set energy systems connection table which includes at least two columns: energy_system_type and associated_building
and may also include dimensioned_energy_system and connection_building_to_dimensioned_energy_system
:param value: DataFrame
"""
self._energy_systems_connection_table = value
@property
def generic_energy_systems(self) -> dict:
"""
Get dictionary with generic energy systems installed in the city
:return: dict
"""
return self._generic_energy_systems
@generic_energy_systems.setter
def generic_energy_systems(self, value):
"""
Set dictionary with generic energy systems installed in the city
:return: dict
"""
self._generic_energy_systems = value