honeybee-core 1.64.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. honeybee/__init__.py +23 -0
  2. honeybee/__main__.py +4 -0
  3. honeybee/_base.py +331 -0
  4. honeybee/_basewithshade.py +310 -0
  5. honeybee/_lockable.py +99 -0
  6. honeybee/altnumber.py +47 -0
  7. honeybee/aperture.py +997 -0
  8. honeybee/boundarycondition.py +358 -0
  9. honeybee/checkdup.py +173 -0
  10. honeybee/cli/__init__.py +118 -0
  11. honeybee/cli/compare.py +132 -0
  12. honeybee/cli/create.py +265 -0
  13. honeybee/cli/edit.py +559 -0
  14. honeybee/cli/lib.py +103 -0
  15. honeybee/cli/setconfig.py +43 -0
  16. honeybee/cli/validate.py +224 -0
  17. honeybee/colorobj.py +363 -0
  18. honeybee/config.json +5 -0
  19. honeybee/config.py +347 -0
  20. honeybee/dictutil.py +54 -0
  21. honeybee/door.py +746 -0
  22. honeybee/extensionutil.py +208 -0
  23. honeybee/face.py +2360 -0
  24. honeybee/facetype.py +153 -0
  25. honeybee/logutil.py +79 -0
  26. honeybee/model.py +4272 -0
  27. honeybee/orientation.py +132 -0
  28. honeybee/properties.py +845 -0
  29. honeybee/room.py +3485 -0
  30. honeybee/search.py +107 -0
  31. honeybee/shade.py +514 -0
  32. honeybee/shademesh.py +362 -0
  33. honeybee/typing.py +498 -0
  34. honeybee/units.py +88 -0
  35. honeybee/writer/__init__.py +7 -0
  36. honeybee/writer/aperture.py +6 -0
  37. honeybee/writer/door.py +6 -0
  38. honeybee/writer/face.py +6 -0
  39. honeybee/writer/model.py +6 -0
  40. honeybee/writer/room.py +6 -0
  41. honeybee/writer/shade.py +6 -0
  42. honeybee/writer/shademesh.py +6 -0
  43. honeybee_core-1.64.12.dist-info/METADATA +94 -0
  44. honeybee_core-1.64.12.dist-info/RECORD +48 -0
  45. honeybee_core-1.64.12.dist-info/WHEEL +5 -0
  46. honeybee_core-1.64.12.dist-info/entry_points.txt +2 -0
  47. honeybee_core-1.64.12.dist-info/licenses/LICENSE +661 -0
  48. honeybee_core-1.64.12.dist-info/top_level.txt +1 -0
honeybee/room.py ADDED
@@ -0,0 +1,3485 @@
1
+ # coding: utf-8
2
+ """Honeybee Room."""
3
+ from __future__ import division
4
+ import math
5
+ import re
6
+ import uuid
7
+
8
+ from ladybug_geometry.geometry2d import Point2D, Vector2D, Polygon2D
9
+ from ladybug_geometry.geometry3d import Point3D, Vector3D, Ray3D, LineSegment3D, \
10
+ Plane, Face3D, Mesh3D, Polyface3D
11
+ from ladybug_geometry.bounding import overlapping_bounding_boxes
12
+ from ladybug_geometry_polyskel.polysplit import perimeter_core_subpolygons
13
+
14
+ import honeybee.writer.room as writer
15
+ from ._basewithshade import _BaseWithShade
16
+ from .typing import float_in_range, int_in_range, clean_string, invalid_dict_error
17
+ from .properties import RoomProperties
18
+ from .face import Face
19
+ from .aperture import Aperture
20
+ from .facetype import AirBoundary, Wall, Floor, RoofCeiling, get_type_from_normal
21
+ from .boundarycondition import get_bc_from_position, Outdoors, Ground, Surface, \
22
+ boundary_conditions
23
+ from .orientation import angles_from_num_orient, orient_index
24
+ from .search import get_attr_nested
25
+ try:
26
+ ad_bc = boundary_conditions.adiabatic
27
+ except AttributeError: # honeybee_energy is not loaded and adiabatic does not exist
28
+ ad_bc = None
29
+
30
+
31
+ class Room(_BaseWithShade):
32
+ """A volume enclosed by faces, representing a single room or space.
33
+
34
+ Note that, if zero is input for tolerance and angle_tolerance, no checks
35
+ will be performed to determine whether the room is a closed volume
36
+ and no attempt will be made to flip faces in the event that they are not
37
+ facing outward from the room volume. As such, an input tolerance of 0
38
+ is intended for workflows where the solidity of the room volume has been
39
+ evaluated elsewhere.
40
+
41
+ Args:
42
+ identifier: Text string for a unique Room ID. Must be < 100 characters and
43
+ not contain any spaces or special characters.
44
+ faces: A list or tuple of honeybee Face objects that together form the
45
+ closed volume of a room.
46
+ tolerance: The maximum difference between x, y, and z values
47
+ at which vertices of adjacent faces are considered equivalent. This is
48
+ used in determining whether the faces form a closed volume. Default
49
+ is 0, which makes no attempt to evaluate whether the Room volume
50
+ is closed.
51
+ angle_tolerance: Deprecated input that is no longer used.
52
+
53
+ Properties:
54
+ * identifier
55
+ * display_name
56
+ * faces
57
+ * multiplier
58
+ * zone
59
+ * story
60
+ * exclude_floor_area
61
+ * indoor_furniture
62
+ * indoor_shades
63
+ * outdoor_shades
64
+ * walls
65
+ * floors
66
+ * roof_ceilings
67
+ * air_boundaries
68
+ * sub_faces
69
+ * doors
70
+ * apertures
71
+ * exterior_apertures
72
+ * geometry
73
+ * center
74
+ * min
75
+ * max
76
+ * exterior_aperture_edges
77
+ * exterior_door_edges
78
+ * volume
79
+ * floor_area
80
+ * exposed_area
81
+ * exterior_wall_area
82
+ * exterior_aperture_area
83
+ * exterior_wall_aperture_area
84
+ * exterior_skylight_aperture_area
85
+ * average_floor_height
86
+ * user_data
87
+ """
88
+ __slots__ = (
89
+ '_geometry', '_faces', '_multiplier', '_zone', '_story',
90
+ '_exclude_floor_area',
91
+ '_parent')
92
+
93
+ def __init__(self, identifier, faces, tolerance=0, angle_tolerance=None):
94
+ """Initialize Room."""
95
+ _BaseWithShade.__init__(self, identifier) # process the identifier
96
+
97
+ # process the zone volume geometry
98
+ if not isinstance(faces, tuple):
99
+ faces = tuple(faces)
100
+ for face in faces:
101
+ assert isinstance(face, Face), \
102
+ 'Expected honeybee Face. Got {}'.format(type(face))
103
+ face._parent = self
104
+
105
+ if tolerance == 0:
106
+ self._faces = faces
107
+ self._geometry = None # calculated later from faces or added by classmethods
108
+ else:
109
+ # try to get a closed volume between the faces
110
+ room_polyface = Polyface3D.from_faces(
111
+ tuple(face.geometry for face in faces), tolerance)
112
+ if not room_polyface.is_solid:
113
+ room_polyface = room_polyface.merge_overlapping_edges(tolerance)
114
+ # replace honeybee face geometry with versions that are facing outwards
115
+ if room_polyface.is_solid:
116
+ for i, correct_face3d in enumerate(room_polyface.faces):
117
+ face = faces[i]
118
+ norm_init = face._geometry.normal
119
+ face._geometry = correct_face3d
120
+ if face.has_sub_faces: # flip sub-faces to align with parent Face
121
+ if norm_init.angle(face._geometry.normal) > (math.pi / 2):
122
+ for ap in face._apertures:
123
+ ap._geometry = ap._geometry.flip()
124
+ for dr in face._doors:
125
+ dr._geometry = dr._geometry.flip()
126
+ self._faces = faces
127
+ self._geometry = room_polyface
128
+
129
+ self._multiplier = 1 # default value that can be overridden later
130
+ self._zone = None # default value that can be overridden later
131
+ self._story = None # default value that can be overridden later
132
+ self._exclude_floor_area = False # default value that can be overridden later
133
+ self._parent = None # completely hidden as it is only used by Dragonfly
134
+ self._properties = RoomProperties(self) # properties for extensions
135
+
136
+ @classmethod
137
+ def from_dict(cls, data, tolerance=0, angle_tolerance=None):
138
+ """Initialize an Room from a dictionary.
139
+
140
+ Args:
141
+ data: A dictionary representation of a Room object.
142
+ tolerance: The maximum difference between x, y, and z values
143
+ at which vertices of adjacent faces are considered equivalent. This is
144
+ used in determining whether the faces form a closed volume. Default
145
+ is 0, which makes no attempt to evaluate whether the Room volume
146
+ is closed.
147
+ angle_tolerance: Deprecated input that is no longer used.
148
+ """
149
+ try:
150
+ # check the type of dictionary
151
+ assert data['type'] == 'Room', 'Expected Room dictionary. ' \
152
+ 'Got {}.'.format(data['type'])
153
+
154
+ # create the room object and assign properties
155
+ faces = []
156
+ for f_dict in data['faces']:
157
+ try:
158
+ faces.append(Face.from_dict(f_dict))
159
+ except Exception as e:
160
+ invalid_dict_error(f_dict, e)
161
+ room = cls(data['identifier'], faces, tolerance)
162
+ if 'display_name' in data and data['display_name'] is not None:
163
+ room.display_name = data['display_name']
164
+ if 'user_data' in data and data['user_data'] is not None:
165
+ room.user_data = data['user_data']
166
+ if 'multiplier' in data and data['multiplier'] is not None:
167
+ room.multiplier = data['multiplier']
168
+ if 'zone' in data and data['zone'] is not None:
169
+ room.zone = data['zone']
170
+ if 'story' in data and data['story'] is not None:
171
+ room.story = data['story']
172
+ if 'exclude_floor_area' in data and data['exclude_floor_area'] is not None:
173
+ room.exclude_floor_area = data['exclude_floor_area']
174
+ room._recover_shades_from_dict(data)
175
+
176
+ if data['properties']['type'] == 'RoomProperties':
177
+ room.properties._load_extension_attr_from_dict(data['properties'])
178
+ return room
179
+ except Exception as e:
180
+ cls._from_dict_error_message(data, e)
181
+
182
+ @classmethod
183
+ def from_polyface3d(cls, identifier, polyface, roof_angle=60, floor_angle=130,
184
+ ground_depth=0):
185
+ """Initialize a Room from a ladybug_geometry Polyface3D object.
186
+
187
+ Args:
188
+ identifier: Text string for a unique Room ID. Must be < 100 characters and
189
+ not contain any spaces or special characters.
190
+ polyface: A ladybug_geometry Polyface3D object representing the closed
191
+ volume of a room. The Polyface3D.is_solid property can be used to
192
+ determine whether the polyface is a closed solid before input here.
193
+ roof_angle: A number between 0 and 90 to set the angle from the horizontal
194
+ plane below which faces will be considered roofs instead of
195
+ walls. 90 indicates that all vertical faces are roofs and 0
196
+ indicates that all horizontal faces are walls. (Default: 60,
197
+ recommended by the ASHRAE 90.1 standard).
198
+ floor_angle: A number between 90 and 180 to set the angle from the horizontal
199
+ plane above which faces will be considered floors instead of
200
+ walls. 180 indicates that all vertical faces are floors and 0
201
+ indicates that all horizontal faces are walls. (Default: 130,
202
+ recommended by the ASHRAE 90.1 standard).
203
+ ground_depth: The Z value above which faces are considered Outdoors
204
+ instead of Ground. Faces will have a Ground boundary condition if
205
+ all of their vertices lie at or below this value. Default: 0.
206
+ """
207
+ assert isinstance(polyface, Polyface3D), \
208
+ 'Expected ladybug_geometry Polyface3D. Got {}'.format(type(polyface))
209
+ faces = []
210
+ for i, face in enumerate(polyface.faces):
211
+ faces.append(Face('{}..Face{}'.format(identifier, i), face,
212
+ get_type_from_normal(face.normal, roof_angle, floor_angle),
213
+ get_bc_from_position(face.boundary, ground_depth)))
214
+ room = cls(identifier, faces)
215
+ room._geometry = polyface
216
+ return room
217
+
218
+ @classmethod
219
+ def from_box(cls, identifier, width=3.0, depth=6.0, height=3.2,
220
+ orientation_angle=0, origin=Point3D(0, 0, 0)):
221
+ """Initialize a Room from parameters describing a box.
222
+
223
+ The resulting faces of the room will always be ordered as follows:
224
+ (Bottom, Front, Right, Back, Left, Top) where the front is facing the
225
+ cardinal direction of the orientation_angle.
226
+
227
+ Args:
228
+ identifier: Text string for a unique Room ID. Must be < 100 characters and
229
+ not contain any spaces or special characters.
230
+ width: Number for the width of the box (in the X direction). Default: 3.0.
231
+ depth: Number for the depth of the box (in the Y direction). Default: 6.0.
232
+ height: Number for the height of the box (in the Z direction). Default: 3.2.
233
+ orientation_angle: A number between 0 and 360 for the clockwise
234
+ orientation of the box in degrees.
235
+ (0 = North, 90 = East, 180 = South, 270 = West)
236
+ origin: A ladybug_geometry Point3D for the origin of the room.
237
+ """
238
+ # create a box Polyface3D
239
+ x_axis = Vector3D(1, 0, 0)
240
+ if orientation_angle != 0:
241
+ angle = -1 * math.radians(
242
+ float_in_range(orientation_angle, 0, 360, 'orientation_angle'))
243
+ x_axis = x_axis.rotate_xy(angle)
244
+ base_plane = Plane(Vector3D(0, 0, 1), origin, x_axis)
245
+ polyface = Polyface3D.from_box(width, depth, height, base_plane)
246
+
247
+ # create the honeybee Faces
248
+ directions = ('Bottom', 'Front', 'Right', 'Back', 'Left', 'Top')
249
+ faces = []
250
+ for face, dir in zip(polyface.faces, directions):
251
+ faces.append(Face('{}_{}'.format(identifier, dir), face,
252
+ get_type_from_normal(face.normal),
253
+ get_bc_from_position(face.boundary)))
254
+ room = cls(identifier, faces)
255
+ room._geometry = polyface
256
+ return room
257
+
258
+ @property
259
+ def faces(self):
260
+ """Get a tuple of all honeybee Faces making up this room volume."""
261
+ return self._faces
262
+
263
+ @property
264
+ def multiplier(self):
265
+ """Get or set an integer noting how many times this Room is repeated.
266
+
267
+ Multipliers are used to speed up the calculation when similar Rooms are
268
+ repeated more than once. Essentially, a given simulation with the
269
+ Room is run once and then the result is multiplied by the multiplier.
270
+ This means that the "repetition" isn't in a particular direction (it's
271
+ essentially in the exact same location) and this comes with some
272
+ inaccuracy. However, this error might not be too large if the Rooms
273
+ are similar enough and it can often be worth it since it can greatly
274
+ speed up the calculation.
275
+ """
276
+ return self._multiplier
277
+
278
+ @multiplier.setter
279
+ def multiplier(self, value):
280
+ self._multiplier = int_in_range(value, 1, input_name='room multiplier')
281
+
282
+ @property
283
+ def zone(self):
284
+ """Get or set text for the zone identifier to which this Room belongs.
285
+
286
+ Rooms sharing the same zone identifier are considered part of the same
287
+ zone in a Model. If the zone identifier has not been specified, it
288
+ will be the same as the Room identifier.
289
+
290
+ Note that the zone identifier has no character restrictions much
291
+ like display_name.
292
+ """
293
+ if self._zone is None:
294
+ return self._identifier
295
+ return self._zone
296
+
297
+ @zone.setter
298
+ def zone(self, value):
299
+ if value is not None:
300
+ try:
301
+ self._zone = str(value)
302
+ except UnicodeEncodeError: # Python 2 machine lacking the character set
303
+ self._zone = value # keep it as unicode
304
+ else:
305
+ self._zone = value
306
+
307
+ @property
308
+ def story(self):
309
+ """Get or set text for the story identifier to which this Room belongs.
310
+
311
+ Rooms sharing the same story identifier are considered part of the same
312
+ story in a Model. Note that the story identifier has no character
313
+ restrictions much like display_name.
314
+ """
315
+ return self._story
316
+
317
+ @story.setter
318
+ def story(self, value):
319
+ if value is not None:
320
+ try:
321
+ self._story = str(value)
322
+ except UnicodeEncodeError: # Python 2 machine lacking the character set
323
+ self._story = value # keep it as unicode
324
+ else:
325
+ self._story = value
326
+
327
+ @property
328
+ def exclude_floor_area(self):
329
+ """Get or set a boolean for whether the floor area contributes to the Model.
330
+
331
+ Note that this will not affect the floor_area property of this Room but
332
+ it will ensure the Room's floor area is excluded from any calculations
333
+ when the Room is part of a Model.
334
+ """
335
+ return self._exclude_floor_area
336
+
337
+ @exclude_floor_area.setter
338
+ def exclude_floor_area(self, value):
339
+ self._exclude_floor_area = bool(value)
340
+
341
+ @property
342
+ def indoor_furniture(self):
343
+ """Array of all indoor furniture Shade objects assigned to this Room.
344
+
345
+ Note that this property is identical to the indoor_shades property but
346
+ it is provided here under an alternate name to make it clear that indoor
347
+ furniture objects should be added here to the Room.
348
+ """
349
+ return tuple(self._indoor_shades)
350
+
351
+ @property
352
+ def walls(self):
353
+ """Get a tuple of all of the Wall Faces of the Room."""
354
+ return tuple(face for face in self._faces if isinstance(face.type, Wall))
355
+
356
+ @property
357
+ def floors(self):
358
+ """Get a tuple of all of the Floor Faces of the Room."""
359
+ return tuple(face for face in self._faces if isinstance(face.type, Floor))
360
+
361
+ @property
362
+ def roof_ceilings(self):
363
+ """Get a tuple of all of the RoofCeiling Faces of the Room."""
364
+ return tuple(face for face in self._faces if isinstance(face.type, RoofCeiling))
365
+
366
+ @property
367
+ def air_boundaries(self):
368
+ """Get a tuple of all of the AirBoundary Faces of the Room."""
369
+ return tuple(face for face in self._faces if isinstance(face.type, AirBoundary))
370
+
371
+ @property
372
+ def sub_faces(self):
373
+ """Get a tuple of all Apertures and Doors of the Room."""
374
+ return self.apertures + self.doors
375
+
376
+ @property
377
+ def doors(self):
378
+ """Get a tuple of all Doors of the Room."""
379
+ drs = []
380
+ for face in self._faces:
381
+ if len(face._doors) > 0:
382
+ drs.extend(face._doors)
383
+ return tuple(drs)
384
+
385
+ @property
386
+ def apertures(self):
387
+ """Get a tuple of all Apertures of the Room."""
388
+ aps = []
389
+ for face in self._faces:
390
+ if len(face._apertures) > 0:
391
+ aps.extend(face._apertures)
392
+ return tuple(aps)
393
+
394
+ @property
395
+ def exterior_apertures(self):
396
+ """Get a tuple of all exterior Apertures of the Room."""
397
+ aps = []
398
+ for face in self._faces:
399
+ if isinstance(face.boundary_condition, Outdoors) and \
400
+ len(face._apertures) > 0:
401
+ aps.extend(face._apertures)
402
+ return tuple(aps)
403
+
404
+ @property
405
+ def geometry(self):
406
+ """Get a ladybug_geometry Polyface3D object representing the room."""
407
+ if self._geometry is None:
408
+ self._geometry = Polyface3D.from_faces(
409
+ tuple(face.geometry for face in self._faces), 0) # use 0 tolerance
410
+ return self._geometry
411
+
412
+ @property
413
+ def center(self):
414
+ """Get a ladybug_geometry Point3D for the center of the room.
415
+
416
+ Note that this is the center of the bounding box around the room Polyface
417
+ geometry and not the volume centroid. Also note that shades assigned to
418
+ this room are not included in this center calculation.
419
+ """
420
+ return self.geometry.center
421
+
422
+ @property
423
+ def min(self):
424
+ """Get a Point3D for the minimum of the bounding box around the object.
425
+
426
+ This includes any shades assigned to this object or its children.
427
+ """
428
+ all_geo = self._outdoor_shades + self._indoor_shades
429
+ all_geo.extend(self._faces)
430
+ return self._calculate_min(all_geo)
431
+
432
+ @property
433
+ def max(self):
434
+ """Get a Point3D for the maximum of the bounding box around the object.
435
+
436
+ This includes any shades assigned to this object or its children.
437
+ """
438
+ all_geo = self._outdoor_shades + self._indoor_shades
439
+ all_geo.extend(self._faces)
440
+ return self._calculate_max(all_geo)
441
+
442
+ @property
443
+ def exterior_aperture_edges(self):
444
+ """Get a list of LineSegment3D for the borders around exterior apertures."""
445
+ edges = []
446
+ for ap in self.apertures:
447
+ if isinstance(ap.boundary_condition, Outdoors):
448
+ edges.extend(ap.geometry.segments)
449
+ return edges
450
+
451
+ @property
452
+ def exterior_door_edges(self):
453
+ """Get a list of LineSegment3D for the borders around exterior doors."""
454
+ edges = []
455
+ for dr in self.doors:
456
+ if isinstance(dr.boundary_condition, Outdoors):
457
+ edges.extend(dr.geometry.segments)
458
+ return edges
459
+
460
+ @property
461
+ def volume(self):
462
+ """Get the volume of the room.
463
+
464
+ Note that, if this room faces do not form a closed solid the value of this
465
+ property will not be accurate.
466
+ """
467
+ return self.geometry.volume
468
+
469
+ @property
470
+ def floor_area(self):
471
+ """Get the combined area of all room floor faces."""
472
+ return sum([face.area for face in self._faces if isinstance(face.type, Floor)])
473
+
474
+ @property
475
+ def exposed_area(self):
476
+ """Get the combined area of all room faces with outdoor boundary conditions.
477
+
478
+ Useful for estimating infiltration, often expressed as a flow per
479
+ unit exposed envelope area.
480
+ """
481
+ return sum([face.area for face in self._faces if
482
+ isinstance(face.boundary_condition, Outdoors)])
483
+
484
+ @property
485
+ def exterior_wall_area(self):
486
+ """Get the combined area of all exterior walls on the room.
487
+
488
+ This is NOT the area of the wall's punched_geometry and it includes BOTH
489
+ the area of opaque and transparent parts of the walls.
490
+ """
491
+ wall_areas = 0
492
+ for f in self._faces:
493
+ if isinstance(f.boundary_condition, Outdoors) and isinstance(f.type, Wall):
494
+ wall_areas += f.area
495
+ return wall_areas
496
+
497
+ @property
498
+ def exterior_roof_area(self):
499
+ """Get the combined area of all exterior roofs on the room.
500
+
501
+ This is NOT the area of the roof's punched_geometry and it includes BOTH
502
+ the area of opaque and transparent parts of the roofs.
503
+ """
504
+ wall_areas = 0
505
+ for f in self._faces:
506
+ if isinstance(f.boundary_condition, Outdoors) and \
507
+ isinstance(f.type, RoofCeiling):
508
+ wall_areas += f.area
509
+ return wall_areas
510
+
511
+ @property
512
+ def exterior_aperture_area(self):
513
+ """Get the combined area of all exterior apertures on the room."""
514
+ ap_areas = 0
515
+ for face in self._faces:
516
+ if isinstance(face.boundary_condition, Outdoors) and \
517
+ len(face._apertures) > 0:
518
+ ap_areas += sum(ap.area for ap in face._apertures)
519
+ return ap_areas
520
+
521
+ @property
522
+ def exterior_wall_aperture_area(self):
523
+ """Get the combined area of all apertures on exterior walls of the room."""
524
+ ap_areas = 0
525
+ for face in self._faces:
526
+ if isinstance(face.boundary_condition, Outdoors) and \
527
+ isinstance(face.type, Wall) and len(face._apertures) > 0:
528
+ ap_areas += sum(ap.area for ap in face._apertures)
529
+ return ap_areas
530
+
531
+ @property
532
+ def exterior_skylight_aperture_area(self):
533
+ """Get the combined area of all apertures on exterior roofs of the room."""
534
+ ap_areas = 0
535
+ for face in self._faces:
536
+ if isinstance(face.boundary_condition, Outdoors) and \
537
+ isinstance(face.type, RoofCeiling) and len(face._apertures) > 0:
538
+ ap_areas += sum(ap.area for ap in face._apertures)
539
+ return ap_areas
540
+
541
+ @property
542
+ def average_floor_height(self):
543
+ """Get the height of the room floor averaged over all floor faces in the room.
544
+
545
+ The resulting value is weighted by the area of each of the floor faces.
546
+ Will be the minimum Z value of the Room volume if the room possesses no floors.
547
+ """
548
+ heights = 0
549
+ areas = 0
550
+ for face in self._faces:
551
+ if isinstance(face.type, Floor):
552
+ heights += face.center.z * face.area
553
+ areas += face.area
554
+ return heights / areas if areas != 0 else self.geometry.min.z
555
+
556
+ @property
557
+ def has_parent(self):
558
+ """Always False as Rooms cannot have parents."""
559
+ return False
560
+
561
+ def is_extrusion(self, tolerance=0.01, angle_tolerance=1.0):
562
+ """Test if this Room is an extruded floor plate with a flat roof.
563
+
564
+ Args:
565
+ tolerance: The absolute tolerance with which the Room geometry will
566
+ be evaluated. (Default: 0.01, suitable for objects in meters).
567
+ angle_tolerance: The angle tolerance at which the geometry will
568
+ be evaluated in degrees. (Default: 1 degree).
569
+
570
+ Returns:
571
+ True if the 3D Room is a pure extrusion. False if not.
572
+ """
573
+ # set up the parameters for evaluating vertical or horizontal
574
+ vert_vec = Vector3D(0, 0, 1)
575
+ min_v_ang = math.radians(angle_tolerance)
576
+ max_v_ang = math.pi - min_v_ang
577
+ min_h_ang = (math.pi / 2) - min_v_ang
578
+ max_h_ang = (math.pi / 2) + min_v_ang
579
+
580
+ # loop through the 3D Room faces and test them
581
+ for face in self._faces:
582
+ try: # first make sure that the geometry is not degenerate
583
+ clean_geo = face.geometry.remove_colinear_vertices(tolerance)
584
+ v_ang = clean_geo.normal.angle(vert_vec)
585
+ if v_ang <= min_v_ang or v_ang >= max_v_ang:
586
+ continue
587
+ elif min_h_ang <= v_ang <= max_h_ang:
588
+ continue
589
+ return False
590
+ except AssertionError: # degenerate face to ignore
591
+ pass
592
+ return True
593
+
594
+ def average_orientation(self, north_vector=Vector2D(0, 1)):
595
+ """Get a number between 0 and 360 for the average orientation of exposed walls.
596
+
597
+ 0 = North, 90 = East, 180 = South, 270 = West. Will be None if the zone has
598
+ no exterior walls. Resulting value is weighted by the area of each of the
599
+ wall faces.
600
+
601
+ Args:
602
+ north_vector: A ladybug_geometry Vector2D for the north direction.
603
+ Default is the Y-axis (0, 1).
604
+ """
605
+ orientations = 0
606
+ areas = 0
607
+ for face in self._faces:
608
+ if isinstance(face.type, Wall) and \
609
+ isinstance(face.boundary_condition, Outdoors):
610
+ orientations += face.horizontal_orientation(north_vector) * face.area
611
+ areas += face.area
612
+ return orientations / areas if areas != 0 else None
613
+
614
+ def classified_edges(self, tolerance=0.01, angle_tolerance=None):
615
+ """Get classified edges of this Room's Polyface3D based on Faces they adjoin.
616
+
617
+ Args:
618
+ tolerance: The maximum difference between point values for them to be
619
+ considered equivalent. (Default: 0.01, suitable for objects in meters).
620
+ angle_tolerance: An optional value in degrees, which can be used to
621
+ exclude edges falling between coplanar Faces. If None, edges falling
622
+ between coplanar Faces will be included. (Default: None).
623
+
624
+ Returns:
625
+ A tuple with eight items where each item is a list containing
626
+ LineSegment3D adjoining different types of Faces.
627
+
628
+ - roof_to_exterior - Roofs meet exterior walls or floors.
629
+
630
+ - slab_to_exterior - Ground floor slabs meet exterior walls or roofs.
631
+
632
+ - exposed_floor_to_exterior_wall - Exposed floors meet exterior walls.
633
+
634
+ - exterior_wall_to_wall - Exterior walls meet.
635
+
636
+ - roof_ridge - Exterior roofs meet.
637
+
638
+ - exposed_floor_to_floor - Exposed floors meet.
639
+
640
+ - underground - Underground faces meet.
641
+
642
+ - interior - Interior faces meet.
643
+ """
644
+ # set up lists to be populated
645
+ roof_to_exterior, slab_to_exterior, exposed_floor_to_exterior_wall = [], [], []
646
+ exterior_wall_to_wall, roof_ridge, exposed_floor_to_floor = [], [], []
647
+ underground, interior = [], []
648
+
649
+ # get all of the edges in a way that colinear edges are broken down
650
+ base_edges = list(self.geometry.edges)
651
+ base_vertices = self.geometry.vertices
652
+ edges = []
653
+ for i, edge in enumerate(base_edges):
654
+ for pt in base_vertices:
655
+ if edge.distance_to_point(pt) < tolerance and \
656
+ not edge.p1.distance_to_point(pt) < tolerance and \
657
+ not edge.p2.distance_to_point(pt) < tolerance:
658
+ # split the edge in two at the point
659
+ base_edges.append(LineSegment3D.from_end_points(edge.p1, pt))
660
+ base_edges.append(LineSegment3D.from_end_points(pt, edge.p2))
661
+ break
662
+ else: # no further subdivision needed
663
+ edges.append(edge)
664
+
665
+ # map the edges to room faces
666
+ edge_faces = [[] for _ in edges]
667
+ for i, edge in enumerate(edges):
668
+ for face in self.faces:
669
+ if overlapping_bounding_boxes(face.geometry, edge, tolerance):
670
+ for f_edge in face.geometry.segments:
671
+ if f_edge.distance_to_point(edge.p1) < tolerance and \
672
+ f_edge.distance_to_point(edge.p2) < tolerance:
673
+ edge_faces[i].append(face)
674
+ break
675
+
676
+ # classify the edges by analyzing the faces they adjoin
677
+ for edge, faces in zip(edges, edge_faces):
678
+ # first check for cases where the edge should be excluded
679
+ if len(faces) <= 1: # not an edge between two faces
680
+ continue
681
+ if angle_tolerance is not None:
682
+ ang_tol = math.radians(angle_tolerance)
683
+ base_normal = faces[0].normal
684
+ if all(f.normal.angle(base_normal) < ang_tol for f in faces[1:]):
685
+ continue
686
+
687
+ # then check for which category the edge should go into
688
+ ext_faces = [f for f in faces if isinstance(f.boundary_condition, Outdoors)
689
+ and not isinstance(f.type, AirBoundary)]
690
+ if len(ext_faces) >= 2: # some type of exterior edge
691
+ if all(isinstance(f.type, Wall) for f in ext_faces):
692
+ exterior_wall_to_wall.append(edge)
693
+ elif all(isinstance(f.type, RoofCeiling) for f in ext_faces):
694
+ roof_ridge.append(edge)
695
+ elif all(isinstance(f.type, Floor) for f in ext_faces):
696
+ exposed_floor_to_floor.append(edge)
697
+ elif any(isinstance(f.type, RoofCeiling) for f in ext_faces):
698
+ roof_to_exterior.append(edge)
699
+ elif any(isinstance(f.type, Floor) for f in ext_faces):
700
+ exposed_floor_to_exterior_wall.append(edge)
701
+ else:
702
+ gnd_faces = [f for f in faces if isinstance(f.boundary_condition, Ground)
703
+ and not isinstance(f.type, AirBoundary)]
704
+ if len(ext_faces) >= 1 and len(gnd_faces) >= 1:
705
+ slab_to_exterior.append(edge)
706
+ elif len(gnd_faces) >= 2: # some type of underground edge
707
+ underground.append(edge)
708
+ else: # some type of interior edge
709
+ interior.append(edge)
710
+
711
+ # return the classified edges
712
+ return roof_to_exterior, slab_to_exterior, exposed_floor_to_exterior_wall, \
713
+ exterior_wall_to_wall, roof_ridge, exposed_floor_to_floor, \
714
+ underground, interior
715
+
716
+ def horizontal_boundary(self, match_walls=False, tolerance=0.01):
717
+ """Get a Face3D representing the horizontal boundary around the Room.
718
+
719
+ This will be generated from all downward-facing Faces of the Room (essentially
720
+ the Floor faces but can also include overhanging slanted walls). So, for
721
+ a valid closed-volume Honeybee Room, the result should always represent
722
+ the Room in the XY plane.
723
+
724
+ The Z height of the resulting Face3D will be at the minimum floor height.
725
+
726
+ Note that, if this Room is not solid, the computation of the horizontal
727
+ boundary may fail with an exception.
728
+
729
+ Args:
730
+ match_walls: Boolean to note whether vertices should be inserted into
731
+ the final Face3D that will help match the segments of the result
732
+ back to the walls that are adjacent to the floors. If False, the
733
+ result may lack some colinear vertices that relate the Face3D
734
+ to the Walls, though setting this to True does not guarantee that
735
+ all walls will relate to a segment in the result. (Default: False).
736
+ tolerance: The minimum difference between x, y, and z coordinate values
737
+ at which points are considered distinct. (Default: 0.01,
738
+ suitable for objects in Meters).
739
+ """
740
+ # get the starting horizontal boundary
741
+ try:
742
+ horiz_bound = self._base_horiz_boundary(tolerance)
743
+ except Exception as e:
744
+ msg = 'Room "{}" is not solid and so a valid horizontal boundary for ' \
745
+ 'the Room could not be established.\n{}'.format(self.full_id, e)
746
+ raise ValueError(msg)
747
+ if match_walls: # insert the wall vertices
748
+ return self._match_walls_to_horizontal_faces([horiz_bound], tolerance)[0]
749
+ return horiz_bound
750
+
751
+ def horizontal_floor_boundaries(self, match_walls=False, tolerance=0.01):
752
+ """Get a list of horizontal Face3D for the boundaries around the Room's Floors.
753
+
754
+ Unlike the horizontal_boundary method, which uses all downward-pointing
755
+ geometries, this method will derive horizontal boundaries using only the
756
+ Floors. This is useful when the resulting geometry is used to specify the
757
+ floor area in the result.
758
+
759
+ The Z height of the resulting Face3D will be at the minimum floor height.
760
+
761
+ Args:
762
+ match_walls: Boolean to note whether vertices should be inserted into
763
+ the final Face3Ds that will help match the segments of the result
764
+ back to the walls that are adjacent to the floors. If False, the
765
+ result may lack some colinear vertices that relate the Face3Ds
766
+ to the Walls, though setting this to True does not guarantee that
767
+ all walls will relate to a segment in the result. (Default: False).
768
+ tolerance: The minimum difference between x, y, and z coordinate values
769
+ at which points are considered distinct. (Default: 0.01,
770
+ suitable for objects in Meters).
771
+ """
772
+ # gather all of the floor geometries
773
+ flr_geo = [face.geometry for face in self.floors]
774
+
775
+ # ensure that all geometries are horizontal with as few faces as possible
776
+ if len(flr_geo) == 0: # degenerate face
777
+ return []
778
+ elif len(flr_geo) == 1:
779
+ if flr_geo[0].is_horizontal(tolerance):
780
+ horiz_bound = flr_geo
781
+ else:
782
+ floor_height = self.geometry.min.z
783
+ bound = [Point3D(p.x, p.y, floor_height) for p in flr_geo[0].boundary]
784
+ holes = None
785
+ if flr_geo[0].has_holes:
786
+ holes = [[Point3D(p.x, p.y, floor_height) for p in hole]
787
+ for hole in flr_geo[0].holes]
788
+ horiz_bound = [Face3D(bound, holes=holes)]
789
+ else: # multiple geometries to be joined together
790
+ floor_height = self.geometry.min.z
791
+ horiz_geo = []
792
+ for fg in flr_geo:
793
+ if fg.is_horizontal(tolerance) and \
794
+ abs(floor_height - fg.min.z) <= tolerance:
795
+ horiz_geo.append(fg)
796
+ else: # project the face geometry into the XY plane
797
+ bound = [Point3D(p.x, p.y, floor_height) for p in fg.boundary]
798
+ holes = None
799
+ if fg.has_holes:
800
+ holes = [[Point3D(p.x, p.y, floor_height) for p in hole]
801
+ for hole in fg.holes]
802
+ horiz_geo.append(Face3D(bound, holes=holes))
803
+ # join the coplanar horizontal faces together
804
+ horiz_bound = Face3D.join_coplanar_faces(horiz_geo, tolerance)
805
+
806
+ if match_walls: # insert the wall vertices
807
+ return self._match_walls_to_horizontal_faces(horiz_bound, tolerance)
808
+ return horiz_bound
809
+
810
+ def add_prefix(self, prefix):
811
+ """Change the identifier of this object and child objects by inserting a prefix.
812
+
813
+ This is particularly useful in workflows where you duplicate and edit
814
+ a starting object and then want to combine it with the original object
815
+ into one Model (like making a model of repeated rooms) since all objects
816
+ within a Model must have unique identifiers.
817
+
818
+ Args:
819
+ prefix: Text that will be inserted at the start of this object's
820
+ (and child objects') identifier and display_name. It is recommended
821
+ that this prefix be short to avoid maxing out the 100 allowable
822
+ characters for honeybee identifiers.
823
+ """
824
+ self._identifier = clean_string('{}_{}'.format(prefix, self.identifier))
825
+ self.display_name = '{}_{}'.format(prefix, self.display_name)
826
+ self.properties.add_prefix(prefix)
827
+ for face in self._faces:
828
+ face.add_prefix(prefix)
829
+ self._add_prefix_shades(prefix)
830
+
831
+ def rename_by_attribute(
832
+ self, format_str='{story} - {display_name}'
833
+ ):
834
+ """Set the display name of this Room using a format string with Room attributes.
835
+
836
+ Args:
837
+ format_str: Text string for the pattern with which the Room will be
838
+ renamed. Any property on this class may be used (eg. story)
839
+ and each property should be put in curly brackets. Nested
840
+ properties can be specified by using "." to denote nesting levels
841
+ (eg. properties.energy.program_type.display_name). Functions that
842
+ return string outputs can also be passed here as long as these
843
+ functions defaults specified for all arguments.
844
+ """
845
+ matches = re.findall(r'{([^}]*)}', format_str)
846
+ attributes = [get_attr_nested(self, m) for m in matches]
847
+ for attr_name, attr_val in zip(matches, attributes):
848
+ format_str = format_str.replace('{{{}}}'.format(attr_name), attr_val)
849
+ self.display_name = format_str
850
+ return format_str
851
+
852
+ def rename_faces_by_attribute(
853
+ self,
854
+ format_str='{parent.display_name} - {gbxml_type} - {cardinal_direction}'
855
+ ):
856
+ """Set the display name for all of this Room's faces using a format string.
857
+
858
+ Args:
859
+ format_str: Text string for the pattern with which the faces will be
860
+ renamed. Any property of the Face class may be used (eg. gbxml_str)
861
+ and each property should be put in curly brackets. Nested
862
+ properties can be specified by using "." to denote nesting levels
863
+ (eg. properties.energy.construction.display_name). Functions that
864
+ return string outputs can also be passed here as long as these
865
+ functions defaults specified for all arguments.
866
+ """
867
+ for face in self.faces:
868
+ face.rename_by_attribute(format_str)
869
+
870
+ def rename_apertures_by_attribute(
871
+ self,
872
+ format_str='{parent.parent.display_name} - {gbxml_type} - {cardinal_direction}'
873
+ ):
874
+ """Set the display name for all of this Room's apertures using a format string.
875
+
876
+ Args:
877
+ format_str: Text string for the pattern with which the apertures will be
878
+ renamed. Any property on the Aperture class may be used (eg. gbxml_str)
879
+ and each property should be put in curly brackets. Nested
880
+ properties can be specified by using "." to denote nesting levels
881
+ (eg. properties.energy.construction.display_name). Functions that
882
+ return string outputs can also be passed here as long as these
883
+ functions defaults specified for all arguments.
884
+ """
885
+ for ap in self.apertures:
886
+ ap.rename_by_attribute(format_str)
887
+
888
+ def rename_doors_by_attribute(
889
+ self,
890
+ format_str='{parent.parent.display_name} - {energyplus_type} - {cardinal_direction}'
891
+ ):
892
+ """Set the display name for all of this Room's doors using a format string.
893
+
894
+ Args:
895
+ format_str: Text string for the pattern with which the doors will be
896
+ renamed. Any property on the Door class may be used (eg. gbxml_str)
897
+ and each property should be put in curly brackets. Nested
898
+ properties can be specified by using "." to denote nesting levels
899
+ (eg. properties.energy.construction.display_name). Functions that
900
+ return string outputs can also be passed here as long as these
901
+ functions defaults specified for all arguments.
902
+ """
903
+ for dr in self.doors:
904
+ dr.rename_by_attribute(format_str)
905
+
906
+ def remove_indoor_furniture(self):
907
+ """Remove all indoor furniture assigned to this Room.
908
+
909
+ Note that this method is identical to the remove_indoor_shade method but
910
+ it is provided here under an alternate name to make it clear that indoor
911
+ furniture objects should be added here to the Room.
912
+ """
913
+ self.remove_indoor_shades()
914
+
915
+ def add_indoor_furniture(self, shade):
916
+ """Add a Shade object representing furniture to the Room.
917
+
918
+ Note that this method is identical to the add_indoor_shade method but
919
+ it is provided here under an alternate name to make it clear that indoor
920
+ furniture objects should be added here to the Room.
921
+
922
+ Args:
923
+ shade: A Shade object to add to the indoors of this Room, representing
924
+ furniture, desks, partitions, etc.
925
+ """
926
+ self.add_indoor_shade(shade)
927
+
928
+ def generate_grid(self, x_dim, y_dim=None, offset=1.0):
929
+ """Get a gridded Mesh3D objects offset from the floors of this room.
930
+
931
+ Note that the x_dim and y_dim refer to dimensions within the XY coordinate
932
+ system of the floor faces's planes. So rotating the planes of the floor faces
933
+ will result in rotated grid cells.
934
+
935
+ Will be None if the Room has no floor faces.
936
+
937
+ Args:
938
+ x_dim: The x dimension of the grid cells as a number.
939
+ y_dim: The y dimension of the grid cells as a number. Default is None,
940
+ which will assume the same cell dimension for y as is set for x.
941
+ offset: A number for how far to offset the grid from the base face.
942
+ Default is 1.0, which will offset the grid to be 1 unit above
943
+ the floor.
944
+
945
+ Usage:
946
+
947
+ .. code-block:: python
948
+
949
+ room = Room.from_box(3.0, 6.0, 3.2, 180)
950
+ floor_mesh = room.generate_grid(0.5, 0.5, 1)
951
+ test_points = floor_mesh.face_centroids
952
+ """
953
+ floor_grids = []
954
+ for face in self._faces:
955
+ if isinstance(face.type, Floor):
956
+ try:
957
+ floor_grids.append(
958
+ face.geometry.mesh_grid(x_dim, y_dim, offset, True))
959
+ except AssertionError: # grid tolerance not fine enough
960
+ pass
961
+ if len(floor_grids) == 1:
962
+ return floor_grids[0]
963
+ elif len(floor_grids) > 1:
964
+ return Mesh3D.join_meshes(floor_grids)
965
+ return None
966
+
967
+ def generate_exterior_face_grid(
968
+ self, dimension, offset=0.1, face_type='Wall', punched_geometry=False):
969
+ """Get a gridded Mesh3D offset from the exterior Faces of this Room.
970
+
971
+ This will be None if the Room has no exterior Faces.
972
+
973
+ Args:
974
+ dimension: The dimension of the grid cells as a number.
975
+ offset: A number for how far to offset the grid from the base face.
976
+ Positive numbers indicate an offset towards the exterior. (Default
977
+ is 0.1, which will offset the grid to be 0.1 unit from the faces).
978
+ face_type: Text to specify the type of face that will be used to
979
+ generate grids. Note that only Faces with Outdoors boundary
980
+ conditions will be used, meaning that most Floors will typically
981
+ be excluded unless they represent the underside of a cantilever.
982
+ Choose from the following. (Default: Wall).
983
+
984
+ * Wall
985
+ * Roof
986
+ * Floor
987
+ * All
988
+
989
+ punched_geometry: Boolean to note whether the punched_geometry of the faces
990
+ should be used (True) with the areas of sub-faces removed from the grid
991
+ or the full geometry should be used (False). (Default:False).
992
+
993
+ Usage:
994
+
995
+ .. code-block:: python
996
+
997
+ room = Room.from_box(3.0, 6.0, 3.2, 180)
998
+ face_mesh = room.generate_exterior_face_grid(0.5)
999
+ test_points = face_mesh.face_centroids
1000
+ """
1001
+ # select the correct face type based on the input
1002
+ face_t = face_type.title()
1003
+ if face_t == 'Wall':
1004
+ ft = Wall
1005
+ elif face_t in ('Roof', 'Roofceiling'):
1006
+ ft = RoofCeiling
1007
+ elif face_t == 'All':
1008
+ ft = (Wall, RoofCeiling, Floor)
1009
+ elif face_t == 'Floor':
1010
+ ft = Floor
1011
+ else:
1012
+ raise ValueError('Unrecognized face_type "{}".'.format(face_type))
1013
+ face_attr = 'punched_geometry' if punched_geometry else 'geometry'
1014
+ # loop through the faces and generate grids
1015
+ face_grids = []
1016
+ for face in self._faces:
1017
+ if isinstance(face.type, ft) and \
1018
+ isinstance(face.boundary_condition, Outdoors):
1019
+ try:
1020
+ f_geo = getattr(face, face_attr)
1021
+ face_grids.append(
1022
+ f_geo.mesh_grid(dimension, None, offset, False))
1023
+ except AssertionError: # grid tolerance not fine enough
1024
+ pass
1025
+ # join the grids together if there are several ones
1026
+ if len(face_grids) == 1:
1027
+ return face_grids[0]
1028
+ elif len(face_grids) > 1:
1029
+ return Mesh3D.join_meshes(face_grids)
1030
+ return None
1031
+
1032
+ def generate_exterior_aperture_grid(
1033
+ self, dimension, offset=0.1, aperture_type='All'):
1034
+ """Get a gridded Mesh3D offset from the exterior Apertures of this room.
1035
+
1036
+ Will be None if the Room has no exterior Apertures.
1037
+
1038
+ Args:
1039
+ dimension: The dimension of the grid cells as a number.
1040
+ offset: A number for how far to offset the grid from the base aperture.
1041
+ Positive numbers indicate an offset towards the exterior while
1042
+ negative numbers indicate an offset towards the interior, essentially
1043
+ modeling the value of sun on the building interior. (Default
1044
+ is 0.1, which will offset the grid to be 0.1 unit from the apertures).
1045
+ aperture_type: Text to specify the type of Aperture that will be used to
1046
+ generate grids. Window indicates Apertures in Walls. Choose from
1047
+ the following. (Default: All).
1048
+
1049
+ * Window
1050
+ * Skylight
1051
+ * All
1052
+
1053
+ Usage:
1054
+
1055
+ .. code-block:: python
1056
+
1057
+ room = Room.from_box(3.0, 6.0, 3.2, 180)
1058
+ room[3].apertures_by_ratio(0.4)
1059
+ aperture_mesh = room.generate_exterior_aperture_grid(0.5)
1060
+ test_points = aperture_mesh.face_centroids
1061
+ """
1062
+ # select the correct face type based on the input
1063
+ ap_t = aperture_type.title()
1064
+ if ap_t == 'Window':
1065
+ ft = Wall
1066
+ elif ap_t == 'Skylight':
1067
+ ft = RoofCeiling
1068
+ elif ap_t == 'All':
1069
+ ft = (Wall, RoofCeiling, Floor)
1070
+ else:
1071
+ raise ValueError('Unrecognized aperture_type "{}".'.format(aperture_type))
1072
+ # loop through the faces and generate grids
1073
+ ap_grids = []
1074
+ for face in self._faces:
1075
+ if isinstance(face.type, ft) and \
1076
+ isinstance(face.boundary_condition, Outdoors):
1077
+ for ap in face.apertures:
1078
+ try:
1079
+ ap_grids.append(
1080
+ ap.geometry.mesh_grid(dimension, None, offset, False))
1081
+ except AssertionError: # grid tolerance not fine enough
1082
+ pass
1083
+ # join the grids together if there are several ones
1084
+ if len(ap_grids) == 1:
1085
+ return ap_grids[0]
1086
+ elif len(ap_grids) > 1:
1087
+ return Mesh3D.join_meshes(ap_grids)
1088
+ return None
1089
+
1090
+ def wall_apertures_by_ratio(self, ratio, tolerance=0.01):
1091
+ """Add apertures to all exterior walls given a ratio of aperture to face area.
1092
+
1093
+ Note this method removes any existing apertures and doors on the Room's walls.
1094
+ This method attempts to generate as few apertures as necessary to meet the ratio.
1095
+
1096
+ Args:
1097
+ ratio: A number between 0 and 1 (but not perfectly equal to 1)
1098
+ for the desired ratio between aperture area and face area.
1099
+ tolerance: The maximum difference between point values for them to be
1100
+ considered equivalent. (Default: 0.01, suitable for objects in meters).
1101
+
1102
+ Usage:
1103
+
1104
+ .. code-block:: python
1105
+
1106
+ room = Room.from_box(3.0, 6.0, 3.2, 180)
1107
+ room.wall_apertures_by_ratio(0.4)
1108
+ """
1109
+ for face in self._faces:
1110
+ if isinstance(face.boundary_condition, Outdoors) and \
1111
+ isinstance(face.type, Wall):
1112
+ face.apertures_by_ratio(ratio, tolerance)
1113
+
1114
+ def skylight_apertures_by_ratio(self, ratio, tolerance=0.01):
1115
+ """Add apertures to all exterior roofs given a ratio of aperture to face area.
1116
+
1117
+ Note this method removes any existing apertures and overhead doors on the
1118
+ Room's roofs. This method attempts to generate as few apertures as
1119
+ necessary to meet the ratio.
1120
+
1121
+ Args:
1122
+ ratio: A number between 0 and 1 (but not perfectly equal to 1)
1123
+ for the desired ratio between aperture area and face area.
1124
+ tolerance: The maximum difference between point values for them to be
1125
+ considered equivalent. (Default: 0.01, suitable for objects in meters).
1126
+
1127
+ Usage:
1128
+
1129
+ .. code-block:: python
1130
+
1131
+ room = Room.from_box(3.0, 6.0, 3.2, 180)
1132
+ room.skylight_apertures_by_ratio(0.05)
1133
+ """
1134
+ for face in self._faces:
1135
+ if isinstance(face.boundary_condition, Outdoors) and \
1136
+ isinstance(face.type, RoofCeiling):
1137
+ face.apertures_by_ratio(ratio, tolerance)
1138
+
1139
+ def simplify_apertures(self, tolerance=0.01):
1140
+ """Convert all Apertures on this Room to be a simple window ratio.
1141
+
1142
+ This is useful for studies where faster simulation times are desired and
1143
+ the window ratio is the critical factor driving the results (as opposed
1144
+ to the detailed geometry of the window). Apertures assigned to concave
1145
+ Faces will not be simplified given that the apertures_by_ratio method
1146
+ likely won't improve the cleanliness of the apertures for such cases.
1147
+
1148
+ Args:
1149
+ tolerance: The maximum difference between point values for them to be
1150
+ considered equivalent. (Default: 0.01, suitable for objects in meters).
1151
+ """
1152
+ for face in self.faces:
1153
+ f_ap = face._apertures
1154
+ if len(f_ap) != 0 and face.geometry.is_convex:
1155
+ # reset boundary conditions to outdoors so new apertures can be added
1156
+ if not isinstance(face.boundary_condition, Outdoors):
1157
+ face.boundary_condition = boundary_conditions.outdoors
1158
+ face.apertures_by_ratio(face.aperture_ratio, tolerance)
1159
+
1160
+ def rectangularize_apertures(
1161
+ self, subdivision_distance=None, max_separation=None, merge_all=False,
1162
+ tolerance=0.01, angle_tolerance=1.0):
1163
+ """Convert all Apertures on this Room to be rectangular.
1164
+
1165
+ This is useful when exporting to simulation engines that only accept
1166
+ rectangular window geometry. This method will always result ing Rooms where
1167
+ all Apertures are rectangular. However, if the subdivision_distance is not
1168
+ set, some Apertures may extend past the parent Face or may collide with
1169
+ one another.
1170
+
1171
+ Args:
1172
+ subdivision_distance: A number for the resolution at which the
1173
+ non-rectangular Apertures will be subdivided into smaller
1174
+ rectangular units. Specifying a number here ensures that the
1175
+ resulting rectangular Apertures do not extend past the parent
1176
+ Face or collide with one another. If None, all non-rectangular
1177
+ Apertures will be rectangularized by taking the bounding rectangle
1178
+ around the Aperture. (Default: None).
1179
+ max_separation: A number for the maximum distance between non-rectangular
1180
+ Apertures at which point the Apertures will be merged into a single
1181
+ rectangular geometry. This is often helpful when there are several
1182
+ triangular Apertures that together make a rectangle when they are
1183
+ merged across their frames. In such cases, this max_separation
1184
+ should be set to a value that is slightly larger than the window frame.
1185
+ If None, no merging of Apertures will happen before they are
1186
+ converted to rectangles. (Default: None).
1187
+ merge_all: Boolean to note whether all apertures should be merged before
1188
+ they are rectangularized. If False, only non-rectangular apertures
1189
+ will be merged before rectangularization. Note that this argument
1190
+ has no effect when the max_separation is None. (Default: False).
1191
+ tolerance: The maximum difference between point values for them to be
1192
+ considered equivalent. (Default: 0.01, suitable for objects in meters).
1193
+ angle_tolerance: The max angle in degrees that the corners of the
1194
+ rectangle can differ from a right angle before it is not
1195
+ considered a rectangle. (Default: 1).
1196
+ """
1197
+ for face in self._faces:
1198
+ face.rectangularize_apertures(
1199
+ subdivision_distance, max_separation, merge_all,
1200
+ tolerance, angle_tolerance
1201
+ )
1202
+
1203
+ def ground_by_custom_surface(self, ground_geometry, tolerance=0.01,
1204
+ angle_tolerance=1.0):
1205
+ """Set ground boundary conditions using an array of Face3D for the ground.
1206
+
1207
+ Room faces that are coplanar with the ground surface or lie completely below it
1208
+ will get a Ground boundary condition while those above will get an Outdoors
1209
+ boundary condition. Existing Faces with an indoor boundary condition will
1210
+ be unaffected.
1211
+
1212
+ Args:
1213
+ ground_geometry: An array of ladybug_geometry Face3D that together
1214
+ represent the ground surface.
1215
+ tolerance: The minimum difference between the coordinate values of two
1216
+ vertices at which they can be considered equivalent. (Default: 0.01,
1217
+ suitable for objects in meters).
1218
+ angle_tolerance: The max angle in degrees that the plane normals can
1219
+ differ from one another in order for them to be considered
1220
+ coplanar. (Default: 1).
1221
+ """
1222
+ select_faces = self.faces_by_guide_surface(
1223
+ ground_geometry, Vector3D(0, 0, -1), tolerance, angle_tolerance)
1224
+ for face in select_faces:
1225
+ if face.can_be_ground and \
1226
+ isinstance(face.boundary_condition, (Outdoors, Ground)):
1227
+ face.boundary_condition = boundary_conditions.ground
1228
+
1229
+ def faces_by_guide_surface(self, surface_geometry, directional_vector=None,
1230
+ tolerance=0.01, angle_tolerance=1.0):
1231
+ """Get the Faces of the Room that are touching and coplanar with a given surface.
1232
+
1233
+ This is useful in workflows were one would like to set the properties
1234
+ of a group of Faces using a guide surface, like setting a series of faces
1235
+ along a given stretch of a parti wall to be adiabatic.
1236
+
1237
+ Args:
1238
+ surface_geometry: An array of ladybug_geometry Face3D that together
1239
+ represent the guide surface.
1240
+ directional_vector: An optional Vector3D to select the room Faces that
1241
+ lie on a certain side of the surface_geometry. For example, using
1242
+ (0, 0, -1) will include all Faces that lie below the surface_geometry
1243
+ in the resulting selection.
1244
+ tolerance: The minimum difference between the coordinate values of two
1245
+ vertices at which they can be considered equivalent. (Default: 0.01,
1246
+ suitable for objects in meters).
1247
+ angle_tolerance: The max angle in degrees that the plane normals can
1248
+ differ from one another in order for them to be considered
1249
+ coplanar. (Default: 1).
1250
+ """
1251
+ selected_faces, ang_tol = [], math.radians(angle_tolerance)
1252
+ if directional_vector is None: # only check for co-planarity
1253
+ for face in self.faces:
1254
+ for srf_geo in surface_geometry:
1255
+ pl1, pl2 = face.geometry.plane, srf_geo.plane
1256
+ if pl1.is_coplanar_tolerance(pl2, tolerance, ang_tol):
1257
+ pt_on_face = face.geometry._point_on_face(tolerance * 2)
1258
+ if srf_geo.is_point_on_face(pt_on_face, tolerance):
1259
+ selected_faces.append(face)
1260
+ break
1261
+ else: # first check to see if the Face is on the correct side of the surface
1262
+ rev_vector = directional_vector.reverse()
1263
+ for face in self.faces:
1264
+ ray = Ray3D(face.center, rev_vector)
1265
+ for srf_geo in surface_geometry:
1266
+ if srf_geo.intersect_line_ray(ray):
1267
+ selected_faces.append(face)
1268
+ break
1269
+ pl1, pl2 = face.geometry.plane, srf_geo.plane
1270
+ if pl1.is_coplanar_tolerance(pl2, tolerance, ang_tol):
1271
+ pt_on_face = face.geometry._point_on_face(tolerance * 2)
1272
+ if srf_geo.is_point_on_face(pt_on_face, tolerance):
1273
+ selected_faces.append(face)
1274
+ break
1275
+ return selected_faces
1276
+
1277
+ def move(self, moving_vec):
1278
+ """Move this Room along a vector.
1279
+
1280
+ Args:
1281
+ moving_vec: A ladybug_geometry Vector3D with the direction and distance
1282
+ to move the room.
1283
+ """
1284
+ for face in self._faces:
1285
+ face.move(moving_vec)
1286
+ self.move_shades(moving_vec)
1287
+ self.properties.move(moving_vec)
1288
+ if self._geometry is not None:
1289
+ self._geometry = self.geometry.move(moving_vec)
1290
+
1291
+ def rotate(self, axis, angle, origin):
1292
+ """Rotate this Room by a certain angle around an axis and origin.
1293
+
1294
+ Args:
1295
+ axis: A ladybug_geometry Vector3D axis representing the axis of rotation.
1296
+ angle: An angle for rotation in degrees.
1297
+ origin: A ladybug_geometry Point3D for the origin around which the
1298
+ object will be rotated.
1299
+ """
1300
+ for face in self._faces:
1301
+ face.rotate(axis, angle, origin)
1302
+ self.rotate_shades(axis, angle, origin)
1303
+ self.properties.rotate(axis, angle, origin)
1304
+ if self._geometry is not None:
1305
+ self._geometry = self.geometry.rotate(axis, math.radians(angle), origin)
1306
+
1307
+ def rotate_xy(self, angle, origin):
1308
+ """Rotate this Room counterclockwise in the world XY plane by a certain angle.
1309
+
1310
+ Args:
1311
+ angle: An angle in degrees.
1312
+ origin: A ladybug_geometry Point3D for the origin around which the
1313
+ object will be rotated.
1314
+ """
1315
+ for face in self._faces:
1316
+ face.rotate_xy(angle, origin)
1317
+ self.rotate_xy_shades(angle, origin)
1318
+ self.properties.rotate_xy(angle, origin)
1319
+ if self._geometry is not None:
1320
+ self._geometry = self.geometry.rotate_xy(math.radians(angle), origin)
1321
+
1322
+ def reflect(self, plane):
1323
+ """Reflect this Room across a plane.
1324
+
1325
+ Args:
1326
+ plane: A ladybug_geometry Plane across which the object will
1327
+ be reflected.
1328
+ """
1329
+ for face in self._faces:
1330
+ face.reflect(plane)
1331
+ self.reflect_shades(plane)
1332
+ self.properties.reflect(plane)
1333
+ if self._geometry is not None:
1334
+ self._geometry = self.geometry.reflect(plane.n, plane.o)
1335
+
1336
+ def scale(self, factor, origin=None):
1337
+ """Scale this Room by a factor from an origin point.
1338
+
1339
+ Args:
1340
+ factor: A number representing how much the object should be scaled.
1341
+ origin: A ladybug_geometry Point3D representing the origin from which
1342
+ to scale. If None, it will be scaled from the World origin (0, 0, 0).
1343
+ """
1344
+ for face in self._faces:
1345
+ face.scale(factor, origin)
1346
+ self.scale_shades(factor, origin)
1347
+ self.properties.scale(factor, origin)
1348
+ if self._geometry is not None:
1349
+ self._geometry = self.geometry.scale(factor, origin)
1350
+
1351
+ def remove_colinear_vertices_envelope(self, tolerance=0.01, delete_degenerate=False):
1352
+ """Remove colinear and duplicate vertices from this object's Faces and Sub-faces.
1353
+
1354
+ If degenerate geometry is found in the process of removing colinear vertices,
1355
+ an exception will be raised unless delete_degenerate is True.
1356
+
1357
+ Args:
1358
+ tolerance: The minimum distance between a vertex and the boundary segments
1359
+ at which point the vertex is considered colinear. Default: 0.01,
1360
+ suitable for objects in meters.
1361
+ delete_degenerate: Boolean to note whether degenerate Faces, Apertures and
1362
+ Doors (objects that evaluate to less than 3 vertices at the tolerance)
1363
+ should be deleted from the Room instead of raising a ValueError.
1364
+ (Default: False).
1365
+ """
1366
+ if delete_degenerate:
1367
+ new_faces, i_to_remove = list(self._faces), []
1368
+ for i, face in enumerate(new_faces):
1369
+ try:
1370
+ face.remove_colinear_vertices(tolerance)
1371
+ face.remove_degenerate_sub_faces(tolerance)
1372
+ except ValueError: # degenerate face found!
1373
+ i_to_remove.append(i)
1374
+ for i in reversed(i_to_remove):
1375
+ new_faces.pop(i)
1376
+ self._faces = tuple(new_faces)
1377
+ else:
1378
+ try:
1379
+ for face in self._faces:
1380
+ face.remove_colinear_vertices(tolerance)
1381
+ for ap in face._apertures:
1382
+ ap.remove_colinear_vertices(tolerance)
1383
+ for dr in face._doors:
1384
+ dr.remove_colinear_vertices(tolerance)
1385
+ except ValueError as e:
1386
+ raise ValueError(
1387
+ 'Room "{}" contains invalid geometry.\n {}'.format(
1388
+ self.full_id, str(e).replace('\n', '\n ')))
1389
+ if self._geometry is not None:
1390
+ self._geometry = Polyface3D.from_faces(
1391
+ tuple(face.geometry for face in self._faces), tolerance)
1392
+
1393
+ def clean_envelope(self, adjacency_dict, tolerance=0.01):
1394
+ """Remove colinear and duplicate vertices from this object's Faces and Sub-faces.
1395
+
1396
+ This method also automatically removes degenerate Faces and coordinates
1397
+ adjacent Faces with the adjacency_dict to ensure matching numbers of
1398
+ vertices, which is a requirement for engines like EnergyPlus.
1399
+
1400
+ Args:
1401
+ adjacency_dict: A dictionary containing the identifiers of Room Faces as
1402
+ keys and Honeybee Face objects as values. This is used to indicate the
1403
+ target number of vertices that each Face should have after colinear
1404
+ vertices are removed. This can be used to ensure adjacent Faces
1405
+ have matching numbers of vertices, which is a requirement for
1406
+ certain interfaces like EnergyPlus.
1407
+ tolerance: The minimum distance between a vertex and the boundary segments
1408
+ at which point the vertex is considered colinear. Default: 0.01,
1409
+ suitable for objects in meters.
1410
+
1411
+ Returns:
1412
+ A dictionary containing the identifiers of adjacent Faces as keys and
1413
+ Honeybee Face objects as values. This can be used as an input in future
1414
+ Rooms on which this method is run to ensure adjacent Faces have matching
1415
+ numbers of vertices, which is a requirement for certain interfaces
1416
+ like EnergyPlus.
1417
+ """
1418
+ adj_dict = {}
1419
+ new_faces, i_to_remove = list(self._faces), []
1420
+ for i, face in enumerate(new_faces):
1421
+ try: # first make sure that the geometry is not degenerate
1422
+ new_geo = face.geometry.remove_colinear_vertices(tolerance)
1423
+ except AssertionError: # degenerate face found!
1424
+ i_to_remove.append(i)
1425
+ continue
1426
+ # see if the geometry matches its adjacent geometry
1427
+ if isinstance(face.boundary_condition, Surface):
1428
+ try:
1429
+ adj_face = adjacency_dict[face.identifier]
1430
+ new_geo = adj_face.geometry.flip()
1431
+ except KeyError: # the adjacent object has not been found yet
1432
+ pass
1433
+ adj_dict[face.boundary_condition.boundary_condition_object] = face
1434
+ # update geometry and remove degenerate Apertures and Doors
1435
+ face._geometry = new_geo
1436
+ face._punched_geometry = None # reset so that it can be re-computed
1437
+ face.remove_degenerate_sub_faces(tolerance)
1438
+ # remove any degenerate Faces from the Room
1439
+ for i in reversed(i_to_remove):
1440
+ new_faces.pop(i)
1441
+ self._faces = tuple(new_faces)
1442
+ if self._geometry is not None:
1443
+ self._geometry = Polyface3D.from_faces(
1444
+ tuple(face.geometry for face in self._faces), tolerance)
1445
+ return adj_dict
1446
+
1447
+ def split_through_holes(self, tolerance=0.01, angle_tolerance=1):
1448
+ """Split any Faces of this Room with holes such that they no longer have holes.
1449
+
1450
+ This method is useful for destination engines that cannot support holes
1451
+ either through dedicated hole loops that are separate from the boundary
1452
+ loop or as a single collapsed list of vertices that winds inward to cut
1453
+ out the holes.
1454
+
1455
+ This method attempts to preserve as many properties as possible for the
1456
+ split Faces, including all extension attributes and sub-faces (as long
1457
+ as they don't fall in the path of the intersection).
1458
+
1459
+ Args:
1460
+ tolerance: The minimum difference between the coordinate values of two
1461
+ faces at which they can be considered adjacent. Default: 0.01,
1462
+ suitable for objects in meters.
1463
+ angle_tolerance: The max angle in degrees that the plane normals can
1464
+ differ from one another in order for them to be considered
1465
+ coplanar. This is used to reassign sub-faces to split room
1466
+ geometry. (Default: 1 degree).
1467
+
1468
+ Returns:
1469
+ A list containing only the new Faces that were created as part of the
1470
+ splitting process. These new Faces will have as many properties of the
1471
+ original Face assigned to them as possible but they will not have a
1472
+ Surface boundary condition if the original Face had one. Having just
1473
+ the new Faces here can be used in operations like setting new Surface
1474
+ boundary conditions.
1475
+ """
1476
+ # make a dictionary of all face geometry to be split
1477
+ geo_dict = {f.identifier: [f.geometry] for f in self.faces}
1478
+
1479
+ # loop through the faces and split them
1480
+ ang_tol = math.radians(angle_tolerance)
1481
+ for face_1 in self.faces:
1482
+ if face_1.geometry.has_holes:
1483
+ new_geo = []
1484
+ f_split = face_1.geometry.split_through_hole_center_lines(tolerance)
1485
+ for sp_g in f_split:
1486
+ try:
1487
+ sp_g = sp_g.remove_colinear_vertices(tolerance)
1488
+ new_geo.append(sp_g)
1489
+ except AssertionError: # degenerate geometry to ignore
1490
+ pass
1491
+ geo_dict[face_1.identifier] = new_geo
1492
+
1493
+ # use the split geometry to remake this room's faces
1494
+ all_faces, new_faces = [], []
1495
+ for face in self.faces:
1496
+ int_faces = geo_dict[face.identifier]
1497
+ if len(int_faces) == 1: # just use the old Face object
1498
+ all_faces.append(face)
1499
+ else: # make new Face objects
1500
+ new_bc = face.boundary_condition \
1501
+ if not isinstance(face.boundary_condition, Surface) \
1502
+ else boundary_conditions.outdoors
1503
+ new_aps = [ap.duplicate() for ap in face.apertures]
1504
+ new_drs = [dr.duplicate() for dr in face.doors]
1505
+ for x, nf_geo in enumerate(int_faces):
1506
+ new_id = '{}_{}'.format(face.identifier, x)
1507
+ new_face = Face(new_id, nf_geo, face.type, new_bc)
1508
+ new_face._display_name = face._display_name
1509
+ new_face._user_data = None if face.user_data is None \
1510
+ else face.user_data.copy()
1511
+ for ap in new_aps:
1512
+ if nf_geo.is_sub_face(ap.geometry, tolerance, ang_tol):
1513
+ new_face.add_aperture(ap)
1514
+ for dr in new_drs:
1515
+ if nf_geo.is_sub_face(dr.geometry, tolerance, ang_tol):
1516
+ new_face.add_door(dr)
1517
+ if x == 0:
1518
+ face._duplicate_child_shades(new_face)
1519
+ new_face._parent = face._parent
1520
+ new_face._properties._duplicate_extension_attr(face._properties)
1521
+ new_faces.append(new_face)
1522
+ all_faces.append(new_face)
1523
+ if len(new_faces) == 0:
1524
+ return new_faces # nothing has been split
1525
+
1526
+ # make a new polyface from the updated faces
1527
+ room_polyface = Polyface3D.from_faces(
1528
+ tuple(face.geometry for face in all_faces), tolerance)
1529
+ if not room_polyface.is_solid:
1530
+ room_polyface = room_polyface.merge_overlapping_edges(tolerance)
1531
+ # replace honeybee face geometry with versions that are facing outwards
1532
+ if room_polyface.is_solid:
1533
+ for i, correct_face3d in enumerate(room_polyface.faces):
1534
+ face = all_faces[i]
1535
+ norm_init = face._geometry.normal
1536
+ face._geometry = correct_face3d
1537
+ if face.has_sub_faces: # flip sub-faces to align with parent Face
1538
+ if norm_init.angle(face._geometry.normal) > (math.pi / 2):
1539
+ for ap in face._apertures:
1540
+ ap._geometry = ap._geometry.flip()
1541
+ for dr in face._doors:
1542
+ dr._geometry = dr._geometry.flip()
1543
+ # reset the faces and geometry of the room with the new faces
1544
+ self._faces = tuple(all_faces)
1545
+ self._geometry = room_polyface
1546
+ return new_faces
1547
+
1548
+ def is_geo_equivalent(self, room, tolerance=0.01):
1549
+ """Get a boolean for whether this object is geometrically equivalent to another.
1550
+
1551
+ This will also check all child Faces, Apertures, Doors and Shades
1552
+ for equivalency.
1553
+
1554
+ Args:
1555
+ room: Another Room for which geometric equivalency will be tested.
1556
+ tolerance: The minimum difference between the coordinate values of two
1557
+ vertices at which they can be considered geometrically equivalent.
1558
+
1559
+ Returns:
1560
+ True if geometrically equivalent. False if not geometrically equivalent.
1561
+ """
1562
+ met_1 = (self.display_name, self.multiplier, self.zone, self.story,
1563
+ self.exclude_floor_area)
1564
+ met_2 = (room.display_name, room.multiplier, room.zone, room.story,
1565
+ room.exclude_floor_area)
1566
+ if met_1 != met_2:
1567
+ return False
1568
+ if len(self._faces) != len(room._faces):
1569
+ return False
1570
+ for f1, f2 in zip(self._faces, room._faces):
1571
+ if not f1.is_geo_equivalent(f2, tolerance):
1572
+ return False
1573
+ if not self._are_shades_equivalent(room, tolerance):
1574
+ return False
1575
+ return True
1576
+
1577
+ def check_solid(self, tolerance=0.01, angle_tolerance=None, raise_exception=True,
1578
+ detailed=False):
1579
+ """Check whether the Room is a closed solid to within the input tolerances.
1580
+
1581
+ Args:
1582
+ tolerance: tolerance: The maximum difference between x, y, and z values
1583
+ at which face vertices are considered equivalent. Default: 0.01,
1584
+ suitable for objects in meters.
1585
+ angle_tolerance: Deprecated input that is no longer used.
1586
+ raise_exception: Boolean to note whether a ValueError should be raised
1587
+ if the room geometry does not form a closed solid.
1588
+ detailed: Boolean for whether the returned object is a detailed list of
1589
+ dicts with error info or a string with a message. (Default: False).
1590
+
1591
+ Returns:
1592
+ A string with the message or a list with a dictionary if detailed is True.
1593
+ """
1594
+ if self._geometry is not None and self.geometry.is_solid:
1595
+ return [] if detailed else ''
1596
+ face_geometries = tuple(face.geometry for face in self._faces)
1597
+ self._geometry = Polyface3D.from_faces(face_geometries, tolerance)
1598
+ if self.geometry.is_solid:
1599
+ return [] if detailed else ''
1600
+ self._geometry = self.geometry.merge_overlapping_edges(tolerance)
1601
+ if self.geometry.is_solid:
1602
+ return [] if detailed else ''
1603
+ msg = 'Room "{}" is not closed to within {} tolerance.' \
1604
+ '\n {} naked edges found\n {} non-manifold edges found'.format(
1605
+ self.full_id, tolerance,
1606
+ len(self._geometry.naked_edges), len(self._geometry.non_manifold_edges))
1607
+ full_msg = self._validation_message(
1608
+ msg, raise_exception, detailed, '000106',
1609
+ error_type='Non-Solid Room Geometry')
1610
+ if detailed: # add the naked and non-manifold edges to helper_geometry
1611
+ help_edges = [ln.to_dict() for ln in self.geometry.naked_edges]
1612
+ help_edges.extend([ln.to_dict() for ln in self.geometry.non_manifold_edges])
1613
+ full_msg[0]['helper_geometry'] = help_edges
1614
+ return full_msg
1615
+
1616
+ def check_sub_faces_valid(self, tolerance=0.01, angle_tolerance=1,
1617
+ raise_exception=True, detailed=False):
1618
+ """Check that room's sub-faces are co-planar with faces and in the face boundary.
1619
+
1620
+ Note this does not check the planarity of the sub-faces themselves, whether
1621
+ they self-intersect, or whether they have a non-zero area.
1622
+
1623
+ Args:
1624
+ tolerance: The minimum difference between the coordinate values of two
1625
+ vertices at which they can be considered equivalent. Default: 0.01,
1626
+ suitable for objects in meters.
1627
+ angle_tolerance: The max angle in degrees that the plane normals can
1628
+ differ from one another in order for them to be considered coplanar.
1629
+ Default: 1 degree.
1630
+ raise_exception: Boolean to note whether a ValueError should be raised
1631
+ if an sub-face is not valid. (Default: True).
1632
+ detailed: Boolean for whether the returned object is a detailed list of
1633
+ dicts with error info or a string with a message. (Default: False).
1634
+
1635
+ Returns:
1636
+ A string with the message or a list with a dictionary if detailed is True.
1637
+ """
1638
+ detailed = False if raise_exception else detailed
1639
+ msgs = []
1640
+ for f in self._faces:
1641
+ msg = f.check_sub_faces_valid(tolerance, angle_tolerance, False, detailed)
1642
+ if detailed:
1643
+ msgs.extend(msg)
1644
+ elif msg != '':
1645
+ msgs.append(msg)
1646
+ if len(msgs) == 0:
1647
+ return [] if detailed else ''
1648
+ elif detailed:
1649
+ return msgs
1650
+ full_msg = 'Room "{}" contains invalid sub-faces (Apertures and Doors).' \
1651
+ '\n {}'.format(self.full_id, '\n '.join(msgs))
1652
+ if raise_exception and len(msgs) != 0:
1653
+ raise ValueError(full_msg)
1654
+ return full_msg
1655
+
1656
+ def check_sub_faces_overlapping(
1657
+ self, tolerance=0.01, raise_exception=True, detailed=False):
1658
+ """Check that this Room's sub-faces do not overlap with one another.
1659
+
1660
+ Args:
1661
+ tolerance: The minimum distance that two sub-faces must overlap in order
1662
+ for them to be considered overlapping and invalid. (Default: 0.01,
1663
+ suitable for objects in meters).
1664
+ raise_exception: Boolean to note whether a ValueError should be raised
1665
+ if a sub-faces overlap with one another. (Default: True).
1666
+ detailed: Boolean for whether the returned object is a detailed list of
1667
+ dicts with error info or a string with a message. (Default: False).
1668
+
1669
+ Returns:
1670
+ A string with the message or a list with a dictionary if detailed is True.
1671
+ """
1672
+ detailed = False if raise_exception else detailed
1673
+ msgs = []
1674
+ for f in self._faces:
1675
+ msg = f.check_sub_faces_overlapping(tolerance, False, detailed)
1676
+ if detailed:
1677
+ msgs.extend(msg)
1678
+ elif msg != '':
1679
+ msgs.append(msg)
1680
+ if len(msgs) == 0:
1681
+ return [] if detailed else ''
1682
+ elif detailed:
1683
+ return msgs
1684
+ full_msg = 'Room "{}" contains overlapping sub-faces.' \
1685
+ '\n {}'.format(self.full_id, '\n '.join(msgs))
1686
+ if raise_exception and len(msgs) != 0:
1687
+ raise ValueError(full_msg)
1688
+ return full_msg
1689
+
1690
+ def check_upside_down_faces(
1691
+ self, angle_tolerance=1, raise_exception=True, detailed=False):
1692
+ """Check whether the Room's Faces have the correct direction for the face type.
1693
+
1694
+ This method will only report Floors that are pointing upwards or RoofCeilings
1695
+ that are pointed downwards. These cases are likely modeling errors and are in
1696
+ danger of having their vertices flipped by EnergyPlus, causing them to
1697
+ not see the sun.
1698
+
1699
+ Args:
1700
+ angle_tolerance: The max angle in degrees that the Face normal can
1701
+ differ from up or down before it is considered a case of a downward
1702
+ pointing RoofCeiling or upward pointing Floor. Default: 1 degree.
1703
+ raise_exception: Boolean to note whether an ValueError should be
1704
+ raised if the Face is an an upward pointing Floor or a downward
1705
+ pointing RoofCeiling.
1706
+ detailed: Boolean for whether the returned object is a detailed list of
1707
+ dicts with error info or a string with a message. (Default: False).
1708
+
1709
+ Returns:
1710
+ A string with the message or a list with a dictionary if detailed is True.
1711
+ """
1712
+ detailed = False if raise_exception else detailed
1713
+ msgs = []
1714
+ for f in self._faces:
1715
+ msg = f.check_upside_down(angle_tolerance, False, detailed)
1716
+ if detailed:
1717
+ msgs.extend(msg)
1718
+ elif msg != '':
1719
+ msgs.append(msg)
1720
+ if len(msgs) == 0:
1721
+ return [] if detailed else ''
1722
+ elif detailed:
1723
+ return msgs
1724
+ full_msg = 'Room "{}" contains upside down Faces.' \
1725
+ '\n {}'.format(self.full_id, '\n '.join(msgs))
1726
+ if raise_exception and len(msgs) != 0:
1727
+ raise ValueError(full_msg)
1728
+ return full_msg
1729
+
1730
+ def check_planar(self, tolerance=0.01, raise_exception=True, detailed=False):
1731
+ """Check that all of the Room's geometry components are planar.
1732
+
1733
+ This includes all of the Room's Faces, Apertures, Doors and Shades.
1734
+
1735
+ Args:
1736
+ tolerance: The minimum distance between a given vertex and a the
1737
+ object's plane at which the vertex is said to lie in the plane.
1738
+ Default: 0.01, suitable for objects in meters.
1739
+ raise_exception: Boolean to note whether an ValueError should be
1740
+ raised if a vertex does not lie within the object's plane.
1741
+ detailed: Boolean for whether the returned object is a detailed list of
1742
+ dicts with error info or a string with a message. (Default: False).
1743
+
1744
+ Returns:
1745
+ A string with the message or a list with a dictionary if detailed is True.
1746
+ """
1747
+ detailed = False if raise_exception else detailed
1748
+ msgs = [self._check_planar_shades(tolerance, detailed)]
1749
+ for face in self._faces:
1750
+ msgs.append(face.check_planar(tolerance, False, detailed))
1751
+ msgs.append(face._check_planar_shades(tolerance, detailed))
1752
+ for ap in face._apertures:
1753
+ msgs.append(ap.check_planar(tolerance, False, detailed))
1754
+ msgs.append(ap._check_planar_shades(tolerance, detailed))
1755
+ for dr in face._doors:
1756
+ msgs.append(dr.check_planar(tolerance, False, detailed))
1757
+ msgs.append(dr._check_planar_shades(tolerance, detailed))
1758
+ full_msgs = [msg for msg in msgs if msg]
1759
+ if len(full_msgs) == 0:
1760
+ return [] if detailed else ''
1761
+ elif detailed:
1762
+ return [m for megs in full_msgs for m in megs]
1763
+ full_msg = 'Room "{}" contains non-planar geometry.' \
1764
+ '\n {}'.format(self.full_id, '\n '.join(full_msgs))
1765
+ if raise_exception and len(full_msgs) != 0:
1766
+ raise ValueError(full_msg)
1767
+ return full_msg
1768
+
1769
+ def check_self_intersecting(self, tolerance=0.01, raise_exception=True,
1770
+ detailed=False):
1771
+ """Check that no edges of the Room's geometry components self-intersect.
1772
+
1773
+ This includes all of the Room's Faces, Apertures, Doors and Shades.
1774
+
1775
+ Args:
1776
+ tolerance: The minimum difference between the coordinate values of two
1777
+ vertices at which they can be considered equivalent. Default: 0.01,
1778
+ suitable for objects in meters.
1779
+ raise_exception: If True, a ValueError will be raised if an object
1780
+ intersects with itself (like a bow tie). (Default: True).
1781
+ detailed: Boolean for whether the returned object is a detailed list of
1782
+ dicts with error info or a string with a message. (Default: False).
1783
+
1784
+ Returns:
1785
+ A string with the message or a list with a dictionary if detailed is True.
1786
+ """
1787
+ detailed = False if raise_exception else detailed
1788
+ msgs = [self._check_self_intersecting_shades(tolerance, detailed)]
1789
+ for face in self._faces:
1790
+ msgs.append(face.check_self_intersecting(tolerance, False, detailed))
1791
+ msgs.append(face._check_self_intersecting_shades(tolerance, detailed))
1792
+ for ap in face._apertures:
1793
+ msgs.append(ap.check_self_intersecting(tolerance, False, detailed))
1794
+ msgs.append(ap._check_self_intersecting_shades(tolerance, detailed))
1795
+ for dr in face._doors:
1796
+ msgs.append(dr.check_self_intersecting(tolerance, False, detailed))
1797
+ msgs.append(dr._check_self_intersecting_shades(tolerance, detailed))
1798
+ full_msgs = [msg for msg in msgs if msg]
1799
+ if len(full_msgs) == 0:
1800
+ return [] if detailed else ''
1801
+ elif detailed:
1802
+ return [m for megs in full_msgs for m in megs]
1803
+ full_msg = 'Room "{}" contains self-intersecting geometry.' \
1804
+ '\n {}'.format(self.full_id, '\n '.join(full_msgs))
1805
+ if raise_exception and len(full_msgs) != 0:
1806
+ raise ValueError(full_msg)
1807
+ return full_msg
1808
+
1809
+ def check_degenerate(self, tolerance=0.01, raise_exception=True, detailed=False):
1810
+ """Check whether the Room is degenerate with zero volume.
1811
+
1812
+ Args:
1813
+ tolerance: tolerance: The maximum difference between x, y, and z values
1814
+ at which face vertices are considered equivalent. (Default: 0.01,
1815
+ suitable for objects in meters).
1816
+ raise_exception: Boolean to note whether a ValueError should be raised
1817
+ if the room geometry is degenerate. (Default: True).
1818
+ detailed: Boolean for whether the returned object is a detailed list of
1819
+ dicts with error info or a string with a message. (Default: False).
1820
+
1821
+ Returns:
1822
+ A string with the message or a list with a dictionary if detailed is True.
1823
+ """
1824
+ # if the room has the correct number of faces, test the envelope geometry
1825
+ if len(self._faces) >= 4 and self.volume > tolerance:
1826
+ msgs, final_faces = [], []
1827
+ for face in self._faces:
1828
+ face_msg = face.check_degenerate(tolerance, False, detailed)
1829
+ if not face_msg:
1830
+ final_faces.append(face)
1831
+ msgs.append(face_msg)
1832
+ for ap in face._apertures:
1833
+ msgs.append(ap.check_degenerate(tolerance, False, detailed))
1834
+ for dr in face._doors:
1835
+ msgs.append(dr.check_degenerate(tolerance, False, detailed))
1836
+ if len(final_faces) < 4:
1837
+ deg_msg = 'Room "{}" is degenerate with zero volume. ' \
1838
+ 'It should be deleted'.format(self.full_id)
1839
+ if detailed:
1840
+ deg_msg = [{
1841
+ 'type': 'ValidationError',
1842
+ 'code': '000107',
1843
+ 'error_type': 'Degenerate Room Volume',
1844
+ 'extension_type': 'Core',
1845
+ 'element_type': 'Room',
1846
+ 'element_id': [self.identifier],
1847
+ 'element_name': [self.display_name],
1848
+ 'message': deg_msg
1849
+ }]
1850
+ msgs.append(deg_msg)
1851
+ full_msgs = [msg for msg in msgs if msg]
1852
+ if len(full_msgs) == 0:
1853
+ return [] if detailed else ''
1854
+ elif detailed:
1855
+ return [m for megs in full_msgs for m in megs]
1856
+ full_msg = 'Room "{}" contains degenerate geometry.' \
1857
+ '\n {}'.format(self.full_id, '\n '.join(full_msgs))
1858
+ if raise_exception and len(full_msgs) != 0:
1859
+ raise ValueError(full_msg)
1860
+ return full_msg
1861
+
1862
+ # otherwise, report the room as invalid
1863
+ msg = 'Room "{}" is degenerate with zero volume. It should be deleted'.format(
1864
+ self.full_id)
1865
+ return self._validation_message(
1866
+ msg, raise_exception, detailed, '000107',
1867
+ error_type='Degenerate Room Volume')
1868
+
1869
+ def remove_duplicate_faces(self, tolerance=0.01):
1870
+ """Remove duplicate face geometries from the Room.
1871
+
1872
+ Such duplicate faces can sometimes happen as a result of performing many
1873
+ coplanar splits.
1874
+
1875
+ Args:
1876
+ tolerance: The minimum difference between the coordinate values of two
1877
+ faces at which they can be considered adjacent. Default: 0.01,
1878
+ suitable for objects in meters.
1879
+
1880
+ Returns:
1881
+ A list containing only the duplicate Faces that were removed.
1882
+ """
1883
+ # first make a list of faces without any duplicate geometries
1884
+ new_faces, removed_faces = [self._faces[0]], []
1885
+ for face in self._faces[1:]:
1886
+ for e_face in new_faces:
1887
+ if face.geometry.is_centered_adjacent(e_face.geometry, tolerance):
1888
+ tol_area = math.sqrt(face.geometry.area) * tolerance
1889
+ if abs(face.geometry.area - e_face.area) < tol_area:
1890
+ removed_faces.append(face)
1891
+ break # duplicate face found
1892
+ else: # the first face with this type of plane
1893
+ new_faces.append(face)
1894
+ if len(removed_faces) == 0:
1895
+ return removed_faces # nothing has been removed
1896
+
1897
+ # make a new polyface from the updated faces
1898
+ room_polyface = Polyface3D.from_faces(
1899
+ tuple(face.geometry for face in new_faces), tolerance)
1900
+ if not room_polyface.is_solid:
1901
+ room_polyface = room_polyface.merge_overlapping_edges(tolerance)
1902
+ # reset the faces and geometry of the room with the new faces
1903
+ self._faces = tuple(new_faces)
1904
+ self._geometry = room_polyface
1905
+ return removed_faces
1906
+
1907
+ def merge_coplanar_faces(
1908
+ self, tolerance=0.01, angle_tolerance=1, orthogonal_only=False):
1909
+ """Merge coplanar Faces of this Room.
1910
+
1911
+ This is often useful before running Room.intersect_adjacency between
1912
+ multiple Rooms as it will ensure the result is clean with any previous
1913
+ intersections erased.
1914
+
1915
+ This method attempts to preserve as many properties as possible for the
1916
+ split Faces but, when Faces are merged, the properties of one of the
1917
+ merged faces will determine the face type and boundary condition. Also, all
1918
+ Face extension attributes will be removed (reset to default) and, if merged
1919
+ Faces originally had Surface boundary conditions, they will be reset
1920
+ to Outdoors.
1921
+
1922
+ Args:
1923
+ tolerance: The minimum difference between the coordinate values of two
1924
+ faces at which they can be considered adjacent. Default: 0.01,
1925
+ suitable for objects in meters.
1926
+ angle_tolerance: The max angle in degrees that the plane normals can
1927
+ differ from one another in order for them to be considered
1928
+ coplanar. (Default: 1 degree).
1929
+ orthogonal_only: A boolean to note whether only vertical and horizontal
1930
+ coplanar faces should be merged, leaving faces with any other tilt
1931
+ intact. Useful for cases where alignment of walls with the
1932
+ Room.horizontal_boundary is desired without disrupting the roof
1933
+ geometry. (Default: False).
1934
+
1935
+ Returns:
1936
+ A list containing only the new Faces that were created as part of the
1937
+ merging process. These new Faces will have as many properties of the
1938
+ original Face assigned to them as possible but they will not have a
1939
+ Surface boundary condition if the original Face had one. Having
1940
+ the new Faces here can be used in operations like setting new Surface
1941
+ boundary conditions or re-assigning extension attributes.
1942
+ """
1943
+ # group the Faces of the Room by their co-planarity
1944
+ tol, a_tol = tolerance, math.radians(angle_tolerance)
1945
+ coplanar_dict = {self._faces[0].geometry.plane: [self._faces[0]]}
1946
+ if not orthogonal_only:
1947
+ for face in self._faces[1:]:
1948
+ for pln, f_list in coplanar_dict.items():
1949
+ if face.geometry.plane.is_coplanar_tolerance(pln, tol, a_tol):
1950
+ f_list.append(face)
1951
+ break
1952
+ else: # the first face with this type of plane
1953
+ coplanar_dict[face.geometry.plane] = [face]
1954
+ else:
1955
+ up_vec = Vector3D(0, 0, 1)
1956
+ min_ang, max_ang = (math.pi / 2) - a_tol, (math.pi / 2) + a_tol
1957
+ max_h_ang = math.pi + a_tol
1958
+ for face in self._faces[1:]:
1959
+ v_ang = up_vec.angle(face.normal)
1960
+ if v_ang < a_tol or min_ang < v_ang < max_ang or v_ang > max_h_ang:
1961
+ for pln, f_list in coplanar_dict.items():
1962
+ if face.geometry.plane.is_coplanar_tolerance(pln, tol, a_tol):
1963
+ f_list.append(face)
1964
+ break
1965
+ else: # the first face with this type of plane
1966
+ coplanar_dict[face.geometry.plane] = [face]
1967
+ else:
1968
+ coplanar_dict[face.geometry.plane] = [face]
1969
+
1970
+ # merge any of the coplanar Faces together
1971
+ all_faces, new_faces = [], []
1972
+ for face_list in coplanar_dict.values():
1973
+ if len(face_list) == 1: # no faces to merge
1974
+ all_faces.append(face_list[0])
1975
+ else: # there are faces to merge
1976
+ f_geos = [f.geometry for f in face_list]
1977
+ joined_geos = Face3D.join_coplanar_faces(f_geos, tolerance)
1978
+ if len(joined_geos) < len(f_geos): # faces were merged
1979
+ prop_f = face_list[0]
1980
+ apertures, doors, in_shades, out_shades = [], [], [], []
1981
+ for f in face_list:
1982
+ apertures.extend(f._apertures)
1983
+ doors.extend(f._doors)
1984
+ in_shades.extend(f._indoor_shades)
1985
+ out_shades.extend(f._outdoor_shades)
1986
+ for i, new_geo in enumerate(joined_geos):
1987
+ fid = prop_f.identifier if i == 0 else \
1988
+ '{}_{}'.format(prop_f.identifier, i)
1989
+ fbc = prop_f.boundary_condition if not \
1990
+ isinstance(prop_f.boundary_condition, Surface) \
1991
+ else boundary_conditions.outdoors
1992
+ nf = Face(fid, new_geo, prop_f.type, fbc)
1993
+ for ap in apertures:
1994
+ if nf.geometry.is_sub_face(ap.geometry, tol, a_tol):
1995
+ try:
1996
+ nf.add_aperture(ap)
1997
+ except AssertionError: # probably adiabatic
1998
+ if not isinstance(nf.boundary_condition, Outdoors):
1999
+ nf.boundary_condition = \
2000
+ boundary_conditions.outdoors
2001
+ if isinstance(nf.type, AirBoundary):
2002
+ nf.type = get_type_from_normal(nf.normal)
2003
+ nf.add_aperture(ap)
2004
+ for dr in doors:
2005
+ if nf.geometry.is_sub_face(dr.geometry, tol, a_tol):
2006
+ try:
2007
+ nf.add_door(dr)
2008
+ except AssertionError: # probably adiabatic
2009
+ if not isinstance(nf.boundary_condition, Outdoors):
2010
+ nf.boundary_condition = \
2011
+ boundary_conditions.outdoors
2012
+ if isinstance(nf.type, AirBoundary):
2013
+ nf.type = get_type_from_normal(nf.normal)
2014
+ nf.add_door(dr)
2015
+ if i == 0: # add all assigned shades to this face
2016
+ nf.add_indoor_shades(in_shades)
2017
+ nf.add_outdoor_shades(out_shades)
2018
+ nf._parent = self
2019
+ all_faces.append(nf)
2020
+ new_faces.append(nf)
2021
+ else: # faces don't overlap and were not merged
2022
+ all_faces.extend(face_list)
2023
+ if len(new_faces) == 0:
2024
+ return new_faces # nothing has been merged
2025
+
2026
+ # make a new polyface from the updated faces
2027
+ room_polyface = Polyface3D.from_faces(
2028
+ tuple(face.geometry for face in all_faces), tolerance)
2029
+ if not room_polyface.is_solid:
2030
+ room_polyface = room_polyface.merge_overlapping_edges(tolerance)
2031
+ # replace honeybee face geometry with versions that are facing outwards
2032
+ if room_polyface.is_solid:
2033
+ for i, correct_face3d in enumerate(room_polyface.faces):
2034
+ face = all_faces[i]
2035
+ norm_init = face._geometry.normal
2036
+ face._geometry = correct_face3d
2037
+ if face.has_sub_faces: # flip sub-faces to align with parent Face
2038
+ if norm_init.angle(face._geometry.normal) > (math.pi / 2):
2039
+ for ap in face._apertures:
2040
+ ap._geometry = ap._geometry.flip()
2041
+ for dr in face._doors:
2042
+ dr._geometry = dr._geometry.flip()
2043
+ # reset the faces and geometry of the room with the new faces
2044
+ self._faces = tuple(all_faces)
2045
+ self._geometry = room_polyface
2046
+ return new_faces
2047
+
2048
+ def coplanar_split(self, geometry, tolerance=0.01, angle_tolerance=1):
2049
+ """Split the Faces of this Room with coplanar geometry (Polyface3D or Face3D).
2050
+
2051
+ This method attempts to preserve as many properties as possible for the
2052
+ split Faces, including all extension attributes and sub-faces (as long
2053
+ as they don't fall in the path of the intersection).
2054
+
2055
+ Args:
2056
+ geometry: A list of coplanar geometry (either Polyface3D or Face3D)
2057
+ that will be used to split the Faces of this Room. Typically, these
2058
+ are Polyface3D of other Room geometries to be intersected with this
2059
+ one but they can also be Face3D if only one intersection is desired.
2060
+ tolerance: The minimum difference between the coordinate values of two
2061
+ faces at which they can be considered adjacent. Default: 0.01,
2062
+ suitable for objects in meters.
2063
+ angle_tolerance: The max angle in degrees that the plane normals can
2064
+ differ from one another in order for them to be considered
2065
+ coplanar. This is used to reassign sub-faces after the room
2066
+ geometry is split. (Default: 1 degree).
2067
+
2068
+ Returns:
2069
+ A list containing only the new Faces that were created as part of the
2070
+ splitting process. These new Faces will have as many properties of the
2071
+ original Face assigned to them as possible but they will not have a
2072
+ Surface boundary condition if the original Face had one. Having just
2073
+ the new Faces here can be used in operations like setting new Surface
2074
+ boundary conditions.
2075
+ """
2076
+ # make a dictionary of all face geometry to be intersected
2077
+ geo_dict = {f.identifier: [f.geometry] for f in self.faces}
2078
+
2079
+ # loop through the polyface geometries and intersect this room's geometry
2080
+ ang_tol = math.radians(angle_tolerance)
2081
+ for s_geo in geometry:
2082
+ if isinstance(s_geo, Polyface3D) and not \
2083
+ Polyface3D.overlapping_bounding_boxes(
2084
+ self.geometry, s_geo, tolerance):
2085
+ continue # no overlap in bounding box; intersection impossible
2086
+ s_geos = s_geo.faces if isinstance(s_geo, Polyface3D) else [s_geo]
2087
+ for face_1 in self.faces:
2088
+ for face_2 in s_geos:
2089
+ if not face_1.geometry.plane.is_coplanar_tolerance(
2090
+ face_2.plane, tolerance, ang_tol):
2091
+ continue # not coplanar; intersection impossible
2092
+ if face_1.geometry.is_centered_adjacent(face_2, tolerance):
2093
+ tol_area = math.sqrt(face_1.geometry.area) * tolerance
2094
+ if abs(face_1.geometry.area - face_2.area) < tol_area:
2095
+ continue # already intersected; no need to re-do
2096
+ new_geo = []
2097
+ for f_geo in geo_dict[face_1.identifier]:
2098
+ f_split, _ = Face3D.coplanar_split(
2099
+ f_geo, face_2, tolerance, ang_tol)
2100
+ for sp_g in f_split:
2101
+ try:
2102
+ sp_g = sp_g.remove_colinear_vertices(tolerance)
2103
+ new_geo.append(sp_g)
2104
+ except AssertionError: # degenerate geometry to ignore
2105
+ pass
2106
+ geo_dict[face_1.identifier] = new_geo
2107
+
2108
+ # use the intersected geometry to remake this room's faces
2109
+ all_faces, new_faces = [], []
2110
+ for face in self.faces:
2111
+ int_faces = geo_dict[face.identifier]
2112
+ if len(int_faces) == 1: # just use the old Face object
2113
+ all_faces.append(face)
2114
+ else: # make new Face objects
2115
+ new_bc = face.boundary_condition \
2116
+ if not isinstance(face.boundary_condition, Surface) \
2117
+ else boundary_conditions.outdoors
2118
+ new_aps = [ap.duplicate() for ap in face.apertures]
2119
+ new_drs = [dr.duplicate() for dr in face.doors]
2120
+ for x, nf_geo in enumerate(int_faces):
2121
+ new_id = '{}_{}'.format(face.identifier, x)
2122
+ new_face = Face(new_id, nf_geo, face.type, new_bc)
2123
+ new_face._display_name = face._display_name
2124
+ new_face._user_data = None if face.user_data is None \
2125
+ else face.user_data.copy()
2126
+ for ap in new_aps:
2127
+ if nf_geo.is_sub_face(ap.geometry, tolerance, ang_tol):
2128
+ new_face.add_aperture(ap)
2129
+ for dr in new_drs:
2130
+ if nf_geo.is_sub_face(dr.geometry, tolerance, ang_tol):
2131
+ new_face.add_door(dr)
2132
+ if x == 0:
2133
+ face._duplicate_child_shades(new_face)
2134
+ new_face._parent = face._parent
2135
+ new_face._properties._duplicate_extension_attr(face._properties)
2136
+ new_faces.append(new_face)
2137
+ all_faces.append(new_face)
2138
+ if len(new_faces) == 0:
2139
+ return new_faces # nothing has been intersected
2140
+
2141
+ # make a new polyface from the updated faces
2142
+ room_polyface = Polyface3D.from_faces(
2143
+ tuple(face.geometry for face in all_faces), tolerance)
2144
+ if not room_polyface.is_solid:
2145
+ room_polyface = room_polyface.merge_overlapping_edges(tolerance)
2146
+ # replace honeybee face geometry with versions that are facing outwards
2147
+ if room_polyface.is_solid:
2148
+ for i, correct_face3d in enumerate(room_polyface.faces):
2149
+ face = all_faces[i]
2150
+ norm_init = face._geometry.normal
2151
+ face._geometry = correct_face3d
2152
+ if face.has_sub_faces: # flip sub-faces to align with parent Face
2153
+ if norm_init.angle(face._geometry.normal) > (math.pi / 2):
2154
+ for ap in face._apertures:
2155
+ ap._geometry = ap._geometry.flip()
2156
+ for dr in face._doors:
2157
+ dr._geometry = dr._geometry.flip()
2158
+ # reset the faces and geometry of the room with the new faces
2159
+ self._faces = tuple(all_faces)
2160
+ self._geometry = room_polyface
2161
+ return new_faces
2162
+
2163
+ @staticmethod
2164
+ def join_adjacent_rooms(rooms, tolerance=0.01):
2165
+ """Get Rooms merged across their adjacent Surface boundary conditions.
2166
+
2167
+ When the input rooms form a continuous volume across their adjacencies,
2168
+ a list with only a single joined Room will be returned. Otherwise, there
2169
+ will be more than one Room in the result for each contiguous volume
2170
+ across the adjacencies.
2171
+
2172
+ In all cases, the Room with the highest volume in the contiguous group
2173
+ will set the properties of the joined Room, including multiplier, zone,
2174
+ story, exclude_floor_area, and all extension attributes
2175
+
2176
+ Args:
2177
+ rooms: A list of Rooms which will be merged into one (or more) Rooms
2178
+ across their adjacent Faces.
2179
+ tolerance: The minimum difference between the coordinate values of two
2180
+ faces at which they can be considered adjacent. (Default: 0.01,
2181
+ suitable for objects in meters).
2182
+ """
2183
+ # group the rooms according to their adjacency
2184
+ adj_groups = Room.group_by_adjacency(rooms)
2185
+ joined_rooms = []
2186
+
2187
+ # create the joined Rooms from each group
2188
+ for adj_group in adj_groups:
2189
+ # first check to see if the group has any adjacencies at all
2190
+ if len(adj_group) == 1:
2191
+ joined_rooms.append(adj_group[0].duplicate())
2192
+ continue
2193
+ # determine the primary room that will set the properties of the new Room
2194
+ volumes = [r.volume for r in adj_group]
2195
+ sort_inds = [i for _, i in sorted(zip(volumes, range(len(volumes))))]
2196
+ primary_room = adj_group[sort_inds[-1]]
2197
+ # gather all of the adjacent faces to be eliminated in the new Room
2198
+ all_adjacencies, remove_faces = {}, set()
2199
+ for room in adj_group:
2200
+ for face in room.faces:
2201
+ if isinstance(face.boundary_condition, Surface):
2202
+ bc_obj = face.boundary_condition.boundary_condition_object
2203
+ all_adjacencies[bc_obj] = face.identifier
2204
+ if face.identifier in all_adjacencies:
2205
+ remove_faces.add(face.identifier)
2206
+ remove_faces.add(all_adjacencies[face.identifier])
2207
+ # gather the faces forming a contiguous volume and make the new room
2208
+ new_faces, new_indoor_shades, new_outdoor_shades = [], [], []
2209
+ for room in adj_group:
2210
+ new_indoor_shades.extend((s.duplicate() for s in room.indoor_shades))
2211
+ new_outdoor_shades.extend((s.duplicate() for s in room.outdoor_shades))
2212
+ for face in room.faces:
2213
+ if face.identifier not in remove_faces:
2214
+ new_faces.append(face.duplicate())
2215
+ # create the new room and assign any shades
2216
+ new_r = Room(primary_room.identifier, new_faces, tolerance)
2217
+ new_r._outdoor_shades = new_outdoor_shades
2218
+ new_r._indoor_shades = new_indoor_shades
2219
+ for oshd in new_outdoor_shades:
2220
+ oshd._parent = new_r
2221
+ for ishd in new_indoor_shades:
2222
+ ishd._parent = new_r
2223
+ ishd._is_indoor = True
2224
+ # transfer the primary room properties to the new room
2225
+ new_r._display_name = primary_room._display_name
2226
+ new_r._user_data = None if primary_room.user_data is None \
2227
+ else primary_room.user_data.copy()
2228
+ new_r._multiplier = primary_room.multiplier
2229
+ new_r._zone = primary_room._zone
2230
+ new_r._story = primary_room._story
2231
+ new_r._exclude_floor_area = primary_room.exclude_floor_area
2232
+ new_r._properties._duplicate_extension_attr(primary_room._properties)
2233
+ joined_rooms.append(new_r)
2234
+
2235
+ return joined_rooms
2236
+
2237
+ @staticmethod
2238
+ def intersect_adjacency(rooms, tolerance=0.01, angle_tolerance=1):
2239
+ """Intersect the Faces of an array of Rooms to ensure matching adjacencies.
2240
+
2241
+ Note that this method may remove Apertures and Doors if they align with
2242
+ an intersection so it is typically recommended that this method be used
2243
+ before sub-faces are assigned (if possible). Sub-faces that do not fall
2244
+ along an intersection will be preserved.
2245
+
2246
+ Also note that this method does not actually set the walls that are next to one
2247
+ another to be adjacent. The solve_adjacency method must be used for this after
2248
+ running this method.
2249
+
2250
+ Args:
2251
+ rooms: A list of Rooms for which adjacent Faces will be intersected.
2252
+ tolerance: The minimum difference between the coordinate values of two
2253
+ faces at which they can be considered adjacent. Default: 0.01,
2254
+ suitable for objects in meters.
2255
+ angle_tolerance: The max angle in degrees that the plane normals can
2256
+ differ from one another in order for them to be considered
2257
+ coplanar. (Default: 1 degree).
2258
+
2259
+ Returns:
2260
+ An array of Rooms that have been intersected with one another.
2261
+ """
2262
+ # get all of the room polyfaces
2263
+ room_geos = [r.geometry for r in rooms]
2264
+ # intersect all adjacencies between rooms
2265
+ for i, room in enumerate(rooms):
2266
+ other_rooms = room_geos[:i] + room_geos[i + 1:]
2267
+ room.coplanar_split(other_rooms, tolerance, angle_tolerance)
2268
+ room.remove_duplicate_faces(tolerance)
2269
+
2270
+ @staticmethod
2271
+ def solve_adjacency(rooms, tolerance=0.01, remove_mismatched_sub_faces=False):
2272
+ """Solve for adjacencies between a list of rooms.
2273
+
2274
+ Note that this method will mutate the input rooms by setting Surface
2275
+ boundary conditions for any adjacent objects. However, it does NOT overwrite
2276
+ existing Surface boundary conditions and only adds new ones if faces are
2277
+ found to be adjacent with equivalent areas.
2278
+
2279
+ Args:
2280
+ rooms: A list of rooms for which adjacencies will be solved.
2281
+ tolerance: The minimum difference between the coordinate values of two
2282
+ faces at which they can be considered centered adjacent. Default: 0.01,
2283
+ suitable for objects in meters.
2284
+ remove_mismatched_sub_faces: Boolean to note whether any mis-matches
2285
+ in sub-faces between adjacent rooms should simply result in
2286
+ the sub-faces being removed rather than raising an
2287
+ exception. (Default: False).
2288
+
2289
+ Returns:
2290
+ A dictionary of information about the objects that had their adjacency set.
2291
+ The dictionary has the following keys.
2292
+
2293
+ - adjacent_faces - A list of tuples with each tuple containing 2 objects
2294
+ for Faces paired in the process of solving adjacency. This data can
2295
+ be used to assign custom properties to the new adjacent Faces (like
2296
+ making all adjacencies an AirBoundary face type or assigning custom
2297
+ materials/constructions).
2298
+
2299
+ - adjacent_apertures - A list of tuples with each tuple containing 2
2300
+ objects for Apertures paired in the process of solving adjacency.
2301
+
2302
+ - adjacent_doors - A list of tuples with each tuple containing 2 objects
2303
+ for Doors paired in the process of solving adjacency.
2304
+ """
2305
+ # lists of adjacencies to track
2306
+ adj_info = {'adjacent_faces': [], 'adjacent_apertures': [],
2307
+ 'adjacent_doors': []}
2308
+
2309
+ # solve all adjacencies between rooms
2310
+ for i, room_1 in enumerate(rooms):
2311
+ try:
2312
+ for room_2 in rooms[i + 1:]:
2313
+ if not Polyface3D.overlapping_bounding_boxes(
2314
+ room_1.geometry, room_2.geometry, tolerance):
2315
+ continue # no overlap in bounding box; adjacency impossible
2316
+ for face_1 in room_1._faces:
2317
+ for face_2 in room_2._faces:
2318
+ if not isinstance(face_2.boundary_condition, Surface):
2319
+ if face_1.geometry.is_centered_adjacent(
2320
+ face_2.geometry, tolerance):
2321
+ if not remove_mismatched_sub_faces:
2322
+ face_info = face_1.set_adjacency(face_2)
2323
+ else:
2324
+ try:
2325
+ face_info = face_1.set_adjacency(face_2)
2326
+ except AssertionError:
2327
+ face_1[0].remove_sub_faces()
2328
+ face_2.remove_sub_faces()
2329
+ face_info = face_1.set_adjacency(face_2)
2330
+ adj_info['adjacent_faces'].append((face_1, face_2))
2331
+ adj_info['adjacent_apertures'].extend(
2332
+ face_info['adjacent_apertures'])
2333
+ adj_info['adjacent_doors'].extend(
2334
+ face_info['adjacent_doors'])
2335
+ break
2336
+ except IndexError:
2337
+ pass # we have reached the end of the list of rooms
2338
+ return adj_info
2339
+
2340
+ @staticmethod
2341
+ def find_adjacency(rooms, tolerance=0.01):
2342
+ """Get a list with all adjacent pairs of Faces between input rooms.
2343
+
2344
+ Note that this method does not change any boundary conditions of the input
2345
+ rooms or mutate them in any way. It's purely a geometric analysis of the
2346
+ faces between rooms.
2347
+
2348
+ Args:
2349
+ rooms: A list of rooms for which adjacencies will be solved.
2350
+ tolerance: The minimum difference between the coordinate values of two
2351
+ faces at which they can be considered centered adjacent. Default: 0.01,
2352
+ suitable for objects in meters.
2353
+
2354
+ Returns:
2355
+ A list of tuples with each tuple containing 2 objects for Faces that
2356
+ are adjacent to one another.
2357
+ """
2358
+ adj_faces = [] # lists of adjacencies to track
2359
+ for i, room_1 in enumerate(rooms):
2360
+ try:
2361
+ for room_2 in rooms[i + 1:]:
2362
+ if not Polyface3D.overlapping_bounding_boxes(
2363
+ room_1.geometry, room_2.geometry, tolerance):
2364
+ continue # no overlap in bounding box; adjacency impossible
2365
+ for face_1 in room_1._faces:
2366
+ for face_2 in room_2._faces:
2367
+ if face_1.geometry.is_centered_adjacent(
2368
+ face_2.geometry, tolerance):
2369
+ adj_faces.append((face_1, face_2))
2370
+ break
2371
+ except IndexError:
2372
+ pass # we have reached the end of the list of zones
2373
+ return adj_faces
2374
+
2375
+ @staticmethod
2376
+ def group_by_adjacency(rooms):
2377
+ """Group Rooms together that are connected by adjacencies.
2378
+
2379
+ This is useful for separating rooms in the case where a Model contains
2380
+ multiple buildings or sections that are separated by adiabatic or
2381
+ outdoor boundary conditions.
2382
+
2383
+ Args:
2384
+ rooms: A list of rooms to be grouped by their adjacency.
2385
+
2386
+ Returns:
2387
+ A list of list with each sub-list containing rooms that share adjacencies.
2388
+ """
2389
+ return Room._adjacency_grouping(rooms, Room._find_adjacent_rooms)
2390
+
2391
+ @staticmethod
2392
+ def group_by_air_boundary_adjacency(rooms):
2393
+ """Group Rooms together that share air boundaries.
2394
+
2395
+ This is useful for understanding the radiant enclosures that will exist
2396
+ when a model is exported to EnergyPlus.
2397
+
2398
+ Args:
2399
+ rooms: A list of rooms to be grouped by their air boundary adjacency.
2400
+
2401
+ Returns:
2402
+ A list of list with each sub-list containing rooms that share adjacent
2403
+ air boundaries. If a Room has no air boundaries it will the the only
2404
+ item within its sub-list.
2405
+ """
2406
+ return Room._adjacency_grouping(rooms, Room._find_adjacent_air_boundary_rooms)
2407
+
2408
+ @staticmethod
2409
+ def group_by_attribute(rooms, attr_name):
2410
+ """Group rooms with the same value for a given attribute.
2411
+
2412
+ Args:
2413
+ attr_name: A string of an attribute that the input rooms should have.
2414
+ This can have '.' that separate the nested attributes from one another.
2415
+ For example, 'properties.energy.program_type'.
2416
+
2417
+ Returns:
2418
+ A tuple with two items.
2419
+
2420
+ - grouped_rooms - A list of lists of honeybee rooms with each sub-list
2421
+ representing a different value for the attribute.
2422
+
2423
+ - values - A list of text strings for the value associated with each
2424
+ sub-list of the output grouped_rooms.
2425
+ """
2426
+ # loop through each of the rooms and get the orientation
2427
+ attr_dict = {}
2428
+ for room in rooms:
2429
+ val = get_attr_nested(room, attr_name)
2430
+ try:
2431
+ attr_dict[val].append(room)
2432
+ except KeyError:
2433
+ attr_dict[val] = [room]
2434
+
2435
+ # sort the rooms by values
2436
+ room_mtx = sorted(attr_dict.items(), key=lambda d: d[0])
2437
+ values = [r_tup[0] for r_tup in room_mtx]
2438
+ grouped_rooms = [r_tup[1] for r_tup in room_mtx]
2439
+ return grouped_rooms, values
2440
+
2441
+ @staticmethod
2442
+ def group_by_orientation(rooms, group_count=None, north_vector=Vector2D(0, 1)):
2443
+ """Group Rooms with the same average outdoor wall orientation.
2444
+
2445
+ Args:
2446
+ rooms: A list of honeybee rooms to be grouped by orientation.
2447
+ group_count: An optional positive integer to set the number of orientation
2448
+ groups to use. For example, setting this to 4 will result in rooms
2449
+ being grouped by four orientations (North, East, South, West). If None,
2450
+ the maximum number of unique groups will be used.
2451
+ north_vector: A ladybug_geometry Vector2D for the north direction.
2452
+ Default is the Y-axis (0, 1).
2453
+
2454
+ Returns:
2455
+ A tuple with three items.
2456
+
2457
+ - grouped_rooms - A list of lists of honeybee rooms with each sub-list
2458
+ representing a different orientation.
2459
+
2460
+ - core_rooms - A list of honeybee rooms with no identifiable orientation.
2461
+
2462
+ - orientations - A list of numbers between 0 and 360 with one orientation
2463
+ for each sub-list of the output grouped_rooms. This will be a list of
2464
+ angle ranges if a value is input for group_count.
2465
+ """
2466
+ # loop through each of the rooms and get the orientation
2467
+ orient_dict = {}
2468
+ core_rooms = []
2469
+ for room in rooms:
2470
+ ori = room.average_orientation(north_vector)
2471
+ if ori is None:
2472
+ core_rooms.append(room)
2473
+ else:
2474
+ ori = round(ori)
2475
+ try:
2476
+ orient_dict[ori].append(room)
2477
+ except KeyError:
2478
+ orient_dict[ori] = [room]
2479
+
2480
+ # sort the rooms by orientation values
2481
+ room_mtx = sorted(orient_dict.items(), key=lambda d: float(d[0]))
2482
+ orientations = [r_tup[0] for r_tup in room_mtx]
2483
+ grouped_rooms = [r_tup[1] for r_tup in room_mtx]
2484
+
2485
+ # group orientations if there is an input group_count
2486
+ if group_count is not None:
2487
+ angs = angles_from_num_orient(group_count)
2488
+ p_rooms = [[] for i in range(group_count)]
2489
+ for ori, rm in zip(orientations, grouped_rooms):
2490
+ or_ind = orient_index(ori, angs)
2491
+ p_rooms[or_ind].extend(rm)
2492
+ orientations = ['{} - {}'.format(int(angs[i - 1]), int(angs[i]))
2493
+ for i in range(group_count)]
2494
+ grouped_rooms = p_rooms
2495
+ return grouped_rooms, core_rooms, orientations
2496
+
2497
+ @staticmethod
2498
+ def group_by_floor_height(rooms, min_difference=0.01):
2499
+ """Group Rooms according to their average floor height.
2500
+
2501
+ Args:
2502
+ rooms: A list of honeybee rooms to be grouped by floor height.
2503
+ min_difference: An float value to denote the minimum difference
2504
+ in floor heights that is considered meaningful. This can be used
2505
+ to ensure rooms like those representing stair landings are grouped
2506
+ with those below them. Default: 0.01, which means that virtually
2507
+ any minor difference in floor heights will result in a new group.
2508
+ This assumption is suitable for models in meters.
2509
+
2510
+ Returns:
2511
+ A tuple with two items.
2512
+
2513
+ - grouped_rooms - A list of lists of honeybee rooms with each sub-list
2514
+ representing a different floor height.
2515
+
2516
+ - floor_heights - A list of floor heights with one floor height for each
2517
+ sub-list of the output grouped_rooms.
2518
+ """
2519
+ # loop through each of the rooms and get the floor height
2520
+ flrhgt_dict = {}
2521
+ for room in rooms:
2522
+ flrhgt = room.average_floor_height
2523
+ try: # assume there is already a story with the room's floor height
2524
+ flrhgt_dict[flrhgt].append(room)
2525
+ except KeyError: # this is the first room with this floor height
2526
+ flrhgt_dict[flrhgt] = []
2527
+ flrhgt_dict[flrhgt].append(room)
2528
+
2529
+ # sort the rooms by floor heights
2530
+ room_mtx = sorted(flrhgt_dict.items(), key=lambda d: float(d[0]))
2531
+ flr_hgts = [r_tup[0] for r_tup in room_mtx]
2532
+ rooms = [r_tup[1] for r_tup in room_mtx]
2533
+
2534
+ # group floor heights if they differ by less than the min_difference
2535
+ floor_heights = [flr_hgts[0]]
2536
+ grouped_rooms = [rooms[0]]
2537
+ for flrh, rm in zip(flr_hgts[1:], rooms[1:]):
2538
+ if flrh - floor_heights[-1] < min_difference:
2539
+ grouped_rooms[-1].extend(rm)
2540
+ else:
2541
+ grouped_rooms.append(rm)
2542
+ floor_heights.append(flrh)
2543
+ return grouped_rooms, floor_heights
2544
+
2545
+ @staticmethod
2546
+ def group_by_story(rooms):
2547
+ """Group Rooms according to their story property.
2548
+
2549
+ The returned room groups will be sorted from the lowest average floor
2550
+ to the highest average floor height.
2551
+
2552
+ Args:
2553
+ rooms: A list of honeybee rooms to be grouped by story.
2554
+
2555
+ Returns:
2556
+ A tuple with three items.
2557
+
2558
+ - grouped_rooms - A list of lists of honeybee rooms with each sub-list
2559
+ representing a different story.
2560
+
2561
+ - story_names - A list of story names with one value for each
2562
+ sub-list of the output grouped_rooms.
2563
+
2564
+ - floor_heights - A list of floor heights with one floor height
2565
+ for each sub-list of the output grouped_rooms.
2566
+ """
2567
+ # group the rooms by story
2568
+ story_dict = {}
2569
+ for room in rooms:
2570
+ try:
2571
+ story_dict[room.story].append(room)
2572
+ except KeyError:
2573
+ story_dict[room.story] = [room]
2574
+ # sort the stories by average floor height
2575
+ grouped_rooms, story_names, floor_heights = [], [], []
2576
+ for s_name, g_rooms in story_dict.items():
2577
+ grouped_rooms.append(g_rooms)
2578
+ story_names.append(s_name)
2579
+ weighted_sum = sum(r.average_floor_height * r.floor_area for r in g_rooms)
2580
+ total_area = sum(r.floor_area for r in g_rooms)
2581
+ avg_flr = weighted_sum / total_area
2582
+ floor_heights.append(avg_flr)
2583
+ zip_obj = zip(floor_heights, grouped_rooms, story_names)
2584
+ floor_heights, grouped_rooms, story_names = \
2585
+ (list(t) for t in zip(*sorted(zip_obj, key=lambda x: x[0])))
2586
+ return grouped_rooms, story_names, floor_heights
2587
+
2588
+ @staticmethod
2589
+ def stories_by_floor_height(rooms, min_difference=2.0):
2590
+ """Assign story properties to a set of Rooms using their floor heights.
2591
+
2592
+ Stories will be named with a standard convention ('Floor1', 'Floor2', etc.).
2593
+ Note that this method will only assign stories to Rooms that do not have
2594
+ a story identifier already assigned to them.
2595
+
2596
+ Args:
2597
+ rooms: A list of rooms for which story properties will be automatically
2598
+ assigned.
2599
+ min_difference: An float value to denote the minimum difference
2600
+ in floor heights that is considered meaningful. This can be used
2601
+ to ensure rooms like those representing stair landings are grouped
2602
+ with those below them. Default: 2.0, which means that any difference
2603
+ in floor heights less than 2.0 will be considered a part of the
2604
+ same story. This assumption is suitable for models in meters.
2605
+
2606
+ Returns:
2607
+ A list of the unique story names that were assigned to the input rooms.
2608
+ """
2609
+ # group the rooms by floor height
2610
+ new_rooms, _ = Room.group_by_floor_height(rooms, min_difference)
2611
+
2612
+ # assign the story property to each of the groups
2613
+ story_names = []
2614
+ for i, room_list in enumerate(new_rooms):
2615
+ story_name = 'Floor{}'.format(i + 1)
2616
+ story_names.append(story_name)
2617
+ for room in room_list:
2618
+ if room.story is not None:
2619
+ continue # preserve any existing user-assigned story values
2620
+ room.story = story_name
2621
+ return story_names
2622
+
2623
+ @staticmethod
2624
+ def automatically_zone(rooms, orient_count=None, north_vector=Vector2D(0, 1),
2625
+ attr_name=None):
2626
+ """Automatically group Rooms with a similar properties into zones.
2627
+
2628
+ Relevant properties that are used to group rooms into zones include story,
2629
+ orientation, and additional attributes (like programs). Note that, if the
2630
+ Rooms are not already assigned a story, then rooms across different floor
2631
+ heights may be part of the same zone. So it may be desirable to run the
2632
+ Room.stories_by_floor_height method before using this one.
2633
+
2634
+ Args:
2635
+ orient_count: An optional positive integer to set the number of orientation
2636
+ groups to use for zoning. For example, setting this to 4 will result
2637
+ in zones being established based on the four orientations (North,
2638
+ East, South, West). If None, the maximum number of unique groups
2639
+ will be used.
2640
+ north_vector: A ladybug_geometry Vector2D for the north direction.
2641
+ Default is the Y-axis (0, 1).
2642
+ attr_name: A string of an attribute that the input rooms should have.
2643
+ This can have '.' that separate the nested attributes from one another.
2644
+ For example, 'properties.energy.program_type'.
2645
+ """
2646
+ # group the rooms by story
2647
+ story_dict = {}
2648
+ for room in rooms:
2649
+ try:
2650
+ story_dict[room.story].append(room)
2651
+ except KeyError:
2652
+ story_dict[room.story] = [room]
2653
+
2654
+ for story_id, story_rooms in story_dict.items():
2655
+ # group the rooms by orientation
2656
+ perim_rooms, core_rooms, orientations, = \
2657
+ Room.group_by_orientation(story_rooms, orient_count, north_vector)
2658
+ if orient_count == 4:
2659
+ orientations = ['N', 'E', 'S', 'W']
2660
+ elif orient_count == 8:
2661
+ orientations = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
2662
+ else:
2663
+ orientations = ['{} deg'.format(orient) for orient in orientations]
2664
+ orientations.append('Core')
2665
+ orient_rooms = perim_rooms + [core_rooms]
2666
+
2667
+ # assign the zone name to each group
2668
+ for orient_id, orient_rooms in zip(orientations, orient_rooms):
2669
+ if attr_name is not None: # group the rooms by attribute
2670
+ attr_rooms, attr_vals = Room.group_by_attribute(orient_rooms, attr_name)
2671
+ for atr_val, zone_rooms in zip(attr_vals, attr_rooms):
2672
+ atr_val = atr_val.split('::')[-1]
2673
+ zone_id = '{} - {} - {}'.format(story_id, orient_id, atr_val)
2674
+ for room in zone_rooms:
2675
+ room.zone = zone_id
2676
+ else:
2677
+ zone_id = '{} - {}'.format(story_id, orient_id)
2678
+ for room in orient_rooms:
2679
+ room.zone = zone_id
2680
+
2681
+ @staticmethod
2682
+ def check_room_volume_collisions(rooms, tolerance=0.01, detailed=False):
2683
+ """Check whether the volumes of Rooms collide with one another beyond tolerance.
2684
+
2685
+ At the moment, this method only checks for the case where coplanar Floor
2686
+ Faces of different Rooms overlap with one another, which clearly indicates
2687
+ that there is definitely a collision between the Room volumes. In the
2688
+ future, this method may be amended to sense more complex cases of
2689
+ colliding Room volumes. For now, it is designed to only detect the most
2690
+ common cases.
2691
+
2692
+ Args:
2693
+ rooms: A list of rooms that will be checked for volumetric collisions.
2694
+ For this method to run most efficiently, these input Rooms should
2695
+ be at the same horizontal floor level. The Room.group_by_floor_height()
2696
+ method can be used to group the Rooms of a model according to their
2697
+ height before running this method.
2698
+ tolerance: The minimum difference between the coordinate values of two
2699
+ vertices at which they can be considered equivalent. (Default: 0.01,
2700
+ suitable for objects in meters.
2701
+ detailed: Boolean for whether the returned object is a detailed list of
2702
+ dicts with error info or a string with a message. (Default: False).
2703
+
2704
+ Returns:
2705
+ A string with the message or a list with a dictionary if detailed is True.
2706
+ """
2707
+ # create Polygon2Ds from the floors of the rooms
2708
+ b_polys, h_polys = [], []
2709
+ for room in rooms:
2710
+ rb_polys, rh_polys = [], []
2711
+ for flr in room.floors:
2712
+ flr_geo = flr.geometry
2713
+ if flr_geo.is_horizontal(tolerance):
2714
+ b_poly = Polygon2D(Point2D(p.x, p.y) for p in flr_geo.boundary)
2715
+ rb_polys.append((b_poly, flr_geo[0].z))
2716
+ if flr_geo.has_holes:
2717
+ h_poly = [Polygon2D(Point2D(p.x, p.y) for p in hole)
2718
+ for hole in flr_geo.holes]
2719
+ rh_polys.append(h_poly)
2720
+ else:
2721
+ rh_polys.append(None)
2722
+ b_polys.append(rb_polys)
2723
+ h_polys.append(rh_polys)
2724
+
2725
+ # find the number of overlaps across the Rooms
2726
+ msgs = []
2727
+ for i, (room_1, polys_1, hp1) in enumerate(zip(rooms, b_polys, h_polys)):
2728
+ overlap_rooms = []
2729
+ if len(polys_1) == 0:
2730
+ continue
2731
+ try:
2732
+ zip_obj = zip(rooms[i + 1:], b_polys[i + 1:], h_polys[i + 1:])
2733
+ for room_2, polys_2, hp2 in zip_obj:
2734
+ collision_found = False
2735
+ for j, (ply_1, z1) in enumerate(polys_1):
2736
+ if collision_found:
2737
+ break
2738
+ for k, (ply_2, z2) in enumerate(polys_2):
2739
+ if collision_found:
2740
+ break
2741
+ if abs(z1 - z2) < tolerance and \
2742
+ ply_1.polygon_relationship(ply_2, tolerance) >= 0:
2743
+ # check that one room is not inside the hole of another
2744
+ inside_hole = False
2745
+ if hp1[j] is not None:
2746
+ for h in hp1[j]:
2747
+ if h.polygon_relationship(ply_2, tolerance) == 1:
2748
+ inside_hole = True
2749
+ break
2750
+ if not inside_hole and hp2[k] is not None:
2751
+ for h in hp2[k]:
2752
+ if h.polygon_relationship(ply_1, tolerance) == 1:
2753
+ inside_hole = True
2754
+ break
2755
+ # if the room is not in a hole, then they overlap
2756
+ if not inside_hole:
2757
+ overlap_rooms.append(room_2)
2758
+ collision_found = True
2759
+ break
2760
+ except IndexError:
2761
+ pass # we have reached the end of the list
2762
+
2763
+ # of colliding rooms were found, create error messages
2764
+ if len(overlap_rooms) != 0:
2765
+ for room_2 in overlap_rooms:
2766
+ msg = 'Room "{}" has a volume that collides with the volume ' \
2767
+ 'of Room "{}" more than the tolerance ({}).'.format(
2768
+ room_1.display_name, room_2.display_name, tolerance)
2769
+ msg = Room._validation_message_child(
2770
+ msg, room_1, detailed, '000108',
2771
+ error_type='Colliding Room Volumes')
2772
+ if detailed:
2773
+ msg['element_id'].append(room_2.identifier)
2774
+ msg['element_name'].append(room_2.display_name)
2775
+ msg['parents'].append(msg['parents'][0])
2776
+ msgs.append(msg)
2777
+ # report any errors
2778
+ if detailed:
2779
+ return msgs
2780
+ full_msg = '\n '.join(msgs)
2781
+ return full_msg
2782
+
2783
+ @staticmethod
2784
+ def grouped_horizontal_boundary(
2785
+ rooms, min_separation=0, tolerance=0.01, floors_only=True):
2786
+ """Get a list of Face3D for the horizontal boundary around several Rooms.
2787
+
2788
+ This method will attempt to produce a boundary that follows along the
2789
+ outer parts of the Floors of the Rooms so it is not suitable for groups
2790
+ of Rooms that overlap one another in plan. This method may return an empty
2791
+ list if the min_separation is so large that a continuous boundary could not
2792
+ be determined.
2793
+
2794
+ Args:
2795
+ rooms: A list of Honeybee Rooms for which the horizontal boundary will
2796
+ be computed.
2797
+ min_separation: A number for the minimum distance between Rooms that
2798
+ is considered a meaningful separation. Gaps between Rooms that
2799
+ are less than this distance will be ignored and the boundary
2800
+ will continue across the gap. When the input rooms represent
2801
+ volumes of interior Faces, this input can be thought of as the
2802
+ maximum interior wall thickness, which should be ignored in
2803
+ the calculation of the overall boundary of the Rooms. When Rooms
2804
+ are touching one another (with Room volumes representing center lines
2805
+ of walls), this value can be set to zero or anything less than
2806
+ or equal to the tolerance. Doing so will yield a cleaner result for
2807
+ the boundary, which will be faster. Note that care should be taken
2808
+ not to set this value higher than the length of any meaningful
2809
+ exterior wall segments. Otherwise, the exterior segments
2810
+ will be ignored in the result. This can be particularly dangerous
2811
+ around curved exterior walls that have been planarized through
2812
+ subdivision into small segments. (Default: 0).
2813
+ tolerance: The maximum difference between coordinate values of two
2814
+ vertices at which they can be considered equivalent. (Default: 0.01,
2815
+ suitable for objects in meters).
2816
+ floors_only: A boolean to note whether the grouped boundary should only
2817
+ surround the Floor geometries of the Rooms (True) or if they should
2818
+ surround the entirety of the Room volumes in plan (False).
2819
+ """
2820
+ # get the horizontal boundary geometry of each room
2821
+ floor_geos = []
2822
+ if floors_only:
2823
+ for room in rooms:
2824
+ floor_geos.extend(room.horizontal_floor_boundaries(tolerance=tolerance))
2825
+ else:
2826
+ for room in rooms:
2827
+ floor_geos.append(room.horizontal_boundary(tolerance=tolerance))
2828
+
2829
+ # remove colinear vertices and degenerate faces
2830
+ clean_floor_geos = []
2831
+ for geo in floor_geos:
2832
+ try:
2833
+ clean_floor_geos.append(geo.remove_colinear_vertices(tolerance))
2834
+ except AssertionError: # degenerate geometry to ignore
2835
+ pass
2836
+ if len(clean_floor_geos) == 0:
2837
+ return [] # no Room boundary to be found
2838
+
2839
+ # convert the floor Face3Ds into counterclockwise Polygon2Ds
2840
+ floor_polys, z_vals = [], []
2841
+ for flr_geo in clean_floor_geos:
2842
+ z_vals.append(flr_geo.min.z)
2843
+ b_poly = Polygon2D([Point2D(pt.x, pt.y) for pt in flr_geo.boundary])
2844
+ floor_polys.append(b_poly)
2845
+ if flr_geo.has_holes:
2846
+ for hole in flr_geo.holes:
2847
+ h_poly = Polygon2D([Point2D(pt.x, pt.y) for pt in hole])
2848
+ floor_polys.append(h_poly)
2849
+ z_min = min(z_vals)
2850
+
2851
+ # if the min_separation is small, use the more reliable intersection method
2852
+ if min_separation <= tolerance:
2853
+ closed_polys = Polygon2D.joined_intersected_boundary(floor_polys, tolerance)
2854
+ else: # otherwise, use the more intense and less reliable gap crossing method
2855
+ closed_polys = Polygon2D.gap_crossing_boundary(
2856
+ floor_polys, min_separation, tolerance)
2857
+
2858
+ # remove colinear vertices from the resulting polygons
2859
+ clean_polys = []
2860
+ for poly in closed_polys:
2861
+ try:
2862
+ clean_polys.append(poly.remove_colinear_vertices(tolerance))
2863
+ except AssertionError:
2864
+ pass # degenerate polygon to ignore
2865
+
2866
+ # figure out if polygons represent holes in the others and make Face3D
2867
+ if len(clean_polys) == 0:
2868
+ return []
2869
+ elif len(clean_polys) == 1: # can be represented with a single Face3D
2870
+ pts3d = [Point3D(pt.x, pt.y, z_min) for pt in clean_polys[0]]
2871
+ return [Face3D(pts3d)]
2872
+ else: # need to separate holes from distinct Face3Ds
2873
+ bound_faces = []
2874
+ for poly in clean_polys:
2875
+ pts3d = tuple(Point3D(pt.x, pt.y, z_min) for pt in poly)
2876
+ bound_faces.append(Face3D(pts3d))
2877
+ return Face3D.merge_faces_to_holes(bound_faces, tolerance)
2878
+
2879
+ @staticmethod
2880
+ def rooms_from_rectangle_plan(
2881
+ width, length, floor_to_floor_height, perimeter_offset=0, story_count=1,
2882
+ orientation_angle=0, outdoor_roof=True, ground_floor=True,
2883
+ unique_id=None, tolerance=0.01):
2884
+ """Create a Rooms that represent a rectangular floor plan.
2885
+
2886
+ Note that the resulting Rooms won't have any windows or solved adjacencies.
2887
+ These can be added by using the Room.solve_adjacency method and the
2888
+ various Face.apertures_by_XXX methods.
2889
+
2890
+ Args:
2891
+ width: Number for the width of the plan (in the X direction).
2892
+ length: Number for the length of the plan (in the Y direction).
2893
+ floor_to_floor_height: Number for the height of each floor of the model
2894
+ (in the Z direction).
2895
+ perimeter_offset: An optional positive number that will be used to offset
2896
+ the perimeter to create core/perimeter Rooms. If this value is 0,
2897
+ no offset will occur and each floor will have one Room. (Default: 0).
2898
+ story_count: An integer for the number of stories to generate. (Default: 1).
2899
+ orientation_angle: A number between 0 and 360 for the counterclockwise
2900
+ orientation that the width of the box faces. (0=North, 90=East,
2901
+ 180=South, 270=West). (Default: 0).
2902
+ outdoor_roof: Boolean to note whether the roof faces of the top floor
2903
+ should be outdoor or adiabatic. (Default: True).
2904
+ ground_floor: Boolean to note whether the floor faces of the bottom
2905
+ floor should be ground or adiabatic. (Default: True).
2906
+ unique_id: Text for a unique identifier to be incorporated into all
2907
+ of the Room identifiers. If None, a default one will be generated.
2908
+ tolerance: The maximum difference between x, y, and z values at which
2909
+ vertices are considered equivalent. (Default: 0.01, suitable
2910
+ for objects in meters).
2911
+ """
2912
+ footprint = [Face3D.from_rectangle(width, length)]
2913
+ if perimeter_offset != 0: # use the straight skeleton methods
2914
+ assert perimeter_offset > 0, 'perimeter_offset cannot be less than than 0.'
2915
+ try:
2916
+ footprint = []
2917
+ base = Polygon2D.from_rectangle(Point2D(), Vector2D(0, 1), width, length)
2918
+ sub_polys_perim, sub_polys_core = perimeter_core_subpolygons(
2919
+ polygon=base, distance=perimeter_offset, tolerance=tolerance)
2920
+ for s_poly in sub_polys_perim + sub_polys_core:
2921
+ sub_face = Face3D([Point3D(pt.x, pt.y, 0) for pt in s_poly])
2922
+ footprint.append(sub_face)
2923
+ except RuntimeError:
2924
+ pass
2925
+ # create the honeybee rooms
2926
+ if unique_id is None:
2927
+ unique_id = str(uuid.uuid4())[:8] # unique identifier for the rooms
2928
+ rm_ids = ['Room'] if len(footprint) == 1 else ['Front', 'Right', 'Back', 'Left']
2929
+ if len(footprint) == 5:
2930
+ rm_ids.append('Core')
2931
+ return Room.rooms_from_footprint(
2932
+ footprint, floor_to_floor_height, rm_ids, unique_id, orientation_angle,
2933
+ story_count, outdoor_roof, ground_floor)
2934
+
2935
+ @staticmethod
2936
+ def rooms_from_l_shaped_plan(
2937
+ width_1, length_1, width_2, length_2, floor_to_floor_height,
2938
+ perimeter_offset=0, story_count=1, orientation_angle=0,
2939
+ outdoor_roof=True, ground_floor=True, unique_id=None, tolerance=0.01):
2940
+ """Create a Rooms that represent an L-shaped floor plan.
2941
+
2942
+ Note that the resulting Rooms in the model won't have any windows or solved
2943
+ adjacencies. These can be added by using the Room.solve_adjacency method
2944
+ and the various Face.apertures_by_XXX methods.
2945
+
2946
+ Args:
2947
+ width_1: Number for the width of the lower part of the L segment.
2948
+ length_1: Number for the length of the lower part of the L segment, not
2949
+ counting the overlap between the upper and lower segments.
2950
+ width_2: Number for the width of the upper (left) part of the L segment.
2951
+ length_2: Number for the length of the upper (left) part of the L segment,
2952
+ not counting the overlap between the upper and lower segments.
2953
+ floor_to_floor_height: Number for the height of each floor of the model
2954
+ (in the Z direction).
2955
+ perimeter_offset: An optional positive number that will be used to offset
2956
+ the perimeter to create core/perimeter Rooms. If this value is 0,
2957
+ no offset will occur and each floor will have one Room. (Default: 0).
2958
+ story_count: An integer for the number of stories to generate. (Default: 1).
2959
+ orientation_angle: A number between 0 and 360 for the counterclockwise
2960
+ orientation that the width of the box faces. (0=North, 90=East,
2961
+ 180=South, 270=West). (Default: 0).
2962
+ outdoor_roof: Boolean to note whether the roof faces of the top floor
2963
+ should be outdoor or adiabatic. (Default: True).
2964
+ ground_floor: Boolean to note whether the floor faces of the bottom
2965
+ floor should be ground or adiabatic. (Default: True).
2966
+ unique_id: Text for a unique identifier to be incorporated into all
2967
+ of the Room identifiers. If None, a default one will be generated.
2968
+ tolerance: The maximum difference between x, y, and z values at which
2969
+ vertices are considered equivalent. (Default: 0.01, suitable
2970
+ for objects in meters).
2971
+ """
2972
+ # create the geometry of the rooms for the first floor
2973
+ max_x, max_y = width_2 + length_1, width_1 + length_2
2974
+ pts = [(0, 0), (max_x, 0), (max_x, width_1), (width_2, width_1),
2975
+ (width_2, max_y), (0, max_y)]
2976
+ footprint = Face3D(tuple(Point3D(*pt) for pt in pts))
2977
+ if perimeter_offset != 0: # use the straight skeleton methods
2978
+ assert perimeter_offset > 0, 'perimeter_offset cannot be less than than 0.'
2979
+ try:
2980
+ footprint = []
2981
+ base = Polygon2D(tuple(Point2D(*pt) for pt in pts))
2982
+ sub_polys_perim, sub_polys_core = perimeter_core_subpolygons(
2983
+ polygon=base, distance=perimeter_offset, tolerance=tolerance)
2984
+ for s_poly in sub_polys_perim + sub_polys_core:
2985
+ sub_face = Face3D([Point3D(pt.x, pt.y, 0) for pt in s_poly])
2986
+ footprint.append(sub_face)
2987
+ except RuntimeError:
2988
+ pass
2989
+ # create the honeybee rooms
2990
+ unique_id = '' if unique_id is None else '_{}'.format(unique_id)
2991
+ rm_ids = ['Room'] if len(footprint) == 1 else \
2992
+ ['LongEdge1', 'End1', 'ShortEdge1', 'ShortEdge2', 'End2', 'LongEdge2']
2993
+ if len(footprint) == 7:
2994
+ rm_ids.append('Core')
2995
+ return Room.rooms_from_footprint(
2996
+ footprint, floor_to_floor_height, rm_ids, unique_id, orientation_angle,
2997
+ story_count, outdoor_roof, ground_floor)
2998
+
2999
+ @staticmethod
3000
+ def rooms_from_footprint(
3001
+ footprints, floor_to_floor_height, room_ids=None, unique_id=None,
3002
+ orientation_angle=0, story_count=1, outdoor_roof=True, ground_floor=True):
3003
+ """Create several Honeybee Rooms from footprint Face3Ds.
3004
+
3005
+ Args:
3006
+ footprints: A list of Face3Ds representing the floors of Rooms.
3007
+ floor_to_floor_height: Number for the height of each floor of the model
3008
+ (in the Z direction).
3009
+ room_ids: A list of strings for the identifiers of the Rooms to be generated.
3010
+ If None, default unique IDs will be generated. (Default: None)
3011
+ unique_id: Text for a unique identifier to be incorporated into all
3012
+ Room identifiers. (Default: None).
3013
+ orientation_angle: A number between 0 and 360 for the counterclockwise
3014
+ orientation that the width of the box faces. (0=North, 90=East,
3015
+ 180=South, 270=West). (Default: 0).
3016
+ story_count: An integer for the number of stories to generate. (Default: 1).
3017
+ outdoor_roof: Boolean to note whether the roof faces of the top floor
3018
+ should be outdoor or adiabatic. (Default: True).
3019
+ ground_floor: Boolean to note whether the floor faces of the bottom
3020
+ floor should be ground or adiabatic. (Default: True).
3021
+ """
3022
+ # set default identifiers if not provided
3023
+ if room_ids is None:
3024
+ room_ids = ['Room_{}'.format(str(uuid.uuid4())[:8]) for _ in footprints]
3025
+ # extrude the footprint into solids
3026
+ first_floor = [Polyface3D.from_offset_face(geo, floor_to_floor_height)
3027
+ for geo in footprints]
3028
+ # rotate the geometries if an orientation angle is specified
3029
+ if orientation_angle != 0:
3030
+ angle, origin = math.radians(orientation_angle), Point3D()
3031
+ first_floor = [geo.rotate_xy(angle, origin) for geo in first_floor]
3032
+ # create the initial rooms for the first floor
3033
+ rooms = []
3034
+ unique_id = '' if unique_id is None else '_{}'.format(unique_id)
3035
+ for polyface, rmid in zip(first_floor, room_ids):
3036
+ rooms.append(Room.from_polyface3d('{}{}'.format(rmid, unique_id), polyface))
3037
+ # if there are multiple stories, duplicate the first floor rooms
3038
+ if story_count != 1:
3039
+ all_rooms = []
3040
+ for i in range(story_count):
3041
+ for room in rooms:
3042
+ new_room = room.duplicate()
3043
+ new_room.add_prefix('Floor{}'.format(i + 1))
3044
+ m_vec = Vector3D(0, 0, floor_to_floor_height * i)
3045
+ new_room.move(m_vec)
3046
+ all_rooms.append(new_room)
3047
+ rooms = all_rooms
3048
+ # assign readable names for the display_name (without the UUID)
3049
+ for room in rooms:
3050
+ room.display_name = room.identifier[:-9]
3051
+ # assign adiabatic boundary conditions if requested
3052
+ if not outdoor_roof and ad_bc:
3053
+ for room in rooms[-len(first_floor):]:
3054
+ room[-1].boundary_condition = ad_bc # make the roof adiabatic
3055
+ if not ground_floor and ad_bc:
3056
+ for room in rooms[:len(first_floor)]:
3057
+ room[0].boundary_condition = ad_bc # make the floor adiabatic
3058
+ return rooms
3059
+
3060
+ def display_dict(self):
3061
+ """Get a list of DisplayFace3D dictionaries for visualizing the object."""
3062
+ base = []
3063
+ for f in self._faces:
3064
+ base.extend(f.display_dict())
3065
+ for shd in self.shades:
3066
+ base.extend(shd.display_dict())
3067
+ return base
3068
+
3069
+ @property
3070
+ def to(self):
3071
+ """Room writer object.
3072
+
3073
+ Use this method to access Writer class to write the room in other formats.
3074
+
3075
+ Usage:
3076
+
3077
+ .. code-block:: python
3078
+
3079
+ room.to.idf(room) -> idf string.
3080
+ room.to.radiance(room) -> Radiance string.
3081
+ """
3082
+ return writer
3083
+
3084
+ def to_extrusion(self, tolerance=0.01, angle_tolerance=1.0):
3085
+ """Get a version of this Room that is an extruded floor plate with a flat roof.
3086
+
3087
+ All boundary conditions and windows applied to vertical walls will be
3088
+ preserved and the resulting Room should have a volume that matches the
3089
+ current Room. If adding back apertures to the room extrusion results in
3090
+ these apertures going past the parent wall Face, the windows of the Face
3091
+ will be reduced to a simple window ratio. Any Surface boundary conditions
3092
+ will be converted to Adiabatic (if honeybee-energy is installed) or
3093
+ Outdoors (if not).
3094
+
3095
+ The multiplier and all extension properties will also be preserved.
3096
+
3097
+ This method is useful for exporting to platforms that cannot model Room
3098
+ geometry beyond simple extrusions. The fact that the resulting room has
3099
+ window areas and volumes that match the original detailed geometry
3100
+ should help ensure the results in these platforms are close to what they
3101
+ would be had the detailed geometry been modeled.
3102
+
3103
+ Args:
3104
+ tolerance: The minimum difference between the coordinate values of two
3105
+ vertices at which point they are considered co-located. (Default: 0.01,
3106
+ suitable for objects in meters).
3107
+ angle_tolerance: The angle tolerance at which the geometry will
3108
+ be evaluated in degrees. (Default: 1 degree).
3109
+
3110
+ Returns:
3111
+ A Room that is an extruded floor plate with a flat roof. Note that,
3112
+ if the Room is already an extrusion, the current Room instance will
3113
+ be returned.
3114
+ """
3115
+ # first, check whether the room is already an extrusion
3116
+ if self.is_extrusion(tolerance, angle_tolerance):
3117
+ return self
3118
+
3119
+ # get the floor_geometry for the Room2D using the horizontal boundary
3120
+ flr_geo = self.horizontal_boundary(match_walls=True, tolerance=tolerance)
3121
+ flr_geo = flr_geo if flr_geo.normal.z >= 0 else flr_geo.flip()
3122
+
3123
+ # match the segments of the floor geometry to walls of the Room
3124
+ segs = flr_geo.boundary_segments if flr_geo.holes is None else \
3125
+ flr_geo.boundary_segments + \
3126
+ tuple(seg for hole in flr_geo.hole_segments for seg in hole)
3127
+ wall_bcs = [boundary_conditions.outdoors] * len(segs)
3128
+ sub_faces = [None] * len(segs)
3129
+ for i, seg in enumerate(segs):
3130
+ wall_f = self._segment_wall_face(seg, tolerance)
3131
+ if wall_f is not None:
3132
+ wall_bcs[i] = wall_f.boundary_condition
3133
+ if len(wall_f._apertures) != 0 or len(wall_f._doors) != 0:
3134
+ sf_objs = [h.duplicate() for h in wall_f._apertures + wall_f._doors]
3135
+ if abs(wall_f.normal.z) <= 0.01: # vertical wall
3136
+ sub_faces[i] = sf_objs
3137
+ else: # angled wall; scale the Y to covert to vertical
3138
+ w_geos = [sf.geometry for sf in sf_objs]
3139
+ w_p = Plane(Vector3D(seg.v.y, -seg.v.x, 0), seg.p, seg.v)
3140
+ w3d = [Face3D([p.project(w_p.n, w_p.o) for p in geo.boundary])
3141
+ for geo in w_geos]
3142
+ proj_sf_objs = []
3143
+ for proj_geo, sf_obj in zip(w3d, sf_objs):
3144
+ sf_obj._geometry = proj_geo
3145
+ proj_sf_objs.append(sf_obj)
3146
+ sub_faces[i] = proj_sf_objs
3147
+
3148
+ # determine the ceiling height, and top/bottom boundary conditions
3149
+ floor_to_ceiling_height = self.volume / flr_geo.area
3150
+ is_ground_contact = all([isinstance(f.boundary_condition, Ground)
3151
+ for f in self.faces if isinstance(f.type, Floor)])
3152
+ is_top_exposed = all([isinstance(f.boundary_condition, Outdoors)
3153
+ for f in self.faces if isinstance(f.type, RoofCeiling)])
3154
+
3155
+ # create the new extruded Room object
3156
+ ext_p_face = Polyface3D.from_offset_face(flr_geo, floor_to_ceiling_height)
3157
+ ext_room = Room.from_polyface3d(
3158
+ self.identifier, ext_p_face, ground_depth=float('-inf'))
3159
+
3160
+ # assign BCs and replace any Surface conditions to be set on the story level
3161
+ for i, bc in enumerate(wall_bcs):
3162
+ if not isinstance(bc, Surface):
3163
+ ext_room[i + 1]._boundary_condition = bc
3164
+ elif ad_bc is not None:
3165
+ ext_room[i + 1]._boundary_condition = ad_bc
3166
+
3167
+ # assign windows and doors to walls
3168
+ for i, sub_objs in enumerate(sub_faces):
3169
+ if sub_objs is not None:
3170
+ ext_f = ext_room[i + 1]
3171
+ if isinstance(ext_f.boundary_condition, Outdoors):
3172
+ ext_f.add_sub_faces(sub_objs)
3173
+ subs_valid = ext_f.check_sub_faces_valid(
3174
+ tolerance, angle_tolerance, False) == ''
3175
+ if not subs_valid: # convert them to a simple ratio
3176
+ wwr = ext_f.aperture_ratio
3177
+ wwr = 0.99 if wwr > 0.99 else wwr
3178
+ ext_f.apertures_by_ratio(wwr, tolerance)
3179
+ base_ap = sub_objs[0] \
3180
+ if isinstance(sub_objs[0], Aperture) else None
3181
+ if base_ap is not None:
3182
+ for ap in ext_f.apertures:
3183
+ ap._is_operable = base_ap._is_operable
3184
+ ap._display_name = base_ap._display_name
3185
+ ap._properties._duplicate_extension_attr(
3186
+ base_ap._properties)
3187
+
3188
+ # assign boundary conditions for the roof and floor
3189
+ if is_ground_contact:
3190
+ ext_room[0].boundary_condition = boundary_conditions.ground
3191
+ elif ad_bc is not None:
3192
+ ext_room[0].boundary_condition = ad_bc
3193
+ if not is_top_exposed:
3194
+ if ad_bc is not None:
3195
+ ext_room[-1].boundary_condition = ad_bc
3196
+ else: # check if there are any skylights to be added
3197
+ rf_ht = flr_geo[0].z + floor_to_ceiling_height
3198
+ skylights = []
3199
+ for f in self.faces:
3200
+ if isinstance(f.type, RoofCeiling):
3201
+ sf_objs = f._apertures + f._doors
3202
+ for sf in sf_objs:
3203
+ new_sf = sf.duplicate()
3204
+ pts = [Point3D(pt.x, pt.y, rf_ht) for pt in sf.geometry.boundary]
3205
+ new_sf._geometry = Face3D(pts)
3206
+ new_sf.remove_shades()
3207
+ skylights.append(new_sf)
3208
+ if len(skylights) != 0:
3209
+ ext_room[-1].add_sub_faces(skylights)
3210
+
3211
+ # add the extra room attributes
3212
+ ext_room._display_name = self._display_name
3213
+ ext_room._user_data = None if self.user_data is None else self.user_data.copy()
3214
+ ext_room._multiplier = self.multiplier
3215
+ ext_room._zone = self._zone
3216
+ ext_room._story = self._story
3217
+ ext_room._exclude_floor_area = self.exclude_floor_area
3218
+ ext_room._properties._duplicate_extension_attr(self._properties)
3219
+ return ext_room
3220
+
3221
+ def _segment_wall_face(self, segment, tolerance):
3222
+ """Get a Wall Face that corresponds with a certain wall segment.
3223
+
3224
+ Args:
3225
+ segment: A LineSegment3D along one of the walls of the room.
3226
+ tolerance: The maximum difference between values at which point vertices
3227
+ are considered to be the same.
3228
+ """
3229
+ for face in self.faces:
3230
+ if isinstance(face.type, (Wall, AirBoundary)):
3231
+ fg = face.geometry
3232
+ try:
3233
+ verts = fg._remove_colinear(
3234
+ fg._boundary, fg.boundary_polygon2d, tolerance)
3235
+ except AssertionError:
3236
+ return None
3237
+ for v1 in verts:
3238
+ if segment.p1.is_equivalent(v1, tolerance):
3239
+ p2 = segment.p2
3240
+ for v2 in verts:
3241
+ if p2.is_equivalent(v2, tolerance):
3242
+ return face
3243
+
3244
+ def to_dict(self, abridged=False, included_prop=None, include_plane=True):
3245
+ """Return Room as a dictionary.
3246
+
3247
+ Args:
3248
+ abridged: Boolean to note whether the extension properties of the
3249
+ object (ie. construction sets) should be included in detail
3250
+ (False) or just referenced by identifier (True). Default: False.
3251
+ included_prop: List of properties to filter keys that must be included in
3252
+ output dictionary. For example ['energy'] will include 'energy' key if
3253
+ available in properties to_dict. By default all the keys will be
3254
+ included. To exclude all the keys from extensions use an empty list.
3255
+ include_plane: Boolean to note wether the planes of the Face3Ds should be
3256
+ included in the output. This can preserve the orientation of the
3257
+ X/Y axes of the planes but is not required and can be removed to
3258
+ keep the dictionary smaller. (Default: True).
3259
+ """
3260
+ base = {'type': 'Room'}
3261
+ base['identifier'] = self.identifier
3262
+ base['display_name'] = self.display_name
3263
+ base['properties'] = self.properties.to_dict(abridged, included_prop)
3264
+ base['faces'] = [f.to_dict(abridged, included_prop, include_plane)
3265
+ for f in self._faces]
3266
+ self._add_shades_to_dict(base, abridged, included_prop, include_plane)
3267
+ if self.multiplier != 1:
3268
+ base['multiplier'] = self.multiplier
3269
+ if self._zone is not None:
3270
+ base['zone'] = self.zone
3271
+ if self.story is not None:
3272
+ base['story'] = self.story
3273
+ if self.exclude_floor_area:
3274
+ base['exclude_floor_area'] = self.exclude_floor_area
3275
+ if self.user_data is not None:
3276
+ base['user_data'] = self.user_data
3277
+ return base
3278
+
3279
+ def _base_horiz_boundary(self, tolerance=0.01):
3280
+ """Get a starting horizontal boundary for the Room.
3281
+
3282
+ This is the raw result obtained by merging all downward-facing Faces of the Room.
3283
+
3284
+ Args:
3285
+ tolerance: The minimum difference between x, y, and z coordinate values
3286
+ at which points are considered distinct. (Default: 0.01,
3287
+ suitable for objects in Meters).
3288
+ """
3289
+ z_axis = Vector3D(0, 0, 1)
3290
+ flr_geo = []
3291
+ for face in self.faces:
3292
+ if math.degrees(z_axis.angle(face.normal)) >= 91:
3293
+ flr_geo.append(face.geometry)
3294
+ if len(flr_geo) == 1:
3295
+ if flr_geo[0].is_horizontal(tolerance):
3296
+ return flr_geo[0]
3297
+ else:
3298
+ floor_height = self.geometry.min.z
3299
+ bound = [Point3D(p.x, p.y, floor_height) for p in flr_geo[0].boundary]
3300
+ holes = None
3301
+ if flr_geo[0].has_holes:
3302
+ holes = [[Point3D(p.x, p.y, floor_height) for p in hole]
3303
+ for hole in flr_geo[0].holes]
3304
+ return Face3D(bound, holes=holes)
3305
+ else: # multiple geometries to be joined together
3306
+ floor_height = self.geometry.min.z
3307
+ horiz_geo = []
3308
+ for fg in flr_geo:
3309
+ if fg.is_horizontal(tolerance) and \
3310
+ abs(floor_height - fg.min.z) <= tolerance:
3311
+ horiz_geo.append(fg)
3312
+ else: # project the face geometry into the XY plane
3313
+ bound = [Point3D(p.x, p.y, floor_height) for p in fg.boundary]
3314
+ holes = None
3315
+ if fg.has_holes:
3316
+ holes = [[Point3D(p.x, p.y, floor_height) for p in hole]
3317
+ for hole in fg.holes]
3318
+ horiz_geo.append(Face3D(bound, holes=holes))
3319
+ # sense if there are overlapping geometries to be boolean unioned
3320
+ overlap_groups = Face3D.group_by_coplanar_overlap(horiz_geo, tolerance)
3321
+ if all(len(g) == 1 for g in overlap_groups): # no overlaps; just join
3322
+ return Face3D.join_coplanar_faces(horiz_geo, tolerance)[0]
3323
+ # we must do a boolean union
3324
+ clean_geo = []
3325
+ for og in overlap_groups:
3326
+ if len(og) == 1:
3327
+ clean_geo.extend(og)
3328
+ else:
3329
+ a_tol = math.radians(1)
3330
+ union = Face3D.coplanar_union_all(og, tolerance, a_tol)
3331
+ if len(union) == 1:
3332
+ clean_geo.extend(union)
3333
+ else:
3334
+ sort_geo = sorted(union, key=lambda x: x.area, reverse=True)
3335
+ clean_geo.append(sort_geo[0])
3336
+ if len(clean_geo) == 1:
3337
+ return clean_geo[0]
3338
+ return Face3D.join_coplanar_faces(clean_geo, tolerance)[0]
3339
+
3340
+ def _match_walls_to_horizontal_faces(self, faces, tolerance):
3341
+ """Insert vertices to horizontal faces so they align with the Room's Walls.
3342
+
3343
+ Args:
3344
+ faces: A list of Face3D into which the vertices of the walls will
3345
+ be inserted.
3346
+ tolerance: The minimum difference between x, y, and z coordinate values
3347
+ at which points are considered distinct. (Default: 0.01,
3348
+ suitable for objects in Meters).
3349
+ """
3350
+ # get 2D vertices for all of the walls
3351
+ wall_st_pts = [
3352
+ f.geometry.lower_left_counter_clockwise_vertices[0] for f in self.walls]
3353
+ wall_st_pts_2d = [Point2D(v[0], v[1]) for v in wall_st_pts]
3354
+ # insert the wall points into each of the faces
3355
+ wall_faces = []
3356
+ for horiz_bound in faces:
3357
+ # get 2D polygons for the horizontal boundary
3358
+ z_val = horiz_bound[0].z
3359
+ polys = [Polygon2D([Point2D(v.x, v.y) for v in horiz_bound.boundary])]
3360
+ if horiz_bound.holes is not None:
3361
+ for hole in horiz_bound.holes:
3362
+ polys.append(Polygon2D([Point2D(v.x, v.y) for v in hole]))
3363
+ # insert the wall vertices into the polygon
3364
+ wall_polys = []
3365
+ for st_poly in polys:
3366
+ st_poly = st_poly.remove_colinear_vertices(tolerance)
3367
+ polygon_update = []
3368
+ for pt in wall_st_pts_2d:
3369
+ for v in st_poly.vertices: # check if pt is already included
3370
+ if pt.is_equivalent(v, tolerance):
3371
+ break
3372
+ else:
3373
+ values = [seg.distance_to_point(pt) for seg in st_poly.segments]
3374
+ if min(values) < tolerance:
3375
+ index_min = min(range(len(values)), key=values.__getitem__)
3376
+ polygon_update.append((index_min, pt))
3377
+ if polygon_update:
3378
+ end_poly = Polygon2D._insert_updates_in_order(st_poly, polygon_update)
3379
+ wall_polys.append(end_poly)
3380
+ else:
3381
+ wall_polys.append(st_poly)
3382
+ # rebuild the Face3D from the polygons
3383
+ pts_3d = [[Point3D(p.x, p.y, z_val) for p in poly] for poly in wall_polys]
3384
+ wall_faces.append(Face3D(pts_3d[0], holes=pts_3d[1:]))
3385
+ return wall_faces
3386
+
3387
+ @staticmethod
3388
+ def _adjacency_grouping(rooms, adj_finding_function):
3389
+ """Group Rooms together according to an adjacency finding function.
3390
+
3391
+ Args:
3392
+ rooms: A list of rooms to be grouped by their adjacency.
3393
+ adj_finding_function: A function that denotes which rooms are adjacent
3394
+ to another.
3395
+
3396
+ Returns:
3397
+ A list of list with each sub-list containing rooms that share adjacencies.
3398
+ """
3399
+ # create a room lookup table and duplicate the list of rooms
3400
+ room_lookup = {rm.identifier: rm for rm in rooms}
3401
+ all_rooms = list(rooms)
3402
+ adj_network = []
3403
+
3404
+ # loop through the rooms and find air boundary adjacencies
3405
+ for room in all_rooms:
3406
+ adj_ids = adj_finding_function(room)
3407
+ if len(adj_ids) == 0: # a room with no adjacencies
3408
+ adj_network.append([room])
3409
+ else: # there are other adjacent rooms to find
3410
+ local_network = [room]
3411
+ local_ids, first_id = set(adj_ids), room.identifier
3412
+ while len(adj_ids) != 0:
3413
+ # add the current rooms to the local network
3414
+ adj_objs = []
3415
+ for rm_id in adj_ids:
3416
+ try:
3417
+ adj_objs.append(room_lookup[rm_id])
3418
+ except KeyError: # missing adjacency among the room groups
3419
+ pass
3420
+ local_network.extend(adj_objs)
3421
+ adj_ids = [] # reset the list of new adjacencies
3422
+ # find any rooms that are adjacent to the adjacent rooms
3423
+ for obj in adj_objs:
3424
+ all_new_ids = adj_finding_function(obj)
3425
+ new_ids = [rid for rid in all_new_ids
3426
+ if rid not in local_ids and rid != first_id]
3427
+ for rm_id in new_ids:
3428
+ local_ids.add(rm_id)
3429
+ adj_ids.extend(new_ids)
3430
+ # after the local network is understood, clean up duplicated rooms
3431
+ clean_local_network, exist_ids = [], set()
3432
+ for room in local_network:
3433
+ if room.identifier not in exist_ids:
3434
+ clean_local_network.append(room)
3435
+ exist_ids.add(room.identifier)
3436
+ adj_network.append(clean_local_network)
3437
+ i_to_remove = [i for i, room_obj in enumerate(all_rooms)
3438
+ if room_obj.identifier in local_ids]
3439
+ for i in reversed(i_to_remove):
3440
+ all_rooms.pop(i)
3441
+ return adj_network
3442
+
3443
+ @staticmethod
3444
+ def _find_adjacent_rooms(room):
3445
+ """Find the identifiers of all rooms with adjacency to a room."""
3446
+ adj_rooms = []
3447
+ for face in room._faces:
3448
+ if isinstance(face.boundary_condition, Surface):
3449
+ adj_rooms.append(face.boundary_condition.boundary_condition_objects[-1])
3450
+ return adj_rooms
3451
+
3452
+ @staticmethod
3453
+ def _find_adjacent_air_boundary_rooms(room):
3454
+ """Find the identifiers of all rooms with air boundary adjacency to a room."""
3455
+ adj_rooms = []
3456
+ for face in room._faces:
3457
+ if isinstance(face.type, AirBoundary) and \
3458
+ isinstance(face.boundary_condition, Surface):
3459
+ adj_rooms.append(face.boundary_condition.boundary_condition_objects[-1])
3460
+ return adj_rooms
3461
+
3462
+ def __copy__(self):
3463
+ new_r = Room(self.identifier, tuple(face.duplicate() for face in self._faces))
3464
+ new_r._display_name = self._display_name
3465
+ new_r._user_data = None if self.user_data is None else self.user_data.copy()
3466
+ new_r._multiplier = self.multiplier
3467
+ new_r._zone = self._zone
3468
+ new_r._story = self._story
3469
+ new_r._exclude_floor_area = self.exclude_floor_area
3470
+ self._duplicate_child_shades(new_r)
3471
+ new_r._geometry = self._geometry
3472
+ new_r._properties._duplicate_extension_attr(self._properties)
3473
+ return new_r
3474
+
3475
+ def __len__(self):
3476
+ return len(self._faces)
3477
+
3478
+ def __getitem__(self, key):
3479
+ return self._faces[key]
3480
+
3481
+ def __iter__(self):
3482
+ return iter(self._faces)
3483
+
3484
+ def __repr__(self):
3485
+ return 'Room: %s' % self.display_name