# coding: utf-8
"""Dragonfly Story."""
from __future__ import division
import math
from ladybug_geometry.geometry2d import Vector2D, Polygon2D
from ladybug_geometry.geometry3d import Vector3D, Ray3D, Polyline3D, Face3D, Polyface3D
from honeybee.typing import float_positive, int_in_range, clean_string, \
invalid_dict_error
from honeybee.boundarycondition import boundary_conditions as bcs
from honeybee.boundarycondition import Outdoors, Surface
from honeybee.facetype import AirBoundary
from honeybee.facetype import face_types as ftyp
from honeybee.altnumber import autocalculate
from honeybee.shade import Shade
from honeybee.room import Room
from ._base import _BaseGeometry
from .room2d import Room2D
from .roof import RoofSpecification
from .windowparameter import DetailedWindows
from .properties import StoryProperties
import dragonfly.writer.story as writer
[docs]
class Story(_BaseGeometry):
"""A Story of a building defined by an extruded Room2Ds.
Args:
identifier: Text string for a unique Story ID. Must be < 100 characters
and not contain any spaces or special characters.
room_2ds: An array of dragonfly Room2D objects that together form an
entire story of a building.
floor_to_floor_height: A number for the distance from the floor plate of
this Story to the floor of the story above this one (if it exists).
This should be in the same units system as the input room_2d geometry.
If None, this value will be the maximum floor_to_ceiling_height of the
input room_2ds plus any difference between the Story floor height
and the room floor heights. (Default: None)
floor_height: A number for the absolute floor height of the Story.
If None, this will be the minimum floor height of all the Story's
room_2ds, which is suitable for cases where there are no floor
plenums. (Default: None).
multiplier: An integer that denotes the number of times that this
Story is repeated over the height of the building. (Default: 1).
roof: An optional RoofSpecification object containing geometry and instructions
for generating sloped roofs over a Story. The RoofSpecification will only
affect the child Room2Ds that have a True is_top_exposed property
and it will only be utilized in translation to Honeybee when the Story
multiplier is 1. If None, all Room2D ceilings will be flat. (Default: None).
Properties:
* identifier
* display_name
* full_id
* room_2ds
* floor_to_floor_height
* multiplier
* roof
* parent
* has_parent
* floor_height
* floor_area
* exterior_wall_area
* exterior_aperture_area
* volume
* is_above_ground
* min
* max
* median_room2d_floor_height
* user_data
"""
__slots__ = ('_room_2ds', '_floor_to_floor_height', '_floor_height',
'_multiplier', '_roof', '_parent')
def __init__(self, identifier, room_2ds, floor_to_floor_height=None,
floor_height=None, multiplier=1, roof=None):
"""A Story of a building defined by an extruded Floor2Ds."""
_BaseGeometry.__init__(self, identifier) # process the identifier
# process the Room2Ds and story geometry
self.room_2ds = room_2ds
# process the input properties
self.floor_height = floor_height
self.floor_to_floor_height = floor_to_floor_height
self.multiplier = multiplier
self.roof = roof
self._parent = None # _parent will be set when Story is added to a Building
self._properties = StoryProperties(self) # properties for extensions
[docs]
@classmethod
def from_dict(cls, data, tolerance=0):
"""Initialize a Story from a dictionary.
Args:
data: A dictionary representation of a Story object.
tolerance: The maximum difference between z values at which point vertices
are considered to be in the same horizontal plane. This is used to check
that all vertices of the input floor_geometry lie in the same horizontal
floor plane. Default is 0, which will not perform any check.
"""
# check the type of dictionary
assert data['type'] == 'Story', 'Expected Story dictionary. ' \
'Got {}.'.format(data['type'])
# serialize the rooms
rooms = []
for r_dict in data['room_2ds']:
try:
rooms.append(Room2D.from_dict(r_dict, tolerance))
except Exception as e:
invalid_dict_error(r_dict, e)
# check if any room boundaries were reversed
dict_pts = [tuple(room['floor_boundary'][0]) for room in data['room_2ds']]
room_pts = [(rm.floor_geometry[0].x, rm.floor_geometry[0].y) for rm in rooms]
not_reversed = [dpt == rpt for dpt, rpt in zip(dict_pts, room_pts)]
# ensure Surface boundary conditions are correct if floors were reversed
if not all(not_reversed): # some room floors have been reversed
bcs_to_update = []
for not_revd, room in zip(not_reversed, rooms):
if not not_revd: # double negative! reversed room boundary
for i, bc in enumerate(room._boundary_conditions):
if isinstance(bc, Surface): # segment must be updated
newid = '{}..Face{}'.format(room.identifier, i + 1)
bc_room = bc.boundary_condition_objects[1]
bc_f_i = bc.boundary_condition_objects[0].split('..Face')[-1]
bc_tup = (bc_room, int(bc_f_i) - 1, (newid, room.identifier))
bcs_to_update.append(bc_tup)
for bc_tup in bcs_to_update: # update any reversed boundary conditions
adj_room = bc_tup[0]
for not_revd, room in zip(not_reversed, rooms): # find adjacent room
if room.identifier == adj_room:
bc_to_update = room._boundary_conditions[bc_tup[1]] if not_revd \
else room._boundary_conditions[-1 - bc_tup[1]]
bc_to_update._boundary_condition_objects = bc_tup[2]
# process the floor_to_floor_height and the multiplier
f2fh = data['floor_to_floor_height'] if 'floor_to_floor_height' in data \
and data['floor_to_floor_height'] != autocalculate.to_dict() else None
fh = data['floor_height'] if 'floor_height' in data \
and data['floor_height'] != autocalculate.to_dict() else None
mult = data['multiplier'] if 'multiplier' in data else 1
# process the roof specification if it exists
roof = RoofSpecification.from_dict(data['roof']) if 'roof' in data \
and data['roof'] is not None else None
# create the story object
story = Story(data['identifier'], rooms, f2fh, fh, mult, roof)
if 'display_name' in data and data['display_name'] is not None:
story._display_name = data['display_name']
if 'user_data' in data and data['user_data'] is not None:
story.user_data = data['user_data']
# load any extension attributes assigned to the story
if data['properties']['type'] == 'StoryProperties':
story.properties._load_extension_attr_from_dict(data['properties'])
return story
[docs]
@classmethod
def from_honeybee(cls, identifier, rooms, tolerance):
"""Initialize a Story from a list of Honeybee Rooms.
Args:
identifier: Text string for a unique Story ID. Must be < 100 characters
and not contain any spaces or special characters.
rooms: A list of Honeybee Room objects.
tolerance: The maximum difference between values at which point vertices
are considered to be the same.
"""
# create the Room2Ds from the Honeybee Rooms
room_2ds = []
for hb_room in rooms:
if not hb_room.exclude_floor_area:
try:
room_2ds.append(Room2D.from_honeybee(hb_room, tolerance))
except Exception: # invalid Honeybee Room that is not a closed solid
msg = 'Room "{}" is not a closed solid and cannot be converted to ' \
'a Room2D.\nTry using the "ExtrudedOnly" option to convert ' \
'the Honeybee Model to Dragonfly'.format(hb_room.display_name)
raise ValueError(msg)
room_2ds = [room for room in room_2ds if room is not None]
# re-set the adjacencies in relation to the Room2D segments
room_2ds = cls._reset_adjacencies_from_honeybee(room_2ds, tolerance)
return cls(identifier, room_2ds)
@staticmethod
def _reset_adjacencies_from_honeybee(room_2ds, tolerance):
"""Re-set the adjacencies in relation to the Room2D segments.
It is customary to run this method after converting the Story from Honeybee.
This will ensure that any Surface boundary conditions from the Honeybee
translation survive the translation process to the Dragonfly conventions
of Surface boundary conditions.
Args:
room_2ds: A list of Room2Ds of the same Story for which adjacencies
will be reset.
tolerance: The maximum difference between values at which point vertices
are considered to be the same.
"""
all_adj_faces = [[x for x, bc in enumerate(room_1._boundary_conditions)
if isinstance(bc, Surface)] for room_1 in room_2ds]
for i, room_1 in enumerate(room_2ds):
try:
for x, room_2 in enumerate(room_2ds[i + 1:]):
if not Polygon2D.overlapping_bounding_rect(
room_1._floor_geometry.boundary_polygon2d,
room_2._floor_geometry.boundary_polygon2d, tolerance):
continue # no overlap in bounding rect; adjacency impossible
for j, seg_1 in enumerate(room_1.floor_segments_2d):
for k, seg_2 in enumerate(room_2.floor_segments_2d):
if isinstance(room_2._boundary_conditions[k], Surface):
if seg_1.distance_to_point(seg_2.p1) <= tolerance and \
seg_1.distance_to_point(seg_2.p2) <= tolerance:
if abs(seg_1.length - seg_2.length) <= tolerance:
# set the boundary conditions of the segments
room_1.set_adjacency(room_2, j, k)
try:
adj_f_1 = all_adj_faces[i]
adj_f_2 = all_adj_faces[i + x + 1]
adj_f_1.pop(adj_f_1.index(j))
adj_f_2.pop(adj_f_2.index(k))
except ValueError:
pass # from honeybee broke adjacency
break
except IndexError:
pass # we have reached the end of the list of zones
# set any adjacencies to default that were not set
try:
default_adj_bc = bcs.adiabatic
remove_win = True
except AttributeError:
default_adj_bc = bcs.outdoors
remove_win = False
for r_i, adj_faces in enumerate(all_adj_faces):
for seg_i in adj_faces:
room_2ds[r_i]._boundary_conditions[seg_i] = default_adj_bc
room_2ds[r_i]._air_boundaries[seg_i] = False
if remove_win:
room_2ds[r_i]._window_parameters[seg_i] = None
return room_2ds
@property
def room_2ds(self):
"""Get or set a tuple of Room2D objects that form the Story."""
return self._room_2ds
@room_2ds.setter
def room_2ds(self, value):
if not isinstance(value, tuple):
value = tuple(value)
assert len(value) > 0, 'Story must have at least one Room2D.'
for room in value:
assert isinstance(room, Room2D), \
'Expected dragonfly Room2D. Got {}'.format(type(room))
room._parent = self
self._room_2ds = value
@property
def floor_to_floor_height(self):
"""Get or set a number for the distance from this floor plate to the next one."""
return self._floor_to_floor_height
@floor_to_floor_height.setter
def floor_to_floor_height(self, value):
if value is None:
ciel_hgt = max([room.ceiling_height for room in self._room_2ds])
value = ciel_hgt - self.floor_height
self._floor_to_floor_height = float_positive(value, 'floor-to-floor height')
@property
def floor_height(self):
"""Get or set a number for the absolute floor height of the Story.
This will be the minimum floor height of all the Story's room_2ds unless
specified otherwise.
"""
return self._floor_height
@floor_height.setter
def floor_height(self, value):
if value is None:
value = min([room.floor_height for room in self._room_2ds])
self._floor_height = float(value)
@property
def multiplier(self):
"""Get or set an integer noting how many times this Story is repeated.
Multipliers are used to speed up the calculation when similar Stories are
repeated more than once. Essentially, a given simulation with the
Story is run once and then the result is multiplied by the multiplier.
This comes with some inaccuracy. However, this error might not be too large
if the Stories are similar enough and it can often be worth it since it can
greatly speed up the calculation.
For more information on multipliers in EnergyPlus see EnergyPlus Tips and Tricks:
https://bigladdersoftware.com/epx/docs/9-1/tips-and-tricks-using-energyplus/\
using-multipliers-zone-and-or-window.html
"""
return self._multiplier
@multiplier.setter
def multiplier(self, value):
self._multiplier = int_in_range(value, 1, input_name='room multiplier')
@property
def roof(self):
"""Get or set a RoofSpecification with instructions for generating sloped roofs.
The RoofSpecification will only affect the child Room2Ds that have a True
is_top_exposed property and it will only be utilized in translation to
Honeybee when the Story multiplier is 1.
"""
return self._roof
@roof.setter
def roof(self, value):
if value is not None:
assert isinstance(value, RoofSpecification), \
'Expected dragonfly RoofSpecification. Got {}'.format(type(value))
value._parent = self
self._roof = value
@property
def parent(self):
"""Parent Building if assigned. None if not assigned."""
return self._parent
@property
def has_parent(self):
"""Boolean noting whether this Story has a parent Building."""
return self._parent is not None
@property
def floor_area(self):
"""Get a number for the total floor area in the Story.
Note that this property is for one Story and does NOT use the multiplier.
However, if this Story is assigned to a parent Building with room_3ds,
it will include the floor area of these 3D Rooms (without the room multiplier).
"""
flr_area = sum([room.floor_area for room in self._room_2ds])
if self.has_parent and self.parent.has_room_3ds:
for r in self.parent.room_3ds_by_story(self.display_name):
if not r.exclude_floor_area:
flr_area += r.floor_area
return flr_area
@property
def exterior_wall_area(self):
"""Get a number for the total exterior wall area in the Story.
Note that this property is for one story and does NOT use the multiplier.
However, if this Story is assigned to a parent Building with room_3ds,
it will include the wall area of these 3D Rooms (without the room multiplier).
"""
ewa = sum([room.exterior_wall_area for room in self._room_2ds])
if self.has_parent and self.parent.has_room_3ds:
for r in self.parent.room_3ds_by_story(self.display_name):
ewa += r.exterior_wall_area
return ewa
@property
def exterior_aperture_area(self):
"""Get a number for the total exterior aperture area in the Story.
Note that this property is for one story and does NOT use the multiplier.
However, if this Story is assigned to a parent Building with room_3ds,
it will include the exterior wall aperture area of these 3D Rooms (without
the room multiplier).
"""
eaa = sum([room.exterior_aperture_area for room in self._room_2ds])
if self.has_parent and self.parent.has_room_3ds:
for r in self.parent.room_3ds_by_story(self.display_name):
eaa += r.exterior_wall_aperture_area
return eaa
@property
def volume(self):
"""Get a number for the volume of all the Rooms in the Story.
Note that this property is for one story and does NOT use the multiplier.
However, if this Story is assigned to a parent Building with room_3ds,
it will include the volume of these 3D Rooms (without the room multiplier).
"""
vol = sum([room.volume for room in self._room_2ds])
if self.has_parent and self.parent.has_room_3ds:
for r in self.parent.room_3ds_by_story(self.display_name):
vol += r.volume
return vol
@property
def is_above_ground(self):
"""Get a boolean to note if this Story is above the ground.
The story is considered above the ground if at least one of its Room2Ds
has an outdoor boundary condition for its walls.
"""
for room in self._room_2ds:
for bc in room._boundary_conditions:
if isinstance(bc, Outdoors):
return True
return False
@property
def min(self):
"""Get a Point2D for the min bounding rectangle vertex in the XY plane.
This is useful in calculations to determine if this Story is in proximity
to others.
"""
return self._calculate_min(self._room_2ds)
@property
def max(self):
"""Get a Point2D for the max bounding rectangle vertex in the XY plane.
This is useful in calculations to determine if this Story is in proximity
to others.
"""
return self._calculate_max(self._room_2ds)
@property
def median_room2d_floor_height(self):
"""Get the median floor height of the Room2Ds of this Story."""
median_i = int(len(self._room_2ds) / 2)
flr_hgt = [room.floor_height for room in self._room_2ds]
flr_hgt.sort()
return flr_hgt[median_i]
[docs]
def floor_geometry(self, tolerance=0.01):
"""Get a ladybug_geometry Polyface3D object representing the floor plate.
Args:
tolerance: The minimum distance between points at which they are
not considered touching. Default: 0.01, suitable for objects
in meters.
"""
story_height = self.floor_height
room_floors = []
for room in self.room_2ds:
diff = story_height - room.floor_height
if abs(diff) <= tolerance:
room_floors.append(room.floor_geometry)
else:
room_floors.append(room.floor_geometry.move(Vector3D(0, 0, diff)))
# TODO: consider returning a list of polyfaces if input rooms are disjointed
return Polyface3D.from_faces(room_floors, tolerance)
[docs]
def outline_segments(self, tolerance=0.01):
"""Get a list of LineSegment3D objects for the outline of the floor plate.
Note that these segments include both the boundary surrounding the floor
and any holes for courtyards that exist within the floor.
Args:
tolerance: The minimum distance between points at which they are
not considered touching. Default: 0.01, suitable for objects
in meters.
"""
return self.floor_geometry(tolerance).naked_edges
[docs]
def outline_polylines(self, tolerance=0.01):
"""Get a list of Polyline3D objects for the outline of the floor plate.
Note that these segments include both the boundary surrounding the floor
and any holes for courtyards that exist within the floor.
Args:
tolerance: The minimum distance between points at which they are
not considered touching. Default: 0.01, suitable for objects
in meters.
"""
return Polyline3D.join_segments(self.outline_segments(tolerance), tolerance)
[docs]
def shade_representation(self, cap=False, tolerance=0.01):
"""A list of honeybee Shade objects representing the story geometry.
This accounts for the story multiplier and can be used to account for
this Story's shade in the simulation of another nearby Story.
Args:
cap: Boolean to note whether the shade representation should be capped
with a top face. Usually, this is not necessary to account for
blocked sun and is only needed when it's important to account for
reflected sun off of roofs. (Default: False).
tolerance: The minimum distance between points at which they are
not considered touching. Default: 0.01, suitable for objects
in meters.
"""
context_shades = []
extru_vec = Vector3D(0, 0, self.floor_to_floor_height * self.multiplier)
for i, seg in enumerate(self.outline_segments(tolerance)):
try:
extru_geo = Face3D.from_extrusion(seg, extru_vec)
shd_id = '{}_{}'.format(self.identifier, i)
context_shades.append(Shade(shd_id, extru_geo))
except ZeroDivisionError:
pass # duplicate vertex resulting in a segment of length 0
if cap:
for i, s in enumerate(self.footprint(tolerance)):
shd_id = '{}_Top_{}'.format(self.identifier, i)
context_shades.append(Shade(shd_id, s.move(extru_vec)))
return context_shades
[docs]
def shade_representation_multiplier(self, exclude_index=0, cap=False,
tolerance=0.01):
"""A list of honeybee Shade objects for just the "multiplier" part of the story.
This includes all of the geometry along the height of the multiplier except
for one of the floors (represented by the exclude_index). This will be an
empty list if the story has a multiplier of 1.
Args:
exclude_index: An optional index for a story along the multiplier to
be excluded from the shade representation. For example, if 0,
the bottom geometry along the multiplier is excluded. (Default: 0).
cap: Boolean to note whether the shade representation should be capped
with a top face. Usually, this is not necessary to account for
blocked sun and is only needed when it's important to account for
reflected sun off of roofs. (Default: False).
tolerance: The minimum distance between points at which they are
not considered touching. Default: 0.01, suitable for objects
in meters.
"""
if self.multiplier == 1:
return []
# get the extrusion and moving vectors
ftf, mult = self.floor_to_floor_height, self.multiplier
context_shades, ceil_vecs, extru_vecs = [], [], []
if exclude_index != 0: # insert vectors for the bottom shade
ceil_vecs.append(Vector3D(0, 0, 0))
extru_vecs.append(Vector3D(0, 0, ftf * exclude_index))
if exclude_index < mult: # insert vectors for the top shade
ceil_vecs.append(Vector3D(0, 0, ftf * (exclude_index + 1)))
extru_vecs.append(Vector3D(0, 0, ftf * (mult - exclude_index - 1)))
# loop through the segments and build up the shades
for i, seg in enumerate(self.outline_segments(tolerance)):
for ceil_vec, extru_vec in zip(ceil_vecs, extru_vecs):
seg = seg.move(ceil_vec)
try:
extru_geo = Face3D.from_extrusion(seg, extru_vec)
shd_id = '{}_{}'.format(self.identifier, i)
context_shades.append(Shade(shd_id, extru_geo))
except ZeroDivisionError:
pass # duplicate vertex resulting in a segment of length 0
# cap the extrusions if requested
if cap and exclude_index < mult:
full_vec = Vector3D(0, 0, ftf * mult)
for i, s in enumerate(self.footprint(tolerance)):
shd_id = '{}_Top_{}'.format(self.identifier, i)
context_shades.append(Shade(shd_id, s.move(full_vec)))
return context_shades
[docs]
def room_by_identifier(self, room_identifier):
"""Get a Room2D from this Story using its identifier.
Result will be None if the Room2D is not found in the Story.
Args:
room_identifier: String for the identifier of the Room2D to be
retrieved from this story.
"""
for room in self._room_2ds:
if room.identifier == room_identifier:
return room
else:
raise ValueError('Room2D "{}" was not found in the story "{}"'
'.'.format(room_identifier, self.identifier))
[docs]
def rooms_by_identifier(self, room_identifiers):
"""Get a list of Room2D objects in this story given Room2D identifiers.
Args:
room_identifier: Array of strings for the identifiers of the Room2D
to be retrieved from this Story.
"""
room_2ds = []
for identifier in room_identifiers:
for room in self._room_2ds:
if room.identifier == identifier:
room_2ds.append(room)
break
else:
raise ValueError('Room2D "{}" was not found in the story '
'"{}".'.format(identifier, self.identifier))
return room_2ds
[docs]
def add_prefix(self, prefix):
"""Change the identifier and all child Room2D ids by inserting a prefix.
This is particularly useful in workflows where you duplicate and edit
a starting object and then want to combine it with the original object
into one Model (like making a model of repeated stories) since all objects
within a Model must have unique identifiers.
This method is used internally to convert from a Story with a multiplier
to fully-detailed Stories with unique identifiers.
Args:
prefix: Text that will be inserted at the start of this object's
(and child segments') identifier and display_name. It is recommended
that this prefix be short to avoid maxing out the 100 allowable
characters for dragonfly identifiers.
"""
self._identifier = clean_string('{}_{}'.format(prefix, self.identifier))
if self._display_name is not None:
self.display_name = '{}_{}'.format(prefix, self.display_name)
self.properties.add_prefix(prefix)
for room in self.room_2ds:
room.add_prefix(prefix)
[docs]
def add_room_2d(self, room_2d):
"""Add a Room2D to this Story.
No check will be performed for whether the input room_2d's identifier
matches one in the current Story.
Args:
room_2d: A Room2D object to be added to this Story.
"""
assert isinstance(room_2d, Room2D), \
'Expected dragonfly Room2D. Got {}'.format(type(room_2d))
room_2d._parent = self
self._room_2ds = self._room_2ds + (room_2d,)
[docs]
def add_room_2ds(self, rooms_2ds, add_duplicate_ids=False):
"""Add a list of Room2Ds to this Story with checks for duplicate identifiers.
Args:
room_2d: A list of Room2D objects to be added to this Story.
add_duplicate_ids: A boolean to note whether added Room2Ds that
have matching identifiers within the current Story should be
ignored (False) or they should be added to the Story creating
an ID collision that can be resolved later (True). (Default: False).
"""
# check to be sure that the input is composed of Room2Ds
for o_room_2d in rooms_2ds:
assert isinstance(o_room_2d, Room2D), \
'Expected dragonfly Room2D. Got {}'.format(type(o_room_2d))
# add the rooms and deal with duplicated IDs appropriately
new_room_2ds = list(self._room_2ds)
if add_duplicate_ids:
for o_room_2d in rooms_2ds:
o_room_2d._parent = self
new_room_2ds.append(o_room_2d)
else:
exist_set = {rm.identifier for rm in self._room_2ds}
for o_room_2d in rooms_2ds:
if o_room_2d.identifier not in exist_set:
o_room_2d._parent = self
new_room_2ds.append(o_room_2d)
# assign the new Room2Ds to this Story
self._room_2ds = tuple(new_room_2ds)
[docs]
def reset_room_2d_boundaries(
self, polygons, identifiers=None, display_names=None,
floor_to_ceiling_heights=None, tolerance=0.01):
"""Rebuild the Room2Ds of the Story using boundary Polygons.
All existing properties of segments along the boundary polygons will be
preserved, including all window geometries. By default, the largest room
that is identified within each of the boundary polygons will determine the
extension properties of the resulting Room2D.
It is recommended that the Room2Ds be aligned to the boundaries of the
polygon and duplicate vertices be removed before using this method.
Args:
polygons: A list of ladybug_geometry Polygon2D, which will become
the new boundaries of the Story's Room2Ds. Note that it is
acceptable to include hole polygons in this list and they will
automatically be sensed by their relationship to the other
polygons.
identifiers: An optional list of text that align with the polygons
and will dictate the identifiers of the Story's Rooms. If this
matches an existing Room2D inside of the polygon, the existing
Room2D will be used to set the extension properties of the output
Room2D. If None, the identifier and extension properties of the
output Room2D will be those of the largest Room2D found inside
of the polygon. (Default: None).
display_names: An optional list of text that align with the
polygons and will dictate the display_names of the Story's Rooms.
If None, the display_name will be taken from the
largest existing Room2D inside the polygon or the existing
Room2D matching the identifier above. (Default: None).
floor_to_ceiling_heights: An optional list of numbers that align with the
polygons and will dictate the the floor-to-ceiling heights of the
resulting Room2Ds. If None, it will be the maximum of the Room2Ds
that are found inside each of the polygon, which ensures
that all window geometries are included in the output. If specified
and it is lower than the maximum Room2D height, any detailed
windows will be automatically trimmed to accommodate the new
floor-to-ceiling height. (Default: None).
tolerance: The minimum distance between a vertex and the polygon
boundary at which point the vertex is considered to lie on the
polygon. (Default: 0.01, suitable for objects in meters).
"""
# set defaults for identifiers and display_names
if identifiers is None:
identifiers = [None] * len(polygons)
if display_names is None:
display_names = [None] * len(polygons)
if floor_to_ceiling_heights is None:
floor_to_ceiling_heights = [None] * len(polygons)
# sort the polygons so they can be correctly interpreted as holes
p_areas = [p.area for p in polygons]
sort_ind = [i for _, i in sorted(zip(p_areas, range(len(p_areas))))]
sort_ind.reverse()
sort_poly = [polygons[i] for i in sort_ind]
sort_ids = [identifiers[i] for i in sort_ind]
sort_names = [display_names[i] for i in sort_ind]
sort_ftcs = [floor_to_ceiling_heights[i] for i in sort_ind]
# loop through the polygons and make the Room2Ds
new_room_2ds = []
skip_i = [] # list to track hole polygons to be skipped
zip_obj = zip(sort_poly, sort_ids, sort_names, sort_ftcs)
for i, (poly, r_id, r_nm, ftc) in enumerate(zip_obj):
if i in skip_i:
continue
holes = []
for j, o_poly in enumerate(sort_poly[i + 1:]):
if poly.is_polygon_inside(o_poly):
holes.append(o_poly)
skip_i.append(i + j + 1)
new_room = Room2D.join_by_boundary(
self._room_2ds, poly, holes, ftc, r_id, r_nm, tolerance=tolerance)
new_room_2ds.append(new_room)
self._room_2ds = tuple(new_room_2ds)
[docs]
def suggested_alignment_axes(
self, distance, direction=Vector2D(0, 1), angle_tolerance=1.0):
"""Get suggested LineSegment2Ds to be used for this Story in the align methods.
This method will return the most common axes across the Story geometry
along with the number of Room2D segments that correspond to each axis.
The latter can be used to filter the suggested alignment axes to get
only the most common ones across the input Room2Ds.
Args:
distance: A number for the distance that will be used in the alignment
operation. This will be used to determine the resolution at which
alignment axes are generated and evaluated. Smaller alignment
distances will result in the generation of more common_axes since
a finer resolution can differentiate common that would typically be
grouped together. For typical building geometry, an alignment distance
of 0.3 meters or 1 foot is typically suitable for eliminating
unwanted details while not changing the geometry too much from
its original location.
direction: A Vector2D object to represent the direction in which the
common axes will be evaluated and generated.
angle_tolerance: The max angle difference in radians that the Room2D
segment direction can differ from the input direction before the
segments are not factored into this calculation of common axes.
Returns:
A tuple with two elements.
- common_axes: A list of LineSegment2D objects for the common
axes across the input Room2Ds.
- axis_values: A list of integers that aligns with the common_axes
and denotes how many segments of the input Room2D each axis
relates to. Higher numbers indicate that that the axis is more
commonly aligned across the Room2Ds.
"""
return Room2D.generate_alignment_axes(
self._room_2ds, distance, direction, angle_tolerance)
[docs]
def align_room_2ds(self, line_ray, distance):
"""Move Room2D vertices within a given distance of a line to be on that line.
Note that, when there are small Room2Ds next to the input line_ray,
this method can create degenerate Room2Ds and so it may be wise to run
the delete_degenerate_room_2ds method after running this one.
Args:
line_ray: A ladybug_geometry Ray2D or LineSegment2D to which the Room2D
vertices will be aligned. Ray2Ds will be interpreted as being infinite
in both directions while LineSegment2Ds will be interpreted as only
existing between two points.
distance: The maximum distance between a vertex and the line_ray where
the vertex will be moved to lie on the line_ray. Vertices beyond
this distance will be left as they are.
"""
for room in self.room_2ds:
room.align(line_ray, distance)
[docs]
def align(self, line_ray, distance, tolerance=0.01):
"""Move Room2D and Roof vertices within a distance of a line to be on that line.
This method differs from the align_room_2ds method in that it will also
align any Roof geometry (if it is present).
Args:
line_ray: A ladybug_geometry Ray2D or LineSegment2D to which the Room2D
and Roof vertices will be aligned. Ray2Ds will be interpreted as being
infinite in both directions while LineSegment2Ds will be interpreted
as only existing between two points.
distance: The maximum distance between a vertex and the line_ray where
the vertex will be moved to lie on the line_ray. Vertices beyond
this distance will be left as they are.
tolerance: The minimum distance between vertices below which they are
considered co-located. This is used to ensure that the alignment process
does not create new overlaps in the roof geometry. (Default: 0.01,
suitable for objects in meters).
"""
self.align_room_2ds(line_ray, distance)
if self.roof is not None:
self.roof.align(line_ray, distance, tolerance)
[docs]
def remove_room_2d_duplicate_vertices(self, tolerance=0.01, delete_degenerate=False):
"""Remove duplicate vertices from all Room2Ds in this Story.
All properties assigned to the Room2D will be preserved and any changed
Surface boundary conditions will be automatically updated based on the
removed wall segment indices.
Args:
tolerance: The minimum distance between a vertex and the line it lies
upon at which point the vertex is considered duplicated. Default: 0.01,
suitable for objects in meters).
delete_degenerate: Boolean to note whether degenerate Room2Ds (with floor
geometries that evaluate to less than 3 vertices at the tolerance)
should be deleted from the Story instead of raising a ValueError.
Note that using this option frequently creates invalid missing
adjacencies, requiring the run of reset_adjacencies followed
by re-running solve_adjacency. (Default: False).
Returns:
A list of all degenerate Room2Ds that were removed if delete_degenerate
is True. Will be None if delete_degenerate is False.
"""
# remove vertices from the rooms and track the removed indices
removed_dict, removed_rooms = {}, None
if delete_degenerate:
new_room_2ds, removed_rooms = [], []
for room in self.room_2ds:
try:
removed_dict[room.identifier] = \
room.remove_duplicate_vertices(tolerance)
new_room_2ds.append(room)
except ValueError: # degenerate room found!
removed_rooms.append(room)
assert len(new_room_2ds) > 0, 'All Room2Ds of Story "{}" are '\
'degenerate.'.format(self.display_name)
self._room_2ds = tuple(new_room_2ds)
else:
for room in self.room_2ds:
removed_dict[room.identifier] = \
room.remove_duplicate_vertices(tolerance)
# go through the rooms and update any changed Surface boundary conditions
if len(self.room_2ds) != 1:
for room in self.room_2ds:
for j, bc in enumerate(room._boundary_conditions):
if isinstance(bc, Surface):
adj_wall, adj_room = bc.boundary_condition_objects
try:
removed_i = removed_dict[adj_room]
except KeyError: # illegal boundary condition; just ignore
continue
if len(removed_i) == 0: # no removed vertices in room
continue
current_i = int(adj_wall.split('..Face')[-1]) - 1
if removed_i[0] <= current_i: # surface bc to be updated
bef_count = len([k for k in removed_i if k <= current_i])
new_i = current_i - bef_count
new_bc = Surface(
('{}..Face{}'.format(adj_room, new_i + 1), adj_room)
)
room._boundary_conditions[j] = new_bc
return removed_rooms
[docs]
def remove_room_2d_colinear_vertices(
self, tolerance=0.01, preserve_wall_props=True, delete_degenerate=False):
"""Automatically remove colinear or duplicate vertices for the Story's Room2Ds.
Args:
tolerance: The minimum difference between the coordinate values at
which they are considered co-located. Default: 0.01,
suitable for objects in meters.
preserve_wall_props: Boolean to note whether existing window parameters
and Ground boundary conditions should be preserved as vertices are
removed. If False, all boundary conditions are replaced with Outdoors,
all window parameters are erased, and this method will execute quickly.
If True, an attempt will be made to merge window parameters together
across colinear segments, translating simple window parameters to
rectangular ones if necessary. Also, existing Ground boundary
conditions will be kept. (Default: True).
delete_degenerate: Boolean to note whether degenerate Room2Ds (with
floor geometries that evaluate to less than 3 vertices at the
tolerance) should be deleted from the Story instead of raising
a ValueError. (Default: False).
Returns:
A list of all degenerate Room2Ds that were removed if delete_degenerate
is True. Will be None if delete_degenerate is False.
"""
# remove vertices from the rooms and track the removed indices
if delete_degenerate:
new_room_2ds, removed_rooms = [], []
for room in self.room_2ds:
try:
new_r = room.remove_colinear_vertices(tolerance, preserve_wall_props)
new_room_2ds.append(new_r)
except ValueError: # degenerate room found!
removed_rooms.append(room)
assert len(new_room_2ds) > 0, 'All Room2Ds of Story "{}" are '\
'degenerate.'.format(self.display_name)
self._room_2ds = tuple(new_room_2ds)
return removed_rooms
else:
new_room_2ds = []
for room in self.room_2ds:
new_room = room.remove_colinear_vertices(tolerance, preserve_wall_props)
new_room_2ds.append(new_room)
self._room_2ds = tuple(new_room_2ds)
[docs]
def remove_room_2d_short_segments(self, distance, angle_tolerance=1.0):
"""Remove consecutive short segments on this Story's Room2Ds.
To patch over the removed segments, an attempt will first be made to find the
intersection of the two neighboring segments. If these two lines are parallel,
they will simply be connected with a segment.
Properties assigned to the Room2Ds will be preserved for the segments that
are not removed. Room2Ds that have all of their walls shorter than the
distance will be removed from the Story.
Args:
distance: The maximum length of a segment below which the segment
will be considered for removal.
angle_tolerance: The max angle difference in degrees that vertices
are allowed to differ from one another in order to consider them
colinear. (Default: 1).
Returns:
A list of all small Room2Ds that were removed.
"""
# remove vertices from the rooms and track the removed indices
new_room_2ds, removed_rooms = [], []
for room in self.room_2ds:
nr = room.remove_short_segments(distance, angle_tolerance)
if nr is not None:
new_room_2ds.append(nr)
else:
removed_rooms.append(room)
assert len(new_room_2ds) > 0, 'All Room2Ds of Story "{}" are '\
'are shorter than the distance {}.'.format(self.display_name, distance)
self._room_2ds = tuple(new_room_2ds)
return removed_rooms
[docs]
def delete_degenerate_room_2ds(self, tolerance=0.01):
"""Remove all Room2Ds with a floor_area of zero from this Story.
This method will also automatically remove any degenerate holes in Room2D
floor geometries, which have an area less than zero.
Args:
tolerance: The minimum difference between the coordinate values at
which they are considered co-located. Default: 0.01,
suitable for objects in meters.
Returns:
A list of all degenerate Room2Ds that were removed.
"""
new_room_2ds, removed_rooms = [], []
for room in self.room_2ds:
max_dim = max((room.max.x - room.min.x, room.max.y - room.min.y))
if room.floor_geometry.area < max_dim * tolerance:
removed_rooms.append(room)
else:
room.remove_degenerate_holes(tolerance)
new_room_2ds.append(room)
assert len(new_room_2ds) > 0, 'All Room2Ds of Story "{}" are '\
'degenerate.'.format(self.display_name)
self._room_2ds = tuple(new_room_2ds)
return removed_rooms
[docs]
def join_small_rooms(self, area_threshold, tolerance=0.01):
"""Join small Room2Ds together within this Story.
This is particularly useful when operations like automatic core/perimeter
zoning creates several small Room2Ds from small segments in the outline
boundary around the Story.
Note that adjacencies should be solved across the Story for this method
to function correctly.
Args:
area_threshold: A number for the Room2D floor area below which it is
considered a small room to be joined into adjacent rooms.
tolerance: The minimum distance between vertices at which point they
are considered equivalent. (Default: 0.01, suitable
for objects in meters).
"""
# first gather all of the small rooms in the model to be joined
all_rooms = list(self._room_2ds)
small_rooms = [rm for rm in all_rooms if rm.floor_area < area_threshold]
if len(small_rooms) == 0:
return
# join Room2Ds together that share adjacency
room_groups = Room2D.group_by_adjacency(small_rooms)
for r_group in room_groups:
if len(r_group) == 1: # no rooms to be joined together
continue
joined_rooms = Room2D.join_room_2ds(r_group, tolerance=tolerance)
del_is = []
for n_rm in r_group:
for in_i, e_rm in enumerate(all_rooms):
if e_rm.identifier == n_rm.identifier:
del_is.append(in_i)
break
del_is.sort()
for del_i in reversed(del_is):
all_rooms.pop(del_i)
for j_room in joined_rooms:
all_rooms.insert(del_is[0], j_room)
# set Room2Ds and re-solve adjacencies to make the result valid
self.room_2ds = all_rooms
self.reset_adjacency()
self.solve_room_2d_adjacency(tolerance=tolerance)
[docs]
def rebuild_detailed_windows(
self, tolerance=0.01, match_adjacency=False, rebuild_skylights=True):
"""Rebuild all detailed windows such that they are bounded by their parent walls.
This method will also ensure that all interior windows on adjacent wall
segments are matched correctly with one another.
This is useful to run after situations where Room2D vertices have been moved,
which can otherwise disrupt the pattern of detailed windows.
Args:
tolerance: The minimum distance between a vertex and the edge of the
wall segment that is considered not touching. (Default: 0.01, suitable
for objects in meters).
match_adjacency: A boolean to note whether this method should ensure
that all interior windows on adjacent wall segments are matched
correctly with one another. This is desirable when the existing
adjacencies across the model are correct but it can create several
unwanted cases when the adjacencies are not correct. (Default: False).
rebuild_skylights: A boolean to note whether skylights should be offset
and rebuilt if they lie outside their parent Room2D.
"""
adj_dict = {}
for room in self.room_2ds:
new_w_pars = []
zip_items = zip(
room._window_parameters, room.floor_segments, room._boundary_conditions)
for i, (w_par, seg, bc) in enumerate(zip_items):
if isinstance(w_par, DetailedWindows):
new_w_par = w_par.adjust_for_segment(
seg, room.floor_to_ceiling_height, tolerance)
if match_adjacency and isinstance(bc, Surface):
try:
adj_seg = bc.boundary_condition_objects[0]
new_w_par = adj_dict[adj_seg].flip(seg.length)
except KeyError: # first of the two adjacencies
this_seg = '{}..Face{}'.format(room.identifier, i + 1)
adj_dict[this_seg] = new_w_par
except AttributeError: # all windows were removed from adjacency
new_w_par = None
else:
new_w_par = w_par
new_w_pars.append(new_w_par)
room._window_parameters = new_w_pars
if rebuild_skylights:
room.offset_skylights_from_edges(tolerance * 2, tolerance)
[docs]
def reset_adjacency(self):
"""Set all Surface boundary conditions on the Story to be Outdoors."""
for room in self.room_2ds:
room.reset_adjacency()
[docs]
def intersect_room_2d_adjacency(self, tolerance=0.01):
"""Automatically intersect the line segments of the Story's Room2Ds.
Note that this method effectively erases window parameters and shading
parameters for any intersected segments as the original segments are
subdivided. As such, it is recommended that this method be used before
assigning window or shading parameters.
Args:
tolerance: The minimum difference between the coordinate values of two
at which they can be considered adjacent. (Default: 0.01,
suitable for objects in meters).
"""
self._room_2ds = Room2D.intersect_adjacency(self._room_2ds, tolerance)
[docs]
def solve_room_2d_adjacency(
self, tolerance=0.01, intersect=False, resolve_window_conflicts=True):
"""Automatically solve adjacencies across the Room2Ds in this Story.
Args:
tolerance: The minimum difference between the coordinate values of two
faces at which they can be considered adjacent. (Default: 0.01,
suitable for objects in meters).
intersect: Boolean to note wether the Room2Ds should be intersected
to obtain matching wall segments before solving adjacency. Note
that setting this to True will result in the loss of windows and
shades assigned to intersected segments. (Default: False).
resolve_window_conflicts: Boolean to note whether conflicts between
window parameters of adjacent segments should be resolved during
adjacency setting or an error should be raised about the mismatch.
Resolving conflicts will default to the window parameters with the
larger are and assign them to the other segment. (Default: True).
"""
if intersect:
self._room_2ds = Room2D.intersect_adjacency(self._room_2ds, tolerance)
Room2D.solve_adjacency(self._room_2ds, tolerance, resolve_window_conflicts)
[docs]
def set_adjacent_air_boundary(self, room_ids=None, guide_lines=None, tolerance=0.01):
"""Set adjacencies between Room2Ds in this Story to use air boundaries.
Args:
room_ids: An optional list of Room2D identifiers to specify a subset
of rooms within the Story that will have air boundaries set
between them. If None, all Room2Ds in the story will have
air boundaries set if they are adjacent to another. (Default: None).
guide_lines: An optional list of LineSegment2Ds to specify a subset
of rooms within the Story that will have air boundaries set
between them. If None, all Room2Ds in the story will have
air boundaries set if they are adjacent to another. (Default: None).
tolerance: The minimum difference between the coordinate values of two
faces at which they can be considered adjacent. (Default: 0.01,
suitable for objects in meters).
"""
# gather all of the Room2Ds which will have air boundaries assigned
rooms = self._room_2ds if room_ids is None \
else self.rooms_by_identifier(room_ids)
# find the adjacencies along which air boundaries will be set
if guide_lines is None or len(guide_lines) == 0:
adj_info = Room2D.find_adjacency(rooms, tolerance)
else:
adj_info = Room2D.find_adjacency_by_guide_lines(
rooms, guide_lines, tolerance)
# assign air boundaries to all of the pairs that were found
for room_pair in adj_info:
for room_adj in room_pair:
room, wall_i = room_adj
try:
room.set_air_boundary(wall_i)
except AssertionError: # segment with windows or non-adjacent BC
pass # ignore this particular segment
[docs]
def set_outdoor_window_parameters(self, window_parameter):
"""Set all of the outdoor walls to have the same window parameters.
Args:
window_parameter: A WindowParameter object that will be assigned to
all wall segments of this story's rooms that have an Outdoors
boundary conditions. This can also be None, to remove all
windows from the story.
"""
for room in self._room_2ds:
room.set_outdoor_window_parameters(window_parameter)
[docs]
def set_outdoor_shading_parameters(self, shading_parameter):
"""Set all of the outdoor walls to have the same shading parameters.
Args:
shading_parameter: A ShadingParameter object that will be assigned to
all wall segments of this story's rooms that have an Outdoors
boundary conditions. This can also be None, to remove all
shades from the story.
"""
for room in self._room_2ds:
room.set_outdoor_shading_parameters(shading_parameter)
[docs]
def to_rectangular_windows(self):
"""Convert all of the windows of the Story to the RectangularWindows format."""
for room in self._room_2ds:
room.to_rectangular_windows()
[docs]
def set_ground_contact(self, is_ground_contact=True):
"""Set all child Room2Ds of this object to have floors with ground contact.
Args:
is_ground_contact: A boolean noting whether all the Story's room_2ds
have floors in contact with the ground. Default: True.
"""
for room in self._room_2ds:
room.is_ground_contact = is_ground_contact
[docs]
def set_top_exposed(self, is_top_exposed=True):
"""Set all child Room2Ds of this object to have ceilings exposed to the outdoors.
Args:
is_top_exposed: A boolean noting whether all the Story's room_2ds
have ceilings exposed to the outdoors. Default: True.
"""
for room in self._room_2ds:
room.is_top_exposed = is_top_exposed
[docs]
def split_with_story_above(self, story_above, tolerance=0.01):
"""Split the child Room2Ds of this object with the footprint of the Story above.
This is useful as a pre-step before running set_top_exposed_by_story_above
as it ensures all top-exposed areas of this Story have a Room2D that can
be set to exposed.
Args:
story_above: A Story object that sits above this Story. Each Room2D
of this Story will be checked to see if it intersects the Story
above and it will be split based on this.
tolerance: The tolerance with which the splitting intersection will be
computed. Default: 0.01, suitable for objects in meters.
"""
# get the footprint geometry of the story above
above_geos = story_above.footprint(tolerance)
# loop through the rooms and split them
split_rooms = []
for room in self._room_2ds:
# split the floor with all geometries above
room_height = room.floor_height
split_geo = [room.floor_geometry]
for i, a_geo in enumerate(above_geos):
# make sure all above geometries are at the room floor_height
if abs(a_geo[0].z - room_height) > tolerance:
a_geo = a_geo.move(Vector3D(0, 0, room_height - a_geo[0].z))
above_geos[i] = a_geo # set it so we hopefully don't move next time
# split the geometries with one another
new_geo = []
for r_geo in split_geo:
floor_split, above_split = Face3D.coplanar_split(
r_geo, a_geo, tolerance, 1)
new_geo.extend(floor_split)
split_geo = new_geo
# create the new Room2D if necessary
if len(split_geo) == 1: # no room splitting needed
split_rooms.append(room)
else: # the Room2D has been split
for j, s_geo in enumerate(split_geo):
# check to make sure the split geometry is not degenerate
max_dim = max((s_geo.max.x - s_geo.min.x, s_geo.max.y - s_geo.min.y))
if s_geo.area < max_dim * tolerance: # degenerate geometry found
continue
new_id = '{}_{}'.format(room.identifier, j)
new_room = Room2D(
new_id, s_geo, room.floor_to_ceiling_height,
is_ground_contact=room.is_ground_contact,
is_top_exposed=room.is_top_exposed)
room._match_and_transfer_wall_props(new_room, tolerance)
new_room._display_name = room._display_name
new_room._user_data = None if room.user_data is None \
else room.user_data.copy()
new_room._has_floor = room._has_floor
new_room._has_ceiling = room._has_ceiling
new_room._skylight_parameters = room._skylight_parameters
new_room._properties._duplicate_extension_attr(room._properties)
split_rooms.append(new_room)
# set the split rooms to this story
self.room_2ds = split_rooms
[docs]
def set_top_exposed_by_story_above(self, story_above, tolerance=0.01):
"""Set the child Room2Ds of this object to have ceilings exposed to the outdoors.
Args:
story_above: A Story object that sits above this Story. Each Room2D
of this Story will be checked to see if the story_above geometry
lies above the room and, if not, the top exposure will be set to True.
tolerance: The tolerance that will be used to compute the point within
the floor boundary that is used to check whether there is geometry
above each Room2D. It is recommended that this number not be less
than 1 centimeter to avoid long computation times. Default: 0.01,
suitable for objects in meters.
"""
up_vec = Vector3D(0, 0, 1)
for room in self._room_2ds:
rm_pt = room.floor_geometry.center if room.floor_geometry.is_convex else \
room.floor_geometry.pole_of_inaccessibility(tolerance)
face_ray = Ray3D(rm_pt, up_vec)
for other_room in story_above._room_2ds:
if other_room._floor_geometry.intersect_line_ray(face_ray) is not None:
room.is_top_exposed = False
break
else:
room.is_top_exposed = True
[docs]
def make_underground(self):
"""Make this Story underground by setting all Room2D segments to have Ground BCs.
Note that this method only changes the outdoor walls of the Room2Ds to have
Ground boundary conditions and, if the floors of the story are also in
contact with the ground, the set_ground_contact should be used in addition
to this method.
Also note that this method will throw an exception if any of the Room2Ds have
WindowParameters assigned to them (since Ground boundary conditions are)
not compatible with windows. So using the set_outdoor_window_parameters
method and passing None to remove all windows is often recommended
before running this method.
"""
for room in self._room_2ds:
for i, bc in enumerate(room._boundary_conditions):
if isinstance(bc, Outdoors):
room.set_boundary_condition(i, bcs.ground)
[docs]
def generate_grid(self, x_dim, y_dim=None, offset=1.0):
"""Get a list of gridded Mesh3D objects offset from the floors of this story.
Args:
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.
Default is 1.0, which will not offset the grid to be 1 unit above
the floor.
"""
return [room.generate_grid(x_dim, y_dim, offset) for room in self._room_2ds]
[docs]
def move(self, moving_vec):
"""Move this Story along a vector.
Args:
moving_vec: A ladybug_geometry Vector3D with the direction and distance
to move the object.
"""
for room in self._room_2ds:
room.move(moving_vec)
self._floor_height = self._floor_height + moving_vec.z
if self._roof is not None:
self._roof.move(moving_vec)
self.properties.move(moving_vec)
[docs]
def rotate_xy(self, angle, origin):
"""Rotate this Story counterclockwise in the XY plane by a certain angle.
Args:
angle: An angle in degrees.
origin: A ladybug_geometry Point3D for the origin around which the
object will be rotated.
"""
for room in self._room_2ds:
room.rotate_xy(angle, origin)
if self._roof is not None:
self._roof.rotate_xy(angle, origin)
self.properties.rotate_xy(angle, origin)
[docs]
def reflect(self, plane):
"""Reflect this Story across a plane.
Args:
plane: A ladybug_geometry Plane across which the object will be reflected.
"""
for room in self._room_2ds:
room.reflect(plane)
if self._roof is not None:
self._roof.reflect(plane)
self.properties.reflect(plane)
[docs]
def scale(self, factor, origin=None):
"""Scale this Story 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 room in self._room_2ds:
room.scale(factor, origin)
self._floor_to_floor_height = self._floor_to_floor_height * factor
self._floor_height = self._floor_height * factor
if self._roof is not None:
self._roof.scale(factor, origin)
self.properties.scale(factor, origin)
[docs]
def check_room2d_floor_heights_valid(self, raise_exception=True, detailed=False):
"""Check that all Room2Ds have floor elevations in range to be on the same Story.
Args:
raise_exception: Boolean to note whether a ValueError should be raised
if rooms with inappropriate floor elevations are found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
if not self.room_2d_story_geometry_valid(self.room_2ds):
# determine if are more offending rooms above or below average floor height
flr_hts = [rm.floor_height for rm in self.room_2ds]
flr_hts, rooms = zip(*sorted(zip(flr_hts, self.room_2ds),
key=lambda pair: pair[0]))
sum_ftc = sum([rm.floor_to_ceiling_height for rm in self.room_2ds])
avg_ftc = sum_ftc / len(self.room_2ds)
rms_below = []
for flr_ht, room in zip(flr_hts, rooms):
f_dif = flr_hts[-1] - flr_ht
if f_dif >= avg_ftc:
r_info = (room, round(f_dif, 3),
round(flr_ht + (f_dif - avg_ftc), 3))
rms_below.append(r_info)
else:
break
rms_above = []
for flr_ht, room in zip(reversed(flr_hts), reversed(rooms)):
f_dif = flr_ht - flr_hts[0]
if f_dif >= avg_ftc:
r_info = (room, round(f_dif, 3),
round(flr_ht - (f_dif - avg_ftc), 3))
rms_above.append(r_info)
else:
break
# prepare to give an exception message
detailed = False if raise_exception else detailed
msg1 = 'Story "{}" has Room floor elevations that are too different from ' \
'one another to be a part of the same Story.'.format(self.display_name)
msg2t = ' The following Rooms have an elevation {} the others:\n{}'
msg3t = 'The Room "{}" has an elevation that is {} {} the others. ' \
'Changing the floor elevation to something {} {} would make it valid.'
# create the exception messages
msgs = []
if detailed:
if len(rms_below) < len(rms_above):
for b_room in rms_below:
msg = msg3t.format(b_room[0].display_name, b_room[1],
'below', 'above', b_room[2])
msg = self._validation_message_child(
msg, b_room[0], detailed, '100106',
error_type='Invalid Room Floor Elevation')
msgs.append(msg)
else:
for a_room in rms_above:
msg = msg3t.format(a_room[0].display_name, a_room[1],
'above', 'below', a_room[2])
msg = self._validation_message_child(
msg, a_room[0], detailed, '100106',
error_type='Invalid Room Floor Elevation')
msgs.append(msg)
return msgs
else:
rms_below = [' {} - distance: -{}'.format(rm[0].display_name, rm[1])
for rm in rms_below]
rms_above = [' {} - distance: +{}'.format(rm[0].display_name, rm[1])
for rm in rms_above]
msg2 = msg2t.format('below', '\n'.join(rms_below)) \
if len(rms_below) < len(rms_above) else \
msg2t.format('above', '\n'.join(rms_above))
msg = '{}\n{}'.format(msg1, msg2)
if raise_exception:
raise ValueError(msg)
return msg
else: # no error to report
return [] if detailed else ''
[docs]
def check_missing_adjacencies(self, raise_exception=True, detailed=False):
"""Check that all Room2Ds have adjacent objects that exist within this Story.
Args:
raise_exception: Boolean to note whether a ValueError should be raised
if missing or invalid adjacencies are found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
detailed = False if raise_exception else detailed
# gather all of the Surface boundary conditions
srf_bc_dict, rid_map = {}, {}
for room in self._room_2ds:
for bc, w_par in zip(room._boundary_conditions, room._window_parameters):
if isinstance(bc, Surface):
bc_objs = bc.boundary_condition_objects
try:
bc_ind = int(bc_objs[0].split('..Face')[-1]) - 1
srf_bc_dict[(bc_objs[-1], bc_ind)] = \
(room.identifier, bc_objs[0], w_par, room)
except ValueError: # Surface BC not following dragonfly convention
# this will be reported as a missing adjacency later
srf_bc_dict[(bc_objs[-1], 10000)] = \
(room.identifier, bc_objs[0], w_par, room)
rid_map[room.identifier] = room.full_id
# check the adjacencies for all Surface boundary conditions
msgs = []
for key, val in srf_bc_dict.items():
rm_id = key[0]
for room in self._room_2ds:
if room.identifier == rm_id:
try:
rm_bc = room._boundary_conditions[key[1]]
rm_w_par = room._window_parameters[key[1]]
except IndexError: # referenced wall segment does not exist
try:
r1, r2 = rid_map[val[0]], rid_map[rm_id]
except KeyError: # completely missing from the model
r1, r2 = val[0], rm_id
msg = 'Room2D "{}" has an adjacency referencing a missing ' \
'wall segment on Room2D "{}".'.format(r1, r2)
msg = self._validation_message_child(
msg, val[3], detailed, '100203',
error_type='Missing Adjacency')
if detailed:
msg['element_id'].append(room.identifier)
msg['element_name'].append(room.display_name)
msg['parents'].append(msg['parents'][0])
msgs.append(msg)
break
if not isinstance(rm_bc, Surface):
try:
r1, r2 = rid_map[rm_id], rid_map[val[1]]
except KeyError: # completely missing from the model
r1, r2 = rm_id, val[1]
msg = 'Room2D "{}" does not have a Surface boundary condition ' \
'at "{}" but its adjacent object does.'.format(r1, r2)
msg = self._validation_message_child(
msg, room, detailed, '100201',
error_type='Mismatched Adjacency')
if detailed:
msg['element_id'].append(val[3].identifier)
msg['element_name'].append(val[3].display_name)
msg['parents'].append(msg['parents'][0])
msgs.append(msg)
if val[2] != rm_w_par:
try:
r1, r2 = rid_map[val[0]], rid_map[rm_id]
except KeyError: # completely missing from the model
r1, r2 = val[0], rm_id
msg = 'Window parameters do not match between ' \
'adjacent Room2Ds "{}" and "{}".'.format(r1, r2)
msg = self._validation_message_child(
msg, room, detailed, '100202',
error_type='Mismatched WindowParameter Adjacency')
if detailed:
msg['element_id'].append(val[3].identifier)
msg['element_name'].append(val[3].display_name)
msg['parents'].append(msg['parents'][0])
msgs.append(msg)
break
else:
try:
r1, r2 = rid_map[val[0]], rid_map[rm_id]
except KeyError: # completely missing from the model
r1, r2 = val[0], rm_id
msg = 'Room2D "{}" has a missing adjacency for Room2D "{}".'.format(
r1, r2)
msg = self._validation_message_child(
msg, val[3], detailed, '100203', error_type='Missing Adjacency')
msgs.append(msg)
# report any errors
if detailed:
return msgs
full_msg = '\n '.join(msgs)
if raise_exception and len(msgs) != 0:
raise ValueError(full_msg)
return full_msg
[docs]
def check_no_room2d_overlaps(
self, tolerance=0.01, raise_exception=True, detailed=False):
"""Check that geometries of Room2Ds do not overlap with one another.
Overlaps in Room2Ds mean that the Room volumes will collide with one
another during translation to Honeybee.
Args:
tolerance: The minimum distance that two Room2Ds geometries can overlap
with one another and still be considered valid. (Default: 0.01,
suitable for objects in meters).
raise_exception: Boolean to note whether a ValueError should be raised
if overlapping geometries are found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
# find the number of overlaps across the Room2Ds
msgs = []
rooms = self.room_2ds
for i, room_1 in enumerate(rooms):
poly_1 = room_1.floor_geometry.polygon2d
try:
for room_2 in rooms[i + 1:]:
poly_2 = room_2.floor_geometry.polygon2d
if poly_1.polygon_relationship(poly_2, tolerance) >= 0:
msg = 'Room2D "{}" overlaps with Room2D "{}" more than the '\
'tolerance ({}) on Story "{}".'.format(
room_1.display_name, room_2.display_name,
tolerance, self.display_name)
msg = self._validation_message_child(
msg, room_1, detailed, '100104',
error_type='Overlapping Room Geometries')
if detailed:
msg['element_id'].append(room_2.identifier)
msg['element_name'].append(room_2.display_name)
msg['parents'].append(msg['parents'][0])
msgs.append(msg)
except IndexError:
pass # we have reached the end of the list
# report any errors
if detailed:
return msgs
full_msg = '\n '.join(msgs)
if raise_exception and len(msgs) != 0:
raise ValueError(full_msg)
return full_msg
[docs]
def check_roofs_above_rooms(
self, tolerance=0.01, raise_exception=True, detailed=False):
"""Check that geometries of RoofSpecifications all lie above the Room2D geometry.
Roofs that lie below the Room2Ds will result in invalid Honeybee Rooms
with self-intersecting walls.
Args:
tolerance: The minimum distance between coordinate values that is
considered a meaningful difference. (Default: 0.01, suitable
for objects in meters).
raise_exception: Boolean to note whether a ValueError should be raised if
roof geometries are found below the Room2D geometries. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
# find the number of cases where the roof is below the story floor
msgs = []
if self.roof is not None:
roof_max = self.roof.max_height
if roof_max < self.floor_height + tolerance:
msg = 'Roof geometry of story "{}" has a maximum height of {}, ' \
'which is lower than the story floor height at {}. ' \
'This may result in invalid room volumes.'.format(
self.display_name, roof_max, self.floor_height)
msg = self._validation_message_child(
msg, self.roof, detailed, '100105', error_type='Invalid Roof')
msgs.append(msg)
# report any errors
if detailed:
return msgs
full_msg = '\n '.join(msgs)
if raise_exception and len(msgs) != 0:
raise ValueError(full_msg)
return full_msg
[docs]
def check_no_roof_overlaps(
self, tolerance=0.01, raise_exception=True, detailed=False):
"""Check that geometries of RoofSpecifications do not overlap with one another.
This is NOT required for the Story to be valid but it is sometimes
useful to check since it can indicate whether the roof can be cleaned
up into a clearer and more concise set of geometries.
Args:
tolerance: The minimum distance that two Roof geometries can overlap
with one another and still be considered valid. Default: 0.01,
suitable for objects in meters.
raise_exception: Boolean to note whether a ValueError should be raised
if overlapping geometries are found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
# find the number of overlaps in the Roof specification
msgs = []
if self.roof is not None:
over_count = self.roof.overlap_count(tolerance)
if over_count > 0:
msg = 'Story "{}" has RoofSpecification geometry with {} overlaps ' \
'in it.'.format(self.display_name, over_count)
msg = self._validation_message_child(
msg, self.roof, detailed, '100105', error_type='Invalid Roof')
msgs.append(msg)
# report any errors
if detailed:
return msgs
full_msg = '\n '.join(msgs)
if raise_exception and len(msgs) != 0:
raise ValueError(full_msg)
return full_msg
[docs]
def to_honeybee(self, use_multiplier=True, add_plenum=False, tolerance=0.01,
enforce_adj=True, enforce_solid=True):
"""Convert Dragonfly Story to a list of Honeybee Rooms.
Args:
use_multiplier: If True, this Story's multiplier will be passed along
to the generated Honeybee Room objects, indicating the simulation
will be run once for the Story and then results will be multiplied.
You will want to set this to False when exporting each Story as
full geometry.
add_plenum: Boolean to indicate whether ceiling/floor plenums should
be auto-generated for the Rooms. (Default: False).
tolerance: The minimum distance in z values of floor_height and
floor_to_ceiling_height at which adjacent Faces will be split.
If None, no splitting will occur. (Default: 0.01, suitable for
objects in meters).
enforce_adj: Boolean to note whether an exception should be raised if
an adjacency between two Room2Ds is invalid (True) or if the invalid
Surface boundary condition should be replaced with an Outdoor
boundary condition (False). If False, any Walls containing
WindowParameters and an illegal boundary condition will also
be replaced with an Outdoor boundary condition. (Default: True).
enforce_solid: Boolean to note whether rooms should be translated
as solid extrusions whenever translating them with custom
roof geometry produces a non-solid result (True) or the non-solid
room geometry should be allowed to remain in the result (False).
The latter is useful for understanding why a particular roof
geometry has produced a non-solid result. (Default: True).
Returns:
A list of honeybee Rooms that represent the Story.
"""
# set up the multiplier
mult = self.multiplier if use_multiplier else 1
# if this story has any overlaps, resolve them before translation
original_roof = None
if self.roof is not None:
if any(room.is_top_exposed for room in self._room_2ds):
original_roof = self.roof
res_roof_geo = self.roof.resolved_geometry(tolerance)
res_roof = RoofSpecification(res_roof_geo)
res_roof._is_resolved = True
self.roof = res_roof
# convert all of the Room2Ds to honeybee Rooms
hb_rooms = []
adjacencies = []
for room in self._room_2ds:
hb_room, adj = room.to_honeybee(
mult, add_plenum=add_plenum, tolerance=tolerance,
enforce_bc=enforce_adj, enforce_solid=enforce_solid)
if isinstance(hb_room, Room):
hb_rooms.append(hb_room)
else: # list of rooms with plenums
hb_rooms.extend(hb_room)
adjacencies.extend(adj)
# assign adjacent boundary conditions that could not be set on the room level
if len(adjacencies) != 0:
adj_set = set()
for adj in adjacencies:
if adj[0].identifier not in adj_set:
for room in hb_rooms:
adj_room = adj[1][-1]
if room.identifier == adj_room:
for face in room.faces:
adj_face = adj[1][-2]
if face.identifier == adj_face:
self._match_apertures(adj[0], face)
other_resolve = False
if self.roof is not None: # two roofs may meet
tol_area = math.sqrt(face.area) * tolerance
if abs(face.area - adj[0].area) > tol_area:
self._resolve_roof_adj(
face, adj[0], tolerance)
other_resolve = True
if not other_resolve:
try:
adj[0].set_adjacency(face, tolerance)
except (AssertionError, ValueError) as e:
if enforce_adj:
raise e
face.boundary_condition = bcs.outdoors
adj[0].boundary_condition = bcs.outdoors
adj_set.add(face.identifier)
break
break
# put back the original roof to avoid mutating the story
if original_roof is not None:
self.roof = original_roof
return hb_rooms
[docs]
def to_dict(self, abridged=False, included_prop=None):
"""Return Story as a dictionary.
Args:
abridged: Boolean to note whether the extension properties of the
object (ie. construction sets) should be included in detail
(False) or just referenced by identifier (True). Default: False.
included_prop: List of properties to filter keys that must be included in
output dictionary. For example ['energy'] will include 'energy' key if
available in properties to_dict. By default all the keys will be
included. To exclude all the keys from extensions use an empty list.
"""
base = {'type': 'Story'}
base['identifier'] = self.identifier
base['display_name'] = self.display_name
base['room_2ds'] = [r.to_dict(abridged, included_prop) for r in self._room_2ds]
base['floor_to_floor_height'] = self.floor_to_floor_height
base['floor_height'] = self.floor_height
base['multiplier'] = self.multiplier
if self.roof is not None:
base['roof'] = self.roof.to_dict()
if self.user_data is not None:
base['user_data'] = self.user_data
base['properties'] = self.properties.to_dict(abridged, included_prop)
return base
@property
def to(self):
"""Story writer object.
Use this method to access Writer class to write the story in other formats.
"""
return writer
[docs]
@staticmethod
def room_2d_story_geometry_valid(room_2ds):
"""Check that a set of Room2Ds have geometry that makes a valid Story.
This means that all of the floors of the Room2Ds are close enough to
one another in elevation that their walls could touch each other.
Args:
room_2ds: An array of Room2Ds that will be checked to ensure their
geometry makes a valid Story.
Returns:
True if the Room2D geometries make a valid Story. False if they do not.
"""
if len(room_2ds) == 1:
return True
flr_hts = sorted([rm.floor_height for rm in room_2ds])
avg_ftc = sum([rm.floor_to_ceiling_height for rm in room_2ds]) / len(room_2ds)
return True if flr_hts[-1] - flr_hts[0] < avg_ftc else False
def _room_roofs(self, room_2d, tolerance):
"""Get a RoofSpecification to be used for a specific Room2D in the Story.
This will account for the case that a Room2D extends to the roof of
another Story within the parent Buildings.
Args:
room_2d: A Room2D object within the Story for which RoofSpecifications
will be evaluated.
"""
# first check whether it's possible for the room to be shaped by a roof
if not room_2d.is_top_exposed or self.multiplier != 1:
return None # it's impossible for the room to be shaped by a roof
# determine all roof specifications that can influence the Room2D
room_roofs = []
if self._roof is not None:
room_roofs.append(self._roof)
if self._parent is not None:
room_ch = room_2d.ceiling_height
story_roofs = self._parent._story_roofs(self)
for hgt, rf in story_roofs:
if room_ch > hgt: # the room extends into the roof
room_roofs.append(rf)
# if there is only one or no roofs, the solution is simple
if len(room_roofs) == 0:
return None # no relevant roofs were found
if len(room_roofs) == 1: # just return a variation of the lone roof
if room_roofs[0]._is_resolved:
return room_roofs[0]
else: # the roof of another story; we must resolve it
res_roof_geo = room_roofs[0].resolved_geometry(tolerance)
res_roof = RoofSpecification(res_roof_geo)
res_roof._is_resolved = True
return res_roof
# if we have multiple roofs, create a new roof with everything resolved
all_geo = [g for roof in room_roofs for g in roof]
base_roof = RoofSpecification(all_geo)
res_roof_geo = base_roof.resolved_geometry(tolerance)
res_roof = RoofSpecification(res_roof_geo)
res_roof._is_resolved = True
return res_roof
@staticmethod
def _match_apertures(face_1, face2):
for ap1, ap2 in zip(face_1.apertures, face2.apertures):
ap1._is_operable, ap2._is_operable = False, False
try:
ap1.properties.energy.vent_opening = None
ap2.properties.energy.vent_opening = None
except AttributeError:
pass # honeybee-energy extension is not loaded
@staticmethod
def _resolve_roof_adj(face_1, face_2, tol):
"""Resolve incorrect adjacency where walls of two roofs meet."""
# remove air boundaries so the split result is valid
use_ab = False
if isinstance(face_1.type, AirBoundary) or isinstance(face_2.type, AirBoundary):
face_1.type = ftyp.wall
face_2.type = ftyp.wall
use_ab = True
# split the adjacent walls with one another to get a match
room_1, room_2 = face_1.parent, face_2.parent
new_faces1 = room_1.coplanar_split([face_2.geometry], tol)
new_faces2 = room_2.coplanar_split([face_1.geometry], tol)
new_faces1 = [face_1] if len(new_faces1) == 0 else new_faces1
new_faces2 = [face_2] if len(new_faces2) == 0 else new_faces2
# find the adjacency and set it
adj_geo = None
for j, f_1 in enumerate(new_faces1):
for k, f_2 in enumerate(new_faces2):
if f_1.geometry.is_centered_adjacent(f_2.geometry, tol):
if f_1.has_sub_faces or f_2.has_sub_faces:
f_1.remove_sub_faces()
f_2.remove_sub_faces()
f_1.set_adjacency(f_2)
adj_geo = f_1.geometry
if use_ab:
f_1.type = ftyp.air_boundary
f_2.type = ftyp.air_boundary
new_faces1.pop(j)
new_faces2.pop(k)
break
# set the boundary conditions of the other newly-created Faces
for nf in new_faces1:
for of in room_2.faces:
if nf.geometry.is_centered_adjacent(of.geometry, tol):
if nf.has_sub_faces or of.has_sub_faces:
nf.remove_sub_faces()
of.remove_sub_faces()
nf.set_adjacency(of)
if use_ab:
nf.type = ftyp.air_boundary
of.type = ftyp.air_boundary
break
else:
if adj_geo is not None:
if nf.center.z < adj_geo.center.z:
nf.remove_sub_faces()
nf.boundary_condition = room_2[0].boundary_condition
elif nf.center.z > adj_geo.center.z:
nf.remove_sub_faces()
nf.boundary_condition = room_2[-1].boundary_condition
for nf in new_faces2:
for of in room_1.faces:
if nf.geometry.is_centered_adjacent(of.geometry, tol):
if nf.has_sub_faces or of.has_sub_faces:
nf.remove_sub_faces()
of.remove_sub_faces()
nf.set_adjacency(of)
if use_ab:
nf.type = ftyp.air_boundary
of.type = ftyp.air_boundary
break
else:
if adj_geo is not None:
if nf.center.z < adj_geo.center.z:
nf.remove_sub_faces()
nf.boundary_condition = room_1[0].boundary_condition
elif nf.center.z > adj_geo.center.z:
nf.remove_sub_faces()
nf.boundary_condition = room_1[-1].boundary_condition
def __copy__(self):
new_s = Story(
self.identifier, tuple(room.duplicate() for room in self._room_2ds),
self._floor_to_floor_height, self._floor_height, self._multiplier)
new_s._roof = None if self._roof is None else self._roof.duplicate()
new_s._display_name = self._display_name
new_s._user_data = None if self.user_data is None else self.user_data.copy()
new_s._parent = self._parent
new_s._properties._duplicate_extension_attr(self._properties)
return new_s
def __len__(self):
return len(self._room_2ds)
def __getitem__(self, key):
return self._room_2ds[key]
def __iter__(self):
return iter(self._room_2ds)
def __repr__(self):
return 'Story: %s' % self.display_name