Source code for honeybee_radiance.sensorgrid

"""A grid of sensors."""
from __future__ import division

from .sensor import Sensor
from .lightpath import light_path_from_room

from honeybee.facetype import AirBoundary
import honeybee.typing as typing
import ladybug.futil as futil
from ladybug_geometry.geometry3d.pointvector import Point3D, Vector3D
from ladybug_geometry.geometry3d.face import Face3D
from ladybug_geometry.geometry3d.mesh import Mesh3D

import os
import json
import math
try:
    from itertools import izip as zip
except ImportError:  # python 3
    pass


[docs]class SensorGrid(object): """A grid of sensors. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object in the exported Radiance files. sensors: A collection of Sensors. Properties: * identifier * display_name * sensors * positions * directions * room_identifier * light_path * mesh * base_geometry * group_identifier * full_identifier """ __slots__ = ('_identifier', '_display_name', '_sensors', '_room_identifier', '_light_path', '_mesh', '_base_geometry', '_group_identifier') def __init__(self, identifier, sensors): """Initialize a SensorGrid.""" self.identifier = identifier self._display_name = None self.sensors = sensors self._room_identifier = None self._group_identifier = None self._light_path = None self._mesh = None self._base_geometry = None
[docs] @classmethod def from_dict(cls, ag_dict): """Create a sensor grid from a dictionary in the following format. .. code-block:: python { "type": "SensorGrid", "identifier": str, # SensorGrid identifier "display_name": str, # SensorGrid display name "sensors": [], # list of Sensor dictionaries 'room_identifier': str, # optional room identifier 'group_identifier': str, # optional group identifier 'light_path': [] # optional list of lists for light path } """ assert ag_dict['type'] == 'SensorGrid', \ 'Expected SensorGrid dictionary. Got {}.'.format(ag_dict['type']) sensors = (Sensor.from_dict(sensor) for sensor in ag_dict['sensors']) new_obj = cls(identifier=ag_dict["identifier"], sensors=sensors) if 'display_name' in ag_dict and ag_dict['display_name'] is not None: new_obj.display_name = ag_dict['display_name'] if 'room_identifier' in ag_dict and ag_dict['room_identifier'] is not None: new_obj.room_identifier = ag_dict['room_identifier'] if 'group_identifier' in ag_dict and ag_dict['group_identifier'] is not None: new_obj.group_identifier = ag_dict['group_identifier'] if 'light_path' in ag_dict and ag_dict['light_path'] is not None: new_obj.light_path = ag_dict['light_path'] if 'mesh' in ag_dict and ag_dict['mesh'] is not None: new_obj.mesh = Mesh3D.from_dict(ag_dict['mesh']) if 'base_geometry' in ag_dict and ag_dict['base_geometry'] is not None: new_obj.base_geometry = \ tuple(Face3D.from_dict(face) for face in ag_dict['base_geometry']) return new_obj
[docs] @classmethod def from_planar_positions(cls, identifier, positions, plane_normal): """Create a sensor grid from a collection of positions with the same direction. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. positions: A list of (x, y ,z) tuples for position of sensors. plane_normal: (x, y, z) tuples for direction of sensors. """ sg = (Sensor(pt, plane_normal) for pt in positions) return cls(identifier, sg)
[docs] @classmethod def from_position_and_direction(cls, identifier, positions, directions): """Create a sensor grid from a collection of positions and directions. The length of positions and directions should be the same. In case the lists have different lengths the shorter list will be used as the reference. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. positions: A list of (x, y ,z) tuples for position of sensors. directions: A list of (x, y, z) tuples for direction of sensors. """ sg = tuple(Sensor(pt, v) for pt, v in zip(positions, directions)) return cls(identifier, sg)
[docs] @classmethod def from_mesh3d(cls, identifier, mesh): """Create a sensor grid from a ladybug_geometry Mesh3D. The centroids of the mesh faces will be used to create the sensor positions and the normals of the faces will set the directions. The mesh will be assigned to the resulting SensorGrid's mesh property. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. mesh: A ladybug_geometry Mesh3D. """ assert isinstance(mesh, Mesh3D), 'Expected ladybug_geometry Mesh3D for ' \ 'SensorGrid.from_mesh3d. Got {}.'.format(type(mesh)) positions = [(pt.x, pt.y, pt.z) for pt in mesh.face_centroids] directions = [(vec.x, vec.y, vec.z) for vec in mesh.face_normals] s_grid = cls.from_position_and_direction(identifier, positions, directions) s_grid.mesh = mesh return s_grid
[docs] @classmethod def from_face3d(cls, identifier, faces, x_dim, y_dim=None, offset=0, flip=False): """Create a sensor grid from an array of ladybug_geometry Face3D. The Face3D will be converted into a gridded mesh using the input x_dim and y_dim. The centroids of the mesh faces will be used to create the sensor positions and the normals of the faces will set the directions. The mesh will be assigned to the resulting SensorGrid's mesh property and the Face3Ds assigned to the base_geometry. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. faces: An array of ladybug_geometry Face3Ds from which a SensorGrid will be generated. x_dim: The x dimension of the grid cells as a number. y_dim: The y dimension of the grid cells as a number. Default is None, which will assume the same cell dimension for y as is set for x. offset: A number for how far to offset the grid from the base face. flip: Set to True to have the mesh normals reversed from the direction of this face and to have the offset input move the mesh in the opposite direction from this face normal. Defaults to False, which means the normal direction of the face will be used as the direction of the sensor grids. """ meshes = [] for face in faces: try: meshes.append(face.mesh_grid(x_dim, y_dim, offset, flip)) except AssertionError: # tiny geometry not compatible with quad faces continue assert len(meshes) > 0, 'None of the Face3Ds input to SensorGrid.from_face3d ' \ 'can produce a quad grid at the specified grid dimensions.' if len(meshes) == 1: s_grid = cls.from_mesh3d(identifier, meshes[0]) elif len(meshes) > 1: s_grid = cls.from_mesh3d(identifier, Mesh3D.join_meshes(meshes)) s_grid.base_geometry = faces return s_grid
[docs] @classmethod def from_positions_radial( cls, identifier, positions, dir_count=8, start_vector=Vector3D(0, -1, 0), mesh_radius=0): """Create a sensor grid from radial directions around sensor positions. This type of sensor grid is particularly helpful for studies of multiple view directions, such as imageless glare studies. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. positions: A list of (x, y ,z) tuples for position of sensors. dir_count: A positive integer for the number of radial directions to be generated around each position. (Default: 8). start_vector: A Vector3D to set the start direction of the generated directions. This can be used to orient the resulting sensors to specific parts of the scene. It can also change the elevation of the resulting directions since this start vector will always be rotated in the XY plane to generate the resulting directions. (Default: (0, -1, 0)). mesh_radius: An optional number that can be used to generate a Mesh3D that is aligned with the resulting sensors and will automatically be assigned to the grid's mesh property. Such meshes will resemble a circle around each sensor with the specified radius and will contain triangular faces that can be colored with simulation results. If zero, no mesh will be generated for the sensor grid. (Default: 0). """ # set up the vectors to generate the rays inc_ang = (math.pi * 2) / dir_count vw_vecs = [start_vector.rotate_xy(i * inc_ang) for i in range(dir_count)] vw_vecs = [(round(v.x, 5), round(v.y, 5), round(v.z, 3)) for v in vw_vecs] # set up the sensor grid object sensors = tuple(Sensor(pt, v) for pt in positions for v in vw_vecs) sg = cls(identifier, sensors) # generate the mesh if it was requested if mesh_radius > 0: sg.mesh = cls.radial_positions_mesh( positions, dir_count, start_vector, mesh_radius) return sg
[docs] @classmethod def from_mesh3d_radial( cls, identifier, mesh, dir_count=8, start_vector=Vector3D(0, -1, 0), mesh_radius=0): """Create a sensor grid from radial directions around centroids of a Mesh3D. This type of sensor grid is particularly helpful for studies of multiple view directions, such as imageless glare studies. Args: identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. mesh: A ladybug_geometry Mesh3D from which the sensor grid will be generated. dir_count: A positive integer for the number of radial directions to be generated around each position. (Default: 8). start_vector: A Vector3D to set the start direction of the generated directions. This can be used to orient the resulting sensors to specific parts of the scene. It can also change the elevation of the resulting directions since this start vector will always be rotated in the XY plane to generate the resulting directions. mesh_radius: An optional number that can be used to generate a Mesh3D that is aligned with the resulting sensors and will automatically be assigned to the grid's mesh property. Such meshes will resemble a circle around each sensor with the specified radius and will contain triangular faces that can be colored with simulation results. If zero, no mesh will be generated for the sensor grid. (Default: 0). """ assert isinstance(mesh, Mesh3D), 'Expected ladybug_geometry Mesh3D for ' \ 'SensorGrid.from_mesh3d. Got {}.'.format(type(mesh)) positions = [(pt.x, pt.y, pt.z) for pt in mesh.face_centroids] return cls.from_positions_radial( identifier, positions, dir_count, start_vector, mesh_radius)
[docs] @classmethod def from_file(cls, file_path, start_line=None, end_line=None, identifier=None): """Create a sensor grid from a sensor (.pts) file. The sensors must be structured as x1, y1, z1, dx1, dy1, dz1 x2, y2, z2, dx2, dy2, dz2 ... The lines that start with # will be considred as commented lines and won't be loaded. However, these commented lines are still considered in total line count for the start_line and end_line inputs. Args: file_path: Full path to sensors file start_line: Start line including the comments (default: 0). end_line: End line as an integer including the comments (default: last line in file). identifier: Text string for a unique SensorGrid ID. Must not contain spaces or special characters. This will be used to identify the object across a model and in the exported Radiance files. If None, the file name will be used. (Default: None) """ if not os.path.isfile(file_path): raise IOError("Can't find {}.".format(file_path)) identifier = identifier or os.path.split(os.path.splitext(file_path)[0])[-1] start_line = int(start_line) if start_line is not None else 0 try: end_line = int(end_line) except TypeError: end_line = float('+inf') line_count = end_line - start_line + 1 sensors = [] with open(file_path, 'r') as inf: for _ in range(start_line): next(inf) for count, l in enumerate(inf): if not count < line_count: break if not l or l[0] == '#': # commented line continue sensors.append(Sensor.from_raw_values(*l.split())) return cls(identifier, sensors)
@property def identifier(self): """Get or set text for a unique SensorGrid identifier.""" return self._identifier @identifier.setter def identifier(self, n): self._identifier = typing.valid_rad_string(n, 'sensor grid identifier') @property def display_name(self): """Get or set a string for the object name without any character restrictions. If not set, this will be equal to the identifier. """ if self._display_name is None: return self._identifier return self._display_name @display_name.setter def display_name(self, value): try: self._display_name = str(value) except UnicodeEncodeError: # Python 2 machine lacking the character set self._display_name = value # keep it as unicode @property def sensors(self): """Get or set a tuple of Sensor objects for the grid sensors.""" return self._sensors @sensors.setter def sensors(self, value): self._sensors = tuple(value) for sen in self._sensors: if not isinstance(sen, Sensor): raise ValueError( 'SensorGrid sensors must be of the Sensor type not %s' % type(sen)) @property def positions(self): """Get a generator of sensor positions as x, y, z.""" return (ap.pos for ap in self.sensors) @property def directions(self): """Get a generator of sensor directions as x, y , z.""" return (ap.dir for ap in self.sensors) @property def count(self): """Get the number of sensors.""" return len(self._sensors) @property def room_identifier(self): """Get or set text for the Room identifier to which this SensorGrid belongs. This will be used in the info_dict method to narrow down the number of aperture groups that have to be run with this sensor grid. If None, the grid will be run with all aperture groups in the model. """ return self._room_identifier @room_identifier.setter def room_identifier(self, n): self._room_identifier = typing.valid_string(n) @property def group_identifier(self): """Get or set text for the group identifier to which this SensorGrid belongs. This will be used in the write to radiance folder method to write all the grids with the same group identifier under the same subfolder. You may use / in name to identify nested grid groups. For example floor_1/living_room create a sensor grid under living_room/floor_1 subfolder. If None, the grid will be written to the root of grids folder. """ return self._group_identifier @group_identifier.setter def group_identifier(self, identifier_key): if identifier_key is not None: identifier_key = \ '/'.join( typing.valid_rad_string(key, 'sensor grid group identifier') for key in identifier_key.split('/') ) self._group_identifier = identifier_key @property def full_identifier(self): """Get full identifier for a sensor grid. For a sensor grid with group identifier it will be group_identifier/identifier """ return self.identifier if not self.group_identifier \ else '%s/%s' % (self.group_identifier, self.identifier) @property def light_path(self): """Get or set list of lists for the light path from the grid to the sky. Each sub-list contains identifiers of aperture groups through which light passes. (eg. [['SouthWindow1'], ['__static_apertures__', 'NorthWindow2']]). Setting this property will override any auto-calculation of the light path from the model upon export to the simulation. """ return self._light_path @light_path.setter def light_path(self, l_path): if l_path is not None: assert isinstance(l_path, (tuple, list)), 'Expected list or tuple for ' \ 'light_path. Got {}.'.format(type(l_path)) for ap_list in l_path: assert isinstance(ap_list, (tuple, list)), 'Expected list or tuple ' \ 'for light_path sub-list. Got {}.'.format(type(ap_list)) for ap in ap_list: assert isinstance(ap, str), 'Expected text for light_path ' \ 'aperture group identifier. Got {}.'.format(type(ap)) self._light_path = l_path @property def mesh(self): """Get or set an optional ladybug_geometry Mesh3D that aligns with the sensors. Note that the number of sensors in the grid must match the number of faces or the number vertices within the Mesh3D. """ return self._mesh @mesh.setter def mesh(self, value): if value is not None: assert isinstance(value, Mesh3D), \ 'Expected Mesh3D for SensorGrid mesh. Got {}.'.format(type(value)) assert self.count == len(value.faces) or self.count == len(value.vertices), \ 'Number of sensors ({}) does not match the number of mesh faces ({}) ' \ 'nor the number of vertices ({}).'.format( self.count, len(value.faces), len(value.vertices)) self._mesh = value @property def base_geometry(self): """Get or set an optional array of ladybug_geometry Face3D used to make the grid. There are no restrictions on how this property relates to the sensors and it is provided only to assist with the display of the grid when the number of sensors or the mesh is too large to be practically visualized. """ return self._base_geometry @base_geometry.setter def base_geometry(self, value): if value is not None: if not isinstance(value, tuple): value = tuple(value) for face in value: assert isinstance(face, Face3D), 'Expected Face3D for SensorGrid ' \ 'base_geometry. Got {}.'.format(type(value)) self._base_geometry = value
[docs] def info_dict(self, model=None): """Get a dictionary with information about the SensorGrid. This can be written as a JSON into a model radiance folder to narrow down the number of aperture groups that have to be run with this sensor grid. Args: model: A honeybee Model object which will be used to identify the aperture groups that will be run with this sensor grid. """ base = { 'count': self.count, 'name': self.display_name, 'identifier': self.identifier, 'group': self.group_identifier or '', 'full_id': self.full_identifier } if self._light_path: base['light_path'] = self._light_path elif model and self._room_identifier: # auto-calculate the light path try: base['light_path'] = light_path_from_room(model, self._room_identifier) except ValueError: # room is not in the model; just ignore light path pass if self._group_identifier: base['group_identifier'] = self._group_identifier return base
[docs] def enclosure_info_dict(self, model, air_boundary_distance=0): """Get a dictionary with information about sensor relation to rooms. This can be written as a JSON in order to map sensors with appropriate energy simulation results in thermal mapping workflows. Args: model: A honeybee Model object which will be used to identify the rooms/enclosure that each sensor in the grid is contained within. air_boundary_distance: An optional number to set the distance from air boundaries over which values should be interpolated. Using 0 will assume a hard edge between Rooms of the same radiant enclosures. (Default: 0). """ # setup rooms and lists to check enclosure info enclosures, sensor_indices, air_bound_proximity = {}, [], {} has_indoor, has_outdoor = False, False rooms = model._rooms if self.room_identifier: # put the assigned room first for faster calculation rooms = model.rooms_by_identifier([self.room_identifier]) + rooms # have a dictionary to track proximity to AirBoundary faces model_ab = {} for room in rooms: model_ab[room.identifier] = \ [f for f in room.faces if isinstance(f.type, AirBoundary)] def _air_boundary_info(distance, face, room_index): """Method to perform interpolation across AirBoundary Faces.""" adj_room = face.boundary_condition.boundary_condition_objects[-1] try: adj_i = enclosures[adj_room] except KeyError: # the first time that this room is needed adj_i = len(enclosures) enclosures[adj_room] = len(enclosures) fac_1 = 0.5 + (distance / (air_boundary_distance * 2)) return {room_index: fac_1, adj_i: 1 - fac_1} # loop through the sensors and verify the room that they belong to for i, sensor in enumerate(self.sensors): sensor_pt = Point3D(*sensor.pos) for room in rooms: if room.geometry.is_point_inside(sensor_pt): # add the room index of the sensor try: sensor_indices.append(enclosures[room.identifier]) except KeyError: # the first time that this room is needed enclosures[room.identifier] = len(enclosures) sensor_indices.append(enclosures[room.identifier]) has_indoor = True # test if the sensor is near any AriBoundary faces air_b = model_ab[room.identifier] if air_boundary_distance > 0 and len(air_b) != 0: for face in air_b: fg = face.geometry close_pt = fg._plane.closest_point(sensor_pt) p_dist = sensor_pt.distance_to_point(close_pt) if p_dist <= air_boundary_distance: close_pt_2d = fg._plane.xyz_to_xy(close_pt) g_dist = fg.polygon2d.distance_to_point(close_pt_2d) f_dist = math.sqrt(p_dist ** 2 + g_dist ** 2) if f_dist <= air_boundary_distance: ab_info = _air_boundary_info( f_dist, face, sensor_indices[-1]) try: air_bound_proximity[i].append(ab_info) except KeyError: air_bound_proximity[i] = [ab_info] break # we found the room and we don't need to iterate else: # the sensor is completely outside and not a part of a room sensor_indices.append(-1) has_outdoor = True # write out the enclosure info JSON mapper = sorted(enclosures, key=enclosures.__getitem__) return { 'has_indoor': has_indoor, 'has_outdoor': has_outdoor, 'mapper': mapper, 'sensor_indices': sensor_indices, 'air_bound_proximity': air_bound_proximity }
[docs] def to_radiance(self): """Return sensors grid as a Radiance string.""" return "\n".join((ap.to_radiance() for ap in self._sensors))
[docs] def to_file(self, folder, file_name=None, mkdir=False, ignore_group=False): """Write this sensor grid to a Radiance sensors file. Args: folder: Target folder. If grid is part of a sensor group identifier it will be written to a subfolder with group identifier name. file_name: Optional file name without extension. (Default: self.identifier) mkdir: A boolean to indicate if the folder should be created in case it doesn't exist already. (Default: False). ignore_group: A boolean to indicate if creating a new subfolder for sensor group should be ignored. (Default: False). Returns: Full path to newly created file. """ identifier = file_name or self.identifier + '.pts' if not identifier.endswith('.pts'): identifier += '.pts' if not ignore_group and self.group_identifier: folder = os.path.normpath(os.path.join(folder, self.group_identifier)) mkdir = True # in most cases the subfolder does not exist already return futil.write_to_file_by_name( folder, identifier, self.to_radiance() + '\n', mkdir)
[docs] def to_files(self, folder, count, base_name=None, mkdir=False): """Split this sensor grid and write them to several files. This method writes the files directly to the folder and doesn't create a subfolder for sensor groups if any. You can add the group subfolder to folder before calling the method. Args: folder: Target folder. count: Number of files. base_name: Optional text string for a unique base name for the sensor grid files. (Default: self.identifier) mkdir: A boolean to indicate if the folder should be created in case it doesn't exist already (Default: False). Returns: A list of dicts containing the grid name, path to the grid and full path to the grid. """ count = typing.int_in_range(count, 1, input_name='file count') base_name = base_name or self.identifier if count == 1 or self.count == 0: name = '%s_0000' % base_name full_path = self.to_file(folder, name, mkdir, ignore_group=True) return [ {'name': name if not name.endswith('.pts') else name.replace('.pts', ''), 'path': name + '.pts' if not name.endswith('.pts') else name, 'full_path': full_path, 'count': self.count} ] # calculate sensor count in each file sc = int(round(self.count / count)) sensors = iter(self._sensors) for fc in range(count - 1): name = '%s_%04d.pts' % (base_name, fc) content = '\n'.join((next(sensors).to_radiance() for _ in range(sc))) futil.write_to_file_by_name(folder, name, content + '\n', mkdir) # write whatever is left to the last file name = '%s_%04d.pts' % (base_name, count - 1) content = '\n'.join((sensor.to_radiance() for sensor in sensors)) futil.write_to_file_by_name(folder, name, content + '\n', mkdir) grids_info = [] for c in range(count): name = '%s_%04d' % (base_name, c) path = '%s.pts' % name full_path = os.path.join(folder, path) grids_info.append({ 'name': name, 'path': path, 'full_path': full_path, 'count': sc }) # adjust the count for the last grid grids_info[-1]['count'] = self.count - sc * (count - 1) return grids_info
[docs] def to_dict(self): """Convert SensorGrid to a dictionary.""" base = { 'type': 'SensorGrid', 'identifier': self.identifier, 'sensors': [sen.to_dict() for sen in self.sensors] } if self._display_name is not None: base['display_name'] = self.display_name if self._room_identifier is not None: base['room_identifier'] = self.room_identifier if self._group_identifier is not None: base['group_identifier'] = self.group_identifier if self._light_path is not None: base['light_path'] = self.light_path if self._mesh is not None: base['mesh'] = self._mesh.to_dict() if self._base_geometry is not None: base['base_geometry'] = [face.to_dict() for face in self._base_geometry] if self._group_identifier is not None: base['group_identifier'] = self.group_identifier return base
[docs] def to_json(self, folder, file_name=None, mkdir=False, ignore_group=False): """Write this sensor grid to a JSON file. Args: folder: Target folder. If grid is part of a sensor group identifier it will be written to a subfolder with group identifier name. file_name: Optional file name without extension. (Default: self.identifier) mkdir: A boolean to indicate if the folder should be created in case it doesn't exist already. (Default: False). ignore_group: A boolean to indicate if creating a new subfolder for sensor group should be ignored. (Default: False). Returns: Full path to newly created file. """ identifier = file_name or self.identifier + '.json' if not identifier.endswith('.json'): identifier += '.json' if not ignore_group and self.group_identifier: folder = os.path.normpath(os.path.join(folder, self.group_identifier)) mkdir = True # in most cases the subfolder does not exist already return futil.write_to_file_by_name( folder, identifier, json.dumps(self.to_dict()), mkdir)
[docs] def to_radial_grid(self, dir_count=8, start_vector=Vector3D(0, -1, 0), mesh_radius=0): """Get a radial sensor grid using the positions of this grid as a base. All properties of this grid will be transferred to the new grid, including the identifier, room_identifier, etc. The mesh will be recomputed based on the input mesh_radius but any base_geometry will be transferred. Note that calling this method on a SensorGrid that is already formatted as a radial grid will result in a lot of unwanted duplication of sensors. Args: dir_count: A positive integer for the number of radial directions to be generated around each position. (Default: 8). start_vector: A Vector3D to set the start direction of the generated directions. This can be used to orient the resulting sensors to specific parts of the scene. It can also change the elevation of the resulting directions since this start vector will always be rotated in the XY plane to generate the resulting directions. (Default: (0, -1, 0)). mesh_radius: An optional number that can be used to generate a Mesh3D that is aligned with the resulting sensors and will automatically be assigned to the grid's mesh property. Such meshes will resemble a circle around each sensor with the specified radius and will contain triangular faces that can be colored with simulation results. If zero, no mesh will be generated for the sensor grid. (Default: 0). """ new_grid = SensorGrid.from_positions_radial( self.identifier, self.positions, dir_count, start_vector, mesh_radius) new_grid._display_name = self._display_name new_grid._room_identifier = self._room_identifier new_grid.group_identifier = self.group_identifier new_grid._light_path = self._light_path new_grid._base_geometry = self._base_geometry return new_grid
[docs] def move(self, moving_vec): """Move this sensor grid along a vector. Args: moving_vec: A ladybug_geometry Vector3D with the direction and distance to move the sensor. """ for sens in self._sensors: sens.move(moving_vec) if self._mesh is not None: self._mesh = self._mesh.move(moving_vec) if self._base_geometry is not None: self._base_geometry = \ tuple(face.move(moving_vec) for face in self._base_geometry)
[docs] def rotate(self, axis, angle, origin): """Rotate this sensor grid by a certain angle around an axis and origin. Args: axis: Rotation axis as a Vector3D. angle: An angle for rotation in degrees. origin: A ladybug_geometry Point3D for the origin around which the object will be rotated. """ for sens in self._sensors: sens.rotate(axis, angle, origin) r_angle = math.radians(angle) if self._mesh is not None: self._mesh = self._mesh.rotate(axis, r_angle, origin) if self._base_geometry is not None: self._base_geometry = \ tuple(face.rotate(axis, r_angle, origin) for face in self._base_geometry)
[docs] def rotate_xy(self, angle, origin): """Rotate this sensor grid counterclockwise in the world XY plane by an angle. Args: angle: An angle in degrees. origin: A ladybug_geometry Point3D for the origin around which the object will be rotated. """ for sens in self._sensors: sens.rotate_xy(angle, origin) r_angle = math.radians(angle) if self._mesh is not None: self._mesh = self._mesh.rotate_xy(r_angle, origin) if self._base_geometry is not None: self._base_geometry = \ tuple(face.rotate_xy(r_angle, origin) for face in self._base_geometry)
[docs] def reflect(self, plane): """Reflect this sensor grid across a plane. Args: plane: A ladybug_geometry Plane across which the object will be reflected. """ for sens in self._sensors: sens.reflect(plane) if self._mesh is not None: self._mesh = self._mesh.reflect(plane.n, plane.o) if self._base_geometry is not None: self._base_geometry = \ tuple(face.reflect(plane.n, plane.o) for face in self._base_geometry)
[docs] def scale(self, factor, origin=None): """Scale this sensor grid by a factor from an origin point. Args: factor: A number representing how much the object should be scaled. origin: A ladybug_geometry Point3D representing the origin from which to scale. If None, it will be scaled from the World origin (0, 0, 0). """ for sens in self._sensors: sens.scale(factor, origin) if self._mesh is not None: self._mesh = self._mesh.scale(factor, origin) if self._base_geometry is not None: self._base_geometry = \ tuple(face.scale(factor, origin) for face in self._base_geometry)
[docs] def duplicate(self): """Get a copy of this object.""" return self.__copy__()
[docs] @staticmethod def from_face3d_arrays( base_identifier, face_arrays, x_dim, y_dim=None, offset=0, flip=False): """Get an array of SensorGrids from an matrix (list of lists) of Face3Ds. This method uses the from_face3d classmethod but includes checks to catch cases where the input Face3Ds cannot support the generation of quad grids. In this case, the invalid SensorGrid will not be generated and will be excluded form the output list of SensorGrids. Args: base_identifier: Text string for a unique SensorGrid ID, which will be used as a base for all of the output SensorGrid IDs. Must not contain spaces or special characters. faces: An matrix (list of lists) of ladybug_geometry Face3Ds from which SensorGrids will be generated. x_dim: The x dimension of the grid cells as a number. y_dim: The y dimension of the grid cells as a number. Default is None, which will assume the same cell dimension for y as is set for x. offset: A number for how far to offset the grid from the base face. flip: Set to True to have the mesh normals reversed from the direction of this face and to have the offset input move the mesh in the opposite direction from this face normal. Defaults to False, which means the normal direction of the face will be used as the direction of the sensor grids. """ grids = [] for i, faces in enumerate(face_arrays): grid_id = '{}_{}'.format(base_identifier, i) try: grids.append( SensorGrid.from_face3d(grid_id, faces, x_dim, y_dim, offset, flip) ) except AssertionError: # none of the Face3Ds make a valid grid continue return grids
[docs] @staticmethod def radial_positions_mesh( positions, dir_count=8, start_vector=Vector3D(0, -1, 0), mesh_radius=1): """Generate a Mesh3D resembling a circle around each position. Args: positions: A list of (x, y ,z) tuples for position of sensors. dir_count: A positive integer for the number of radial directions to be generated around each position. (Default: 8). start_vector: A Vector3D to set the start direction of the generated directions. (Default: (0, -1, 0)). mesh_radius: A number for the radius of the radial mesh to be generated around each sensor. (Default: 1). """ # set up the start vector and rotation angles st_vec = Vector3D(start_vector.x, start_vector.y, 0).normalize() st_vec = st_vec * mesh_radius inc_ang = (math.pi * 2) / dir_count st_vec = st_vec.rotate_xy(-inc_ang / 2) # loop through the positions and angles to create the mesh verts, faces = [], [] v_count = 0 for pt in positions: st_pt = Point3D(*pt) nxt_pt = st_pt.move(st_vec) verts.extend([st_pt, nxt_pt]) for i in range(dir_count - 1): new_pt = verts[-1].rotate_xy(inc_ang, st_pt) new_f = (v_count, v_count + i + 1, v_count + i + 2) verts.append(new_pt) faces.append(new_f) faces.append((v_count, v_count + dir_count, v_count + 1)) v_count += (dir_count + 1) return Mesh3D(verts, faces)
def __len__(self): """Number of sensors in this grid.""" return len(self.sensors) def __getitem__(self, index): """Get a sensor for an index.""" return self.sensors[index] def __copy__(self): new_obj = SensorGrid(self.identifier, (sen.duplicate() for sen in self.sensors)) new_obj._display_name = self._display_name new_obj._room_identifier = self._room_identifier new_obj.group_identifier = self.group_identifier new_obj._light_path = self._light_path new_obj._mesh = self._mesh new_obj._base_geometry = self._base_geometry return new_obj def __key(self): """A tuple based on the object properties, useful for hashing.""" return ( self.identifier, self._display_name, self._room_identifier, self._room_identifier) + tuple(hash(sensor) for sensor in self.sensors) def __hash__(self): return hash(self.__key()) def __eq__(self, other): return isinstance(other, SensorGrid) and self.__key() == other.__key() and \ self.light_path == other.light_path def __ne__(self, value): return not self.__eq__(value) def __iter__(self): """Sensors iterator.""" return iter(self.sensors) def __str__(self): """String repr.""" return self.to_radiance()
[docs] def ToString(self): """Overwrite ToString .NET method.""" return self.__repr__()
def __repr__(self): """Get the string representation of the sensor grid.""" return 'SensorGrid: {} [{} sensors]'.format(self.display_name, len(self.sensors))