Source code for eureca_building.surface

"""
This module includes functions to model a 3D surface
"""

__author__ = "Enrico Prataviera"
__credits__ = ["Enrico Prataviera"]
__license__ = "MIT"
__version__ = "0.1"
__maintainer__ = "Enrico Prataviera"

import logging

import numpy as np
import pyclipper as pc

from eureca_building.config import CONFIG
from eureca_building.construction import Construction
from eureca_building.window import SimpleWindow
from eureca_building.exceptions import (
    Non3ComponentsVertex,
    SurfaceWrongNumberOfVertices,
    WindowToWallRatioOutsideBoundaries,
    InvalidSurfaceType,
    NonPlanarSurface,
    NegativeSurfaceArea,
)
from eureca_building._geometry_auxiliary_functions import (
    check_complanarity,
    polygon_area,
    normal_versor_2,
    centroid,
    _project,
    _project_inv,
)


# %% Surface class


[docs]class Surface: """Class surface checks the complanarity and calculates the area. Then calculates the azimuth and tilt of the surface and set a surface type depending on the tilt angle co planarity: https://www.geeksforgeeks.org/program-to-check-whether-4-points-in-a-3-d-plane-are-coplanar/ the area is calculated from: https://stackoverflow.com/questions/12642256/python-find-area-of-polygon-from-xyz-coordinates """ __warning_azimuth_subdivisions = False __warning_height_subdivisions = False _discharge_coefficient_nat_vent = 0.6
[docs] def __init__( self, name: str, vertices: tuple = ((0, 0, 0), (0, 0, 0), (0, 0, 0)), wwr=None, subdivisions_solar_calc=None, surface_type=None, construction=None, window=None, n_window_layers: int = 1 ): """Creates the surface object. Checks all the inputs using properties setter methods Parameters ---------- name : str Name. vertices : tuple, default ((0, 0, 0), (0, 0, 0), (0, 0, 0)) List of vertices coordinates [m]. The default is ([0, 0, 0], [0, 0, 0], [0, 0, 0]). wwr : float, default None window to wall ratio (between and 0 and 1). The default is 0.0. subdivisions_solar_calc : dict, default None Something like { 'azimuth_subdivisions': 8, 'height_subdivisions': 3, } keys: azimuth_subdivisions : int, optional Number of azimuth discretization for radiation purposes. The default is 8. height_subdivisions : int, optional Number of height discretization for radiation purposes. The default is 3. surface_type : str, default None Type of surface 'ExtWall' or 'GroundFloor' or 'Roof'. If not provided autocalculate. construction : eureca_building.construction.Construction the construction object with the materials window : eureca_building.window.SimpleWindow the Window object with the materials """ self.name = name self._vertices = vertices self._centroid = centroid(self._vertices) # Area calculation self._area = polygon_area(self._vertices) # Considering only three points in calculating the normal vector could create # reverse orientations if the three points are in a non-convex angle of the surface # # for this reason theres an alternative way to calculate the normal, # implemented in function: normalAlternative # # reference: https://stackoverflow.com/questions/32274127/how-to-efficiently-determine-the-normal-to-a-polygon-in-3d-space self._normal = normal_versor_2(self._vertices) self._set_azimuth_and_zenith() if wwr is not None: self._wwr = wwr else: self._wwr = 0.0 # Param Solar Calc if subdivisions_solar_calc is not None: self.subdivisions_solar_calc = subdivisions_solar_calc else: self.subdivisions_solar_calc = {"height_subdivisions": CONFIG.height_subdivisions, "azimuth_subdivisions": CONFIG.azimuth_subdivisions, } # Surfcae type if surface_type is None: self._set_auto_surface_type() else: self.surface_type = surface_type if construction is not None: self.construction = construction if window is not None: self.window = window # Window layout for natural ventilation self._define_windows_layout(n_window_layers=n_window_layers)
@property def _vertices(self) -> tuple: return self.__vertices @_vertices.setter def _vertices(self, value: tuple): try: value = tuple(value) except ValueError: raise TypeError(f"Vertices of surface {self.name} are not a tuple: {value}") if len(value) < 3: # Not a plane - no area raise SurfaceWrongNumberOfVertices( f"Surface {self.name}. Number of vertices lower than 3: {value}" ) for vtx in value: if not isinstance(vtx, tuple): raise TypeError( f"Vertices of surface {self.name} are not a tuple: {value}" ) if len(vtx) != 3: raise Non3ComponentsVertex( f"Surface {self.name} has a vertex with len() != 3: {value}" ) try: float(vtx[0]) float(vtx[1]) float(vtx[2]) except ValueError: raise ValueError( f"Surface {self.name}. One vertex contains non float values: {vtx}" ) # Check coplanarity if not check_complanarity(value): raise NonPlanarSurface(f"Surface {self.name}. Non planar points") self.__vertices = value @property def _area(self) -> float: return self.__area @_area.setter def _area(self, value: float): try: value = float(value) except ValueError: raise TypeError(f"Surface {self.name}, area is not an float: {value}") if value < 0.0: raise NegativeSurfaceArea( f"Surface {self.name}, negative surface area: {value}" ) if float(value) == 0.0: self.__area = 1e-10 else: self.__area = value @property def _wwr(self) -> float: return self.__wwr @_wwr.setter def _wwr(self, value: float): try: value = float(value) except ValueError: raise TypeError(f"Surface {self.name}, wwr is not an float: {value}") if value < 0.0 or value > 0.999: raise WindowToWallRatioOutsideBoundaries( f"Surface {self.name}, wwrS must included between 0 and 1: {value}" ) self._calc_glazed_and_opaque_areas(value) self.__wwr = value @property def subdivisions_solar_calc(self) -> dict: return self._subdivisions_solar_calc @subdivisions_solar_calc.setter def subdivisions_solar_calc(self, value: dict): if not isinstance(value, dict): raise TypeError( f"Surface {self.name}, subdivisions_solar_calc must be a dict: {value}" ) try: self._azimuth_subdivisions = value["azimuth_subdivisions"] except KeyError: raise KeyError( f"Surface {self.name}, subdivisions_solar_calc must contain an azimuth_subdivisions key: {value}" ) try: self._height_subdivisions = value["height_subdivisions"] except KeyError: raise KeyError( f"Surface {self.name}, subdivisions_solar_calc must contain an height_subdivisions key: {value}" ) self._subdivisions_solar_calc = value self._set_azimuth_and_zenith_solar_radiation() @property def _azimuth_subdivisions(self) -> int: return self.__azimuth_subdivisions @_azimuth_subdivisions.setter def _azimuth_subdivisions(self, value: int): try: value = int(value) except ValueError: raise TypeError( f"Surface {self.name}, azimuth_subdivisions is not an int: {value}" ) if value < 1 or value > 100: # Check if unreasonable values provided raise ValueError( f"Surface {self.name}, azimuth_subdivisions must be > 1 and lower than 100: {value}" ) if value > 16 and not self.__warning_azimuth_subdivisions: logging.warning( f"For one or more surfaces azimuth_subdivisions is high: {value}.\nThe calculation time can be long" ) self.__warning_azimuth_subdivisions = True self.__azimuth_subdivisions = value @property def _height_subdivisions(self) -> int: return self.__height_subdivisions @_height_subdivisions.setter def _height_subdivisions(self, value: int): try: value = int(value) except ValueError: raise TypeError( f"Surface {self.name}, height_subdivisions is not an int: {value}" ) if value < 1 or value > 50: # Check if unreasonable values provided raise ValueError( f"Surface {self.name}, height_subdivisions must be > 1 and lower than 50: {value}" ) if value > 6 and not self.__warning_height_subdivisions: logging.warning( f"For one or more surfaces height_subdivisions is high: {value}.\nThe calculation time can be long" ) self.__warning_height_subdivisions = True self.__height_subdivisions = value @property def surface_type(self): return self._surface_type @surface_type.setter def surface_type(self, value): if not isinstance(value, str) and value is not None: raise TypeError(f"Surface {self.name}, surface_type is not a str: {value}") if value not in ["ExtWall", "GroundFloor", "Roof"]: raise InvalidSurfaceType( f"Surface {self.name}, surface_type must choosen from: [ExtWall, GroundFloor, Roof] {value}" ) self._surface_type = value @property def construction(self): return self._construction @construction.setter def construction(self, value): if not isinstance(value, Construction): raise TypeError(f"Surface {self.name}, construction must be a Construction object: {type(value)}") self._construction = value @property def window(self): return self._window @window.setter def window(self, value): if not isinstance(value, SimpleWindow): raise TypeError(f"Surface {self.name}, window must be a SimpleWindow object: {type(value)}") self._window = value def _set_azimuth_and_zenith(self): """Internal method to calculate azimuth and zenith """ # set the azimuth and zenith if self._normal[2] == 1: self._height = 0 self._azimuth = 0 elif self._normal[2] == -1: self._height = 180 self._azimuth = 0 else: self._height = 90 - np.degrees( np.arctan( ( self._normal[2] / (np.sqrt(self._normal[0] ** 2 + self._normal[1] ** 2)) ) ) ) if self._normal[1] == 0: if self._normal[0] > 0: self._azimuth = -90 elif self._normal[0] < 0: self._azimuth = 90 else: if self._normal[1] < 0: self._azimuth = np.degrees( np.arctan(self._normal[0] / self._normal[1]) ) else: if self._normal[0] < 0: self._azimuth = 180 + np.degrees( np.arctan(self._normal[0] / self._normal[1]) ) else: self._azimuth = -180 + np.degrees( np.arctan(self._normal[0] / self._normal[1]) ) def _calc_glazed_and_opaque_areas(self, wwr): """Internal method to calculate glazed and opaque ares Parameters ---------- wwr : float Window-to-wall ration. Number between 0 and 1 """ self._opaque_area = (1 - wwr) * self._area self._glazed_area = wwr * self._area def _define_windows_layout(self, n_window_layers: int = 1): """USED FOR NATURAL VENTILATION Defines the windows layout (sill height, width, number, ...) Parameters ---------- n_window_layers : int, default 1 Number of rows to consider """ _h_window_default = 1.5 if not isinstance(n_window_layers, int): raise TypeError(f"Surface {self.name}: number of window layers is not an integer: n_window_layers {n_window_layers}") area_layer = self._glazed_area/n_window_layers self._w_window = area_layer/_h_window_default self._h_window = _h_window_default self._h_bottom_windows = np.array([1.2 + n*3.3 for n in range(n_window_layers)]) self._h_top_windows = self._h_bottom_windows + self._h_window self._a_coeff = self._discharge_coefficient_nat_vent*self._w_window # Coeff a = discharge coeff * width window for calculation of natural ventilation flow rate def _set_azimuth_and_zenith_solar_radiation(self): """Internal method to calculate rounded azimuth and zenith """ # Azimuth and tilt approximation delta_a = 360 / (2 * self._azimuth_subdivisions) delta_h = 90 / (2 * self._height_subdivisions) x = np.arange(-delta_h, 90 + 2 * delta_h, 2 * delta_h) for n in range(len(x) - 1): if self._height >= x[n] and self._height < x[n + 1]: self._height_round = int((x[n] + x[n + 1]) / 2) self._sky_view_factor = (1 + np.cos(np.radians(self._height_round))) / 2 elif self._height >= x[-1] and self._height < 150: self._height_round = 90 self._sky_view_factor = (1 + np.cos(np.radians(self._height_round))) / 2 else: self._height_round = 0 # Only to avoid errors y = np.arange(-180 - delta_a, 180 + 2 * delta_a, 2 * delta_a) for n in range(len(y) - 1): if self._azimuth >= y[n] and self._azimuth < y[n + 1]: self._azimuth_round = int((y[n] + y[n + 1]) / 2) if self._azimuth_round == 180: self._azimuth_round = -180 if self._height_round == 0: self._azimuth_round = 0 def _set_auto_surface_type(self): """Uses tilt to autoset surface type. tilt > 150 --> GroundFloor tilt < 40 --> Roof else --> ExtWall """ # Set surface inclination if self._height < 40: self.surface_type = "Roof" elif self._height > 150: self.surface_type = "GroundFloor" else: self.surface_type = "ExtWall"
[docs] def max_height(self): """Calculates max height from the most high vertex """ hmax = 0 for vert in self.__vertices: hmax = max(hmax, vert[2]) return hmax
[docs] def min_height(self): """Calculates max height from the most low vertex """ hmin = 10000 for vert in self.__vertices: hmin = min(hmin, vert[2]) return hmin
[docs] def get_VDI6007_surface_params(self, asim=None): """Calculates R and C using VDI6007 method. Parameters ---------- asim : bool Whether the surface is asimmetric (True) or not (False) Returns ------- tuple R, C -> Thermal Resistance and Capacity """ if asim is None: if self.surface_type in ["ExtWall", "GroundFloor", "Roof"]: asim = True else: asim = False try: R1, C1 = self.construction._VDI6007_surface_params(self._area, asim) except AttributeError: raise AttributeError( f"Surface {self.name}, construction not specified" ) return R1, C1
[docs] def get_surface_external_radiative_coefficient(self): """Returns the radiative heat exchange coefficient. Returns ------- float """ # From standard average value return 5 * 0.9 # W/(m2 K)
[docs] def check_surface_coincidence(self, other_surface): """Check if two surface are coincident returning True or False Parameters ---------- other_surface : eureca_building.surface.Surface another surface object Returns ------- bool Are the surfaces coincident? True/False """ # Check Input data type if not isinstance(other_surface, Surface): raise ValueError( f"ERROR Surface class, surface {self.name}, check_surface_coincidence. other_surface is not a Surface object: otherSurface {other_surface}") # Check the coincidence of two surface looking firstly at the coplanarity # of the points and then the direction of the normals vectors flagPoints = False plane = list(self._vertices) # Coplanarity test for i in other_surface._vertices: if check_complanarity(plane + [i], precision=5): flagPoints = True # Normal vector test flagNormal = False if np.linalg.norm(self._normal + other_surface._normal) < 0.2: flagNormal = True return (flagNormal and flagPoints)
[docs] def calculate_intersection_area(self, other_surface): '''Calculates the area between two adjacent surfaces reference: https://stackoverflow.com/questions/39003450/transform-3d-polygon-to-2d-perform-clipping-and-transform-back-to-3d Parameters ---------- other_surface : eureca_building.surface.Surface another surface object Returns ------- float The intersection area [m2] ''' # Check Input data type if not isinstance(other_surface, Surface): raise ValueError( f"ERROR Surface class, surface {self.name}, calculate_intersection_area. other_surface is not a Surface object: otherSurface {other_surface}") # Check the coincidence of two surface looking firstly at the coplanarity # of the points and then the direction of the normals vectors a = self._normal[0] * self._vertices[0][0] + self._normal[1] * self._vertices[0][1] + self._normal[2] * \ self._vertices[0][2] proj_axis = max(range(3), key=lambda i: abs(self._normal[i])) projA = [_project(x, proj_axis) for x in self._vertices] projB = [_project(x, proj_axis) for x in other_surface._vertices] scaledA = pc.scale_to_clipper(projA) scaledB = pc.scale_to_clipper(projB) clipper = pc.Pyclipper() clipper.AddPath(scaledA, poly_type=pc.PT_SUBJECT, closed=True) clipper.AddPath(scaledB, poly_type=pc.PT_CLIP, closed=True) intersections = clipper.Execute(pc.CT_INTERSECTION, pc.PFT_NONZERO, pc.PFT_NONZERO) intersections = [pc.scale_from_clipper(i) for i in intersections] if len(intersections) == 0: return 0 intersection = tuple([_project_inv(x, proj_axis, a, self._normal) for x in intersections[0]]) area = polygon_area(intersection) return area if area > 0 else 0.
[docs] def reduce_area(self, area_to_reduce): '''Reduces the area of the surface by an input area Parameters ---------- area_to_reduce : float the area to subtract [m2] to the total area ''' # Check Input data type if not isinstance(area_to_reduce, float) or area_to_reduce < 0.: try: area_to_reduce = float(area_to_reduce) except ValueError: raise ValueError( f"ERROR Surface class, surface {self.name}, reduce_area. The area is not a positive float: AreaToReduce {area_to_reduce}") # Area reduction if self._area - area_to_reduce > 0.0000001: self._area = self._area - area_to_reduce else: self._area = 0.0000001 self._calc_glazed_and_opaque_areas(self._wwr) # This runs again the glazed and opaque calculation
# %%--------------------------------------------------------------------------------------------------- # %% SurfaceInternalMass class
[docs]class SurfaceInternalMass: """Class to define a surface for thermal capacity using area and surface type with a specific geometry """
[docs] def __init__(self, name: str, area: float = 0., surface_type=None, construction=None): """It creates the SurfaceInternalMass object, like the Surface class, but without vertexes and geometry Parameters ---------- name : string name of the surface area : float, default 0. area of the internal surface surface_type : str, default None Type of internal surface: 'IntWall' or 'IntCeiling' construction : eureca_building.construction.Construction The construction to be assigned to the SurfaceInternalMass """ # Check input data type self.name = name self._area = area self.surface_type = surface_type if construction is not None: self.construction = construction
@property def _area(self) -> float: return self.__area @_area.setter def _area(self, value: float): try: value = float(value) except ValueError: raise TypeError(f"SurfaceInternalMass {self.name}, area is not an float: {value}") if value < 0.0: raise NegativeSurfaceArea( f"SurfaceInternalMass {self.name}, negative surface area: {value}" ) if float(value) == 0.0: self.__area = 1e-10 else: self.__area = value self._opaque_area = self._area self._glazed_area = 0. @property def surface_type(self): return self._surface_type @surface_type.setter def surface_type(self, value): if not isinstance(value, str) and value is not None: raise TypeError(f"SurfaceInternalMass {self.name}, surface_type is not a str: {value}") if value == None: logging.warning(f"SurfaceInternalMass {self.name}, surface_type is None: {value}. IntWall will be assigned") value = "IntWall" if value not in ["IntWall", "IntCeiling", "IntFloor"]: raise InvalidSurfaceType( f"SurfaceInternalMass {self.name}, surface_type must choosen from: [IntWall, IntCeiling, IntFloor] {value}" ) self._surface_type = value @property def construction(self): return self._construction @construction.setter def construction(self, value): if not isinstance(value, Construction): raise TypeError( f"SurfaceInternalMass {self.name}, construction must be a Construction object: {type(value)}") self._construction = value
[docs] def get_VDI6007_surface_params(self, asim=False): """Calculates R and C using VDI6007 method. Parameters ---------- asim : bool Whether the surface is asimmetric (True) or not (False) Returns ------- tuple R, C -> Thermal Resistance and Capacity """ try: R1, C1 = self.construction._VDI6007_surface_params(self._opaque_area, asim) except AttributeError: raise AttributeError( f"SurfaceInternalMass {self.name}, construction not specified" ) return R1, C1