# coding=utf-8
"""Module for coloring Model geometry with energy simulation results."""
from __future__ import division
from .match import match_rooms_to_data, match_faces_to_data
from honeybee.face import Face
from honeybee.room import Room
from honeybee.facetype import Floor
from honeybee.typing import int_in_range
from ladybug.dt import Date, DateTime
from ladybug.analysisperiod import AnalysisPeriod
from ladybug.datacollection import MonthlyCollection, DailyCollection, \
MonthlyPerHourCollection, HourlyContinuousCollection, HourlyDiscontinuousCollection
from ladybug.graphic import GraphicContainer
from ladybug.legend import LegendParameters
from ladybug_geometry.geometry3d.pointvector import Point3D
class _ColorObject(object):
"""Base class for coloring geometry with simulation results.
Properties:
* data_collections
* legend_parameters
* simulation_step
* geo_unit
* title_text
* data_type_text
* data_type
* unit
* analysis_period
* min_point
* max_point
"""
__slots__ = ('_data_collections', '_legend_parameters', '_simulation_step',
'_normalize', '_geo_unit', '_matched_objects', '_base_collection',
'_base_type', '_base_unit', '_min_point', '_max_point')
UNITS = ('m', 'mm', 'ft', 'in', 'cm')
def __init__(self, data_collections, legend_parameters=None,
simulation_step=None, geo_unit='m'):
"""Initialize ColorObject."""
# check the input collections
acceptable_colls = (MonthlyCollection, DailyCollection, MonthlyPerHourCollection,
HourlyContinuousCollection, HourlyDiscontinuousCollection)
try:
data_collections = list(data_collections)
except TypeError:
raise TypeError('Input data_collections must be an array. Got {}.'.format(
type(data_collections)))
assert len(data_collections) > 0, \
'ColorObject must have at least one data_collection.'
for i, coll in enumerate(data_collections):
assert isinstance(coll, acceptable_colls), 'Expected data collection for ' \
'ColorObject data_collections. Got {}.'.format(type(coll))
if not coll.validated_a_period:
data_collections[i] = coll.validate_analysis_period()
self._base_collection = data_collections[0]
self._base_type = self._base_collection.header.data_type
self._base_unit = self._base_collection.header.unit
for coll in data_collections[1:]:
assert coll.header.unit == self._base_unit, \
'ColorObject data_collections must all have matching units. ' \
'{} != {}.'.format(coll.header.unit, self._base_unit)
assert len(coll.values) == len(self._base_collection.values), \
'ColorObject data_collections must all be aligned with one another.' \
'{} != {}'.format(len(coll.values), len(self._base_collection.values))
self._data_collections = data_collections
# assign the other properties of this object
self.legend_parameters = legend_parameters
self.simulation_step = simulation_step
self.geo_unit = geo_unit
self._normalize = False
@property
def data_collections(self):
"""Get a tuple of data collections assigned to this object."""
return tuple(self._data_collections)
@property
def legend_parameters(self):
"""Get or set the legend parameters."""
return self._legend_parameters
@legend_parameters.setter
def legend_parameters(self, value):
if value is not None:
assert isinstance(value, LegendParameters), \
'Expected LegendParameters. Got {}.'.format(type(value))
self._legend_parameters = value.duplicate()
else:
self._legend_parameters = LegendParameters()
@property
def simulation_step(self):
"""Get or set an integer to select a specific step of the data collections."""
return self._simulation_step
@simulation_step.setter
def simulation_step(self, value):
if value is not None:
value = int_in_range(
value, 0, len(self._base_collection) - 1, 'simulation_step')
self._simulation_step = value
@property
def geo_unit(self):
"""Text to note the units that the object geometry is in.
This will be used to ensure the legend units display correctly when
data is floor-normalized. Examples include 'm', 'mm', 'ft'.
"""
return self._geo_unit
@geo_unit.setter
def geo_unit(self, value):
self._geo_unit = str(value)
assert self._geo_unit in self.UNITS, \
'Unit "{}" is not supported in color object.'.format(self._geo_unit)
@property
def title_text(self):
"""Text string for the title of the color zones."""
d_type_text = self.data_type_text
if self._simulation_step is not None: # specific index from all collections
time_text = self.time_interval_text(self.simulation_step)
if self._base_type.normalized_type is not None and self._normalize:
d_type_text = '{} {}'.format(d_type_text, 'Intensity')
else: # average or total the data
time_text = str(self.analysis_period).split('@')[0]
if self._base_type.normalized_type is None or not self._normalize:
if not self._base_type.cumulative:
d_type_text = '{} {}'.format('Average', d_type_text)
else:
d_type_text = '{} {}'.format('Total', d_type_text)
else:
if not self._base_type.cumulative:
d_type_text = '{} {} {}'.format('Average', d_type_text, 'Intensity')
else:
d_type_text = '{} {} {}'.format('Total', d_type_text, 'Intensity')
return '{}\n{}'.format('{} ({})'.format(d_type_text, self.unit), time_text)
@property
def data_type_text(self):
"""Text for the data type.
This will be the full name of the EnergyPlus output if the DataCollection
header metadata contains a 'type' key. Otherwise, this will be the name
of the data_type object.
"""
m_data = self._base_collection.header.metadata
return m_data['type'] if 'type' in m_data else str(self.data_type)
@property
def data_type(self):
"""The data type of this object's data collections."""
if self._base_type.normalized_type is None or not self._normalize:
return self._base_type
else:
return self._base_type.normalized_type()
@property
def unit(self):
"""The unit of this object's data collections."""
if self._base_type.normalized_type is not None and self._normalize:
_geo_unit = 'ft' if self._geo_unit in ('ft', 'in') else 'm'
return '{}/{}2'.format(self._base_unit, _geo_unit) if '/' not in \
self._base_unit else '{}-{}2'.format(self._base_unit, _geo_unit)
else:
return self._base_unit
@property
def analysis_period(self):
"""The analysis_period of this object's data collections."""
return self._base_collection.header.analysis_period
@property
def min_point(self):
"""Get a Point3D for the minimum of the box around the rooms."""
return self._min_point
@property
def max_point(self):
"""Get a Point3D for the maximum of the box around the rooms."""
return self._max_point
def time_interval_text(self, simulation_step):
"""Get text for a specific time simulation_step of the data collections.
Args:
simulation_step: An integer for the step of simulation for which
text should be generated.
"""
hourly_colls = (HourlyContinuousCollection, HourlyDiscontinuousCollection)
if isinstance(self._base_collection, hourly_colls):
return str(self._base_collection.datetimes[simulation_step])
elif isinstance(self._base_collection, MonthlyCollection):
month_names = AnalysisPeriod.MONTHNAMES
return month_names[self._base_collection.datetimes[simulation_step]]
elif isinstance(self._base_collection, DailyCollection):
return str(Date.from_doy(self._base_collection.datetimes[simulation_step]))
elif isinstance(self._base_collection, MonthlyPerHourCollection):
dt_tuple = self._base_collection.datetimes[simulation_step]
date_time = DateTime(month=dt_tuple[0], hour=dt_tuple[1])
return date_time.strftime('%b %H:%M')
def _calculate_min_max(self, hb_objs):
"""Calculate maximum and minimum Point3D for a set of rooms."""
st_rm_min, st_rm_max = hb_objs[0].geometry.min, hb_objs[0].geometry.max
min_pt = [st_rm_min.x, st_rm_min.y, st_rm_min.z]
max_pt = [st_rm_max.x, st_rm_max.y, st_rm_max.z]
for room in hb_objs[1:]:
rm_min, rm_max = room.geometry.min, room.geometry.max
if rm_min.x < min_pt[0]:
min_pt[0] = rm_min.x
if rm_min.y < min_pt[1]:
min_pt[1] = rm_min.y
if rm_min.z < min_pt[2]:
min_pt[2] = rm_min.z
if rm_max.x > max_pt[0]:
max_pt[0] = rm_max.x
if rm_max.y > max_pt[1]:
max_pt[1] = rm_max.y
if rm_max.z > max_pt[2]:
max_pt[2] = rm_max.z
self._min_point = Point3D(min_pt[0], min_pt[1], min_pt[2])
self._max_point = Point3D(max_pt[0], max_pt[1], max_pt[2])
def ToString(self):
"""Overwrite .NET ToString."""
return self.__repr__()
def __repr__(self):
return 'Color Object:'
[docs]
class ColorRoom(_ColorObject):
"""Object for visualization zone-level simulation results on Honeybee Room geometry.
Args:
data_collections: An array of data collections of the same data type,
which will be used to color Rooms with simulation results. Data collections
can be of any class (eg. MonthlyCollection, DailyCollection) but they
should all have headers with metadata dictionaries with 'Zone' or
'System' keys. These keys will be used to match the data in the collections
to the input rooms.
rooms: An array of honeybee Rooms, which will be matched to the data_collections.
The length of these Rooms does not have to match the data_collections
and this object will only create visualizations for rooms that are
found to be matching.
legend_parameters: An optional LegendParameter object to change the display
of the ColorRooms (Default: None).
simulation_step: An optional integer (greater than or equal to 0) to select
a specific step of the data collections for which result values will be
generated. If None, the geometry will be colored with the total of
results in the data_collections if the data type is cumulative or with
the average of results if the data type is not cumulative. Default: None.
normalize_by_floor: Boolean to note whether results should be normalized
by the floor area of the Room if the data type of the data_collections
supports it. If False, values will be generated using sum total of
the data collection values. Note that this input has no effect if
the data type of the data_collections is not normalizable since data
collection values will always be averaged for this case. Default: True.
geo_unit: Optional text to note the units that the Room geometry is in.
This will be used to ensure the legend units display correctly when
data is floor-normalized. Examples include 'm', 'mm', 'ft'.
(Default: 'm' for meters).
space_based: Boolean to note whether the result is reported on the EnergyPlus
Space level instead of the Zone level. In this case, the matching to
the Room will account for the fact that the Space name is the Room
name with _Space added to it. (Default: False).
Properties:
* data_collections
* rooms
* legend_parameters
* simulation_step
* normalize_by_floor
* geo_unit
* space_based
* matched_rooms
* matched_data
* matched_values
* matched_floor_faces
* matched_floor_areas
* graphic_container
* title_text
* data_type_text
* data_type
* unit
* analysis_period
* min_point
* max_point
"""
__slots__ = ('_rooms', '_space_based')
def __init__(self, data_collections, rooms, legend_parameters=None,
simulation_step=None, normalize_by_floor=True, geo_unit='m',
space_based=False):
"""Initialize ColorRoom."""
# initialize the base object
_ColorObject.__init__(self, data_collections, legend_parameters,
simulation_step, geo_unit)
for coll in self._data_collections:
assert 'Zone' in coll.header.metadata or 'System' in coll.header.metadata, \
'ColorRoom data collection does not have metadata associated with Zones.'
try: # check the input rooms
rooms = tuple(rooms)
except TypeError:
raise TypeError('Input rooms must be an array. Got {}.'.format(type(rooms)))
assert len(rooms) > 0, 'ColorRooms must have at least one room.'
for room in rooms:
assert isinstance(room, Room), 'Expected honeybee Room for ' \
'ColorRoom rooms. Got {}.'.format(type(room))
self._rooms = rooms
self._calculate_min_max(self._rooms)
# match the rooms with the data collections
self._space_based = bool(space_based)
self._matched_objects = match_rooms_to_data(
data_collections, rooms, space_based=self._space_based)
if len(self._matched_objects) == 0:
raise ValueError('None of the ColorRoom data collections could be '
'matched to the input rooms')
# assign the normalize property
self.normalize_by_floor = normalize_by_floor
@property
def rooms(self):
"""Get a tuple of honeybee Rooms assigned to this object."""
return self._rooms
@property
def normalize_by_floor(self):
"""Get or set a boolean for whether results should be normalized by floor area.
"""
return self._normalize
@normalize_by_floor.setter
def normalize_by_floor(self, value):
self._normalize = bool(value)
@property
def space_based(self):
"""Get a boolean for whether results are set to be space-based."""
return self._space_based
@property
def matched_rooms(self):
"""Get a tuple of honeybee Rooms that have been matched to the data."""
return tuple(obj[0] for obj in self._matched_objects)
@property
def matched_data(self):
"""Get a tuple of data collections that have been matched to the rooms."""
return tuple(obj[1] for obj in self._matched_objects)
@property
def matched_values(self):
"""Get an array of numbers that correspond to the matched_rooms.
These values are derived from the data_collections but they will be
averaged/totaled and normalized by Room floor area depending on the
other inputs to this object.
"""
if self._simulation_step is not None: # specific index from all collections
if self._base_type.normalized_type is None or not self._normalize:
return tuple(obj[1][self._simulation_step] for obj in
self._matched_objects)
else: # normalize the data by the floor area
vals = []
for obj, f_area in zip(self._matched_objects, self.matched_floor_areas):
try:
vals.append(obj[1][self._simulation_step] / (f_area * obj[2]))
except ZeroDivisionError: # no floor faces in the Room
vals.append(0)
return vals
else: # average or total the data based on data type
if self._base_type.normalized_type is None or not self._normalize:
if self._base_type.cumulative:
return tuple(obj[1].total for obj in self._matched_objects)
else:
return tuple(obj[1].average for obj in self._matched_objects)
else: # normalize the data by floor area
vals = []
zip_obj = zip(self._matched_objects, self.matched_floor_areas)
if self._base_type.cumulative: # divide total values by floor area
for obj, f_area in zip_obj:
try:
vals.append(obj[1].total / (f_area * obj[2]))
except ZeroDivisionError: # no floor faces in the Room
vals.append(0)
else: # divide average values by floor area
for obj, f_area in zip_obj:
try:
vals.append(obj[1].average / f_area)
except ZeroDivisionError: # no floor faces in the Room
vals.append(0)
return vals
@property
def matched_floor_faces(self):
"""Get a nested array with each sub-array having all floor Face3Ds of each room.
"""
flr_faces = []
for room in self.matched_rooms:
flr_faces.append(
[face.geometry for face in room.faces if isinstance(face.type, Floor)])
return flr_faces
@property
def matched_floor_areas(self):
"""Get a list for all of the room floor areas that were matches with data.
These floor areas will always be in either square meters or square feet
depending on whether the geo_unit is either SI or IP.
"""
if self._geo_unit in ('m', 'ft'): # no need to do unit conversions
return [room.floor_area for room in self.matched_rooms]
elif self._geo_unit == 'mm': # convert to meters
return [room.floor_area / 1000000.0 for room in self.matched_rooms]
elif self._geo_unit == 'in': # convert to feet
return [room.floor_area / 144.0 for room in self.matched_rooms]
else: # assume it's cm; convert to meters
return [room.floor_area / 10000.0 for room in self.matched_rooms]
@property
def graphic_container(self):
"""Get a ladybug GraphicContainer that relates to this object.
The GraphicContainer possesses almost all things needed to visualize the
ColorRoom object including the legend, value_colors, lower_title_location,
upper_title_location, etc.
"""
return GraphicContainer(
self.matched_values, self.min_point, self.max_point,
self.legend_parameters, self.data_type, str(self.unit))
def __repr__(self):
"""Color Room representation."""
return 'Color Room: [{} Rooms] [{}]'.format(
len(self._matched_objects), self._base_collection.header)
[docs]
class ColorFace(_ColorObject):
"""Object for visualization face and sub-face-level simulation results on geometry.
Args:
data_collections: An array of data collections of the same data type,
which will be used to color Faces with simulation results. Data collections
can be of any class (eg. MonthlyCollection, DailyCollection) but they
should all have headers with metadata dictionaries with 'Surface'
keys. These keys will be used to match the data in the collections
to the input faces.
faces: An array of honeybee Faces, Apertures, and/or Doors which will be
matched to the data_collections.
legend_parameters: An optional LegendParameter object to change the display
of the ColorFace (Default: None).
simulation_step: An optional integer (greater than or equal to 0) to select
a specific step of the data collections for which result values will be
generated. If None, the geometry will be colored with the total of
results in the data_collections if the data type is cumulative or with
the average of results if the data type is not cumulative. Default: None.
normalize: Boolean to note whether results should be normalized by the
face/sub-face area if the data type of the data_collections supports it.
If False, values will be generated using sum total of the data collection
values. Note that this input has no effect if the data type of the
data_collections is not normalizable since data collection values will
always be averaged for this case. Default: True.
geo_unit: Optional text to note the units that the Face geometry is in.
This will be used to ensure the legend units display correctly when
data is floor-normalized. Examples include 'm', 'mm', 'ft'.
(Default: 'm' for meters).
Properties:
* data_collections
* faces
* legend_parameters
* simulation_step
* normalize
* geo_unit
* matched_flat_faces
* matched_data
* matched_values
* matched_flat_geometry
* matched_flat_areas
* graphic_container
* title_text
* data_type_text
* data_type
* unit
* analysis_period
* min_point
* max_point
"""
__slots__ = ('_faces',)
def __init__(self, data_collections, faces, legend_parameters=None,
simulation_step=None, normalize=True, geo_unit='m'):
"""Initialize ColorFace."""
# initialize the base object
_ColorObject.__init__(self, data_collections, legend_parameters,
simulation_step, geo_unit)
for coll in self._data_collections:
assert 'Surface' in coll.header.metadata, 'ColorFace data collection ' \
'does not have metadata associated with Surfaces.'
try: # check the input faces
faces = tuple(faces)
except TypeError:
raise TypeError('Input faces must be an array. Got {}.'.format(type(faces)))
assert len(faces) > 0, 'ColorFaces must have at least one face.'
self._faces = faces
self._calculate_min_max(faces)
# match the faces with the data collections
self._matched_objects = match_faces_to_data(data_collections, faces)
if len(self._matched_objects) == 0:
raise ValueError('None of the ColorFace data collections could be '
'matched to the input faces')
# assign the normalize property
self.normalize = normalize
@property
def faces(self):
"""Get the honeybee Faces, Apertures, Doors and Shades assigned to this object.
"""
return self._faces
@property
def normalize(self):
"""Get or set a boolean for whether results are normalized by face/sub-face area.
"""
return self._normalize
@normalize.setter
def normalize(self, value):
self._normalize = bool(value)
@property
def matched_flat_faces(self):
"""Get a tuple of honeybee objects that have been matched to the data."""
return tuple(obj[0] for obj in self._matched_objects)
@property
def matched_data(self):
"""Get a tuple of data collections that have been matched to the flat_faces."""
return tuple(obj[1] for obj in self._matched_objects)
@property
def matched_values(self):
"""Get an array of numbers that correspond to the matched_flat_faces.
These values are derived from the data_collections but they will be
averaged/totaled and normalized by the face/sub-face area depending on the
other inputs to this object.
"""
if self._simulation_step is not None: # specific index from all collections
if self._base_type.normalized_type is None or not self._normalize:
return tuple(obj[1][self._simulation_step] for obj in
self._matched_objects)
else: # normalize the data by the face area
vals = []
for obj, f_area in zip(self._matched_objects, self.matched_flat_areas):
vals.append(obj[1][self._simulation_step] / f_area)
return vals
else: # average or total the data based on data type
if self._base_type.normalized_type is None or not self._normalize:
if self._base_type.cumulative:
return tuple(obj[1].total for obj in self._matched_objects)
else:
return tuple(obj[1].average for obj in self._matched_objects)
else: # normalize the data by face area
vals = []
zip_obj = zip(self._matched_objects, self.matched_flat_areas)
if self._base_type.cumulative: # divide total values by face area
for obj, f_area in zip_obj:
vals.append(obj[1].total / f_area)
else: # divide average values by face area
for obj, f_area in zip_obj:
vals.append(obj[1].average / f_area)
return vals
@property
def matched_flat_geometry(self):
"""Get non-nested array of faces/sub-faces on this object.
The geometries here align with the attributes and graphic_container colors.
"""
return tuple(face.geometry if not isinstance(face, Face)
else face.punched_geometry for face in self.matched_flat_faces)
@property
def matched_flat_areas(self):
"""Get a list numbers for the area of each of the matched_flat_faces.
These areas will always be in either square meters or square feet
depending on whether the geo_unit is either SI or IP. They also use
punched geometry in the case of a Face with child Apertures.
"""
if self._geo_unit in ('m', 'ft'): # no need to do unit conversions
return [face.area for face in self.matched_flat_geometry]
elif self._geo_unit == 'mm': # convert to meters
return [face.area / 1000000.0 for face in self.matched_flat_geometry]
elif self._geo_unit == 'in': # convert to feet
return [face.area / 144.0 for face in self.matched_flat_geometry]
else: # assume it's cm; convert to meters
return [face.area / 10000.0 for face in self.matched_flat_geometry]
@property
def graphic_container(self):
"""Get a ladybug GraphicContainer that relates to this object.
The GraphicContainer possesses almost all things needed to visualize the
ColorFace object including the legend, value_colors, lower_title_location,
upper_title_location, etc.
"""
return GraphicContainer(
self.matched_values, self.min_point, self.max_point,
self.legend_parameters, self.data_type, str(self.unit))
def __repr__(self):
"""Color Face representation."""
return 'Color Face: [{} Objects] [{}]'.format(
len(self._matched_objects), self._base_collection.header)