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/model.py ADDED
@@ -0,0 +1,4272 @@
1
+ # coding: utf-8
2
+ """Honeybee Model."""
3
+ from __future__ import division
4
+ import os
5
+ import sys
6
+ import io
7
+ import re
8
+ import json
9
+ import math
10
+ import uuid
11
+ try: # check if we are in IronPython
12
+ import cPickle as pickle
13
+ except ImportError: # wea are in cPython
14
+ import pickle
15
+
16
+ from ladybug_geometry.geometry2d import Polygon2D
17
+ from ladybug_geometry.geometry3d import Vector3D, Plane, Face3D, Mesh3D, Polyface3D
18
+ from ladybug_geometry.bounding import overlapping_bounding_boxes
19
+ from ladybug_geometry.interop.stl import STL
20
+
21
+ from ._base import _Base
22
+ from .units import conversion_factor_to_meters, parse_distance_string, \
23
+ UNITS, UNITS_TOLERANCES
24
+ from .checkdup import check_duplicate_identifiers, check_duplicate_identifiers_parent
25
+ from .properties import ModelProperties
26
+ from .room import Room
27
+ from .face import Face
28
+ from .shade import Shade
29
+ from .aperture import Aperture
30
+ from .door import Door
31
+ from .shademesh import ShadeMesh
32
+ from .typing import float_positive, invalid_dict_error, clean_string, \
33
+ clean_and_number_string
34
+ from .config import folders
35
+ from .boundarycondition import Outdoors, Surface
36
+ from .facetype import AirBoundary, Wall, Floor, RoofCeiling, face_types
37
+ import honeybee.writer.model as writer
38
+ from honeybee.boundarycondition import boundary_conditions as bcs
39
+ try:
40
+ ad_bc = bcs.adiabatic
41
+ except AttributeError: # honeybee_energy is not loaded and adiabatic does not exist
42
+ ad_bc = None
43
+
44
+
45
+ class Model(_Base):
46
+ """A collection of Rooms, Faces, Shades, Apertures, and Doors representing a model.
47
+
48
+ Args:
49
+ identifier: Text string for a unique Model ID. Must be < 100 characters and
50
+ not contain any spaces or special characters.
51
+ rooms: A list of Room objects in the model.
52
+ orphaned_faces: A list of the Face objects in the model that lack
53
+ a parent Room. Note that orphaned Faces are translated to sun-blocking
54
+ shade objects in energy simulation.
55
+ orphaned_shades: A list of the Shade objects in the model that lack
56
+ a parent.
57
+ orphaned_apertures: A list of the Aperture objects in the model that lack
58
+ a parent Face. Note that orphaned Apertures are translated to sun-blocking
59
+ shade objects in energy simulation.
60
+ orphaned_doors: A list of the Door objects in the model that lack
61
+ a parent Face. Note that orphaned Doors are translated to sun-blocking
62
+ shade objects in energy simulation.
63
+ shade_meshes: A list of the ShadeMesh objects in the model.
64
+ units: Text for the units system in which the model geometry
65
+ exists. Default: 'Meters'. Choose from the following:
66
+
67
+ * Meters
68
+ * Millimeters
69
+ * Feet
70
+ * Inches
71
+ * Centimeters
72
+
73
+ tolerance: The maximum difference between x, y, and z values at which
74
+ vertices are considered equivalent. Zero indicates that no tolerance
75
+ checks should be performed. None indicates that the tolerance will be
76
+ set based on the units above, with the tolerance consistently being
77
+ between 1 cm and 1 mm (roughly the tolerance implicit in the OpenStudio
78
+ SDK and EnergyPlus). (Default: None).
79
+ angle_tolerance: The max angle difference in degrees that vertices are allowed
80
+ to differ from one another in order to consider them colinear. Zero indicates
81
+ that no angle tolerance checks should be performed. (Default: 1.0).
82
+
83
+ Properties:
84
+ * identifier
85
+ * display_name
86
+ * units
87
+ * tolerance
88
+ * angle_tolerance
89
+ * rooms
90
+ * faces
91
+ * apertures
92
+ * doors
93
+ * shades
94
+ * shade_meshes
95
+ * indoor_shades
96
+ * outdoor_shades
97
+ * orphaned_faces
98
+ * orphaned_shades
99
+ * orphaned_apertures
100
+ * orphaned_doors
101
+ * stories
102
+ * volume
103
+ * floor_area
104
+ * exposed_area
105
+ * exterior_wall_area
106
+ * exterior_roof_area
107
+ * exterior_aperture_area
108
+ * exterior_wall_aperture_area
109
+ * exterior_skylight_aperture_area
110
+ * min
111
+ * max
112
+ * roof_to_exterior_edges
113
+ * slab_to_exterior_edges
114
+ * exposed_floor_to_exterior_wall_edges
115
+ * exterior_wall_to_wall_edges
116
+ * roof_ridge_edges
117
+ * exposed_floor_to_floor_edges
118
+ * underground_edges
119
+ * interior_edges
120
+ * exterior_aperture_edges
121
+ * exterior_door_edges
122
+ * exterior_aperture_edges
123
+ * exterior_door_edges
124
+ * top_level_dict
125
+ * user_data
126
+ """
127
+ __slots__ = (
128
+ '_rooms', '_orphaned_faces', '_orphaned_apertures', '_orphaned_doors',
129
+ '_orphaned_shades', '_shade_meshes',
130
+ '_units', '_tolerance', '_angle_tolerance'
131
+ )
132
+
133
+ # dictionary mapping validation error codes to a corresponding check function
134
+ ERROR_MAP = {
135
+ '000001': 'check_duplicate_shade_identifiers',
136
+ '000002': 'check_duplicate_sub_face_identifiers',
137
+ '000003': 'check_duplicate_face_identifiers',
138
+ '000004': 'check_duplicate_room_identifiers',
139
+ '000101': 'check_planar',
140
+ '000102': 'check_self_intersecting',
141
+ '000103': 'check_degenerate_rooms',
142
+ '000104': 'check_sub_faces_valid',
143
+ '000105': 'check_sub_faces_overlapping',
144
+ '000106': 'check_rooms_solid',
145
+ '000107': 'check_degenerate_rooms',
146
+ '000108': 'check_room_volume_collisions',
147
+ '000109': 'check_upside_down_faces',
148
+ '000201': 'check_missing_adjacencies',
149
+ '000202': 'check_missing_adjacencies',
150
+ '000203': 'check_missing_adjacencies',
151
+ '000204': 'check_missing_adjacencies',
152
+ '000205': 'check_matching_adjacent_areas',
153
+ '000206': 'check_all_air_boundaries_adjacent'
154
+ }
155
+ UNITS = UNITS
156
+ UNITS_TOLERANCES = UNITS_TOLERANCES
157
+
158
+ def __init__(self, identifier, rooms=None, orphaned_faces=None, orphaned_shades=None,
159
+ orphaned_apertures=None, orphaned_doors=None, shade_meshes=None,
160
+ units='Meters', tolerance=None, angle_tolerance=1.0):
161
+ """A collection of Rooms, Faces, Apertures, and Doors for an entire model."""
162
+ _Base.__init__(self, identifier) # process the identifier
163
+
164
+ self.units = units
165
+ self.tolerance = tolerance
166
+ self.angle_tolerance = angle_tolerance
167
+
168
+ self.rooms = rooms
169
+ self.orphaned_faces = orphaned_faces
170
+ self.orphaned_apertures = orphaned_apertures
171
+ self.orphaned_doors = orphaned_doors
172
+ self.orphaned_shades = orphaned_shades
173
+ self.shade_meshes = shade_meshes
174
+
175
+ self._properties = ModelProperties(self)
176
+
177
+ @classmethod
178
+ def from_dict(cls, data, cleanup_irrational=False):
179
+ """Initialize a Model from a dictionary.
180
+
181
+ Args:
182
+ data: A dictionary representation of a Model object.
183
+ cleanup_irrational: Boolean to note whether common types of irrational
184
+ objects should be cleaned or removed from the dictionary before
185
+ serializing the model to Python. Typical cases that are removed
186
+ this way include Face3Ds with fewer than 3 vertices, Rooms that
187
+ have no Face geometry, etc. (Default: False).
188
+ """
189
+ # check the type of dictionary
190
+ assert data['type'] == 'Model', 'Expected Model dictionary. ' \
191
+ 'Got {}.'.format(data['type'])
192
+
193
+ # import the units and tolerance values
194
+ units = 'Meters' if 'units' not in data or data['units'] is None \
195
+ else data['units']
196
+ tol = cls.UNITS_TOLERANCES[units] if 'tolerance' not in data or \
197
+ data['tolerance'] is None else data['tolerance']
198
+ angle_tol = 1.0 if 'angle_tolerance' not in data or \
199
+ data['angle_tolerance'] is None else data['angle_tolerance']
200
+
201
+ # clean the irrational objects out if requested
202
+ if cleanup_irrational:
203
+ cls.clean_irrational_geometry(data)
204
+
205
+ # import all of the geometry
206
+ rooms = None # import rooms
207
+ if 'rooms' in data and data['rooms'] is not None:
208
+ rooms = []
209
+ for r in data['rooms']:
210
+ try:
211
+ rooms.append(Room.from_dict(r, tol))
212
+ except Exception as e:
213
+ invalid_dict_error(r, e)
214
+ orphaned_faces = None # import orphaned faces
215
+ if 'orphaned_faces' in data and data['orphaned_faces'] is not None:
216
+ orphaned_faces = []
217
+ for f in data['orphaned_faces']:
218
+ try:
219
+ orphaned_faces.append(Face.from_dict(f))
220
+ except Exception as e:
221
+ invalid_dict_error(f, e)
222
+ orphaned_apertures = None # import orphaned apertures
223
+ if 'orphaned_apertures' in data and data['orphaned_apertures'] is not None:
224
+ orphaned_apertures = []
225
+ for a in data['orphaned_apertures']:
226
+ try:
227
+ orphaned_apertures.append(Aperture.from_dict(a))
228
+ except Exception as e:
229
+ invalid_dict_error(a, e)
230
+ orphaned_doors = None # import orphaned doors
231
+ if 'orphaned_doors' in data and data['orphaned_doors'] is not None:
232
+ orphaned_doors = []
233
+ for d in data['orphaned_doors']:
234
+ try:
235
+ orphaned_doors.append(Door.from_dict(d))
236
+ except Exception as e:
237
+ invalid_dict_error(d, e)
238
+ orphaned_shades = None # import orphaned shades
239
+ if 'orphaned_shades' in data and data['orphaned_shades'] is not None:
240
+ orphaned_shades = []
241
+ for s in data['orphaned_shades']:
242
+ try:
243
+ orphaned_shades.append(Shade.from_dict(s))
244
+ except Exception as e:
245
+ invalid_dict_error(s, e)
246
+ shade_meshes = None # import shade meshes
247
+ if 'shade_meshes' in data and data['shade_meshes'] is not None:
248
+ shade_meshes = []
249
+ for sm in data['shade_meshes']:
250
+ try:
251
+ shade_meshes.append(ShadeMesh.from_dict(sm))
252
+ except Exception as e:
253
+ invalid_dict_error(sm, e)
254
+
255
+ # build the model object
256
+ model = Model(
257
+ data['identifier'], rooms, orphaned_faces, orphaned_shades,
258
+ orphaned_apertures, orphaned_doors, shade_meshes,
259
+ units, tol, angle_tol)
260
+ if 'display_name' in data and data['display_name'] is not None:
261
+ model.display_name = data['display_name']
262
+ if 'user_data' in data and data['user_data'] is not None:
263
+ model.user_data = data['user_data']
264
+
265
+ # assign extension properties to the model
266
+ model.properties.apply_properties_from_dict(data)
267
+ return model
268
+
269
+ @classmethod
270
+ def from_file(cls, hb_file, cleanup_irrational=False):
271
+ """Initialize a Model from a HBJSON or HBpkl file, auto-sensing the type.
272
+
273
+ Args:
274
+ hb_file: Path to either a HBJSON or HBpkl file.
275
+ cleanup_irrational: Boolean to note whether common types of irrational
276
+ objects should be cleaned or removed from the dictionary before
277
+ serializing the model to Python. Typical cases that are removed
278
+ this way include Face3Ds with fewer than 3 vertices, Rooms that
279
+ have no Face geometry, etc. (Default: False).
280
+ """
281
+ # sense the file type from the first character to avoid maxing memory with JSON
282
+ # this is needed since queenbee overwrites all file extensions
283
+ with io.open(hb_file, encoding='utf-8') as inf:
284
+ first_char = inf.read(1)
285
+ second_char = inf.read(1)
286
+ is_json = True if first_char == '{' or second_char == '{' else False
287
+ # load the file using either HBJSON pathway or HBpkl
288
+ if is_json:
289
+ return cls.from_hbjson(hb_file, cleanup_irrational)
290
+ return cls.from_hbpkl(hb_file, cleanup_irrational)
291
+
292
+ @classmethod
293
+ def from_hbjson(cls, hbjson_file, cleanup_irrational=False):
294
+ """Initialize a Model from a HBJSON file.
295
+
296
+ Args:
297
+ hbjson_file: Path to HBJSON file.
298
+ cleanup_irrational: Boolean to note whether common types of irrational
299
+ objects should be cleaned or removed from the dictionary before
300
+ serializing the model to Python. Typical cases that are removed
301
+ this way include Face3Ds with fewer than 3 vertices, Rooms that
302
+ have no Face geometry, etc. (Default: False).
303
+ """
304
+ assert os.path.isfile(hbjson_file), 'Failed to find %s' % hbjson_file
305
+ with io.open(hbjson_file, encoding='utf-8') as inf:
306
+ inf.read(1)
307
+ second_char = inf.read(1)
308
+ with io.open(hbjson_file, encoding='utf-8') as inf:
309
+ if second_char == '{':
310
+ inf.read(1)
311
+ data = json.load(inf)
312
+ return cls.from_dict(data, cleanup_irrational)
313
+
314
+ @classmethod
315
+ def from_hbpkl(cls, hbpkl_file, cleanup_irrational=False):
316
+ """Initialize a Model from a HBpkl file.
317
+
318
+ Args:
319
+ hbpkl_file: Path to HBpkl file.
320
+ cleanup_irrational: Boolean to note whether common types of irrational
321
+ objects should be cleaned or removed from the dictionary before
322
+ serializing the model to Python. Typical cases that are removed
323
+ this way include Face3Ds with fewer than 3 vertices, Rooms that
324
+ have no Face geometry, etc. (Default: False).
325
+ """
326
+ assert os.path.isfile(hbpkl_file), 'Failed to find %s' % hbpkl_file
327
+ with open(hbpkl_file, 'rb') as inf:
328
+ data = pickle.load(inf)
329
+ return cls.from_dict(data, cleanup_irrational)
330
+
331
+ @classmethod
332
+ def from_stl(cls, file_path, geometry_to_faces=False, units='Meters',
333
+ tolerance=None, angle_tolerance=1.0):
334
+ """Create a Honeybee Model from an STL file.
335
+
336
+ Args:
337
+ file_path: Path to an STL file as a text string. The STL file can be
338
+ in either ASCII or binary format.
339
+ geometry_to_faces: A boolean to note whether the geometry in the STL
340
+ file should be imported as Faces (with Walls/Floors/RoofCeiling
341
+ set according to the normal). If False, all geometry will be
342
+ imported as ShadeMeshes instead of Faces. (Default: False).
343
+ units: Text for the units system in which the model geometry
344
+ exists. Default: 'Meters'. Choose from the following:
345
+
346
+ * Meters
347
+ * Millimeters
348
+ * Feet
349
+ * Inches
350
+ * Centimeters
351
+
352
+ tolerance: The maximum difference between x, y, and z values at which
353
+ vertices are considered equivalent. Zero indicates that no tolerance
354
+ checks should be performed. None indicates that the tolerance will be
355
+ set based on the units above, with the tolerance consistently being
356
+ between 1 cm and 1 mm (roughly the tolerance implicit in the OpenStudio
357
+ SDK and EnergyPlus). (Default: None).
358
+ angle_tolerance: The max angle difference in degrees that vertices
359
+ are allowed to differ from one another in order to consider them
360
+ colinear. Zero indicates that no angle tolerance checks should be
361
+ performed. (Default: 1.0).
362
+ """
363
+ stl_obj = STL.from_file(file_path)
364
+ all_id = clean_string(stl_obj.name)
365
+ all_geo = []
366
+ if geometry_to_faces:
367
+ for verts, normal in zip(stl_obj.face_vertices, stl_obj.face_normals):
368
+ all_geo.append(Face3D(verts, plane=Plane(normal, verts[0])))
369
+ hb_objs = [Face(all_id + '_' + str(uuid.uuid4())[:8], go) for go in all_geo]
370
+ return Model(all_id, orphaned_faces=hb_objs, units=units,
371
+ tolerance=tolerance, angle_tolerance=angle_tolerance)
372
+ else:
373
+ mesh3d = Mesh3D.from_face_vertices(stl_obj.face_vertices)
374
+ hb_objs = [ShadeMesh(all_id, mesh3d)]
375
+ return Model(all_id, shade_meshes=hb_objs, units=units,
376
+ tolerance=tolerance, angle_tolerance=angle_tolerance)
377
+
378
+ @classmethod
379
+ def from_sync(cls, base_model, other_model, sync_instructions):
380
+ """Initialize a Model from two models and instructions for syncing them.
381
+
382
+ The SyncInstructions dictionary schema is essentially a variant of the
383
+ ComparisonReport schema that can be obtained by calling
384
+ base_model.comparison_report(other_model). The main difference is
385
+ that the XXX_changed properties should be replaced with update_XXX properties
386
+ for whether the change from the other_model should be accepted into
387
+ the new model or rejected from it.
388
+
389
+ Args:
390
+ base_model: An base Honeybee Model that forms the base of
391
+ the new model to be created.
392
+ other_model: An other Honeybee Model that contains changes to
393
+ the base model to be merged into the base_model.
394
+ sync_instructions: A dictionary of SyncInstructions that states which
395
+ changes from the other_model should be accepted or rejected
396
+ when building a new Model from the base_model.
397
+ """
398
+ # make sure the unit systems of the two models align
399
+ if base_model.units != other_model.units:
400
+ other_model = other_model.duplicate()
401
+ other_model.convert_to_units(base_model.units)
402
+ # set up dictionaries of objects and lists of changes
403
+ exist_dict = base_model.top_level_dict
404
+ other_dict = other_model.top_level_dict
405
+ add_dict = {
406
+ 'Room': [], 'Face': [], 'Aperture': [], 'Door': [],
407
+ 'Shade': [], 'ShadeMesh': []
408
+ }
409
+ del_dict = {
410
+ 'Room': [], 'Face': [], 'Aperture': [], 'Door': [],
411
+ 'Shade': [], 'ShadeMesh': []
412
+ }
413
+ # loop through the changed objects and record changes
414
+ if 'changed_objects' in sync_instructions:
415
+ for change in sync_instructions['changed_objects']:
416
+ ex_obj = exist_dict[change['element_id']]
417
+ up_obj = other_dict[change['element_id']]
418
+ base_obj = up_obj if 'update_geometry' in change \
419
+ and change['update_geometry'] else ex_obj
420
+ base_obj.properties._update_by_sync(
421
+ change, ex_obj.properties, up_obj.properties)
422
+ del_dict[change['element_type']].append(change['element_id'])
423
+ add_dict[change['element_type']].append(base_obj)
424
+ # loop through deleted objects and record changes
425
+ if 'deleted_objects' in sync_instructions:
426
+ for change in sync_instructions['deleted_objects']:
427
+ del_dict[change['element_type']].append(change['element_id'])
428
+ # loop through added objects and record changes
429
+ if 'added_objects' in sync_instructions:
430
+ for change in sync_instructions['added_objects']:
431
+ up_obj = other_dict[change['element_id']]
432
+ add_dict[change['element_type']].append(up_obj)
433
+ # duplicate the base model and make changes to it
434
+ new_model = base_model.duplicate()
435
+ new_model.remove_rooms(del_dict['Room'])
436
+ new_model.remove_faces(del_dict['Face'])
437
+ new_model.remove_apertures(del_dict['Aperture'])
438
+ new_model.remove_doors(del_dict['Door'])
439
+ new_model.remove_shades(del_dict['Shade'])
440
+ new_model.remove_shade_meshes(del_dict['ShadeMesh'])
441
+ new_model.add_rooms(add_dict['Room'])
442
+ new_model.add_faces(add_dict['Face'])
443
+ new_model.add_apertures(add_dict['Aperture'])
444
+ new_model.add_doors(add_dict['Door'])
445
+ new_model.add_shades(add_dict['Shade'])
446
+ new_model.add_shade_meshes(add_dict['ShadeMesh'])
447
+ return new_model
448
+
449
+ @classmethod
450
+ def from_sync_files(
451
+ cls, base_model_file, other_model_file, sync_instructions_file):
452
+ """Initialize a Model from two model files and instructions for syncing them.
453
+
454
+ Args:
455
+ base_model_file: An base Honeybee Model (as HBJSON or HBPkl)
456
+ that forms the base of the new model to be created.
457
+ other_model_file: An other Honeybee Model (as HBJSON or HBPkl)
458
+ that contains changes to the base model to be merged into
459
+ the base_model.
460
+ sync_instructions: A JSON of SyncInstructions that states which
461
+ changes from the other_model should be accepted or rejected
462
+ when building a new Model from the base_model. The SyncInstructions
463
+ schema is essentially a variant of the ComparisonReport schema
464
+ that can be obtained by calling base_model.comparison_report(
465
+ other_model). The main difference is that the XXX_changed
466
+ properties should be replaced with update_XXX properties for
467
+ whether the change from the other_model should be accepted into
468
+ the new model or rejected from it.
469
+ """
470
+ base_model = cls.from_file(base_model_file)
471
+ other_model = cls.from_file(other_model_file)
472
+ assert os.path.isfile(sync_instructions_file), \
473
+ 'Failed to find %s' % sync_instructions_file
474
+ if sys.version_info < (3, 0):
475
+ with open(sync_instructions_file) as inf:
476
+ sync_instructions = json.load(inf)
477
+ else:
478
+ with open(sync_instructions_file, encoding='utf-8') as inf:
479
+ sync_instructions = json.load(inf)
480
+ return cls.from_sync(base_model, other_model, sync_instructions)
481
+
482
+ @classmethod
483
+ def from_objects(cls, identifier, objects, units='Meters',
484
+ tolerance=None, angle_tolerance=1.0):
485
+ """Initialize a Model from a list of any type of honeybee-core geometry objects.
486
+
487
+ Args:
488
+ identifier: Text string for a unique Model ID. Must be < 100 characters and
489
+ not contain any spaces or special characters.
490
+ objects: A list of honeybee Rooms, Faces, Shades, ShadeMEshes,
491
+ Apertures and Doors.
492
+ units: Text for the units system in which the model geometry
493
+ exists. Default: 'Meters'. Choose from the following:
494
+
495
+ * Meters
496
+ * Millimeters
497
+ * Feet
498
+ * Inches
499
+ * Centimeters
500
+
501
+ tolerance: The maximum difference between x, y, and z values at which
502
+ vertices are considered equivalent. Zero indicates that no tolerance
503
+ checks should be performed. None indicates that the tolerance will be
504
+ set based on the units above, with the tolerance consistently being
505
+ between 1 cm and 1 mm (roughly the tolerance implicit in the OpenStudio
506
+ SDK and EnergyPlus). (Default: None).
507
+ angle_tolerance: The max angle difference in degrees that vertices
508
+ are allowed to differ from one another in order to consider them
509
+ colinear. Zero indicates that no angle tolerance checks should be
510
+ performed. (Default: 1.0).
511
+ """
512
+ rooms = []
513
+ faces = []
514
+ shades = []
515
+ shade_meshes = []
516
+ apertures = []
517
+ doors = []
518
+ for obj in objects:
519
+ if isinstance(obj, Room):
520
+ rooms.append(obj)
521
+ elif isinstance(obj, Face):
522
+ faces.append(obj)
523
+ elif isinstance(obj, Shade):
524
+ shades.append(obj)
525
+ elif isinstance(obj, ShadeMesh):
526
+ shade_meshes.append(obj)
527
+ elif isinstance(obj, Aperture):
528
+ apertures.append(obj)
529
+ elif isinstance(obj, Door):
530
+ doors.append(obj)
531
+ else:
532
+ raise TypeError('Expected Room, Face, Shade, Aperture or Door '
533
+ 'for Model. Got {}'.format(type(obj)))
534
+
535
+ return cls(identifier, rooms, faces, shades, apertures, doors, shade_meshes,
536
+ units, tolerance, angle_tolerance)
537
+
538
+ @classmethod
539
+ def from_shoe_box(
540
+ cls, width, depth, height, orientation_angle=0, window_ratio=0,
541
+ adiabatic=True, units='Meters', tolerance=None, angle_tolerance=1.0):
542
+ """Create a model with a single shoe box Room.
543
+
544
+ Args:
545
+ width: Number for the width of the box (in the X direction).
546
+ depth: Number for the depth of the box (in the Y direction).
547
+ height: Number for the height of the box (in the Z direction).
548
+ orientation_angle: A number between 0 and 360 for the clockwise
549
+ orientation of the box in degrees. (0=North, 90=East, 180=South,
550
+ 270=West). (Default: 0).
551
+ window_ratio: A number between 0 and 1 (but not equal to 1) for the ratio
552
+ between aperture area and area of the face pointing towards the
553
+ orientation-angle. Using 0 will generate no windows. (Default: 0).
554
+ adiabatic: Boolean to note whether the faces that are not in the direction
555
+ of the orientation-angle are adiabatic or outdoors. (Default: True)
556
+ units: Text for the units system in which the model geometry
557
+ exists. (Default: 'Meters').
558
+ tolerance: The maximum difference between x, y, and z values at which
559
+ vertices are considered equivalent. Zero indicates that no tolerance
560
+ checks should be performed. None indicates that the tolerance will be
561
+ set based on the units above, with the tolerance consistently being
562
+ between 1 cm and 1 mm (roughly the tolerance implicit in the OpenStudio
563
+ SDK and EnergyPlus). (Default: None).
564
+ angle_tolerance: The max angle difference in degrees that vertices
565
+ are allowed to differ from one another in order to consider them
566
+ colinear. Zero indicates that no angle tolerance checks should be
567
+ performed. (Default: 1.0).
568
+ """
569
+ # create the box room and assign all of the attributes
570
+ unique_id = str(uuid.uuid4())[:8] # unique identifier for the shoe box
571
+ tolerance = tolerance if tolerance is not None else UNITS_TOLERANCES[units]
572
+ room_id = 'Shoe_Box_Room_{}'.format(unique_id)
573
+ room = Room.from_box(room_id, width, depth, height, orientation_angle)
574
+ room.display_name = 'Shoe_Box_Room'
575
+ front_face = room[1]
576
+ front_face.apertures_by_ratio(window_ratio, tolerance)
577
+ if adiabatic and ad_bc:
578
+ room[0].boundary_condition = ad_bc # make the floor adiabatic
579
+ for face in room[2:]: # make all other face adiabatic
580
+ face.boundary_condition = ad_bc
581
+ # create the model object
582
+ model_id = 'Shoe_Box_Model_{}'.format(unique_id)
583
+ return cls(model_id, [room], units=units, tolerance=tolerance,
584
+ angle_tolerance=angle_tolerance)
585
+
586
+ @classmethod
587
+ def from_rectangle_plan(
588
+ cls, width, length, floor_to_floor_height, perimeter_offset=0, story_count=1,
589
+ orientation_angle=0, outdoor_roof=True, ground_floor=True,
590
+ units='Meters', tolerance=None, angle_tolerance=1.0):
591
+ """Create a model with a rectangular floor plan.
592
+
593
+ Note that the resulting Rooms in the model won't have any windows or solved
594
+ adjacencies. These can be added by using the Model.solve_adjacency method
595
+ and the various Face.apertures_by_XXX methods.
596
+
597
+ Args:
598
+ width: Number for the width of the plan (in the X direction).
599
+ length: Number for the length of the plan (in the Y direction).
600
+ floor_to_floor_height: Number for the height of each floor of the model
601
+ (in the Z direction).
602
+ perimeter_offset: An optional positive number that will be used to offset
603
+ the perimeter to create core/perimeter Rooms. If this value is 0,
604
+ no offset will occur and each floor will have one Room. (Default: 0).
605
+ story_count: An integer for the number of stories to generate. (Default: 1).
606
+ orientation_angle: A number between 0 and 360 for the counterclockwise
607
+ orientation that the width of the box faces. (0=North, 90=East,
608
+ 180=South, 270=West). (Default: 0).
609
+ outdoor_roof: Boolean to note whether the roof faces of the top floor
610
+ should be outdoor or adiabatic. (Default: True).
611
+ ground_floor: Boolean to note whether the floor faces of the bottom
612
+ floor should be ground or adiabatic. (Default: True).
613
+ units: Text for the units system in which the model geometry
614
+ exists. (Default: 'Meters').
615
+ tolerance: The maximum difference between x, y, and z values at which
616
+ vertices are considered equivalent. Zero indicates that no tolerance
617
+ checks should be performed. None indicates that the tolerance will be
618
+ set based on the units above, with the tolerance consistently being
619
+ between 1 cm and 1 mm (roughly the tolerance implicit in the OpenStudio
620
+ SDK and EnergyPlus). (Default: None).
621
+ angle_tolerance: The max angle difference in degrees that vertices
622
+ are allowed to differ from one another in order to consider them
623
+ colinear. Zero indicates that no angle tolerance checks should be
624
+ performed. (Default: 1.0).
625
+ """
626
+ # create the honeybee rooms
627
+ tolerance = tolerance if tolerance is not None else UNITS_TOLERANCES[units]
628
+ unique_id = str(uuid.uuid4())[:8] # unique identifier for the model
629
+ rooms = Room.rooms_from_rectangle_plan(
630
+ width, length, floor_to_floor_height, perimeter_offset, story_count,
631
+ orientation_angle, outdoor_roof, ground_floor, unique_id, tolerance)
632
+ # create the model object
633
+ model_id = 'Rectangle_Plan_Model_{}'.format(unique_id)
634
+ return cls(model_id, rooms, units=units, tolerance=tolerance,
635
+ angle_tolerance=angle_tolerance)
636
+
637
+ @classmethod
638
+ def from_l_shaped_plan(
639
+ cls, width_1, length_1, width_2, length_2, floor_to_floor_height,
640
+ perimeter_offset=0, story_count=1, orientation_angle=0,
641
+ outdoor_roof=True, ground_floor=True,
642
+ units='Meters', tolerance=None, angle_tolerance=1.0):
643
+ """Create a model with an L-shaped floor plan.
644
+
645
+ Note that the resulting Rooms in the model won't have any windows or solved
646
+ adjacencies. These can be added by using the Model.solve_adjacency method
647
+ and the various Face.apertures_by_XXX methods.
648
+
649
+ Args:
650
+ width_1: Number for the width of the lower part of the L segment.
651
+ length_1: Number for the length of the lower part of the L segment, not
652
+ counting the overlap between the upper and lower segments.
653
+ width_2: Number for the width of the upper (left) part of the L segment.
654
+ length_2: Number for the length of the upper (left) part of the L segment,
655
+ not counting the overlap between the upper and lower segments.
656
+ floor_to_floor_height: Number for the height of each floor of the model
657
+ (in the Z direction).
658
+ perimeter_offset: An optional positive number that will be used to offset
659
+ the perimeter to create core/perimeter Rooms. If this value is 0,
660
+ no offset will occur and each floor will have one Room. (Default: 0).
661
+ story_count: An integer for the number of stories to generate. (Default: 1).
662
+ orientation_angle: A number between 0 and 360 for the counterclockwise
663
+ orientation that the width of the box faces. (0=North, 90=East,
664
+ 180=South, 270=West). (Default: 0).
665
+ outdoor_roof: Boolean to note whether the roof faces of the top floor
666
+ should be outdoor or adiabatic. (Default: True).
667
+ ground_floor: Boolean to note whether the floor faces of the bottom
668
+ floor should be ground or adiabatic. (Default: True).
669
+ units: Text for the units system in which the model geometry
670
+ exists. (Default: 'Meters').
671
+ tolerance: The maximum difference between x, y, and z values at which
672
+ vertices are considered equivalent. Zero indicates that no tolerance
673
+ checks should be performed. None indicates that the tolerance will be
674
+ set based on the units above, with the tolerance consistently being
675
+ between 1 cm and 1 mm (roughly the tolerance implicit in the OpenStudio
676
+ SDK and EnergyPlus). (Default: None).
677
+ angle_tolerance: The max angle difference in degrees that vertices
678
+ are allowed to differ from one another in order to consider them
679
+ colinear. Zero indicates that no angle tolerance checks should be
680
+ performed. (Default: 1.0).
681
+ """
682
+ # create the honeybee rooms
683
+ tolerance = tolerance if tolerance is not None else UNITS_TOLERANCES[units]
684
+ unique_id = str(uuid.uuid4())[:8] # unique identifier for the model
685
+ rooms = Room.rooms_from_l_shaped_plan(
686
+ width_1, length_1, width_2, length_2, floor_to_floor_height,
687
+ perimeter_offset, story_count,
688
+ orientation_angle, outdoor_roof, ground_floor, unique_id, tolerance)
689
+ # create the model object
690
+ model_id = 'L_Shaped_Plan_Model_{}'.format(unique_id)
691
+ return cls(model_id, rooms, units=units, tolerance=tolerance,
692
+ angle_tolerance=angle_tolerance)
693
+
694
+ @property
695
+ def units(self):
696
+ """Get or set Text for the units system in which the model geometry exists."""
697
+ return self._units
698
+
699
+ @units.setter
700
+ def units(self, value):
701
+ value = value.title()
702
+ assert value in UNITS, '{} is not supported as a units system. ' \
703
+ 'Choose from the following: {}'.format(value, UNITS)
704
+ self._units = value
705
+
706
+ @property
707
+ def tolerance(self):
708
+ """Get or set a number for the max meaningful difference between x, y, z values.
709
+
710
+ This value should be in the Model's units. Zero indicates cases
711
+ where no tolerance checks should be performed.
712
+ """
713
+ return self._tolerance
714
+
715
+ @tolerance.setter
716
+ def tolerance(self, value):
717
+ self._tolerance = float_positive(value, 'model tolerance') if value is not None \
718
+ else UNITS_TOLERANCES[self.units]
719
+
720
+ @property
721
+ def angle_tolerance(self):
722
+ """Get or set a number for the max meaningful angle difference in degrees.
723
+
724
+ Face3D normal vectors differing by this amount are not considered parallel
725
+ and Face3D segments that differ from 180 by this amount are not considered
726
+ colinear. Zero indicates cases where no angle_tolerance checks should be
727
+ performed.
728
+ """
729
+ return self._angle_tolerance
730
+
731
+ @angle_tolerance.setter
732
+ def angle_tolerance(self, value):
733
+ self._angle_tolerance = float_positive(value, 'model angle_tolerance')
734
+
735
+ @property
736
+ def rooms(self):
737
+ """Get a tuple of all Room objects in the model."""
738
+ return tuple(self._rooms)
739
+
740
+ @rooms.setter
741
+ def rooms(self, value):
742
+ self._rooms = []
743
+ if value is not None:
744
+ for room in value:
745
+ self.add_room(room)
746
+
747
+ @property
748
+ def faces(self):
749
+ """Get a list of all Face objects in the model."""
750
+ child_faces = [face for room in self._rooms for face in room._faces]
751
+ return child_faces + self._orphaned_faces
752
+
753
+ @property
754
+ def apertures(self):
755
+ """Get a list of all Aperture objects in the model."""
756
+ child_apertures = []
757
+ for room in self._rooms:
758
+ for face in room._faces:
759
+ child_apertures.extend(face._apertures)
760
+ for face in self._orphaned_faces:
761
+ child_apertures.extend(face._apertures)
762
+ return child_apertures + self._orphaned_apertures
763
+
764
+ @property
765
+ def doors(self):
766
+ """Get a list of all Door objects in the model."""
767
+ child_doors = []
768
+ for room in self._rooms:
769
+ for face in room._faces:
770
+ child_doors.extend(face._doors)
771
+ for face in self._orphaned_faces:
772
+ child_doors.extend(face._doors)
773
+ return child_doors + self._orphaned_doors
774
+
775
+ @property
776
+ def shades(self):
777
+ """Get a list of all Shade objects in the model."""
778
+ child_shades = []
779
+ for room in self._rooms:
780
+ child_shades.extend(room.shades)
781
+ for face in room.faces:
782
+ child_shades.extend(face.shades)
783
+ for ap in face._apertures:
784
+ child_shades.extend(ap.shades)
785
+ for dr in face._doors:
786
+ child_shades.extend(dr.shades)
787
+ for face in self._orphaned_faces:
788
+ child_shades.extend(face.shades)
789
+ for ap in face._apertures:
790
+ child_shades.extend(ap.shades)
791
+ for dr in face._doors:
792
+ child_shades.extend(dr.shades)
793
+ for ap in self._orphaned_apertures:
794
+ child_shades.extend(ap.shades)
795
+ for dr in self._orphaned_doors:
796
+ child_shades.extend(dr.shades)
797
+ return child_shades + self._orphaned_shades
798
+
799
+ @property
800
+ def indoor_shades(self):
801
+ """Get a list of all indoor Shade objects in the model."""
802
+ child_shades = []
803
+ for room in self._rooms:
804
+ child_shades.extend(room._indoor_shades)
805
+ for face in room.faces:
806
+ child_shades.extend(face._indoor_shades)
807
+ for ap in face._apertures:
808
+ child_shades.extend(ap._indoor_shades)
809
+ for dr in face._doors:
810
+ child_shades.extend(dr._indoor_shades)
811
+ for face in self._orphaned_faces:
812
+ child_shades.extend(face._indoor_shades)
813
+ for ap in face._apertures:
814
+ child_shades.extend(ap._indoor_shades)
815
+ for dr in face._doors:
816
+ child_shades.extend(dr._indoor_shades)
817
+ for ap in self._orphaned_apertures:
818
+ child_shades.extend(ap._indoor_shades)
819
+ for dr in self._orphaned_doors:
820
+ child_shades.extend(dr._indoor_shades)
821
+ return child_shades
822
+
823
+ @property
824
+ def outdoor_shades(self):
825
+ """Get a list of all outdoor Shade objects in the model.
826
+
827
+ This includes all of the orphaned_shades.
828
+ """
829
+ child_shades = []
830
+ for room in self._rooms:
831
+ child_shades.extend(room._outdoor_shades)
832
+ for face in room.faces:
833
+ child_shades.extend(face._outdoor_shades)
834
+ for ap in face._apertures:
835
+ child_shades.extend(ap._outdoor_shades)
836
+ for dr in face._doors:
837
+ child_shades.extend(dr._outdoor_shades)
838
+ for face in self._orphaned_faces:
839
+ child_shades.extend(face._outdoor_shades)
840
+ for ap in face._apertures:
841
+ child_shades.extend(ap._outdoor_shades)
842
+ for dr in face._doors:
843
+ child_shades.extend(dr._outdoor_shades)
844
+ for ap in self._orphaned_apertures:
845
+ child_shades.extend(ap._outdoor_shades)
846
+ for dr in self._orphaned_doors:
847
+ child_shades.extend(dr._outdoor_shades)
848
+ return child_shades + self._orphaned_shades
849
+
850
+ @property
851
+ def shade_meshes(self):
852
+ """Get or set a tuple of all ShadeMesh objects in the model."""
853
+ return tuple(self._shade_meshes)
854
+
855
+ @shade_meshes.setter
856
+ def shade_meshes(self, value):
857
+ self._shade_meshes = []
858
+ if value is not None:
859
+ for shd_m in value:
860
+ self.add_shade_mesh(shd_m)
861
+
862
+ @property
863
+ def grouped_shades(self):
864
+ """Get a list of lists where each sub-list contains Shades and/or ShadeMeshes
865
+ with the same display_name.
866
+
867
+ Assigning a common display_name to Shades and ShadeMeshes is the officially
868
+ recommended way to group these objects for export to platforms that
869
+ support shade groups. In this case, it is customary to use the common
870
+ display_name as the name of the shade group.
871
+
872
+ Note that, if no display_names have been assigned to the Shades and
873
+ ShadeMeshes, the unique object identifier is used, meaning each sublist
874
+ returned here should have only one item in it.
875
+ """
876
+ all_shades = self.shades + self._shade_meshes
877
+ group_dict = {}
878
+ for shade in all_shades:
879
+ try:
880
+ group_dict[shade.display_name].append(shade)
881
+ except KeyError:
882
+ group_dict[shade.display_name] = [shade]
883
+ return list(group_dict.values())
884
+
885
+ @property
886
+ def orphaned_faces(self):
887
+ """Get or set a tuple of all Face objects without parent Rooms in the model."""
888
+ return tuple(self._orphaned_faces)
889
+
890
+ @orphaned_faces.setter
891
+ def orphaned_faces(self, value):
892
+ self._orphaned_faces = []
893
+ if value is not None:
894
+ for face in value:
895
+ self.add_face(face)
896
+
897
+ @property
898
+ def orphaned_apertures(self):
899
+ """Get or set a tuple of all Aperture objects without parent Faces in the model.
900
+ """
901
+ return tuple(self._orphaned_apertures)
902
+
903
+ @orphaned_apertures.setter
904
+ def orphaned_apertures(self, value):
905
+ self._orphaned_apertures = []
906
+ if value is not None:
907
+ for ap in value:
908
+ self.add_aperture(ap)
909
+
910
+ @property
911
+ def orphaned_doors(self):
912
+ """Get or set a tuple of all Door objects without parent Faces in the model."""
913
+ return tuple(self._orphaned_doors)
914
+
915
+ @orphaned_doors.setter
916
+ def orphaned_doors(self, value):
917
+ self._orphaned_doors = []
918
+ if value is not None:
919
+ for dr in value:
920
+ self.add_door(dr)
921
+
922
+ @property
923
+ def orphaned_shades(self):
924
+ """Get or set a tuple of all Shade objects without parent Rooms in the model."""
925
+ return tuple(self._orphaned_shades)
926
+
927
+ @orphaned_shades.setter
928
+ def orphaned_shades(self, value):
929
+ self._orphaned_shades = []
930
+ if value is not None:
931
+ for shd in value:
932
+ self.add_shade(shd)
933
+
934
+ @property
935
+ def stories(self):
936
+ """Get a list of text for each unique story identifier in the Model.
937
+
938
+ Note that this will be an empty list if the model has to rooms.
939
+ """
940
+ _stories = set()
941
+ for room in self._rooms:
942
+ if room.story is not None:
943
+ _stories.add(room.story)
944
+ return list(_stories)
945
+
946
+ @property
947
+ def volume(self):
948
+ """Get the combined volume of all rooms in the Model.
949
+
950
+ Note that this property accounts for the room multipliers. Also note that,
951
+ if this model's rooms are not closed solids, the value of this property
952
+ will not be accurate.
953
+ """
954
+ return sum([room.volume * room.multiplier for room in self._rooms])
955
+
956
+ @property
957
+ def floor_area(self):
958
+ """Get the combined area of all room floor faces in the Model.
959
+
960
+ Note that this property accounts for the room multipliers.
961
+ """
962
+ return sum([room.floor_area * room.multiplier for room in self._rooms
963
+ if not room.exclude_floor_area])
964
+
965
+ @property
966
+ def exposed_area(self):
967
+ """Get the combined area of all room faces with outdoor boundary conditions.
968
+
969
+ Useful for estimating infiltration, often expressed as a flow per unit exposed
970
+ envelope area. Note that this property accounts for the room multipliers.
971
+ """
972
+ return sum([room.exposed_area * room.multiplier for room in self._rooms])
973
+
974
+ @property
975
+ def exterior_wall_area(self):
976
+ """Get the combined area of all exterior walls on the model's rooms.
977
+
978
+ This is NOT the area of the wall's punched_geometry and it includes BOTH
979
+ the area of opaque and transparent parts of the walls. Note that this
980
+ property accounts for the room multipliers.
981
+ """
982
+ return sum([room.exterior_wall_area * room.multiplier for room in self._rooms])
983
+
984
+ @property
985
+ def exterior_roof_area(self):
986
+ """Get the combined area of all exterior roofs on the model's rooms.
987
+
988
+ This is NOT the area of the roof's punched_geometry and it includes BOTH
989
+ the area of opaque and transparent parts of the roofs. Note that this
990
+ property accounts for the room multipliers.
991
+ """
992
+ return sum([room.exterior_roof_area * room.multiplier for room in self._rooms])
993
+
994
+ @property
995
+ def exterior_aperture_area(self):
996
+ """Get the combined area of all exterior apertures on the model's rooms.
997
+
998
+ Note that this property accounts for the room multipliers.
999
+ """
1000
+ return sum([room.exterior_aperture_area * room.multiplier
1001
+ for room in self._rooms])
1002
+
1003
+ @property
1004
+ def exterior_wall_aperture_area(self):
1005
+ """Get the combined area of all apertures on exterior walls of the model's rooms.
1006
+
1007
+ Note that this property accounts for the room multipliers.
1008
+ """
1009
+ return sum([room.exterior_wall_aperture_area * room.multiplier
1010
+ for room in self._rooms])
1011
+
1012
+ @property
1013
+ def exterior_skylight_aperture_area(self):
1014
+ """Get the combined area of all apertures on exterior roofs of the model's rooms.
1015
+
1016
+ Note that this property accounts for the room multipliers.
1017
+ """
1018
+ return sum([room.exterior_skylight_aperture_area * room.multiplier
1019
+ for room in self._rooms])
1020
+
1021
+ @property
1022
+ def min(self):
1023
+ """Get a Point3D for the min bounding box vertex in the XY plane."""
1024
+ return self._calculate_min(self._all_objects())
1025
+
1026
+ @property
1027
+ def max(self):
1028
+ """Get a Point3D for the max bounding box vertex in the XY plane."""
1029
+ return self._calculate_max(self._all_objects())
1030
+
1031
+ @property
1032
+ def roof_to_exterior_edges(self):
1033
+ """Get LineSegment3Ds where roofs meet exterior walls (or floors).
1034
+
1035
+ Note that both the roof Face and the wall/floor Face must be next to one
1036
+ another in the model's outer envelope and have outdoor boundary conditions for
1037
+ the edge to show up in this list.
1038
+ """
1039
+ return self.classified_envelope_edges()[0]
1040
+
1041
+ @property
1042
+ def slab_to_exterior_edges(self):
1043
+ """Get LineSegment3Ds where ground floor slabs meet exterior walls or roofs.
1044
+
1045
+ Note that the floor Face must have a ground boundary condition and the wall or
1046
+ roof Face must have an outdoor boundary condition for the edge between the
1047
+ two Faces to show up in this list.
1048
+ """
1049
+ return self.classified_envelope_edges()[1]
1050
+
1051
+ @property
1052
+ def exposed_floor_to_exterior_wall_edges(self):
1053
+ """Get LineSegment3Ds where exposed floors meet exterior walls.
1054
+
1055
+ Note that both the wall Face and the floor Face must be next to one
1056
+ another in the model's outer envelope and have outdoor boundary conditions for
1057
+ the edge to show up in this list.
1058
+ """
1059
+ return self.classified_envelope_edges()[2]
1060
+
1061
+ @property
1062
+ def exterior_wall_to_wall_edges(self):
1063
+ """Get LineSegment3Ds where exterior walls meet one another.
1064
+
1065
+ Note that both wall Faces must be next to one another in the model's
1066
+ outer envelope and have outdoor boundary conditions for the edge to
1067
+ show up in this list.
1068
+ """
1069
+ return self.classified_envelope_edges()[3]
1070
+
1071
+ @property
1072
+ def roof_ridge_edges(self):
1073
+ """Get a list of LineSegment3D where exterior roofs meet one another.
1074
+
1075
+ Note that both roof Faces must be next to one another in the model's
1076
+ outer envelope and have outdoor boundary conditions for the edge to
1077
+ show up in this list.
1078
+ """
1079
+ return self.classified_envelope_edges()[4]
1080
+
1081
+ @property
1082
+ def exposed_floor_to_floor_edges(self):
1083
+ """Get LineSegment3Ds where exposed floors meet one another.
1084
+
1085
+ Note that both floor Faces must be next to one another in the model's
1086
+ outer envelope and have outdoor boundary conditions for the edge to
1087
+ show up in this list.
1088
+ """
1089
+ return self.classified_envelope_edges()[5]
1090
+
1091
+ @property
1092
+ def underground_edges(self):
1093
+ """Get a list of LineSegment3D where underground Faces meet one another.
1094
+
1095
+ Note that both Faces must be next to one another in the model's outer envelope
1096
+ and have ground boundary conditions for the edge to show up in this list.
1097
+ """
1098
+ return self.classified_envelope_edges()[6]
1099
+
1100
+ @property
1101
+ def exterior_aperture_edges(self):
1102
+ """Get a list of LineSegment3D for the borders around room exterior apertures.
1103
+ """
1104
+ edges = []
1105
+ for room in self.rooms:
1106
+ edges.extend(room.exterior_aperture_edges)
1107
+ return edges
1108
+
1109
+ @property
1110
+ def exterior_door_edges(self):
1111
+ """Get a list of LineSegment3D for the borders around room exterior doors."""
1112
+ edges = []
1113
+ for room in self.rooms:
1114
+ edges.extend(room.exterior_door_edges)
1115
+ return edges
1116
+
1117
+ @property
1118
+ def top_level_dict(self):
1119
+ """Get dictionary of top-level model objects with identifiers as the keys.
1120
+
1121
+ This is useful for matching these objects to others using identifiers.
1122
+ """
1123
+ base = {r.identifier: r for r in self._rooms}
1124
+ for f in self._orphaned_faces:
1125
+ base[f.identifier] = f
1126
+ for a in self._orphaned_apertures:
1127
+ base[a.identifier] = a
1128
+ for d in self._orphaned_doors:
1129
+ base[d.identifier] = d
1130
+ for s in self._orphaned_shades:
1131
+ base[s.identifier] = s
1132
+ for sm in self._shade_meshes:
1133
+ base[sm.identifier] = sm
1134
+ return base
1135
+
1136
+ @property
1137
+ def has_zones(self):
1138
+ """Get a boolean for whether any Rooms in the model have zones assigned."""
1139
+ return any(room._zone is not None for room in self._rooms)
1140
+
1141
+ @property
1142
+ def zone_dict(self):
1143
+ """Get dictionary of Rooms with zone identifiers as the keys.
1144
+
1145
+ This is useful for grouping rooms by their Zone for export.
1146
+ """
1147
+ zones = {}
1148
+ for room in self.rooms:
1149
+ try:
1150
+ zones[room.zone].append(room)
1151
+ except KeyError: # first room to be found in the zone
1152
+ zones[room.zone] = [room]
1153
+ return zones
1154
+
1155
+ def add_model(self, other_model):
1156
+ """Add another Model object to this model."""
1157
+ assert isinstance(other_model, Model), \
1158
+ 'Expected Model. Got {}.'.format(type(other_model))
1159
+ if self.units != other_model.units:
1160
+ other_model.convert_to_units(self.units)
1161
+ for room in other_model._rooms:
1162
+ self._rooms.append(room)
1163
+ for face in other_model._orphaned_faces:
1164
+ self._orphaned_faces.append(face)
1165
+ for shade in other_model._orphaned_shades:
1166
+ self._orphaned_shades.append(shade)
1167
+ for shade_mesh in other_model._shade_meshes:
1168
+ self._shade_meshes.append(shade_mesh)
1169
+ for aperture in other_model._orphaned_apertures:
1170
+ self._orphaned_apertures.append(aperture)
1171
+ for door in other_model._orphaned_doors:
1172
+ self._orphaned_doors.append(door)
1173
+
1174
+ def add_room(self, obj):
1175
+ """Add a Room object to the model."""
1176
+ assert isinstance(obj, Room), 'Expected Room. Got {}.'.format(type(obj))
1177
+ self._rooms.append(obj)
1178
+
1179
+ def add_face(self, obj):
1180
+ """Add an orphaned Face object without a parent to the model."""
1181
+ assert isinstance(obj, Face), 'Expected Face. Got {}.'.format(type(obj))
1182
+ assert not obj.has_parent, 'Face "{}"" has a parent Room. Add the Room to '\
1183
+ 'the model instead of the Face.'.format(obj.display_name)
1184
+ self._orphaned_faces.append(obj)
1185
+
1186
+ def add_aperture(self, obj):
1187
+ """Add an orphaned Aperture object to the model."""
1188
+ assert isinstance(obj, Aperture), 'Expected Aperture. Got {}.'.format(type(obj))
1189
+ assert not obj.has_parent, 'Aperture "{}"" has a parent Face. Add the Face to '\
1190
+ 'the model instead of the Aperture.'.format(obj.display_name)
1191
+ self._orphaned_apertures.append(obj)
1192
+
1193
+ def add_door(self, obj):
1194
+ """Add an orphaned Door object to the model."""
1195
+ assert isinstance(obj, Door), 'Expected Door. Got {}.'.format(type(obj))
1196
+ assert not obj.has_parent, 'Door "{}"" has a parent Face. Add the Face to '\
1197
+ 'the model instead of the Door.'.format(obj.display_name)
1198
+ self._orphaned_doors.append(obj)
1199
+
1200
+ def add_shade(self, obj):
1201
+ """Add an orphaned Shade object to the model, typically representing context."""
1202
+ assert isinstance(obj, Shade), 'Expected Shade. Got {}.'.format(type(obj))
1203
+ assert not obj.has_parent, 'Shade "{}"" has a parent object. Add the object to '\
1204
+ 'the model instead of the Shade.'.format(obj.display_name)
1205
+ self._orphaned_shades.append(obj)
1206
+
1207
+ def add_shade_mesh(self, obj):
1208
+ """Add a ShadeMesh object to the model."""
1209
+ assert isinstance(obj, ShadeMesh), 'Expected ShadeMesh. Got {}.'.format(type(obj))
1210
+ self._shade_meshes.append(obj)
1211
+
1212
+ def remove_rooms(self, room_ids=None):
1213
+ """Remove Rooms from the model.
1214
+
1215
+ Args:
1216
+ room_ids: An optional list of Room identifiers to only remove certain rooms
1217
+ from the model. If None, all Rooms will be removed. (Default: None).
1218
+ """
1219
+ self._rooms = self._remove_by_ids(self.rooms, room_ids)
1220
+
1221
+ def remove_faces(self, face_ids=None):
1222
+ """Remove orphaned Faces from the model.
1223
+
1224
+ Args:
1225
+ face_ids: An optional list of Face identifiers to only remove certain faces
1226
+ from the model. If None, all Faces will be removed. (Default: None).
1227
+ """
1228
+ self._orphaned_faces = self._remove_by_ids(self._orphaned_faces, face_ids)
1229
+
1230
+ def remove_apertures(self, aperture_ids=None):
1231
+ """Remove orphaned Apertures from the model.
1232
+
1233
+ Args:
1234
+ aperture_ids: An optional list of Aperture identifiers to only remove
1235
+ certain apertures from the model. If None, all Apertures will
1236
+ be removed. (Default: None).
1237
+ """
1238
+ self._orphaned_apertures = self._remove_by_ids(
1239
+ self._orphaned_apertures, aperture_ids)
1240
+
1241
+ def remove_doors(self, door_ids=None):
1242
+ """Remove orphaned Doors from the model.
1243
+
1244
+ Args:
1245
+ door_ids: An optional list of Door identifiers to only remove certain doors
1246
+ from the model. If None, all Doors will be removed. (Default: None).
1247
+ """
1248
+ self._orphaned_doors = self._remove_by_ids(self._orphaned_doors, door_ids)
1249
+
1250
+ def remove_shades(self, shade_ids=None):
1251
+ """Remove orphaned Shades from the model.
1252
+
1253
+ Args:
1254
+ shade_ids: An optional list of Shade identifiers to only remove
1255
+ certain shades from the model. If None, all Shades will be
1256
+ removed. (Default: None).
1257
+ """
1258
+ self._orphaned_shades = self._remove_by_ids(self._orphaned_shades, shade_ids)
1259
+
1260
+ def remove_shade_meshes(self, shade_mesh_ids=None):
1261
+ """Remove ShadeMeshes from the model.
1262
+
1263
+ Args:
1264
+ shade_mesh_ids: An optional list of ShadeMesh identifiers to only remove
1265
+ certain shades from the model. If None, all Shades will be
1266
+ removed. (Default: None).
1267
+ """
1268
+ self._shade_meshes = self._remove_by_ids(self._shade_meshes, shade_mesh_ids)
1269
+
1270
+ def remove_assigned_apertures(self):
1271
+ """Remove all Apertures assigned to the model's Faces.
1272
+
1273
+ This includes nested apertures like those assigned to Faces with parent Rooms.
1274
+ """
1275
+ for room in self._rooms:
1276
+ for face in room.faces:
1277
+ face.remove_apertures()
1278
+ for face in self._orphaned_faces:
1279
+ face.remove_apertures()
1280
+
1281
+ def remove_assigned_doors(self):
1282
+ """Remove all Doors assigned to the model's Faces.
1283
+
1284
+ This includes nested doors like those assigned to Faces with parent Rooms.
1285
+ """
1286
+ for room in self._rooms:
1287
+ for face in room.faces:
1288
+ face.remove_doors()
1289
+ for face in self._orphaned_faces:
1290
+ face.remove_doors()
1291
+
1292
+ def remove_assigned_shades(self):
1293
+ """Remove all Shades assigned to the model's Rooms, Faces, Apertures and Doors.
1294
+
1295
+ This includes nested shades like those assigned to Apertures with parent
1296
+ Faces that have parent Rooms.
1297
+ """
1298
+ for room in self._rooms:
1299
+ room.remove_shades()
1300
+ for face in room.faces:
1301
+ face.remove_shades()
1302
+ for ap in face.apertures:
1303
+ ap.remove_shades()
1304
+ for dr in face.doors:
1305
+ dr.remove_shades()
1306
+ for face in self._orphaned_faces:
1307
+ face.remove_shades()
1308
+ for ap in face.apertures:
1309
+ ap.remove_shades()
1310
+ for dr in face.doors:
1311
+ dr.remove_shades()
1312
+ for aperture in self._orphaned_apertures:
1313
+ aperture.remove_shades()
1314
+ for door in self._orphaned_doors:
1315
+ door.remove_shades()
1316
+
1317
+ def remove_all_apertures(self):
1318
+ """Remove all Apertures from the model.
1319
+
1320
+ This includes assigned apertures as well as orphaned apertures.
1321
+ """
1322
+ self.remove_apertures()
1323
+ self.remove_assigned_apertures()
1324
+
1325
+ def remove_all_doors(self):
1326
+ """Remove all Doors from the model.
1327
+
1328
+ This includes assigned doors as well as orphaned doors.
1329
+ """
1330
+ self.remove_doors()
1331
+ self.remove_assigned_doors()
1332
+
1333
+ def remove_all_shades(self):
1334
+ """Remove all Shades from the model.
1335
+
1336
+ This includes assigned shades as well as orphaned shades.
1337
+ """
1338
+ self.remove_shades()
1339
+ self.remove_assigned_shades()
1340
+
1341
+ def add_rooms(self, objs):
1342
+ """Add a list of Room objects to the model."""
1343
+ for obj in objs:
1344
+ self.add_room(obj)
1345
+
1346
+ def add_faces(self, objs):
1347
+ """Add a list of orphaned Face objects to the model."""
1348
+ for obj in objs:
1349
+ self.add_face(obj)
1350
+
1351
+ def add_apertures(self, objs):
1352
+ """Add a list of orphaned Aperture objects to the model."""
1353
+ for obj in objs:
1354
+ self.add_aperture(obj)
1355
+
1356
+ def add_doors(self, objs):
1357
+ """Add a list of orphaned Door objects to the model."""
1358
+ for obj in objs:
1359
+ self.add_door(obj)
1360
+
1361
+ def add_shades(self, objs):
1362
+ """Add a list of orphaned Shade objects to the model."""
1363
+ for obj in objs:
1364
+ self.add_shade(obj)
1365
+
1366
+ def add_shade_meshes(self, objs):
1367
+ """Add a list of ShadeMesh objects to the model."""
1368
+ for obj in objs:
1369
+ self.add_shade_mesh(obj)
1370
+
1371
+ def rooms_by_identifier(self, identifiers):
1372
+ """Get a list of Room objects in the model given the Room identifiers."""
1373
+ rooms, missing_ids = [], []
1374
+ model_rooms = self._rooms
1375
+ for obj_id in identifiers:
1376
+ for room in model_rooms:
1377
+ if room.identifier == obj_id:
1378
+ rooms.append(room)
1379
+ break
1380
+ else:
1381
+ missing_ids.append(obj_id)
1382
+ if len(missing_ids) != 0:
1383
+ all_objs = ' '.join(['"' + rid + '"' for rid in missing_ids])
1384
+ raise ValueError(
1385
+ 'The following Rooms were not found in the model: {}'.format(all_objs)
1386
+ )
1387
+ return rooms
1388
+
1389
+ def faces_by_identifier(self, identifiers):
1390
+ """Get a list of Face objects in the model given the Face identifiers."""
1391
+ faces, missing_ids = [], []
1392
+ model_faces = self.faces
1393
+ for obj_id in identifiers:
1394
+ for face in model_faces:
1395
+ if face.identifier == obj_id:
1396
+ faces.append(face)
1397
+ break
1398
+ else:
1399
+ missing_ids.append(obj_id)
1400
+ if len(missing_ids) != 0:
1401
+ all_objs = ' '.join(['"' + rid + '"' for rid in missing_ids])
1402
+ raise ValueError(
1403
+ 'The following Faces were not found in the model: {}'.format(all_objs)
1404
+ )
1405
+ return faces
1406
+
1407
+ def apertures_by_identifier(self, identifiers):
1408
+ """Get a list of Aperture objects in the model given the Aperture identifiers."""
1409
+ apertures, missing_ids = [], []
1410
+ model_apertures = self.apertures
1411
+ for obj_id in identifiers:
1412
+ for aperture in model_apertures:
1413
+ if aperture.identifier == obj_id:
1414
+ apertures.append(aperture)
1415
+ break
1416
+ else:
1417
+ missing_ids.append(obj_id)
1418
+ if len(missing_ids) != 0:
1419
+ all_objs = ' '.join(['"' + rid + '"' for rid in missing_ids])
1420
+ raise ValueError(
1421
+ 'The following Apertures were not found in the model:\n'
1422
+ '{}'.format(all_objs)
1423
+ )
1424
+ return apertures
1425
+
1426
+ def doors_by_identifier(self, identifiers):
1427
+ """Get a list of Door objects in the model given the Door identifiers."""
1428
+ doors, missing_ids = [], []
1429
+ model_doors = self.doors
1430
+ for obj_id in identifiers:
1431
+ for door in model_doors:
1432
+ if door.identifier == obj_id:
1433
+ doors.append(door)
1434
+ break
1435
+ else:
1436
+ missing_ids.append(obj_id)
1437
+ if len(missing_ids) != 0:
1438
+ all_objs = ' '.join(['"' + rid + '"' for rid in missing_ids])
1439
+ raise ValueError(
1440
+ 'The following Doors were not found in the model: {}'.format(all_objs)
1441
+ )
1442
+ return doors
1443
+
1444
+ def shades_by_identifier(self, identifiers):
1445
+ """Get a list of Shade objects in the model given the Shade identifiers."""
1446
+ shades, missing_ids = [], []
1447
+ model_shades = self.shades
1448
+ for obj_id in identifiers:
1449
+ for face in model_shades:
1450
+ if face.identifier == obj_id:
1451
+ shades.append(face)
1452
+ break
1453
+ else:
1454
+ missing_ids.append(obj_id)
1455
+ if len(missing_ids) != 0:
1456
+ all_objs = ' '.join(['"' + rid + '"' for rid in missing_ids])
1457
+ raise ValueError(
1458
+ 'The following Shades were not found in the model: {}'.format(all_objs)
1459
+ )
1460
+ return shades
1461
+
1462
+ def shade_meshes_by_identifier(self, identifiers):
1463
+ """Get a list of ShadeMesh objects in the model given the ShadeMesh identifiers.
1464
+ """
1465
+ shades, missing_ids = [], []
1466
+ model_shades = self._shade_meshes
1467
+ for obj_id in identifiers:
1468
+ for sm in model_shades:
1469
+ if sm.identifier == obj_id:
1470
+ shades.append(sm)
1471
+ break
1472
+ else:
1473
+ missing_ids.append(obj_id)
1474
+ if len(missing_ids) != 0:
1475
+ a_os = ' '.join(['"' + rid + '"' for rid in missing_ids])
1476
+ raise ValueError(
1477
+ 'The following ShadeMeshes were not found in the model: {}'.format(a_os)
1478
+ )
1479
+ return shades
1480
+
1481
+ def classified_envelope_edges(self, tolerance=None, exclude_coplanar=True):
1482
+ """Get classified edges of this Model's envelope based on Faces they adjoin.
1483
+
1484
+ The edges returned by this method will only exist along the exterior
1485
+ envelope of the Model's Rooms as defined by the contiguous volume across
1486
+ all Room interior adjacencies.
1487
+
1488
+ Args:
1489
+ tolerance: The maximum difference between point values for them to be
1490
+ considered equivalent. If None, the Model's tolerance will be used.
1491
+ exclude_coplanar: Boolean to note whether edges falling between two
1492
+ coplanar Faces in the building envelope should be included
1493
+ in the result (False) or excluded from it (True). (Default: True).
1494
+
1495
+ Returns:
1496
+ A tuple with eight items where each item is a list containing
1497
+ LineSegment3D adjoining different types of Faces.
1498
+
1499
+ - roof_to_exterior - Roofs meet exterior walls or floors.
1500
+
1501
+ - slab_to_exterior - Ground floor slabs meet exterior walls or roofs.
1502
+
1503
+ - exposed_floor_to_exterior_wall - Exposed floors meet exterior walls.
1504
+
1505
+ - exterior_wall_to_wall - Exterior walls meet.
1506
+
1507
+ - roof_ridge - Exterior roofs meet.
1508
+
1509
+ - exposed_floor_to_floor - Exposed floors meet.
1510
+
1511
+ - underground - Underground faces meet.
1512
+ """
1513
+ # set up lists to be populated
1514
+ roof_to_exterior, slab_to_exterior, exposed_floor_to_exterior_wall = [], [], []
1515
+ exterior_wall_to_wall, roof_ridge, exposed_floor_to_floor = [], [], []
1516
+ underground, interior = [], []
1517
+ tol = tolerance if tolerance else self.tolerance
1518
+ ang_tol = self.angle_tolerance if exclude_coplanar else None
1519
+
1520
+ # join all of the rooms in the model across their adjacencies
1521
+ merged_rooms = Room.join_adjacent_rooms(self.rooms, tol)
1522
+ for room in merged_rooms:
1523
+ rf_to_ext, slb_to_ext, ex_flr_to_ext, ext_wl_to_wl, rf_ridge, \
1524
+ ex_flr_to_flr, under_gnd, inter = room.classified_edges(tol, ang_tol)
1525
+ roof_to_exterior.extend(rf_to_ext)
1526
+ slab_to_exterior.extend(slb_to_ext)
1527
+ exposed_floor_to_exterior_wall.extend(ex_flr_to_ext)
1528
+ exterior_wall_to_wall.extend(ext_wl_to_wl)
1529
+ roof_ridge.extend(rf_ridge)
1530
+ exposed_floor_to_floor.extend(ex_flr_to_flr)
1531
+ underground.extend(under_gnd)
1532
+ interior.extend(inter)
1533
+
1534
+ # return the classified edges
1535
+ return roof_to_exterior, slab_to_exterior, exposed_floor_to_exterior_wall, \
1536
+ exterior_wall_to_wall, roof_ridge, exposed_floor_to_floor, underground
1537
+
1538
+ def classified_sub_face_edges(
1539
+ self, mullion_thickness=None, tolerance=None, angle_tolerance=None
1540
+ ):
1541
+ """Get classified edges around this Model's Apertures and Doors.
1542
+
1543
+ The edges returned by this method will only exist along the exterior
1544
+ sub-faces.
1545
+
1546
+ Args:
1547
+ mullion_thickness: The maximum difference that apertures can be from
1548
+ one another for the edges to be considered a mullion rather than
1549
+ a frame. If None, the Model's tolerance will be used.
1550
+ tolerance: The maximum difference between point values for them to be
1551
+ considered equivalent. If None, the Model's tolerance will be used.
1552
+ angle_tolerance: The max angle difference in degrees where sub-face
1553
+ normals are no longer considered coplanar. If None, the Model
1554
+ angle_tolerance will be used. (Default: None).
1555
+
1556
+ Returns:
1557
+ A tuple with three items where each item is a list containing
1558
+ LineSegment3D surrounding different sub-face conditions.
1559
+
1560
+ - window_frames - Apertures meet their parent exterior wall or roof.
1561
+
1562
+ - window_mullions - Apertures meet one another.
1563
+
1564
+ - door_frames - Doors meet their parent exterior wall or roof.
1565
+
1566
+ - door_mullions - Doors meet one another.
1567
+ """
1568
+ # set up variables to be used for all sub_faces
1569
+ tol = tolerance if tolerance is not None else self.tolerance
1570
+ a_tol = math.radians(angle_tolerance) if angle_tolerance is not None else \
1571
+ math.radians(self.angle_tolerance)
1572
+ mul_thick = tol if mullion_thickness is None else mullion_thickness
1573
+
1574
+ # get the edges of the sub_faces
1575
+ window_frames, window_mullions = \
1576
+ self._classify_mullions(self.apertures, mul_thick, tol, a_tol)
1577
+ door_frames, door_mullions = \
1578
+ self._classify_mullions(self.doors, mul_thick, tol, a_tol)
1579
+ return window_frames, window_mullions, door_frames, door_mullions
1580
+
1581
+ @staticmethod
1582
+ def _classify_mullions(sub_faces, mul_thick, tol, a_tol):
1583
+ """Organize subface edges depending on whether they are frames or mullions."""
1584
+ sub_face_frames, sub_face_mullions = [], []
1585
+ # group the apertures by the plane in which they exist
1586
+ coplanar_dict = {}
1587
+ sub_faces = [sf for sf in sub_faces
1588
+ if isinstance(sf.boundary_condition, Outdoors)]
1589
+ if len(sub_faces) != 0:
1590
+ coplanar_dict = {sub_faces[0].geometry.plane: [sub_faces[0]]}
1591
+ for sf in sub_faces[1:]:
1592
+ for pln, f_list in coplanar_dict.items():
1593
+ if sf.geometry.plane.is_coplanar_tolerance(pln, tol, a_tol):
1594
+ f_list.append(sf)
1595
+ break
1596
+ else: # the first face with this type of plane
1597
+ coplanar_dict[sf.geometry.plane] = [sf]
1598
+
1599
+ # for each group, intersect their edges and extract edges from a Polyface3D
1600
+ for plane, sfs in coplanar_dict.items():
1601
+ # intersect edges that are close enough to one another within thickness
1602
+ polygons = []
1603
+ for sf in sfs:
1604
+ pts_2d = [plane.xyz_to_xy(pt) for pt in sf.geometry.boundary]
1605
+ try:
1606
+ poly = Polygon2D(pts_2d).remove_colinear_vertices(mul_thick)
1607
+ polygons.append(poly)
1608
+ except AssertionError:
1609
+ pass # too small of a geometry
1610
+ polygons = Polygon2D.intersect_polygon_segments(polygons, mul_thick)
1611
+ faces = []
1612
+ for poly in polygons:
1613
+ faces.append(Face3D([plane.xy_to_xyz(pt) for pt in poly], plane))
1614
+ # create a joined Polyface3D and classify the edges
1615
+ ap_polyface = Polyface3D.from_faces(faces, mul_thick)
1616
+ sub_face_frames.extend(ap_polyface.naked_edges)
1617
+ sub_face_mullions.extend(ap_polyface.internal_edges)
1618
+
1619
+ return sub_face_frames, sub_face_mullions
1620
+
1621
+ def rooms_relevant_to_edge(self, edge, mullion_thickness=None, tolerance=None):
1622
+ """Get a list of Rooms in the model that are relevant to a given edge.
1623
+
1624
+ This is useful for grouping instances of different edges obtained from
1625
+ the classified edge methods.
1626
+
1627
+ Args:
1628
+ edge: A ladybug-geometry LineSegment3D or Polyline3D for an edge to
1629
+ be evaluated against the Model rooms.
1630
+ mullion_thickness: The maximum difference that apertures can be from
1631
+ the input edge for it to be associated with a given model room.
1632
+ If None, the input edge will only be checked against the model's
1633
+ room Faces and not the Apertures or Doors.
1634
+ tolerance: The maximum difference between point values for them to be
1635
+ considered equivalent. If None, the Model's tolerance will be used.
1636
+
1637
+ Returns:
1638
+ A list of Honeybee Rooms in the model that are relevant to the input
1639
+ edge, either touching a room Face edge within the tolerance or
1640
+ touching an Aperture or Door edge within the mullion_thickness.
1641
+ """
1642
+ tol = tolerance if tolerance is not None else self.tolerance
1643
+ # loop through the rooms and evaluate the edge in terms of it
1644
+ rel_rooms = {}
1645
+ for room in self.rooms:
1646
+ if overlapping_bounding_boxes(room.geometry, edge, tol):
1647
+ for pt in room.geometry.vertices:
1648
+ if edge.distance_to_point(pt) < tol:
1649
+ rel_rooms[room.identifier] = room
1650
+ break
1651
+ else: # if a mullion thickness is specified
1652
+ if mullion_thickness is not None:
1653
+ for sf in room.sub_faces:
1654
+ if overlapping_bounding_boxes(sf.geometry, edge, tol):
1655
+ for sf_pt in sf.geometry.vertices:
1656
+ if edge.distance_to_point(sf_pt) < tol:
1657
+ rel_rooms[room.identifier] = room
1658
+ break
1659
+ return list(rel_rooms.values())
1660
+
1661
+ def add_prefix(self, prefix):
1662
+ """Change the identifier of this object and child objects by inserting a prefix.
1663
+
1664
+ This is particularly useful in workflows where you duplicate and edit
1665
+ a starting object and then want to combine it with the original object
1666
+ since all objects within a Model must have unique identifiers.
1667
+
1668
+ Args:
1669
+ prefix: Text that will be inserted at the start of this object's
1670
+ (and child objects') identifier and display_name. It is recommended
1671
+ that this prefix be short to avoid maxing out the 100 allowable
1672
+ characters for honeybee identifiers.
1673
+ """
1674
+ for room in self._rooms:
1675
+ room.add_prefix(prefix)
1676
+ for face in self._orphaned_faces:
1677
+ face.add_prefix(prefix)
1678
+ for aperture in self._orphaned_apertures:
1679
+ aperture.add_prefix(prefix)
1680
+ for door in self._orphaned_doors:
1681
+ door.add_prefix(prefix)
1682
+ for shade in self._orphaned_shades:
1683
+ shade.add_prefix(prefix)
1684
+ for shade_mesh in self._shade_meshes:
1685
+ shade_mesh.add_prefix(prefix)
1686
+
1687
+ def reset_room_ids(self):
1688
+ """Reset the identifiers of the Model Rooms to be derived from display_names.
1689
+
1690
+ In the event that duplicate Room identifiers are found, an integer will
1691
+ be automatically appended to the new Room ID to make it unique.
1692
+
1693
+ Returns:
1694
+ A dictionary that relates the old identifiers (keys) to the new
1695
+ identifiers (values). This can be used to map between old and new
1696
+ objects and update things like Surface boundary conditions.
1697
+ """
1698
+ room_dict, room_map = {}, {}
1699
+ for room in self.rooms:
1700
+ new_id = clean_and_number_string(
1701
+ room.display_name, room_dict, 'Room identifier')
1702
+ room_map[room.identifier] = new_id
1703
+ room.identifier = new_id
1704
+ return room_map
1705
+
1706
+ def reset_ids(self, repair_surface_bcs=True):
1707
+ """Reset the identifiers of all Model objects to be derived from display_names.
1708
+
1709
+ In the event that duplicate identifiers are found, an integer will be
1710
+ automatically appended to the new ID to make it unique. This is similar
1711
+ to the routines that automatically assign unique names to OpenStudio SDK objects.
1712
+
1713
+ Args:
1714
+ repair_surface_bcs: A Boolean to note whether all Surface boundary
1715
+ conditions across the model should be updated with the new
1716
+ identifiers that were generated from the display names. (Default: True).
1717
+
1718
+ Returns:
1719
+ A dictionary that relates the old identifiers (keys) to the new
1720
+ identifiers (values). This can be used to map between old and new
1721
+ objects. This dictionary has the following keys that house
1722
+ sub-dictionaries that map between old and new IDs.
1723
+
1724
+ - rooms: dict with old Room IDs as keys and new IDs as values.
1725
+
1726
+ - faces: dict with old Face IDs as keys and new IDs as values.
1727
+
1728
+ - apertures: dict with old Aperture IDs as keys and new IDs as values.
1729
+
1730
+ - doors: dict with old Door IDs as keys and new IDs as values.
1731
+ """
1732
+ # set up dictionaries to hold various pieces of information
1733
+ room_map = self.reset_room_ids()
1734
+ face_dict, ap_dict, dr_dict, shd_dict, sm_dict = {}, {}, {}, {}, {}
1735
+ face_map, ap_map, dr_map = {}, {}, {}
1736
+ # loop through the objects and change their identifiers
1737
+ for face in self.faces:
1738
+ new_id = clean_and_number_string(
1739
+ face.display_name, face_dict, 'Face identifier')
1740
+ face_map[face.identifier] = new_id
1741
+ face.identifier = new_id
1742
+ for ap in self.apertures:
1743
+ new_id = clean_and_number_string(
1744
+ ap.display_name, ap_dict, 'Aperture identifier')
1745
+ ap_map[ap.identifier] = new_id
1746
+ ap.identifier = new_id
1747
+ for dr in self.doors:
1748
+ new_id = clean_and_number_string(
1749
+ dr.display_name, dr_dict, 'Door identifier')
1750
+ dr_map[dr.identifier] = new_id
1751
+ dr.identifier = new_id
1752
+ for shade in self.shades:
1753
+ shade.identifier = clean_and_number_string(
1754
+ shade.display_name, shd_dict, 'Shade identifier')
1755
+ for shade_mesh in self.shade_meshes:
1756
+ shade_mesh.identifier = clean_and_number_string(
1757
+ shade_mesh.display_name, sm_dict, 'ShadeMesh identifier')
1758
+ # reset all of the Surface boundary conditions if requested
1759
+ if repair_surface_bcs:
1760
+ self._repair_surface_bcs(room_map, face_map, ap_map, dr_map)
1761
+ # return a dictionary that maps between old and new IDs
1762
+ return {
1763
+ 'rooms': room_map,
1764
+ 'faces': face_map,
1765
+ 'apertures': ap_map,
1766
+ 'doors': dr_map
1767
+ }
1768
+
1769
+ def reset_ids_to_integers(self, start_integer=0, repair_surface_bcs=True):
1770
+ """Reset the identifiers of all Model geometry objects to be a unique integer.
1771
+
1772
+ Integers are simply incremented from the start_integer, assigning integers
1773
+ first to Rooms, then to Faces, then to Apertures/Doors and lastly to
1774
+ Shades/ShadeMeshes.
1775
+
1776
+ Args:
1777
+ start_integer: The starting integer that will be used to set a lower
1778
+ limit on the integers assigned to the geometry elements.
1779
+ repair_surface_bcs: A Boolean to note whether all Surface boundary
1780
+ conditions across the model should be updated with the new
1781
+ identifiers that were generated from the display names. (Default: True).
1782
+
1783
+ Returns:
1784
+ An integer for the last value assigned to the model geometry objects.
1785
+ This can be used to ensure that any future IDs assigned after running
1786
+ this method do not have IDs that collide with the model objects.
1787
+ """
1788
+ # set up dictionaries to hold various pieces of information
1789
+ room_map, face_map, ap_map, dr_map = {}, {}, {}, {}
1790
+ # loop through the objects and change their identifiers
1791
+ for room in self.rooms:
1792
+ room_map[room.identifier] = str(start_integer)
1793
+ room.identifier = str(start_integer)
1794
+ start_integer += 1
1795
+ for face in self.faces:
1796
+ face_map[face.identifier] = str(start_integer)
1797
+ face.identifier = str(start_integer)
1798
+ start_integer += 1
1799
+ for ap in self.apertures:
1800
+ ap_map[ap.identifier] = str(start_integer)
1801
+ ap.identifier = str(start_integer)
1802
+ start_integer += 1
1803
+ for dr in self.doors:
1804
+ dr_map[dr.identifier] = str(start_integer)
1805
+ dr.identifier = str(start_integer)
1806
+ start_integer += 1
1807
+ for shade in self.shades:
1808
+ shade.identifier = str(start_integer)
1809
+ start_integer += 1
1810
+ for shade_mesh in self.shade_meshes:
1811
+ shade_mesh.identifier = str(start_integer)
1812
+ start_integer += 1
1813
+ # reset all of the Surface boundary conditions if requested
1814
+ if repair_surface_bcs:
1815
+ self._repair_surface_bcs(room_map, face_map, ap_map, dr_map)
1816
+ return start_integer
1817
+
1818
+ def _repair_surface_bcs(self, room_map, face_map, ap_map, dr_map):
1819
+ """Repair Surface boundary conditions across the model using dict maps."""
1820
+ for room in self.rooms:
1821
+ for face in room.faces:
1822
+ if isinstance(face.boundary_condition, Surface):
1823
+ old_objs = face.boundary_condition.boundary_condition_objects
1824
+ try:
1825
+ new_objs = (face_map[old_objs[0]], room_map[old_objs[1]])
1826
+ except KeyError: # missing adjacency
1827
+ try: # see if maybe the room reference is still there
1828
+ new_objs = (old_objs[0], room_map[old_objs[1]])
1829
+ except KeyError: # just let the invalid adjacency pass
1830
+ continue
1831
+ new_bc = Surface(new_objs)
1832
+ face.boundary_condition = new_bc
1833
+ for ap in face.apertures:
1834
+ old_objs = ap.boundary_condition.boundary_condition_objects
1835
+ try:
1836
+ new_objs = (ap_map[old_objs[0]], face_map[old_objs[1]],
1837
+ room_map[old_objs[2]])
1838
+ except KeyError: # missing adjacency
1839
+ new_objs = (old_objs[0], old_objs[1],
1840
+ room_map[old_objs[2]])
1841
+ new_bc = Surface(new_objs, True)
1842
+ ap.boundary_condition = new_bc
1843
+ for dr in face.doors:
1844
+ old_objs = dr.boundary_condition.boundary_condition_objects
1845
+ try:
1846
+ new_objs = (dr_map[old_objs[0]], face_map[old_objs[1]],
1847
+ room_map[old_objs[2]])
1848
+ except KeyError: # missing adjacency
1849
+ new_objs = (old_objs[0], old_objs[1],
1850
+ room_map[old_objs[2]])
1851
+ new_bc = Surface(new_objs, True)
1852
+ dr.boundary_condition = new_bc
1853
+
1854
+ def solve_adjacency(
1855
+ self, merge_coplanar=False, intersect=False, overwrite=False,
1856
+ remove_mismatched_sub_faces=True, air_boundary=False, adiabatic=False,
1857
+ tolerance=None, angle_tolerance=None):
1858
+ """Solve adjacency between Rooms of the Model.
1859
+
1860
+ Args:
1861
+ merge_coplanar: Boolean to note whether coplanar Faces of the Rooms
1862
+ should be merged before proceeding with the rest of the adjacency
1863
+ solving. This is particularly helpful when used with the intersect
1864
+ option since it will ensure the Room geometry is relatively
1865
+ clean before the intersection and adjacency solving
1866
+ occurs. (Default: False).
1867
+ intersect: Boolean to note whether the Faces of the Rooms should be
1868
+ intersected with one another before the adjacencies are
1869
+ solved. (Default: False).
1870
+ overwrite: Boolean to note whether existing Surface boundary
1871
+ conditions should be overwritten. (Default: False).
1872
+ remove_mismatched_sub_faces: Boolean to note whether any mis-matches
1873
+ in sub-faces between adjacent rooms should simply result in
1874
+ the sub-faces being removed rather than raising an
1875
+ exception. (Default: True).
1876
+ air_boundary: Boolean to note whether the wall adjacencies should be
1877
+ of the air boundary face type. (Default: False).
1878
+ adiabatic: Boolean to note whether the adjacencies should be
1879
+ surface or adiabatic. Note that this requires honeybee-energy
1880
+ to be installed in order to have any meaning. (Default: False).
1881
+ tolerance: The maximum difference between point values for them to be
1882
+ considered equivalent. If None, the Model tolerance will be
1883
+ used. (Default: None).
1884
+ angle_tolerance: The max angle difference in degrees where Face normals
1885
+ are no longer considered coplanar. If None, the Model
1886
+ angle_tolerance will be used. (Default: None).
1887
+ """
1888
+ tol = tolerance if tolerance else self.tolerance
1889
+ ang_tol = angle_tolerance if angle_tolerance else self.angle_tolerance
1890
+
1891
+ # merge coplanar faces if requested
1892
+ if merge_coplanar:
1893
+ for room in self.rooms:
1894
+ room.merge_coplanar_faces(tol, ang_tol)
1895
+
1896
+ # intersect adjacencies if requested
1897
+ if intersect:
1898
+ Room.intersect_adjacency(self.rooms, tol, ang_tol)
1899
+
1900
+ # solve adjacency
1901
+ if not overwrite: # only assign new adjacencies
1902
+ adj_info = Room.solve_adjacency(self.rooms, tol, remove_mismatched_sub_faces)
1903
+ else: # overwrite existing Surface BC
1904
+ adj_faces = Room.find_adjacency(self.rooms, tol)
1905
+ if remove_mismatched_sub_faces:
1906
+ for face_pair in adj_faces:
1907
+ try:
1908
+ face_pair[0].set_adjacency(face_pair[1])
1909
+ except AssertionError:
1910
+ face_pair[0].remove_sub_faces()
1911
+ face_pair[1].remove_sub_faces()
1912
+ face_pair[0].set_adjacency(face_pair[1])
1913
+ else:
1914
+ for face_pair in adj_faces:
1915
+ face_pair[0].set_adjacency(face_pair[1])
1916
+ adj_info = {'adjacent_faces': adj_faces}
1917
+
1918
+ # try to assign the air boundary face type
1919
+ if air_boundary:
1920
+ for face_pair in adj_info['adjacent_faces']:
1921
+ if isinstance(face_pair[0].type, Wall):
1922
+ face_pair[0].type = face_types.air_boundary
1923
+ face_pair[1].type = face_types.air_boundary
1924
+
1925
+ # try to assign the adiabatic boundary condition
1926
+ if adiabatic and ad_bc:
1927
+ for face_pair in adj_info['adjacent_faces']:
1928
+ face_pair[0].boundary_condition = ad_bc
1929
+ face_pair[1].boundary_condition = ad_bc
1930
+
1931
+ def move(self, moving_vec):
1932
+ """Move this Model along a vector.
1933
+
1934
+ Args:
1935
+ moving_vec: A ladybug_geometry Vector3D with the direction and distance
1936
+ to move the Model.
1937
+ """
1938
+ for room in self._rooms:
1939
+ room.move(moving_vec)
1940
+ for face in self._orphaned_faces:
1941
+ face.move(moving_vec)
1942
+ for aperture in self._orphaned_apertures:
1943
+ aperture.move(moving_vec)
1944
+ for door in self._orphaned_doors:
1945
+ door.move(moving_vec)
1946
+ for shade in self._orphaned_shades:
1947
+ shade.move(moving_vec)
1948
+ for shade_mesh in self._shade_meshes:
1949
+ shade_mesh.move(moving_vec)
1950
+ self.properties.move(moving_vec)
1951
+
1952
+ def rotate(self, axis, angle, origin):
1953
+ """Rotate this Model by a certain angle around an axis and origin.
1954
+
1955
+ Args:
1956
+ axis: A ladybug_geometry Vector3D axis representing the axis of rotation.
1957
+ angle: An angle for rotation in degrees.
1958
+ origin: A ladybug_geometry Point3D for the origin around which the
1959
+ object will be rotated.
1960
+ """
1961
+ for room in self._rooms:
1962
+ room.rotate(axis, angle, origin)
1963
+ for face in self._orphaned_faces:
1964
+ face.rotate(axis, angle, origin)
1965
+ for aperture in self._orphaned_apertures:
1966
+ aperture.rotate(axis, angle, origin)
1967
+ for door in self._orphaned_doors:
1968
+ door.rotate(axis, angle, origin)
1969
+ for shade in self._orphaned_shades:
1970
+ shade.rotate(axis, angle, origin)
1971
+ for shade_mesh in self._shade_meshes:
1972
+ shade_mesh.rotate(axis, angle, origin)
1973
+ self.properties.rotate(axis, angle, origin)
1974
+
1975
+ def rotate_xy(self, angle, origin):
1976
+ """Rotate this Model counterclockwise in the world XY plane by a certain angle.
1977
+
1978
+ Args:
1979
+ angle: An angle in degrees.
1980
+ origin: A ladybug_geometry Point3D for the origin around which the
1981
+ object will be rotated.
1982
+ """
1983
+ for room in self._rooms:
1984
+ room.rotate_xy(angle, origin)
1985
+ for face in self._orphaned_faces:
1986
+ face.rotate_xy(angle, origin)
1987
+ for aperture in self._orphaned_apertures:
1988
+ aperture.rotate_xy(angle, origin)
1989
+ for door in self._orphaned_doors:
1990
+ door.rotate_xy(angle, origin)
1991
+ for shade in self._orphaned_shades:
1992
+ shade.rotate_xy(angle, origin)
1993
+ for shade_mesh in self._shade_meshes:
1994
+ shade_mesh.rotate_xy(angle, origin)
1995
+ self.properties.rotate_xy(angle, origin)
1996
+
1997
+ def reflect(self, plane):
1998
+ """Reflect this Model across a plane with the input normal vector and origin.
1999
+
2000
+ Args:
2001
+ plane: A ladybug_geometry Plane across which the object will
2002
+ be reflected.
2003
+ """
2004
+ for room in self._rooms:
2005
+ room.reflect(plane)
2006
+ for face in self._orphaned_faces:
2007
+ face.reflect(plane)
2008
+ for aperture in self._orphaned_apertures:
2009
+ aperture.reflect(plane)
2010
+ for door in self._orphaned_doors:
2011
+ door.reflect(plane)
2012
+ for shade in self._orphaned_shades:
2013
+ shade.reflect(plane)
2014
+ for shade_mesh in self._shade_meshes:
2015
+ shade_mesh.reflect(plane)
2016
+ self.properties.reflect(plane)
2017
+
2018
+ def scale(self, factor, origin=None):
2019
+ """Scale this Model by a factor from an origin point.
2020
+
2021
+ Note that using this method does NOT scale the model tolerance and, if
2022
+ it is desired that this tolerance be scaled with the model geometry,
2023
+ it must be scaled separately.
2024
+
2025
+ Args:
2026
+ factor: A number representing how much the object should be scaled.
2027
+ origin: A ladybug_geometry Point3D representing the origin from which
2028
+ to scale. If None, it will be scaled from the World origin (0, 0, 0).
2029
+ """
2030
+ for room in self._rooms:
2031
+ room.scale(factor, origin)
2032
+ for face in self._orphaned_faces:
2033
+ face.scale(factor, origin)
2034
+ for aperture in self._orphaned_apertures:
2035
+ aperture.scale(factor, origin)
2036
+ for door in self._orphaned_doors:
2037
+ door.scale(factor, origin)
2038
+ for shade in self._orphaned_shades:
2039
+ shade.scale(factor, origin)
2040
+ for shade_mesh in self._shade_meshes:
2041
+ shade_mesh.scale(factor, origin)
2042
+ self.properties.scale(factor, origin)
2043
+
2044
+ def generate_exterior_face_grid(
2045
+ self, dimension, offset=0.1, face_type='Wall', punched_geometry=False):
2046
+ """Get a gridded Mesh3D offset from the exterior Faces of this Model.
2047
+
2048
+ This will be None if the Model has no exterior Faces.
2049
+
2050
+ Args:
2051
+ dimension: The dimension of the grid cells as a number.
2052
+ offset: A number for how far to offset the grid from the base face.
2053
+ Positive numbers indicate an offset towards the exterior. (Default
2054
+ is 0.1, which will offset the grid to be 0.1 unit from the faces).
2055
+ face_type: Text to specify the type of face that will be used to
2056
+ generate grids. Note that only Faces with Outdoors boundary
2057
+ conditions will be used, meaning that most Floors will typically
2058
+ be excluded unless they represent the underside of a cantilever.
2059
+ Choose from the following. (Default: Wall).
2060
+
2061
+ * Wall
2062
+ * Roof
2063
+ * Floor
2064
+ * All
2065
+
2066
+ punched_geometry: Boolean to note whether the punched_geometry of the faces
2067
+ should be used (True) with the areas of sub-faces removed from the grid
2068
+ or the full geometry should be used (False). (Default:False).
2069
+ """
2070
+ # select the correct face type based on the input
2071
+ face_t = face_type.title()
2072
+ if face_t == 'Wall':
2073
+ ft = Wall
2074
+ elif face_t in ('Roof', 'Roofceiling'):
2075
+ ft = RoofCeiling
2076
+ elif face_t == 'All':
2077
+ ft = (Wall, RoofCeiling, Floor)
2078
+ elif face_t == 'Floor':
2079
+ ft = Floor
2080
+ else:
2081
+ raise ValueError('Unrecognized face_type "{}".'.format(face_type))
2082
+ face_attr = 'punched_geometry' if punched_geometry else 'geometry'
2083
+ # loop through the faces and generate grids
2084
+ face_grids = []
2085
+ for face in self.faces:
2086
+ if isinstance(face.type, ft) and \
2087
+ isinstance(face.boundary_condition, Outdoors):
2088
+ try:
2089
+ f_geo = getattr(face, face_attr)
2090
+ face_grids.append(
2091
+ f_geo.mesh_grid(dimension, None, offset, False))
2092
+ except AssertionError: # grid tolerance not fine enough
2093
+ pass
2094
+ # join the grids together if there are several ones
2095
+ if len(face_grids) == 1:
2096
+ return face_grids[0]
2097
+ elif len(face_grids) > 1:
2098
+ return Mesh3D.join_meshes(face_grids)
2099
+ return None
2100
+
2101
+ def generate_exterior_aperture_grid(
2102
+ self, dimension, offset=0.1, aperture_type='All'):
2103
+ """Get a gridded Mesh3D offset from the exterior Apertures of this Model.
2104
+
2105
+ Will be None if the Model has no exterior Apertures.
2106
+
2107
+ Args:
2108
+ dimension: The dimension of the grid cells as a number.
2109
+ offset: A number for how far to offset the grid from the base aperture.
2110
+ Positive numbers indicate an offset towards the exterior while
2111
+ negative numbers indicate an offset towards the interior, essentially
2112
+ modeling the value of sun on the building interior. (Default
2113
+ is 0.1, which will offset the grid to be 0.1 unit from the aperture).
2114
+ aperture_type: Text to specify the type of Aperture that will be used to
2115
+ generate grids. Window indicates Apertures in Walls. Choose from
2116
+ the following. (Default: All).
2117
+
2118
+ * Window
2119
+ * Skylight
2120
+ * All
2121
+ """
2122
+ # select the correct face type based on the input
2123
+ ap_t = aperture_type.title()
2124
+ if ap_t == 'Window':
2125
+ ft = Wall
2126
+ elif ap_t == 'Skylight':
2127
+ ft = RoofCeiling
2128
+ elif ap_t == 'All':
2129
+ ft = (Wall, RoofCeiling, Floor)
2130
+ else:
2131
+ raise ValueError('Unrecognized aperture_type "{}".'.format(aperture_type))
2132
+ # loop through the faces and generate grids
2133
+ ap_grids = []
2134
+ for face in self.faces:
2135
+ if isinstance(face.type, ft) and \
2136
+ isinstance(face.boundary_condition, Outdoors):
2137
+ for ap in face.apertures:
2138
+ try:
2139
+ ap_grids.append(
2140
+ ap.geometry.mesh_grid(dimension, None, offset, False))
2141
+ except AssertionError: # grid tolerance not fine enough
2142
+ pass
2143
+ # join the grids together if there are several ones
2144
+ if len(ap_grids) == 1:
2145
+ return ap_grids[0]
2146
+ elif len(ap_grids) > 1:
2147
+ return Mesh3D.join_meshes(ap_grids)
2148
+ return None
2149
+
2150
+ def simplify_apertures(self, resolve_adjacency=True, tolerance=None):
2151
+ """Convert all Apertures in this Model to be a simple window ratio.
2152
+
2153
+ This is useful for studies where faster simulation times are desired and
2154
+ the window ratio is the critical factor driving the results (as opposed
2155
+ to the detailed geometry of the window). Apertures assigned to concave
2156
+ Faces will not be simplified given that the Face.apertures_by_ratio method
2157
+ likely won't improve the cleanliness of the apertures for such cases.
2158
+
2159
+ Args:
2160
+ resolve_adjacency: Boolean to note whether Room adjacencies should be
2161
+ re-solved after the Apertures have been simplified. Setting this
2162
+ to True should ensure that and interior Apertures that are
2163
+ simplified retain their Surface boundary conditions. If False,
2164
+ all interior Apertures that have been simplified will have an
2165
+ Outdoors boundary condition. (Default: True).
2166
+ tolerance: The maximum difference between point values for them to be
2167
+ considered equivalent. If None, the Model tolerance will be
2168
+ used. (Default: None).
2169
+ """
2170
+ tol = tolerance if tolerance else self.tolerance
2171
+ for room in self._rooms:
2172
+ room.simplify_apertures(tol)
2173
+ if resolve_adjacency:
2174
+ self.solve_adjacency()
2175
+
2176
+ def rectangularize_apertures(
2177
+ self, subdivision_distance=None, max_separation=None, merge_all=False,
2178
+ resolve_adjacency=True, tolerance=None, angle_tolerance=None):
2179
+ """Convert all Apertures on this Room to be rectangular.
2180
+
2181
+ This is useful when exporting to simulation engines that only accept
2182
+ rectangular window geometry. This method will always result ing Rooms where
2183
+ all Apertures are rectangular. However, if the subdivision_distance is not
2184
+ set, some Apertures may extend past the parent Face or may collide with
2185
+ one another.
2186
+
2187
+ Args:
2188
+ subdivision_distance: A number for the resolution at which the
2189
+ non-rectangular Apertures will be subdivided into smaller
2190
+ rectangular units. Specifying a number here ensures that the
2191
+ resulting rectangular Apertures do not extend past the parent
2192
+ Face or collide with one another. If None, all non-rectangular
2193
+ Apertures will be rectangularized by taking the bounding rectangle
2194
+ around the Aperture. (Default: None).
2195
+ max_separation: A number for the maximum distance between non-rectangular
2196
+ Apertures at which point the Apertures will be merged into a single
2197
+ rectangular geometry. This is often helpful when there are several
2198
+ triangular Apertures that together make a rectangle when they are
2199
+ merged across their frames. In such cases, this max_separation
2200
+ should be set to a value that is slightly larger than the window frame.
2201
+ If None, no merging of Apertures will happen before they are
2202
+ converted to rectangles. (Default: None).
2203
+ merge_all: Boolean to note whether all apertures should be merged before
2204
+ they are rectangularized. If False, only non-rectangular apertures
2205
+ will be merged before rectangularization. Note that this argument
2206
+ has no effect when the max_separation is None. (Default: False).
2207
+ resolve_adjacency: Boolean to note whether Room adjacencies should be
2208
+ re-solved after the Apertures have been rectangularized. Setting this
2209
+ to True should ensure that and interior Apertures that are
2210
+ rectangularized retain their Surface boundary conditions. If False,
2211
+ all interior Apertures that have been rectangularized will have an
2212
+ Outdoors boundary condition. (Default: True).
2213
+ tolerance: The maximum difference between point values for them to be
2214
+ considered equivalent. If None, the Model tolerance will be
2215
+ used. (Default: None).
2216
+ angle_tolerance: The max angle in degrees that the corners of the
2217
+ rectangle can differ from a right angle before it is not
2218
+ considered a rectangle. If None, the Model angle_tolerance will be
2219
+ used. (Default: None).
2220
+ """
2221
+ tol = tolerance if tolerance else self.tolerance
2222
+ a_tol = angle_tolerance if angle_tolerance else self.angle_tolerance
2223
+ for room in self._rooms:
2224
+ room.rectangularize_apertures(
2225
+ subdivision_distance, max_separation, merge_all, tol, a_tol)
2226
+ if resolve_adjacency:
2227
+ self.solve_adjacency()
2228
+
2229
+ def wall_apertures_by_ratio(self, ratio, tolerance=None):
2230
+ """Add apertures to all exterior walls given a ratio of aperture to face area.
2231
+
2232
+ Note this method only affects the Models rooms (no orphaned faces) and it
2233
+ removes any existing apertures and doors on the room's exterior walls.
2234
+ This method attempts to generate as few apertures as necessary to meet the ratio.
2235
+
2236
+ Args:
2237
+ ratio: A number between 0 and 1 (but not perfectly equal to 1)
2238
+ for the desired ratio between aperture area and face area.
2239
+ tolerance: The maximum difference between point values for them to be
2240
+ considered a part of a rectangle. This is used in the event that
2241
+ this face is concave and an attempt to subdivide the face into a
2242
+ rectangle is made. It does not affect the ability to produce apertures
2243
+ for convex Faces. If None, the Model tolerance will be
2244
+ used. (Default: None).
2245
+ """
2246
+ tol = tolerance if tolerance else self.tolerance
2247
+ for room in self._rooms:
2248
+ room.wall_apertures_by_ratio(ratio, tol)
2249
+
2250
+ def skylight_apertures_by_ratio(self, ratio, tolerance=None):
2251
+ """Add apertures to all exterior roofs given a ratio of aperture to face area.
2252
+
2253
+ Note this method only affects the Models rooms (no orphaned faces) and
2254
+ removes any existing apertures and overhead doors on the Room's roofs.
2255
+ This method attempts to generate as few apertures as necessary to meet the ratio.
2256
+
2257
+ Args:
2258
+ ratio: A number between 0 and 1 (but not perfectly equal to 1)
2259
+ for the desired ratio between aperture area and face area.
2260
+ tolerance: The maximum difference between point values for them to be
2261
+ considered a part of a rectangle. This is used in the event that
2262
+ this face is concave and an attempt to subdivide the face into a
2263
+ rectangle is made. It does not affect the ability to produce apertures
2264
+ for convex Faces. If None, the Model tolerance will be
2265
+ used. (Default: None).
2266
+ """
2267
+ tol = tolerance if tolerance else self.tolerance
2268
+ for room in self._rooms:
2269
+ room.skylight_apertures_by_ratio(ratio, tol)
2270
+
2271
+ def assign_stories_by_floor_height(self, min_difference=2.0, overwrite=False):
2272
+ """Assign story properties to the rooms of this Model using their floor heights.
2273
+
2274
+ Stories will be named with a standard convention ('Floor1', 'Floor2', etc.).
2275
+
2276
+ Args:
2277
+ min_difference: An float value to denote the minimum difference
2278
+ in floor heights that is considered meaningful. This can be used
2279
+ to ensure rooms like those representing stair landings are grouped
2280
+ with floors. Default: 2.0, which means that any difference in
2281
+ floor heights less than 2.0 will be considered a part of the
2282
+ same story. This assumption is suitable for models in meters.
2283
+ overwrite: If True, all story properties of this model's rooms will
2284
+ be overwritten by this method. If False, this method will only
2285
+ assign stories to Rooms that do not already have a story identifier
2286
+ already assigned to them. (Default: False).
2287
+
2288
+ Returns:
2289
+ A list of the unique story names that were assigned to the input rooms.
2290
+ """
2291
+ if overwrite:
2292
+ for room in self._rooms:
2293
+ room.story = None
2294
+ return Room.stories_by_floor_height(self._rooms, min_difference)
2295
+
2296
+ def split_rooms_through_holes(self, tolerance=None, angle_tolerance=None):
2297
+ """Split any Faces with holes such that they no longer have holes.
2298
+
2299
+ This method is useful for destination engines that cannot support holes
2300
+ either through dedicated hole loops that are separate from the boundary
2301
+ loop or as a single collapsed list of vertices that winds inward to cut
2302
+ out the holes.
2303
+
2304
+ Args:
2305
+ tolerance: The maximum difference between point values for them to be
2306
+ considered equivalent. If None, the Model tolerance will be
2307
+ used. (Default: None).
2308
+ angle_tolerance: The max angle in degrees that the corners of the
2309
+ rectangle can differ from a right angle before it is not
2310
+ considered a rectangle. If None, the Model angle_tolerance will be
2311
+ used. (Default: None).
2312
+
2313
+ Returns:
2314
+ A list containing only the new Faces that were created as part of the
2315
+ splitting process. These new Faces will have as many properties of the
2316
+ original Face assigned to them as possible but they will not have a
2317
+ Surface boundary condition if the original Face had one. Having just
2318
+ the new Faces here can be used in operations like setting new Surface
2319
+ boundary conditions.
2320
+ """
2321
+ tol = tolerance if tolerance else self.tolerance
2322
+ a_tol = angle_tolerance if angle_tolerance else self.angle_tolerance
2323
+ new_faces = []
2324
+ for room in self._rooms:
2325
+ new_faces.extend(room.split_through_holes(tol, a_tol))
2326
+ return new_faces
2327
+
2328
+ def rooms_to_extrusions(self, tolerance=None, angle_tolerance=None):
2329
+ """Convert all Rooms in the model to extruded floor plates with flat roofs.
2330
+
2331
+ Rooms that already extrusions will be left as they are. For non-extrusion
2332
+ rooms, all boundary conditions and windows applied to vertical walls will
2333
+ be preserved and the resulting Room should have a volume that matches the
2334
+ original Room. If adding back apertures to the room extrusion results in
2335
+ these apertures going past the parent wall Face, the windows of the Face
2336
+ will be reduced to a simple window ratio. Any Surface boundary conditions
2337
+ will be converted to Adiabatic (if honeybee-energy is installed) or
2338
+ Outdoors (if not).
2339
+
2340
+ This method is useful for exporting to platforms that cannot model Room
2341
+ geometry beyond simple extrusions. The fact that the resulting room has
2342
+ window areas and volumes that match the original detailed geometry
2343
+ should help ensure the results in these platforms are close to what they
2344
+ would be had the detailed geometry been modeled.
2345
+
2346
+ Args:
2347
+ tolerance: The maximum difference between point values for them to be
2348
+ considered equivalent. If None, the Model tolerance will be
2349
+ used. (Default: None).
2350
+ angle_tolerance: The max angle in degrees that the corners of the
2351
+ rectangle can differ from a right angle before it is not
2352
+ considered a rectangle. If None, the Model angle_tolerance will be
2353
+ used. (Default: None).
2354
+ """
2355
+ tol = tolerance if tolerance else self.tolerance
2356
+ a_tol = angle_tolerance if angle_tolerance else self.angle_tolerance
2357
+ extrusion_rooms = []
2358
+ for room in self._rooms:
2359
+ extrusion_rooms.append(room.to_extrusion(tol, a_tol))
2360
+ self._rooms = extrusion_rooms
2361
+
2362
+ def shade_meshes_to_shades(self):
2363
+ """Convert all ShadeMesh objects on the Model to planar Shades."""
2364
+ new_shades = []
2365
+ for shade_mesh in self.shade_meshes:
2366
+ try:
2367
+ shade_mesh.triangulate_and_remove_degenerate_faces(self.tolerance)
2368
+ new_shades.extend(shade_mesh.to_shades())
2369
+ except AssertionError:
2370
+ pass # completely degenerate ShadeMesh to ignore
2371
+ self._orphaned_shades.extend(new_shades)
2372
+ self._shade_meshes = []
2373
+
2374
+ def convert_to_units(self, units='Meters'):
2375
+ """Convert all of the geometry in this model to certain units.
2376
+
2377
+ This involves scaling the geometry, scaling the Model tolerance, and
2378
+ changing the Model's units property.
2379
+
2380
+ Args:
2381
+ units: Text for the units to which the Model geometry should be
2382
+ converted. Default: Meters. Choose from the following:
2383
+
2384
+ * Meters
2385
+ * Millimeters
2386
+ * Feet
2387
+ * Inches
2388
+ * Centimeters
2389
+ """
2390
+ if self.units != units:
2391
+ scale_fac1 = conversion_factor_to_meters(self.units)
2392
+ scale_fac2 = conversion_factor_to_meters(units)
2393
+ scale_fac = scale_fac1 / scale_fac2
2394
+ self.scale(scale_fac)
2395
+ self.tolerance = self.tolerance * scale_fac
2396
+ self.units = units
2397
+
2398
+ def reset_coordinate_system(self, new_origin=None):
2399
+ """Set the origin of the coordinate system in which the model exists.
2400
+
2401
+ This is useful for resolving cases where the model geometry lies so
2402
+ far from the origin in its current coordinate system that it creates
2403
+ problems. For example, the model geometry might be so high above the
2404
+ origin that EnergyPlus models it in the conditions of the stratosphere.
2405
+ Another possibility is that the float values of the coordinates are so
2406
+ high that floating point tolerance interferes with the proper
2407
+ representation of the model's details.
2408
+
2409
+ Args:
2410
+ new_origin: A Point3D in the model's current coordinate system that
2411
+ will become the origin of the new coordinate system. If unspecified,
2412
+ the minimum of the bounding box around the model geometry will
2413
+ be used. (Default: None).
2414
+ """
2415
+ if new_origin is None:
2416
+ new_origin = self.min
2417
+ # move the geometry using a vector that is the inverse of the origin
2418
+ ref_vec = Vector3D(-new_origin.x, -new_origin.y, -new_origin.z)
2419
+ self.move(ref_vec)
2420
+
2421
+ def rooms_to_orphaned(self):
2422
+ """Convert all Rooms in this Model to orphaned geometry objects.
2423
+
2424
+ This is useful when the energy load balance of Rooms is not important
2425
+ and they are only significant as context shading. Note that this method
2426
+ will effectively discount any geometries with a Surface boundary condition
2427
+ or with an AirBoundary face type.
2428
+ """
2429
+ for room in self._rooms:
2430
+ for face in room._faces:
2431
+ face._parent = None
2432
+ if not isinstance(face.boundary_condition, Surface) and not \
2433
+ isinstance(face.type, AirBoundary):
2434
+ self._orphaned_faces.append(face)
2435
+ self._rooms = []
2436
+
2437
+ def remove_degenerate_geometry(self, tolerance=None):
2438
+ """Remove any degenerate geometry from the model.
2439
+
2440
+ Degenerate geometry refers to any objects that evaluate to less than 3 vertices
2441
+ when duplicate and colinear vertices are removed at the tolerance.
2442
+
2443
+ Args:
2444
+ tolerance: The minimum distance between a vertex and the boundary segments
2445
+ at which point the vertex is considered distinct. If None, the
2446
+ Model's tolerance will be used. (Default: None).
2447
+ """
2448
+ tolerance = self.tolerance if tolerance is None else tolerance
2449
+ adj_dict = {} # dictionary to track adjacent geometries
2450
+ for room in self.rooms:
2451
+ try:
2452
+ r_adj = room.clean_envelope(adj_dict, tolerance=tolerance)
2453
+ adj_dict.update(r_adj)
2454
+ except AssertionError as e: # room removed; likely wrong units
2455
+ error = 'Failed to remove degenerate geometry for Room {}.\n{}'.format(
2456
+ room.full_id, e)
2457
+ raise ValueError(error)
2458
+ self._remove_degenerate_faces(self._orphaned_faces, tolerance)
2459
+ self._remove_degenerate_faces(self._orphaned_apertures, tolerance)
2460
+ self._remove_degenerate_faces(self._orphaned_doors, tolerance)
2461
+ self._remove_degenerate_faces(self._orphaned_shades, tolerance)
2462
+ sm_to_remove = []
2463
+ for i, sm in enumerate(self._shade_meshes):
2464
+ try:
2465
+ sm.triangulate_and_remove_degenerate_faces(tolerance)
2466
+ except AssertionError: # completely degenerate Shade Mesh
2467
+ sm_to_remove.append(i)
2468
+ if len(sm_to_remove) != 0:
2469
+ for ri in reversed(sm_to_remove):
2470
+ self._shade_meshes.pop(ri)
2471
+
2472
+ def triangulate_non_planar_quads(self, tolerance=None):
2473
+ """Triangulate any non-planar orphaned geometry in the model.
2474
+
2475
+ This method will only planarize the orphaned Faces, Apertures, Doors and
2476
+ Shades that are quadrilaterals, which usually has a minimal impact on results.
2477
+ It does not impact the Rooms at all.
2478
+
2479
+ Args:
2480
+ tolerance: The minimum distance from the geometry plane at which the
2481
+ geometry is not considered planar. If None, the Model's tolerance
2482
+ will be used. (Default: None).
2483
+ """
2484
+ tolerance = self.tolerance if tolerance is None else tolerance
2485
+ self._orphaned_apertures = \
2486
+ self._triangulate_quad_faces(self._orphaned_apertures, tolerance)
2487
+ self._orphaned_doors = \
2488
+ self._triangulate_quad_faces(self._orphaned_doors, tolerance)
2489
+ self._orphaned_shades = \
2490
+ self._triangulate_quad_faces(self._orphaned_shades, tolerance)
2491
+
2492
+ def comparison_report(self, other_model, ignore_deleted=False, ignore_added=False):
2493
+ """Get a dictionary outlining the differences between this model and another.
2494
+
2495
+ The resulting dictionary will only report top-level objects that are different
2496
+ between this model and the other. If an object has not changed at all,
2497
+ then it will not show up in the report.
2498
+
2499
+ Changes to geometry are reported separately from changes in metadata
2500
+ (aka. properties) for each of the top level objects.
2501
+
2502
+ If the Model units or tolerance are different between the two models,
2503
+ then the units and tolerance of this model will take precedence and
2504
+ the other_model will be converted to these units and tolerance for
2505
+ geometry comparison.
2506
+
2507
+ Args:
2508
+ other_model: A new Model to which this current model will be compared.
2509
+ ignore_deleted: A boolean to note whether objects that appear in this
2510
+ current model but not in the other model should be reported. It is
2511
+ useful to set this to True when the other model represents only a
2512
+ subset of the current model. (Default: False).
2513
+ ignore_added: A boolean to note whether objects that appear in the other
2514
+ model but not in the current model should be reported. (Default: False).
2515
+
2516
+ Returns:
2517
+ A dictionary of differences between this model and the other model in
2518
+ the format below.
2519
+ """
2520
+ # make sure the unit systems of the two models align
2521
+ tol = self.tolerance
2522
+ if self.units != other_model.units:
2523
+ other_model = other_model.duplicate()
2524
+ other_model.convert_to_units(self.units)
2525
+ # set up lists and dictionaries of objects for comparison
2526
+ compare_dict = {'type': 'ComparisonReport'}
2527
+ self_dict = self.top_level_dict
2528
+ other_dict = other_model.top_level_dict
2529
+ # loop through the new objects and detect changes between them
2530
+ changed, added_objs = [], []
2531
+ for obj_id, new_obj in other_dict.items():
2532
+ try:
2533
+ exist_obj = self_dict[obj_id]
2534
+ change_dict = exist_obj._changed_dict(new_obj, tol)
2535
+ if change_dict is not None:
2536
+ changed.append(change_dict)
2537
+ except KeyError:
2538
+ added_objs.append(new_obj)
2539
+ compare_dict['changed_objects'] = changed
2540
+ # include the added objects in the comparison dictionary
2541
+ if not ignore_added:
2542
+ added = []
2543
+ for new_obj in added_objs:
2544
+ added.append(new_obj._base_report_dict('AddedObject'))
2545
+ compare_dict['added_objects'] = added
2546
+ # include the deleted objects in the comparison dictionary
2547
+ if not ignore_deleted:
2548
+ deleted = []
2549
+ for obj_id, exist_obj in self_dict.items():
2550
+ try:
2551
+ new_obj = other_dict[obj_id]
2552
+ except KeyError:
2553
+ deleted.append(exist_obj._base_report_dict('DeletedObject'))
2554
+ compare_dict['deleted_objects'] = deleted
2555
+ return compare_dict
2556
+
2557
+ def check_for_extension(self, extension_name='Generic',
2558
+ raise_exception=True, detailed=False):
2559
+ """Check that the Model is valid for a specific Honeybee extension.
2560
+
2561
+ This process will typically include both honeybee-core checks as well
2562
+ as checks that apply only to the extension. However, any checks that
2563
+ are not relevant for the specified extension will be ignored.
2564
+
2565
+ Note that the specified Honeybee extension must be installed in order
2566
+ for this method to run successfully.
2567
+
2568
+ Args:
2569
+ extension_name: Text for the name of the extension to be checked.
2570
+ The value input here is case-insensitive such that "radiance"
2571
+ and "Radiance" will both result in the model being checked for
2572
+ validity with honeybee-radiance. This value can also be set to
2573
+ "Generic" in order to run checks for all installed extensions.
2574
+ Using "Generic" will run all except the most limiting of
2575
+ checks (eg. DOE2's lack of support for courtyards) with the
2576
+ goal of producing a model that is export-able to multiple
2577
+ engines (albeit with a little extra postprocessing for
2578
+ particularly limited engines). Some common honeybee extension
2579
+ names that can be input here if they are installed include:
2580
+
2581
+ * Radiance
2582
+ * EnergyPlus
2583
+ * OpenStudio
2584
+ * DesignBuilder
2585
+ * DOE2
2586
+ * IES
2587
+ * IDAICE
2588
+
2589
+ Note that EnergyPlus, OpenStudio, and DesignBuilder are all set
2590
+ to run the same checks with honeybee-energy.
2591
+ raise_exception: Boolean to note whether a ValueError should be raised
2592
+ if any errors are found. If False, this method will simply
2593
+ return a text string with all errors that were found. (Default: True).
2594
+ detailed: Boolean for whether the returned object is a detailed list of
2595
+ dicts with error info or a string with a message. (Default: False).
2596
+
2597
+ Returns:
2598
+ A text string with all errors that were found or a list if detailed is True.
2599
+ This string (or list) will be empty if no errors were found.
2600
+ """
2601
+ # set up defaults to ensure the method runs correctly
2602
+ detailed = False if raise_exception else detailed
2603
+ extension_name = extension_name.lower()
2604
+ if extension_name in ('all', 'generic'):
2605
+ all_ext_checks = extension_name == 'all'
2606
+ return self.check_all(raise_exception, detailed, all_ext_checks)
2607
+ energy_extensions = ('energyplus', 'openstudio', 'designbuilder')
2608
+ if extension_name in energy_extensions:
2609
+ extension_name = 'energy'
2610
+ elif extension_name == 'iesve': # TODO: remove when honeybee-iesve is published
2611
+ extension_name = 'ies'
2612
+
2613
+ # check the extension attributes
2614
+ assert self.tolerance != 0, \
2615
+ 'Model must have a non-zero tolerance in order to perform geometry checks.'
2616
+ assert self.angle_tolerance != 0, \
2617
+ 'Model must have a non-zero angle_tolerance to perform geometry checks.'
2618
+ msgs = self._properties._check_for_extension(extension_name, detailed)
2619
+ if detailed:
2620
+ msgs = [m for m in msgs if isinstance(m, list)]
2621
+
2622
+ # output a final report of errors or raise an exception
2623
+ full_msgs = [msg for msg in msgs if msg]
2624
+ if detailed:
2625
+ return [m for msg in full_msgs for m in msg]
2626
+ full_msg = '\n'.join(full_msgs)
2627
+ if raise_exception and len(full_msgs) != 0:
2628
+ raise ValueError(full_msg)
2629
+ return full_msg
2630
+
2631
+ def check_for_error(self, error_code, raise_exception=True, detailed=False):
2632
+ """Check that the Model is valid for a specific validation error code.
2633
+
2634
+ Note that, in order for error codes from a given honeybee extension to
2635
+ run correctly with this method, the specified honeybee extension related
2636
+ to the error code must be installed.
2637
+
2638
+ Args:
2639
+ error_code: Text for the error code for which the check will be performed.
2640
+ This can be the value under the "code" key in the dictionary of
2641
+ the validation error whenever the detailed option is used.
2642
+ raise_exception: Boolean to note whether a ValueError should be raised
2643
+ if any errors are found. If False, this method will simply
2644
+ return a text string with all errors that were found. (Default: True).
2645
+ detailed: Boolean for whether the returned object is a detailed list of
2646
+ dicts with error info or a string with a message. (Default: False).
2647
+
2648
+ Returns:
2649
+ A text string with all errors that were found or a list if detailed is True.
2650
+ This string (or list) will be empty if no errors were found.
2651
+ """
2652
+ # set up defaults to ensure the method runs correctly
2653
+ detailed = False if raise_exception else detailed
2654
+ assert self.tolerance != 0, \
2655
+ 'Model must have a non-zero tolerance in order to perform geometry checks.'
2656
+ assert self.angle_tolerance != 0, \
2657
+ 'Model must have a non-zero angle_tolerance to perform geometry checks.'
2658
+ error_code = str(error_code) # catch the case someone passed an int
2659
+
2660
+ # get the check function to be run from the error code
2661
+ try: # fist see if the check function exists on the cor object
2662
+ check_func = self.ERROR_MAP[error_code]
2663
+ check_func = getattr(self, check_func)
2664
+ except KeyError: # next, see if the check function exists in an extension
2665
+ check_func = self._properties._check_func_from_code(error_code)
2666
+ if check_func is None:
2667
+ err_msg = 'No check function was found matching the error ' \
2668
+ 'code "{}".'.format(error_code)
2669
+ raise ValueError(err_msg)
2670
+
2671
+ # run the check function
2672
+ return check_func(raise_exception=raise_exception, detailed=detailed)
2673
+
2674
+ def check_all(self, raise_exception=True, detailed=False, all_ext_checks=False):
2675
+ """Check all of the aspects of the Model for validation errors.
2676
+
2677
+ This includes basic properties like adjacency checks and all geometry checks.
2678
+ Furthermore, all extension attributes will be checked assuming the extension
2679
+ Model properties have a check_all function. Note that an exception will
2680
+ always be raised if the model has a tolerance of zero as this means that
2681
+ no geometry checks can be performed.
2682
+
2683
+ Args:
2684
+ raise_exception: Boolean to note whether a ValueError should be raised
2685
+ if any Model errors are found. If False, this method will simply
2686
+ return a text string with all errors that were found. (Default: True).
2687
+ detailed: Boolean for whether the returned object is a detailed list of
2688
+ dicts with error info or a string with a message. (Default: False).
2689
+ all_ext_checks: Boolean to note whether every single check that is
2690
+ available for all installed extensions should be run (True) or only
2691
+ generic checks that cover all except the most limiting of
2692
+ cases should be run (False). Examples of checks that are skipped
2693
+ include DOE2's lack of support for courtyards and floor plates
2694
+ with holes. (Default: False).
2695
+
2696
+ Returns:
2697
+ A text string with all errors that were found or a list if detailed is True.
2698
+ This string (or list) will be empty if no errors were found.
2699
+ """
2700
+ # set up defaults to ensure the method runs correctly
2701
+ detailed = False if raise_exception else detailed
2702
+ msgs = []
2703
+ # check that a tolerance has been specified in the model
2704
+ assert self.tolerance != 0, \
2705
+ 'Model must have a non-zero tolerance in order to perform geometry checks.'
2706
+ assert self.angle_tolerance != 0, \
2707
+ 'Model must have a non-zero angle_tolerance to perform geometry checks.'
2708
+ tol = self.tolerance
2709
+ ang_tol = self.angle_tolerance
2710
+ e_tol = parse_distance_string('1cm', self.units)
2711
+
2712
+ # perform checks for duplicate identifiers, which might mess with other checks
2713
+ msgs.append(self.check_all_duplicate_identifiers(False, detailed))
2714
+
2715
+ # perform several checks for the Honeybee schema geometry rules
2716
+ msgs.append(self.check_planar(tol, False, detailed))
2717
+ msgs.append(self.check_self_intersecting(tol, False, detailed))
2718
+ msgs.append(self.check_degenerate_rooms(e_tol, False, detailed))
2719
+
2720
+ # perform geometry checks related to parent-child relationships
2721
+ msgs.append(self.check_sub_faces_valid(tol, ang_tol, False, detailed))
2722
+ msgs.append(self.check_sub_faces_overlapping(tol, False, detailed))
2723
+ msgs.append(self.check_upside_down_faces(ang_tol, False, detailed))
2724
+ msgs.append(self.check_rooms_solid(tol, raise_exception=False, detailed=detailed))
2725
+
2726
+ # perform checks related to adjacency relationships
2727
+ msgs.append(self.check_room_volume_collisions(tol, False, detailed))
2728
+ msgs.append(self.check_missing_adjacencies(False, detailed))
2729
+ msgs.append(self.check_matching_adjacent_areas(tol, False, detailed))
2730
+ msgs.append(self.check_all_air_boundaries_adjacent(False, detailed))
2731
+
2732
+ # check the extension attributes
2733
+ ext_msgs = self._properties._check_all_extension_attr(detailed, all_ext_checks)
2734
+ if detailed:
2735
+ ext_msgs = [m for m in ext_msgs if isinstance(m, list)]
2736
+ msgs.extend(ext_msgs)
2737
+
2738
+ # output a final report of errors or raise an exception
2739
+ full_msgs = [msg for msg in msgs if msg]
2740
+ if detailed:
2741
+ return [m for msg in full_msgs for m in msg]
2742
+ full_msg = '\n'.join(full_msgs)
2743
+ if raise_exception and len(full_msgs) != 0:
2744
+ raise ValueError(full_msg)
2745
+ return full_msg
2746
+
2747
+ def check_all_duplicate_identifiers(self, raise_exception=True, detailed=False):
2748
+ """Check that there are no duplicate identifiers for any geometry objects.
2749
+
2750
+ This includes Rooms, Faces, Apertures, Doors, Shades, and ShadeMeshes.
2751
+
2752
+ Args:
2753
+ raise_exception: Boolean to note whether a ValueError should be raised
2754
+ if any Model errors are found. If False, this method will simply
2755
+ return a text string with all errors that were found. (Default: True).
2756
+ detailed: Boolean for whether the returned object is a detailed list of
2757
+ dicts with error info or a string with a message. (Default: False).
2758
+
2759
+ Returns:
2760
+ A text string with all errors that were found or a list if detailed is True.
2761
+ This string (or list) will be empty if no errors were found.
2762
+ """
2763
+ # set up defaults to ensure the method runs correctly
2764
+ detailed = False if raise_exception else detailed
2765
+ msgs = []
2766
+ # perform checks for duplicate identifiers
2767
+ msgs.append(self.check_duplicate_room_identifiers(False, detailed))
2768
+ msgs.append(self.check_duplicate_face_identifiers(False, detailed))
2769
+ msgs.append(self.check_duplicate_sub_face_identifiers(False, detailed))
2770
+ msgs.append(self.check_duplicate_shade_identifiers(False, detailed))
2771
+ msgs.append(self.check_duplicate_shade_mesh_identifiers(False, detailed))
2772
+ # output a final report of errors or raise an exception
2773
+ full_msgs = [msg for msg in msgs if msg]
2774
+ if detailed:
2775
+ return [m for msg in full_msgs for m in msg]
2776
+ full_msg = '\n'.join(full_msgs)
2777
+ if raise_exception and len(full_msgs) != 0:
2778
+ raise ValueError(full_msg)
2779
+ return full_msg
2780
+
2781
+ def check_duplicate_room_identifiers(self, raise_exception=True, detailed=False):
2782
+ """Check that there are no duplicate Room identifiers in the model.
2783
+
2784
+ Args:
2785
+ raise_exception: Boolean to note whether a ValueError should be raised
2786
+ if duplicate identifiers are found. (Default: True).
2787
+ detailed: Boolean for whether the returned object is a detailed list of
2788
+ dicts with error info or a string with a message. (Default: False).
2789
+
2790
+ Returns:
2791
+ A string with the message or a list with a dictionary if detailed is True.
2792
+ """
2793
+ return check_duplicate_identifiers(
2794
+ self._rooms, raise_exception, 'Room', detailed, '000004', 'Core',
2795
+ 'Duplicate Room Identifier')
2796
+
2797
+ def check_duplicate_face_identifiers(self, raise_exception=True, detailed=False):
2798
+ """Check that there are no duplicate Face identifiers in the model.
2799
+
2800
+ Args:
2801
+ raise_exception: Boolean to note whether a ValueError should be raised
2802
+ if duplicate identifiers are found. (Default: True).
2803
+ detailed: Boolean for whether the returned object is a detailed list of
2804
+ dicts with error info or a string with a message. (Default: False).
2805
+
2806
+ Returns:
2807
+ A string with the message or a list with a dictionary if detailed is True.
2808
+ """
2809
+ return check_duplicate_identifiers_parent(
2810
+ self.faces, raise_exception, 'Face', detailed, '000003', 'Core',
2811
+ 'Duplicate Face Identifier')
2812
+
2813
+ def check_duplicate_sub_face_identifiers(self, raise_exception=True, detailed=False):
2814
+ """Check that there are no duplicate sub-face identifiers in the model.
2815
+
2816
+ Note that both Apertures and Doors are checked for duplicates since the two
2817
+ are counted together by EnergyPlus.
2818
+
2819
+ Args:
2820
+ raise_exception: Boolean to note whether a ValueError should be raised
2821
+ if duplicate identifiers are found. (Default: True).
2822
+ detailed: Boolean for whether the returned object is a detailed list of
2823
+ dicts with error info or a string with a message. (Default: False).
2824
+
2825
+ Returns:
2826
+ A string with the message or a list with a dictionary if detailed is True.
2827
+ """
2828
+ sub_faces = self.apertures + self.doors
2829
+ return check_duplicate_identifiers_parent(
2830
+ sub_faces, raise_exception, 'SubFace', detailed, '000002', 'Core',
2831
+ 'Duplicate Sub-Face Identifier')
2832
+
2833
+ def check_duplicate_shade_identifiers(self, raise_exception=True, detailed=False):
2834
+ """Check that there are no duplicate Shade identifiers in the model.
2835
+
2836
+ Args:
2837
+ raise_exception: Boolean to note whether a ValueError should be raised
2838
+ if duplicate identifiers are found. (Default: True).
2839
+ detailed: Boolean for whether the returned object is a detailed list of
2840
+ dicts with error info or a string with a message. (Default: False).
2841
+
2842
+ Returns:
2843
+ A string with the message or a list with a dictionary if detailed is True.
2844
+ """
2845
+ return check_duplicate_identifiers_parent(
2846
+ self.shades, raise_exception, 'Shade', detailed, '000001', 'Core',
2847
+ 'Duplicate Shade Identifier')
2848
+
2849
+ def check_duplicate_shade_mesh_identifiers(
2850
+ self, raise_exception=True, detailed=False):
2851
+ """Check that there are no duplicate ShadeMesh identifiers in the model.
2852
+
2853
+ Args:
2854
+ raise_exception: Boolean to note whether a ValueError should be raised
2855
+ if duplicate identifiers are found. (Default: True).
2856
+ detailed: Boolean for whether the returned object is a detailed list of
2857
+ dicts with error info or a string with a message. (Default: False).
2858
+
2859
+ Returns:
2860
+ A string with the message or a list with a dictionary if detailed is True.
2861
+ """
2862
+ return check_duplicate_identifiers(
2863
+ self._shade_meshes, raise_exception, 'ShadeMesh', detailed, '000001', 'Core',
2864
+ 'Duplicate ShadeMesh Identifier')
2865
+
2866
+ def check_planar(self, tolerance=None, raise_exception=True, detailed=False):
2867
+ """Check that all of the Model's geometry components are planar.
2868
+
2869
+ This includes all of the Model's Faces, Apertures, Doors and Shades.
2870
+
2871
+ Args:
2872
+ tolerance: The minimum distance between a given vertex and a the
2873
+ object's plane at which the vertex is said to lie in the plane.
2874
+ If None, the Model tolerance will be used. (Default: None).
2875
+ raise_exception: Boolean to note whether an ValueError should be
2876
+ raised if a vertex does not lie within the object's plane.
2877
+ detailed: Boolean for whether the returned object is a detailed list of
2878
+ dicts with error info or a string with a message. (Default: False).
2879
+
2880
+ Returns:
2881
+ A string with the message or a list with a dictionary if detailed is True.
2882
+ """
2883
+ tolerance = self.tolerance if tolerance is None else tolerance
2884
+ detailed = False if raise_exception else detailed
2885
+ msgs = []
2886
+ for face in self.faces:
2887
+ msgs.append(face.check_planar(tolerance, False, detailed))
2888
+ for shd in self.shades:
2889
+ msgs.append(shd.check_planar(tolerance, False, detailed))
2890
+ for ap in self.apertures:
2891
+ msgs.append(ap.check_planar(tolerance, False, detailed))
2892
+ for dr in self.doors:
2893
+ msgs.append(dr.check_planar(tolerance, False, detailed))
2894
+ full_msgs = [msg for msg in msgs if msg]
2895
+ if detailed:
2896
+ return [m for msg in full_msgs for m in msg]
2897
+ full_msg = '\n'.join(full_msgs)
2898
+ if raise_exception and len(full_msgs) != 0:
2899
+ raise ValueError(full_msg)
2900
+ return full_msg
2901
+
2902
+ def check_self_intersecting(self, tolerance=None, raise_exception=True,
2903
+ detailed=False):
2904
+ """Check that no edges of the Model's geometry components self-intersect.
2905
+
2906
+ This includes all of the Model's Faces, Apertures, Doors and Shades.
2907
+
2908
+ Args:
2909
+ tolerance: The minimum difference between the coordinate values of two
2910
+ vertices at which they can be considered equivalent. If None, the
2911
+ Model tolerance will be used. (Default: None).
2912
+ raise_exception: If True, a ValueError will be raised if an object
2913
+ intersects with itself (like a bowtie). (Default: True).
2914
+ detailed: Boolean for whether the returned object is a detailed list of
2915
+ dicts with error info or a string with a message. (Default: False).
2916
+
2917
+ Returns:
2918
+ A string with the message or a list with a dictionary if detailed is True.
2919
+ """
2920
+ tolerance = self.tolerance if tolerance is None else tolerance
2921
+ detailed = False if raise_exception else detailed
2922
+ msgs = []
2923
+ for room in self.rooms:
2924
+ msgs.append(room.check_self_intersecting(tolerance, False, detailed))
2925
+ for face in self.orphaned_faces:
2926
+ msgs.append(face.check_self_intersecting(tolerance, False, detailed))
2927
+ for shd in self.orphaned_shades:
2928
+ msgs.append(shd.check_self_intersecting(tolerance, False, detailed))
2929
+ for ap in self.orphaned_apertures:
2930
+ msgs.append(ap.check_self_intersecting(tolerance, False, detailed))
2931
+ for dr in self.orphaned_doors:
2932
+ msgs.append(dr.check_self_intersecting(tolerance, False, detailed))
2933
+ full_msgs = [msg for msg in msgs if msg]
2934
+ if detailed:
2935
+ return [m for msg in full_msgs for m in msg]
2936
+ full_msg = '\n'.join(full_msgs)
2937
+ if raise_exception and len(full_msgs) != 0:
2938
+ raise ValueError(full_msg)
2939
+ return full_msg
2940
+
2941
+ def check_degenerate_rooms(
2942
+ self, tolerance=None, raise_exception=True, detailed=False):
2943
+ """Check whether there are degenerate Rooms (with zero volume) within the Model.
2944
+
2945
+ Args:
2946
+ tolerance: The maximum difference between x, y, and z values
2947
+ at which face vertices are considered equivalent. If None, the
2948
+ Model tolerance will be used. (Default: None).
2949
+ raise_exception: Boolean to note whether a ValueError should be raised
2950
+ if degenerate Rooms are found. (Default: True).
2951
+ detailed: Boolean for whether the returned object is a detailed list of
2952
+ dicts with error info or a string with a message. (Default: False).
2953
+
2954
+ Returns:
2955
+ A string with the message or a list with a dictionary if detailed is True.
2956
+ """
2957
+ tolerance = self.tolerance if tolerance is None else tolerance
2958
+ detailed = False if raise_exception else detailed
2959
+ msgs = []
2960
+ for room in self._rooms:
2961
+ msg = room.check_degenerate(tolerance, False, detailed)
2962
+ if detailed:
2963
+ msgs.extend(msg)
2964
+ elif msg != '':
2965
+ msgs.append(msg)
2966
+ if detailed:
2967
+ return msgs
2968
+ full_msg = '\n'.join(msgs)
2969
+ if raise_exception and len(msgs) != 0:
2970
+ raise ValueError(full_msg)
2971
+ return full_msg
2972
+
2973
+ def check_sub_faces_valid(self, tolerance=None, angle_tolerance=None,
2974
+ raise_exception=True, detailed=False):
2975
+ """Check that model's sub-faces are co-planar with faces and in their boundary.
2976
+
2977
+ Note this does not check the planarity of the sub-faces themselves, whether
2978
+ they self-intersect, or whether they have a non-zero area.
2979
+
2980
+ Args:
2981
+ tolerance: The minimum difference between the coordinate values of two
2982
+ vertices at which they can be considered equivalent. If None, the
2983
+ Model tolerance will be used. (Default: None).
2984
+ angle_tolerance: The max angle in degrees that the plane normals can
2985
+ differ from one another in order for them to be considered coplanar.
2986
+ If None, the Model angle_tolerance will be used. (Default: None).
2987
+ raise_exception: Boolean to note whether a ValueError should be raised
2988
+ if an sub-face is not valid. (Default: True).
2989
+ detailed: Boolean for whether the returned object is a detailed list of
2990
+ dicts with error info or a string with a message. (Default: False).
2991
+
2992
+ Returns:
2993
+ A string with the message or a list with a dictionary if detailed is True.
2994
+ """
2995
+ tolerance = self.tolerance if tolerance is None else tolerance
2996
+ angle_tolerance = self.angle_tolerance \
2997
+ if angle_tolerance is None else angle_tolerance
2998
+ detailed = False if raise_exception else detailed
2999
+ msgs = []
3000
+ for rm in self._rooms:
3001
+ msg = rm.check_sub_faces_valid(tolerance, angle_tolerance, False, detailed)
3002
+ if detailed:
3003
+ msgs.extend(msg)
3004
+ elif msg != '':
3005
+ msgs.append(msg)
3006
+ for f in self._orphaned_faces:
3007
+ msg = f.check_sub_faces_valid(tolerance, angle_tolerance, False, detailed)
3008
+ if detailed:
3009
+ msgs.extend(msg)
3010
+ elif msg != '':
3011
+ msgs.append(msg)
3012
+ if detailed:
3013
+ return msgs
3014
+ full_msg = '\n'.join(msgs)
3015
+ if raise_exception and len(msgs) != 0:
3016
+ raise ValueError(full_msg)
3017
+ return full_msg
3018
+
3019
+ def check_sub_faces_overlapping(
3020
+ self, tolerance=None, raise_exception=True, detailed=False):
3021
+ """Check that model's sub-faces do not overlap with one another.
3022
+
3023
+ Args:
3024
+ tolerance: The minimum distance that two sub-faces must overlap in order
3025
+ for them to be considered overlapping and invalid. If None, the
3026
+ Model tolerance will be used. (Default: None).
3027
+ raise_exception: Boolean to note whether a ValueError should be raised
3028
+ if a sub-faces overlap with one another.
3029
+ detailed: Boolean for whether the returned object is a detailed list of
3030
+ dicts with error info or a string with a message. (Default: False).
3031
+
3032
+ Returns:
3033
+ A string with the message or a list with a dictionary if detailed is True.
3034
+ """
3035
+ tolerance = self.tolerance if tolerance is None else tolerance
3036
+ detailed = False if raise_exception else detailed
3037
+ msgs = []
3038
+ for rm in self._rooms:
3039
+ msg = rm.check_sub_faces_overlapping(tolerance, False, detailed)
3040
+ if detailed:
3041
+ msgs.extend(msg)
3042
+ elif msg != '':
3043
+ msgs.append(msg)
3044
+ for f in self._orphaned_faces:
3045
+ msg = f.check_sub_faces_overlapping(tolerance, False, detailed)
3046
+ if detailed:
3047
+ msgs.extend(msg)
3048
+ elif msg != '':
3049
+ msgs.append(msg)
3050
+ if detailed:
3051
+ return msgs
3052
+ full_msg = '\n'.join(msgs)
3053
+ if raise_exception and len(msgs) != 0:
3054
+ raise ValueError(full_msg)
3055
+ return full_msg
3056
+
3057
+ def check_upside_down_faces(
3058
+ self, angle_tolerance=None, raise_exception=True, detailed=False):
3059
+ """Check that the Model's Faces have the correct direction for the face type.
3060
+
3061
+ This method will only report Floors that are pointing upwards or RoofCeilings
3062
+ that are pointed downwards. These cases are likely modeling errors and are in
3063
+ danger of having their vertices flipped by EnergyPlus, causing them to
3064
+ not see the sun.
3065
+
3066
+ Args:
3067
+ angle_tolerance: The max angle in degrees that the Face normal can
3068
+ differ from up or down before it is considered a case of a downward
3069
+ pointing RoofCeiling or upward pointing Floor. If None, it
3070
+ will be the model angle tolerance. (Default: None).
3071
+ raise_exception: Boolean to note whether an ValueError should be
3072
+ raised if the Face is an an upward pointing Floor or a downward
3073
+ pointing RoofCeiling.
3074
+ detailed: Boolean for whether the returned object is a detailed list of
3075
+ dicts with error info or a string with a message. (Default: False).
3076
+
3077
+ Returns:
3078
+ A string with the message or a list with a dictionary if detailed is True.
3079
+ """
3080
+ a_tol = self.angle_tolerance if angle_tolerance is None else angle_tolerance
3081
+ detailed = False if raise_exception else detailed
3082
+ msgs = []
3083
+ for rm in self._rooms:
3084
+ msg = rm.check_upside_down_faces(a_tol, False, detailed)
3085
+ if detailed:
3086
+ msgs.extend(msg)
3087
+ elif msg != '':
3088
+ msgs.append(msg)
3089
+ if detailed:
3090
+ return msgs
3091
+ full_msg = '\n'.join(msgs)
3092
+ if raise_exception and len(msgs) != 0:
3093
+ raise ValueError(full_msg)
3094
+ return full_msg
3095
+
3096
+ def check_rooms_solid(self, tolerance=None, angle_tolerance=None,
3097
+ raise_exception=True, detailed=False):
3098
+ """Check whether the Model's rooms are closed solid to within tolerances.
3099
+
3100
+ Args:
3101
+ tolerance: tolerance: The maximum difference between x, y, and z values
3102
+ at which face vertices are considered equivalent. If None, the Model
3103
+ tolerance will be used. (Default: None).
3104
+ angle_tolerance: Deprecated input that is no longer used.
3105
+ raise_exception: Boolean to note whether a ValueError should be raised
3106
+ if the room geometry does not form a closed solid. (Default: True).
3107
+ detailed: Boolean for whether the returned object is a detailed list of
3108
+ dicts with error info or a string with a message. (Default: False).
3109
+
3110
+ Returns:
3111
+ A string with the message or a list with a dictionary if detailed is True.
3112
+ """
3113
+ tolerance = self.tolerance if tolerance is None else tolerance
3114
+ detailed = False if raise_exception else detailed
3115
+ msgs = []
3116
+ for room in self._rooms:
3117
+ msg = room.check_solid(tolerance, raise_exception=False, detailed=detailed)
3118
+ if detailed:
3119
+ msgs.extend(msg)
3120
+ elif msg != '':
3121
+ msgs.append(msg)
3122
+ if detailed:
3123
+ return msgs
3124
+ full_msg = '\n'.join(msgs)
3125
+ if raise_exception and len(msgs) != 0:
3126
+ raise ValueError(full_msg)
3127
+ return full_msg
3128
+
3129
+ def check_room_volume_collisions(
3130
+ self, tolerance=None, raise_exception=True, detailed=False):
3131
+ """Check whether the Model's rooms collide with one another beyond the tolerance.
3132
+
3133
+ Args:
3134
+ tolerance: tolerance: The maximum difference between x, y, and z values
3135
+ at which face vertices are considered equivalent. If None, the Model
3136
+ tolerance will be used. (Default: None).
3137
+ raise_exception: Boolean to note whether a ValueError should be raised
3138
+ if the room geometry does not form a closed solid. (Default: True).
3139
+ detailed: Boolean for whether the returned object is a detailed list of
3140
+ dicts with error info or a string with a message. (Default: False).
3141
+
3142
+ Returns:
3143
+ A string with the message or a list with a dictionary if detailed is True.
3144
+ """
3145
+ # set default values
3146
+ tolerance = self.tolerance if tolerance is None else tolerance
3147
+ detailed = False if raise_exception else detailed
3148
+ # group the rooms by their floor heights to enable collision checking
3149
+ if len(self.rooms) == 0:
3150
+ return [] if detailed else ''
3151
+ room_groups, _ = Room.group_by_floor_height(self.rooms, tolerance)
3152
+ # loop trough the groups and detect collisions
3153
+ msgs = []
3154
+ for rg in room_groups:
3155
+ msg = Room.check_room_volume_collisions(rg, tolerance, detailed)
3156
+ if detailed:
3157
+ msgs.extend(msg)
3158
+ elif msg != '':
3159
+ msgs.append(msg)
3160
+ if detailed:
3161
+ return msgs
3162
+ full_msg = '\n'.join(msgs)
3163
+ if raise_exception and len(msgs) != 0:
3164
+ raise ValueError(full_msg)
3165
+ return full_msg
3166
+
3167
+ def check_missing_adjacencies(self, raise_exception=True, detailed=False):
3168
+ """Check that all Faces Apertures, and Doors have adjacent objects in the model.
3169
+
3170
+ Args:
3171
+ raise_exception: Boolean to note whether a ValueError should be raised
3172
+ if invalid adjacencies are found. (Default: True).
3173
+ detailed: Boolean for whether the returned object is a detailed list of
3174
+ dicts with error info or a string with a message. (Default: False).
3175
+
3176
+ Returns:
3177
+ A string with the message or a list with a dictionary if detailed is True.
3178
+ """
3179
+ detailed = False if raise_exception else detailed
3180
+ # loop through all objects and get their adjacent object
3181
+ room_ids = []
3182
+ face_bc_ids, face_set = [], set()
3183
+ ap_bc_ids, ap_set = [], set()
3184
+ door_bc_ids, dr_set = [], set()
3185
+ sr = []
3186
+ for room in self._rooms:
3187
+ for face in room._faces:
3188
+ if isinstance(face.boundary_condition, Surface):
3189
+ sr.append(self._self_adj_check(
3190
+ 'Face', face, face_bc_ids, room_ids, face_set, detailed))
3191
+ for ap in face.apertures:
3192
+ assert isinstance(ap.boundary_condition, Surface), \
3193
+ 'Aperture "{}" must have Surface boundary condition ' \
3194
+ 'if the parent Face has a Surface BC.'.format(ap.full_id)
3195
+ sr.append(self._self_adj_check(
3196
+ 'Aperture', ap, ap_bc_ids, room_ids, ap_set, detailed))
3197
+ for dr in face.doors:
3198
+ assert isinstance(dr.boundary_condition, Surface), \
3199
+ 'Door "{}" must have Surface boundary condition ' \
3200
+ 'if the parent Face has a Surface BC.'.format(dr.full_id)
3201
+ sr.append(self._self_adj_check(
3202
+ 'Door', dr, door_bc_ids, room_ids, dr_set, detailed))
3203
+ # check to see if the adjacent objects are in the model
3204
+ mr = self._missing_adj_check(self.rooms_by_identifier, room_ids)
3205
+ mf = self._missing_adj_check(self.faces_by_identifier, face_bc_ids)
3206
+ ma = self._missing_adj_check(self.apertures_by_identifier, ap_bc_ids)
3207
+ md = self._missing_adj_check(self.doors_by_identifier, door_bc_ids)
3208
+ # if not, go back and find the original object with the missing BC object
3209
+ msgs = []
3210
+ if len(mr) != 0 or len(mf) != 0 or len(ma) != 0 or len(md) != 0:
3211
+ for room in self._rooms:
3212
+ for face in room._faces:
3213
+ if isinstance(face.boundary_condition, Surface):
3214
+ bc_obj, bc_room = self._adj_objects(face)
3215
+ if bc_obj in mf:
3216
+ self._missing_adj_msg(
3217
+ msgs, face, bc_obj, 'Face', 'Face', detailed)
3218
+ if bc_room in mr:
3219
+ self._missing_adj_msg(
3220
+ msgs, face, bc_room, 'Face', 'Room', detailed)
3221
+ for ap in face.apertures:
3222
+ bc_obj, bc_room = self._adj_objects(ap)
3223
+ if bc_obj in ma:
3224
+ self._missing_adj_msg(
3225
+ msgs, ap, bc_obj, 'Aperture', 'Aperture', detailed)
3226
+ if bc_room in mr:
3227
+ self._missing_adj_msg(
3228
+ msgs, ap, bc_room, 'Aperture', 'Room', detailed)
3229
+ for dr in face.doors:
3230
+ bc_obj, bc_room = self._adj_objects(dr)
3231
+ if bc_obj in md:
3232
+ self._missing_adj_msg(
3233
+ msgs, dr, bc_obj, 'Door', 'Door', detailed)
3234
+ if bc_room in mr:
3235
+ self._missing_adj_msg(
3236
+ msgs, dr, bc_room, 'Door', 'Room', detailed)
3237
+ # return the final error messages
3238
+ all_msgs = [m for m in sr + msgs if m]
3239
+ if detailed:
3240
+ return [m for msg in all_msgs for m in msg]
3241
+ msg = '\n'.join(all_msgs)
3242
+ if msg != '' and raise_exception:
3243
+ raise ValueError(msg)
3244
+ return msg
3245
+
3246
+ def check_matching_adjacent_areas(self, tolerance=None, raise_exception=True,
3247
+ detailed=False):
3248
+ """Check that all adjacent Faces have areas that match within the tolerance.
3249
+
3250
+ This is required for energy simulation in order to get matching heat flow
3251
+ across adjacent Faces. Otherwise, conservation of energy is violated.
3252
+ Note that, if there are missing adjacencies in the model, the message from
3253
+ this method will simply note this fact without reporting on mis-matched areas.
3254
+
3255
+ Args:
3256
+ tolerance: tolerance: The maximum difference between x, y, and z values
3257
+ at which face vertices are considered equivalent. If None, the Model
3258
+ tolerance will be used. (Default: None).
3259
+ raise_exception: Boolean to note whether a ValueError should be raised
3260
+ if invalid adjacencies are found. (Default: True).
3261
+ detailed: Boolean for whether the returned object is a detailed list of
3262
+ dicts with error info or a string with a message. (Default: False).
3263
+
3264
+ Returns:
3265
+ A string with the message or a list with a dictionary if detailed is True.
3266
+ """
3267
+ tolerance = self.tolerance if tolerance is None else tolerance
3268
+ detailed = False if raise_exception else detailed
3269
+
3270
+ # first gather all interior faces in the model and their adjacent object
3271
+ base_faces, adj_ids = [], []
3272
+ for room in self._rooms:
3273
+ for face in room._faces:
3274
+ if isinstance(face.boundary_condition, Surface):
3275
+ base_faces.append(face)
3276
+ adj_ids.append(face.boundary_condition.boundary_condition_object)
3277
+
3278
+ # get the adjacent faces
3279
+ try:
3280
+ adj_faces = self.faces_by_identifier(adj_ids)
3281
+ except ValueError as e: # the model has missing adjacencies
3282
+ if detailed: # the user will get a more detailed error in honeybee-core
3283
+ return []
3284
+ else:
3285
+ msg = 'Matching adjacent areas could not be verified because ' \
3286
+ 'of missing adjacencies in the model. \n{}'.format(e)
3287
+ if raise_exception:
3288
+ raise ValueError(msg)
3289
+ return msg
3290
+
3291
+ # loop through the adjacent face pairs and report if areas are not matched
3292
+ full_msgs, reported_items = [], set()
3293
+ for base_f, adj_f in zip(base_faces, adj_faces):
3294
+ if (base_f.identifier, adj_f.identifier) in reported_items:
3295
+ continue
3296
+ two_tol = 2 * tolerance
3297
+ base_p = base_f.geometry.boundary_polygon2d
3298
+ max_dim = max((base_p.max.x - base_p.min.x, base_p.max.y - base_p.min.y))
3299
+ tol_area = max_dim * two_tol
3300
+ tol_area = 2 * two_tol if tol_area < 2 * two_tol else tol_area
3301
+ if abs(base_f.area - adj_f.area) > tol_area:
3302
+ f_msg = 'Face "{}" with area {} is adjacent to Face "{}" with area {}.' \
3303
+ ' This difference is greater than what is permitted by {} ' \
3304
+ 'tolerance ({}).'.format(
3305
+ base_f.full_id, base_f.area, adj_f.full_id, adj_f.area,
3306
+ tolerance, tol_area
3307
+ )
3308
+ f_msg = self._validation_message_child(
3309
+ f_msg, base_f, detailed, '000205',
3310
+ error_type='Mismatched Area Adjacency')
3311
+ if detailed:
3312
+ f_msg['element_id'].append(adj_f.identifier)
3313
+ f_msg['element_name'].append(adj_f.display_name)
3314
+ parents = []
3315
+ rel_obj = adj_f
3316
+ while getattr(rel_obj, '_parent', None) is not None:
3317
+ rel_obj = getattr(rel_obj, '_parent')
3318
+ par_dict = {
3319
+ 'parent_type': rel_obj.__class__.__name__,
3320
+ 'id': rel_obj.identifier,
3321
+ 'name': rel_obj.display_name
3322
+ }
3323
+ parents.append(par_dict)
3324
+ f_msg['parents'].append(parents)
3325
+ full_msgs.append(f_msg)
3326
+ reported_items.add((adj_f.identifier, base_f.identifier))
3327
+ else: # check to ensure the shapes are the same when vertices are removed
3328
+ try:
3329
+ base_f_geo = base_f.geometry.remove_colinear_vertices(tolerance)
3330
+ adj_f_geo = adj_f.geometry.remove_colinear_vertices(tolerance)
3331
+ except AssertionError: # degenerate Faces to ignore
3332
+ continue
3333
+ if len(base_f_geo) != len(adj_f_geo):
3334
+ f_msg = 'Face "{}" is a shape with {} distinct vertices and is ' \
3335
+ 'adjacent to Face "{}", which has {} distinct vertices' \
3336
+ ' within the model tolerance of {}.'.format(
3337
+ base_f.full_id, len(base_f_geo),
3338
+ adj_f.full_id, len(adj_f_geo), tolerance
3339
+ )
3340
+ f_msg = self._validation_message_child(
3341
+ f_msg, base_f, detailed, '000205',
3342
+ error_type='Mismatched Area Adjacency')
3343
+ if detailed:
3344
+ f_msg['element_id'].append(adj_f.identifier)
3345
+ f_msg['element_name'].append(adj_f.display_name)
3346
+ parents = []
3347
+ rel_obj = adj_f
3348
+ while getattr(rel_obj, '_parent', None) is not None:
3349
+ rel_obj = getattr(rel_obj, '_parent')
3350
+ par_dict = {
3351
+ 'parent_type': rel_obj.__class__.__name__,
3352
+ 'id': rel_obj.identifier,
3353
+ 'name': rel_obj.display_name
3354
+ }
3355
+ parents.append(par_dict)
3356
+ f_msg['parents'].append(parents)
3357
+ full_msgs.append(f_msg)
3358
+ reported_items.add((adj_f.identifier, base_f.identifier))
3359
+
3360
+ # ensure that adjacent sub-faces have matching areas
3361
+ if base_f.has_sub_faces:
3362
+ base_subs, adj_subs, sub_ids = [], [], []
3363
+ for sf in base_f.sub_faces:
3364
+ if isinstance(sf.boundary_condition, Surface):
3365
+ base_subs.append(sf)
3366
+ sub_ids.append(sf.boundary_condition.boundary_condition_object)
3367
+ missing_sfs = False
3368
+ for obj_id in sub_ids:
3369
+ for adj_sf in adj_f.sub_faces:
3370
+ if adj_sf.identifier == obj_id:
3371
+ adj_subs.append(adj_sf)
3372
+ break
3373
+ else: # missing sub-face adjacencies will get reported elsewhere
3374
+ missing_sfs = True
3375
+ if not missing_sfs:
3376
+ for base_sf, adj_sf in zip(base_subs, adj_subs):
3377
+ two_tol = 2 * tolerance
3378
+ tol_area = math.sqrt(base_sf.area) * two_tol
3379
+ tol_area = 2 * two_tol if tol_area < 2 * two_tol else tol_area
3380
+ if abs(base_sf.area - adj_sf.area) > tol_area:
3381
+ f_msg = 'SubFace "{}" with area {} is adjacent to ' \
3382
+ 'SubFace "{}" with area {}. This difference is greater ' \
3383
+ 'than what is permitted at {} tolerance ({}).'.format(
3384
+ base_sf.full_id, base_sf.area,
3385
+ adj_sf.full_id, adj_sf.area, tolerance, tol_area
3386
+ )
3387
+ f_msg = self._validation_message_child(
3388
+ f_msg, base_sf, detailed, '000205',
3389
+ error_type='Mismatched Area Adjacency')
3390
+ if detailed:
3391
+ f_msg['element_id'].append(adj_sf.identifier)
3392
+ f_msg['element_name'].append(adj_sf.display_name)
3393
+ parents = []
3394
+ rel_obj = adj_sf
3395
+ while getattr(rel_obj, '_parent', None) is not None:
3396
+ rel_obj = getattr(rel_obj, '_parent')
3397
+ par_dict = {
3398
+ 'parent_type': rel_obj.__class__.__name__,
3399
+ 'id': rel_obj.identifier,
3400
+ 'name': rel_obj.display_name
3401
+ }
3402
+ parents.append(par_dict)
3403
+ f_msg['parents'].append(parents)
3404
+ full_msgs.append(f_msg)
3405
+ reported_items.add((adj_f.identifier, base_f.identifier))
3406
+
3407
+ # return all of the validation error messages that were gathered
3408
+ full_msg = full_msgs if detailed else '\n'.join(full_msgs)
3409
+ if raise_exception and len(full_msgs) != 0:
3410
+ raise ValueError(full_msg)
3411
+ return full_msg
3412
+
3413
+ def check_all_air_boundaries_adjacent(self, raise_exception=True, detailed=False):
3414
+ """Check that all Faces with the AirBoundary type are adjacent to other Faces.
3415
+
3416
+ This is a requirement for energy simulation.
3417
+
3418
+ Args:
3419
+ raise_exception: Boolean to note whether a ValueError should be raised
3420
+ if an AirBoundary without an adjacency is found. (Default: True).
3421
+ detailed: Boolean for whether the returned object is a detailed list of
3422
+ dicts with error info or a string with a message. (Default: False).
3423
+
3424
+ Returns:
3425
+ A string with the message or a list with a dictionary if detailed is True.
3426
+ """
3427
+ detailed = False if raise_exception else detailed
3428
+ msgs = []
3429
+ for face in self.faces:
3430
+ if isinstance(face.type, AirBoundary) and not \
3431
+ isinstance(face.boundary_condition, Surface):
3432
+ msg = 'Face "{}" is an AirBoundary but is not adjacent ' \
3433
+ 'to another Face.'.format(face.full_id)
3434
+ msg = self._validation_message_child(
3435
+ msg, face, detailed, '000206', error_type='Non-Adjacent AirBoundary')
3436
+ msgs.append(msg)
3437
+ if detailed:
3438
+ return msgs
3439
+ full_msg = '\n'.join(msgs)
3440
+ if raise_exception and len(msgs) != 0:
3441
+ raise ValueError(full_msg)
3442
+ return full_msg
3443
+
3444
+ def triangulated_apertures(self):
3445
+ """Get triangulated versions of the model Apertures that have more than 4 sides.
3446
+
3447
+ This is necessary for energy simulation since EnergyPlus cannot accept
3448
+ sub-faces with more than 4 sides. Note that this method does not alter the
3449
+ Apertures within the Model object but just returns a list of modified
3450
+ Apertures that all have 3 or 4 sides.
3451
+
3452
+ Returns:
3453
+ A tuple with two elements
3454
+
3455
+ - triangulated_apertures: A list of lists where each list is a set of
3456
+ triangle Apertures meant to replace an Aperture with more than
3457
+ 4 sides in the model.
3458
+
3459
+ - parents_to_edit: An list of lists that parallels the triangulated
3460
+ apertures in that each item represents an Aperture that has been
3461
+ triangulated in the model. However, each of these lists holds between
3462
+ 1 and 3 values for the identifiers of the original aperture and parents
3463
+ of the aperture. This information is intended to help edit parent
3464
+ faces that have had their child faces triangulated. The 3 values
3465
+ are as follows:
3466
+
3467
+ * 0 = The identifier of the original Aperture that was triangulated.
3468
+ * 1 = The identifier of the parent Face of the original Aperture
3469
+ (if it exists).
3470
+ * 2 = The identifier of the parent Room of the parent Face of the
3471
+ original Aperture (if it exists).
3472
+ """
3473
+ triangulated_apertures = []
3474
+ parents_to_edit = []
3475
+ all_apertures = self.apertures
3476
+ adj_check = [] # confirms when interior apertures are triangulated by adjacency
3477
+ for ap in all_apertures:
3478
+ if len(ap.geometry) <= 4:
3479
+ pass
3480
+ elif ap.identifier not in adj_check:
3481
+ # generate the new triangulated apertures
3482
+ ap_mesh3d = ap.triangulated_mesh3d
3483
+ new_verts = [[ap_mesh3d[v] for v in face] for face in ap_mesh3d.faces]
3484
+ new_ap_geo = [Face3D(verts, ap.geometry.plane) for verts in new_verts]
3485
+ new_ap_geo = self._remove_sliver_geometries(new_ap_geo)
3486
+ new_aps, parent_edit_info = self._replace_aperture(ap, new_ap_geo)
3487
+ triangulated_apertures.append(new_aps)
3488
+ if parent_edit_info is not None:
3489
+ parents_to_edit.append(parent_edit_info)
3490
+ # coordinate new apertures with any adjacent apertures
3491
+ if isinstance(ap.boundary_condition, Surface):
3492
+ bc_obj_identifier = ap.boundary_condition.boundary_condition_object
3493
+ for other_ap in all_apertures:
3494
+ if other_ap.identifier == bc_obj_identifier:
3495
+ adj_ap = other_ap
3496
+ break
3497
+ new_adj_ap_geo = [face.flip() for face in new_ap_geo]
3498
+ new_adj_aps, edit_in = self._replace_aperture(adj_ap, new_adj_ap_geo)
3499
+ for new_ap, new_adj_ap in zip(new_aps, new_adj_aps):
3500
+ new_ap.set_adjacency(new_adj_ap)
3501
+ triangulated_apertures.append(new_adj_aps)
3502
+ if edit_in is not None:
3503
+ parents_to_edit.append(edit_in)
3504
+ adj_check.append(adj_ap.identifier)
3505
+ return triangulated_apertures, parents_to_edit
3506
+
3507
+ def triangulated_doors(self):
3508
+ """Get triangulated versions of the model Doors that have more than 4 sides.
3509
+
3510
+ This is necessary for energy simulation since EnergyPlus cannot accept
3511
+ sub-faces with more than 4 sides. Note that this method does not alter the
3512
+ Doors within the Model object but just returns a list of Doors that
3513
+ all have 3 or 4 sides.
3514
+
3515
+ Returns:
3516
+ A tuple with two elements
3517
+
3518
+ - triangulated_doors: A list of lists where each list is a set of triangle
3519
+ Doors meant to replace a Door with more than 4 sides in the model.
3520
+
3521
+ - parents_to_edit: An list of lists that parallels the triangulated_doors
3522
+ in that each item represents a Door that has been triangulated
3523
+ in the model. However, each of these lists holds between 1 and 3 values
3524
+ for the identifiers of the original door and parents of the door.
3525
+ This information is intended to help edit parent faces that have had
3526
+ their child faces triangulated. The 3 values are as follows:
3527
+
3528
+ * 0 = The identifier of the original Door that was triangulated.
3529
+ * 1 = The identifier of the parent Face of the original Door
3530
+ (if it exists).
3531
+ * 2 = The identifier of the parent Room of the parent Face of the
3532
+ original Door (if it exists).
3533
+ """
3534
+ triangulated_doors = []
3535
+ parents_to_edit = []
3536
+ all_doors = self.doors
3537
+ adj_check = [] # confirms when interior doors are triangulated by adjacency
3538
+ for dr in all_doors:
3539
+ if len(dr.geometry) <= 4:
3540
+ pass
3541
+ elif dr.identifier not in adj_check:
3542
+ # generate the new triangulated doors
3543
+ dr_mesh3d = dr.triangulated_mesh3d
3544
+ new_verts = [[dr_mesh3d[v] for v in face] for face in dr_mesh3d.faces]
3545
+ new_dr_geo = [Face3D(verts, dr.geometry.plane) for verts in new_verts]
3546
+ new_dr_geo = self._remove_sliver_geometries(new_dr_geo)
3547
+ new_drs, parent_edit_info = self._replace_door(dr, new_dr_geo)
3548
+ triangulated_doors.append(new_drs)
3549
+ if parent_edit_info is not None:
3550
+ parents_to_edit.append(parent_edit_info)
3551
+ # coordinate new doors with any adjacent doors
3552
+ if isinstance(dr.boundary_condition, Surface):
3553
+ bc_obj_identifier = dr.boundary_condition.boundary_condition_object
3554
+ for other_dr in all_doors:
3555
+ if other_dr.identifier == bc_obj_identifier:
3556
+ adj_dr = other_dr
3557
+ break
3558
+ new_adj_dr_geo = [face.flip() for face in new_dr_geo]
3559
+ new_adj_drs, edit_in = self._replace_door(adj_dr, new_adj_dr_geo)
3560
+ for new_dr, new_adj_dr in zip(new_drs, new_adj_drs):
3561
+ new_dr.set_adjacency(new_adj_dr)
3562
+ triangulated_doors.append(new_adj_drs)
3563
+ if edit_in is not None:
3564
+ parents_to_edit.append(edit_in)
3565
+ adj_check.append(adj_dr.identifier)
3566
+ return triangulated_doors, parents_to_edit
3567
+
3568
+ def _remove_sliver_geometries(self, face3ds):
3569
+ """Remove sliver geometries from a list of Face3Ds."""
3570
+ clean_face3ds = []
3571
+ for face in face3ds:
3572
+ try:
3573
+ if face.area >= self.tolerance:
3574
+ clean_face3ds.append(face.remove_colinear_vertices(self.tolerance))
3575
+ except ValueError:
3576
+ pass # degenerate triangle; remove it
3577
+ return clean_face3ds
3578
+
3579
+ def _remove_degenerate_faces(self, hb_objs, tolerance):
3580
+ """Remove degenerate Faces, Apertures, Doors, or Shades from a list."""
3581
+ i_to_remove = []
3582
+ for i, face in enumerate(hb_objs):
3583
+ try:
3584
+ face.remove_colinear_vertices(tolerance)
3585
+ except ValueError: # degenerate face found!
3586
+ i_to_remove.append(i)
3587
+ for i in reversed(i_to_remove):
3588
+ hb_objs.pop(i)
3589
+
3590
+ def _triangulate_quad_faces(self, hb_objs, tolerance):
3591
+ """Triangulate quad geometries."""
3592
+ clean_objects = []
3593
+ for i, geo_obj in enumerate(hb_objs):
3594
+ geo = geo_obj.geometry
3595
+ if len(geo.vertices) == 4 and not geo.check_planar(tolerance, False):
3596
+ verts = geo.vertices
3597
+ obj_1 = geo_obj.duplicate()
3598
+ obj_1.identifier = '{}..0'.format(geo_obj.identifier)
3599
+ obj_1._geometry = Face3D((verts[0], verts[1], verts[2]))
3600
+ clean_objects.append(obj_1)
3601
+ obj_2 = geo_obj.duplicate()
3602
+ obj_2.identifier = '{}..1'.format(geo_obj.identifier)
3603
+ obj_2._geometry = Face3D((verts[2], verts[3], verts[0]))
3604
+ clean_objects.append(obj_2)
3605
+ else:
3606
+ clean_objects.append(geo_obj)
3607
+ return clean_objects
3608
+
3609
+ def _replace_aperture(self, original_ap, new_ap_geo):
3610
+ """Get new Apertures generated from new_ap_geo and the properties of original_ap.
3611
+
3612
+ Note that this method does not re-link the new apertures to new adjacent
3613
+ apertures in the model. This must be done with the returned apertures.
3614
+
3615
+ Args:
3616
+ original_ap: The original Aperture object from which properties
3617
+ are borrowed.
3618
+ new_ap_geo: A list of ladybug_geometry Face3D objects that will be used
3619
+ to generate the new Aperture objects.
3620
+
3621
+ Returns:
3622
+ A tuple with two elements
3623
+
3624
+ - new_aps: A list of the new Aperture objects.
3625
+
3626
+ - parent_edit_info: An array of up to 3 values meant to help edit
3627
+ parents that have had their child faces triangulated. The 3 values
3628
+ are as follows:
3629
+
3630
+ * 0 = The identifier of the original Aperture that was triangulated.
3631
+ * 1 = The identifier of the parent Face of the original Aperture
3632
+ (if it exists).
3633
+ * 2 = The identifier of the parent Room of the parent Face of the
3634
+ original Aperture (if it exists).
3635
+ """
3636
+ # make the new Apertures and add them to the model
3637
+ new_aps = []
3638
+ for i, ap_face in enumerate(new_ap_geo):
3639
+ new_ap = Aperture('{}..{}'.format(original_ap.identifier, i),
3640
+ ap_face, None, original_ap.is_operable)
3641
+ new_ap._properties = original_ap._properties # transfer extension properties
3642
+ if original_ap.has_parent:
3643
+ new_ap._parent = original_ap.parent
3644
+ new_aps.append(new_ap)
3645
+
3646
+ # transfer over any child shades to the first triangulated object
3647
+ if len(original_ap._indoor_shades) != 0:
3648
+ new_shds = [shd.duplicate() for shd in original_ap._indoor_shades]
3649
+ new_aps[0].add_indoor_shades(new_shds)
3650
+ if len(original_ap._outdoor_shades) != 0:
3651
+ new_shds = [shd.duplicate() for shd in original_ap._outdoor_shades]
3652
+ new_aps[0].add_outdoor_shades(new_shds)
3653
+
3654
+ # create the parent edit info
3655
+ parent_edit_info = [original_ap.identifier]
3656
+ if original_ap.has_parent:
3657
+ parent_edit_info.append(original_ap.parent.identifier)
3658
+ if original_ap.parent.has_parent:
3659
+ parent_edit_info.append(original_ap.parent.parent.identifier)
3660
+ return new_aps, parent_edit_info
3661
+
3662
+ def _replace_door(self, original_dr, new_dr_geo):
3663
+ """Get new Doors generated from new_dr_geo and the properties of original_dr.
3664
+
3665
+ Note that this method does not re-link the new doors to new adjacent
3666
+ doors in the model. This must be done with the returned doors.
3667
+
3668
+ Args:
3669
+ original_dr: The original Door object from which properties
3670
+ are borrowed.
3671
+ new_dr_geo: A list of ladybug_geometry Face3D objects that will be used
3672
+ to generate the new Door objects.
3673
+
3674
+ Returns:
3675
+ A tuple with four elements
3676
+
3677
+ - new_drs: A list of the new Door objects.
3678
+
3679
+ - parent_edit_info: An array of up to 3 values meant to help edit
3680
+ parents that have had their child faces triangulated. The 3 values
3681
+ are as follows:
3682
+
3683
+ * 0 = The identifier of the original Door that was triangulated.
3684
+ * 1 = The identifier of the parent Face of the original Door
3685
+ (if it exists).
3686
+ * 2 = The identifier of the parent Room of the parent Face of the
3687
+ original Door (if it exists).
3688
+ """
3689
+ # make the new doors and add them to the model
3690
+ new_drs = []
3691
+ for i, dr_face in enumerate(new_dr_geo):
3692
+ new_dr = Door('{}..{}'.format(original_dr.identifier, i), dr_face)
3693
+ new_dr._properties = original_dr._properties # transfer extension properties
3694
+ if original_dr.has_parent:
3695
+ new_dr._parent = original_dr.parent
3696
+ new_drs.append(new_dr)
3697
+
3698
+ # transfer over any child shades to the first triangulated object
3699
+ if len(original_dr._indoor_shades) != 0:
3700
+ new_shds = [shd.duplicate() for shd in original_dr._indoor_shades]
3701
+ new_drs[0].add_indoor_shades(new_shds)
3702
+ if len(original_dr._outdoor_shades) != 0:
3703
+ new_shds = [shd.duplicate() for shd in original_dr._outdoor_shades]
3704
+ new_drs[0].add_outdoor_shades(new_shds)
3705
+
3706
+ # create the parent edit info
3707
+ parent_edit_info = [original_dr.identifier]
3708
+ if original_dr.has_parent:
3709
+ parent_edit_info.append(original_dr.parent.identifier)
3710
+ if original_dr.parent.has_parent:
3711
+ parent_edit_info.append(original_dr.parent.parent.identifier)
3712
+ return new_drs, parent_edit_info
3713
+
3714
+ @property
3715
+ def to(self):
3716
+ """Model writer object.
3717
+
3718
+ Use this method to access Writer class to write the model in other formats.
3719
+
3720
+ Usage:
3721
+
3722
+ .. code-block:: python
3723
+
3724
+ model.to.idf(model) -> idf string.
3725
+ model.to.radiance(model) -> Radiance string.
3726
+ """
3727
+ return writer
3728
+
3729
+ def to_dict(self, included_prop=None, triangulate_sub_faces=False,
3730
+ include_plane=True):
3731
+ """Return Model as a dictionary.
3732
+
3733
+ Args:
3734
+ included_prop: List of properties to filter keys that must be included in
3735
+ output dictionary. For example ['energy'] will include 'energy' key if
3736
+ available in properties to_dict. By default all the keys will be
3737
+ included. To exclude all the keys from extensions use an empty list.
3738
+ triangulate_sub_faces: Boolean to note whether sub-faces (including
3739
+ Apertures and Doors) should be triangulated if they have more than
3740
+ 4 sides (True) or whether they should be left as they are (False).
3741
+ This triangulation is necessary when exporting directly to EnergyPlus
3742
+ since it cannot accept sub-faces with more than 4 vertices. Note that
3743
+ setting this to True will only triangulate sub-faces with parent Faces
3744
+ that also have parent Rooms since orphaned Apertures and Faces are
3745
+ not relevant for energy simulation. (Default: False).
3746
+ include_plane: Boolean to note wether the planes of the Face3Ds should be
3747
+ included in the output. This can preserve the orientation of the
3748
+ X/Y axes of the planes but is not required and can be removed to
3749
+ keep the dictionary smaller. (Default: True).
3750
+ """
3751
+ # write all of the geometry objects and their properties
3752
+ base = {'type': 'Model'}
3753
+ base['identifier'] = self.identifier
3754
+ base['display_name'] = self.display_name
3755
+ base['units'] = self.units
3756
+ base['properties'] = self.properties.to_dict(included_prop)
3757
+ if self._rooms != []:
3758
+ base['rooms'] = [r.to_dict(True, included_prop, include_plane)
3759
+ for r in self._rooms]
3760
+ if self._orphaned_faces != []:
3761
+ base['orphaned_faces'] = [f.to_dict(True, included_prop, include_plane)
3762
+ for f in self._orphaned_faces]
3763
+ if self._orphaned_apertures != []:
3764
+ base['orphaned_apertures'] = [ap.to_dict(True, included_prop, include_plane)
3765
+ for ap in self._orphaned_apertures]
3766
+ if self._orphaned_doors != []:
3767
+ base['orphaned_doors'] = [dr.to_dict(True, included_prop, include_plane)
3768
+ for dr in self._orphaned_doors]
3769
+ if self._orphaned_shades != []:
3770
+ base['orphaned_shades'] = [shd.to_dict(True, included_prop, include_plane)
3771
+ for shd in self._orphaned_shades]
3772
+ if self._shade_meshes != []:
3773
+ base['shade_meshes'] = [sm.to_dict(True, included_prop)
3774
+ for sm in self._shade_meshes]
3775
+ if self.tolerance != 0:
3776
+ base['tolerance'] = self.tolerance
3777
+ if self.angle_tolerance != 0:
3778
+ base['angle_tolerance'] = self.angle_tolerance
3779
+
3780
+ # triangulate sub-faces if this was requested
3781
+ if triangulate_sub_faces:
3782
+ apertures, parents_to_edit = self.triangulated_apertures()
3783
+ for tri_aps, edit_infos in zip(apertures, parents_to_edit):
3784
+ if len(edit_infos) == 3:
3785
+ for room in base['rooms']:
3786
+ if room['identifier'] == edit_infos[2]:
3787
+ break
3788
+ for face in room['faces']:
3789
+ if face['identifier'] == edit_infos[1]:
3790
+ break
3791
+ for i, ap in enumerate(face['apertures']):
3792
+ if ap['identifier'] == edit_infos[0]:
3793
+ break
3794
+ del face['apertures'][i]
3795
+ face['apertures'].extend(
3796
+ [a.to_dict(True, included_prop) for a in tri_aps])
3797
+ doors, parents_to_edit = self.triangulated_doors()
3798
+ for tri_drs, edit_infos in zip(doors, parents_to_edit):
3799
+ if len(edit_infos) == 3:
3800
+ for room in base['rooms']:
3801
+ if room['identifier'] == edit_infos[2]:
3802
+ break
3803
+ for face in room['faces']:
3804
+ if face['identifier'] == edit_infos[1]:
3805
+ break
3806
+ for i, ap in enumerate(face['doors']):
3807
+ if ap['identifier'] == edit_infos[0]:
3808
+ break
3809
+ del face['doors'][i]
3810
+ face['doors'].extend(
3811
+ [dr.to_dict(True, included_prop) for dr in tri_drs])
3812
+
3813
+ # write in the optional keys if they are not None
3814
+ if self.user_data is not None:
3815
+ base['user_data'] = self.user_data
3816
+ if folders.honeybee_schema_version is not None:
3817
+ base['version'] = folders.honeybee_schema_version_str
3818
+
3819
+ return base
3820
+
3821
+ def to_hbjson(self, name=None, folder=None, indent=None,
3822
+ included_prop=None, triangulate_sub_faces=False):
3823
+ """Write Honeybee model to HBJSON.
3824
+
3825
+ Args:
3826
+ name: A text string for the name of the HBJSON file. If None, the model
3827
+ identifier wil be used. (Default: None).
3828
+ folder: A text string for the directory where the HBJSON will be written.
3829
+ If unspecified, the default simulation folder will be used. This
3830
+ is usually at "C:\\Users\\USERNAME\\simulation."
3831
+ indent: A positive integer to set the indentation used in the resulting
3832
+ HBJSON file. (Default: None).
3833
+ included_prop: List of properties to filter keys that must be included in
3834
+ output dictionary. For example ['energy'] will include 'energy' key if
3835
+ available in properties to_dict. By default all the keys will be
3836
+ included. To exclude all the keys from extensions use an empty list.
3837
+ triangulate_sub_faces: Boolean to note whether sub-faces (including
3838
+ Apertures and Doors) should be triangulated if they have more than
3839
+ 4 sides (True) or whether they should be left as they are (False).
3840
+ This triangulation is necessary when exporting directly to EnergyPlus
3841
+ since it cannot accept sub-faces with more than 4 vertices. Note that
3842
+ setting this to True will only triangulate sub-faces with parent Faces
3843
+ that also have parent Rooms since orphaned Apertures and Faces are
3844
+ not relevant for energy simulation. (Default: False).
3845
+ """
3846
+ # create dictionary from the Honeybee Model
3847
+ hb_dict = self.to_dict(included_prop=included_prop,
3848
+ triangulate_sub_faces=triangulate_sub_faces)
3849
+
3850
+ # set up a name and folder for the HBJSON
3851
+ if name is None:
3852
+ name = self.identifier
3853
+ file_name = name if name.lower().endswith('.hbjson') or \
3854
+ name.lower().endswith('.json') else '{}.hbjson'.format(name)
3855
+ folder = folder if folder is not None else folders.default_simulation_folder
3856
+ hb_file = os.path.join(folder, file_name)
3857
+ # write HBJSON
3858
+ with open(hb_file, 'w') as fp:
3859
+ json.dump(hb_dict, fp, indent=indent)
3860
+ return hb_file
3861
+
3862
+ def to_hbpkl(self, name=None, folder=None, included_prop=None,
3863
+ triangulate_sub_faces=False):
3864
+ """Write Honeybee model to compressed pickle file (HBpkl).
3865
+
3866
+ Args:
3867
+ name: A text string for the name of the pickle file. If None, the model
3868
+ identifier wil be used. (Default: None).
3869
+ folder: A text string for the directory where the pickle file will be
3870
+ written. If unspecified, the default simulation folder will be used.
3871
+ This is usually at "C:\\Users\\USERNAME\\simulation."
3872
+ included_prop: List of properties to filter keys that must be included in
3873
+ output dictionary. For example ['energy'] will include 'energy' key if
3874
+ available in properties to_dict. By default all the keys will be
3875
+ included. To exclude all the keys from extensions use an empty list.
3876
+ triangulate_sub_faces: Boolean to note whether sub-faces (including
3877
+ Apertures and Doors) should be triangulated if they have more than
3878
+ 4 sides (True) or whether they should be left as they are (False).
3879
+ This triangulation is necessary when exporting directly to EnergyPlus
3880
+ since it cannot accept sub-faces with more than 4 vertices. Note that
3881
+ setting this to True will only triangulate sub-faces with parent Faces
3882
+ that also have parent Rooms since orphaned Apertures and Faces are
3883
+ not relevant for energy simulation. (Default: False).
3884
+ """
3885
+ # create dictionary from the Honeybee Model
3886
+ hb_dict = self.to_dict(included_prop=included_prop,
3887
+ triangulate_sub_faces=triangulate_sub_faces)
3888
+
3889
+ # set up a name and folder for the HBpkl
3890
+ if name is None:
3891
+ name = self.identifier
3892
+ file_name = name if name.lower().endswith('.hbpkl') or \
3893
+ name.lower().endswith('.pkl') else '{}.hbpkl'.format(name)
3894
+ folder = folder if folder is not None else folders.default_simulation_folder
3895
+ hb_file = os.path.join(folder, file_name)
3896
+ # write the Model dictionary into a file
3897
+ with open(hb_file, 'wb') as fp:
3898
+ pickle.dump(hb_dict, fp)
3899
+ return hb_file
3900
+
3901
+ def to_stl(self, name=None, folder=None):
3902
+ """Write Honeybee model to an ASCII STL file.
3903
+
3904
+ Note that all geometry is triangulated when it is converted to STL.
3905
+
3906
+ Args:
3907
+ name: A text string for the name of the STL file. If None, the model
3908
+ identifier wil be used. (Default: None).
3909
+ folder: A text string for the directory where the STL will be written.
3910
+ If unspecified, the default simulation folder will be used. This
3911
+ is usually at "C:\\Users\\USERNAME\\simulation."
3912
+ """
3913
+ # set up a name and folder for the STL
3914
+ if name is None:
3915
+ name = self.identifier
3916
+ file_name = name if name.lower().endswith('.stl') else '{}.stl'.format(name)
3917
+ folder = folder if folder is not None else folders.default_simulation_folder
3918
+
3919
+ # collect all of the Face3Ds across the model as triangles and normals
3920
+ all_geo = []
3921
+ for face in self.faces:
3922
+ all_geo.append(face.punched_geometry)
3923
+ for ap in self.apertures:
3924
+ all_geo.append(ap.geometry)
3925
+ for dr in self.doors:
3926
+ all_geo.append(dr.geometry)
3927
+ for shd in self.doors:
3928
+ all_geo.append(shd.geometry)
3929
+
3930
+ # convert the Face3Ds into a format for export to STL
3931
+ _face_vertices, _face_normals = [], []
3932
+ for face_3d in all_geo:
3933
+ # add the geometry of a Face3D to the lists for STL export
3934
+ if len(face_3d) == 3:
3935
+ _face_vertices.append(face_3d.vertices)
3936
+ _face_normals.append(face_3d.normal)
3937
+ else:
3938
+ tri_mesh = face_3d.triangulated_mesh3d
3939
+ for m_fac in tri_mesh.face_vertices:
3940
+ _face_vertices.append(m_fac)
3941
+ _face_normals.append(face_3d.normal)
3942
+
3943
+ # convert any shade meshes into STL vertices
3944
+ for sm in self._shade_meshes:
3945
+ for fvs, fns in zip(sm.geometry.face_vertices, sm.geometry.face_normals):
3946
+ _face_vertices.append(fvs)
3947
+ _face_normals.append(fns)
3948
+
3949
+ # write the geometry into an STL file
3950
+ stl_obj = STL(_face_vertices, _face_normals, self.identifier)
3951
+ return stl_obj.to_file(folder, file_name)
3952
+
3953
+ def _all_objects(self):
3954
+ """Get a single list of all the Honeybee objects in a Model."""
3955
+ return self._rooms + self._orphaned_faces + self._orphaned_shades + \
3956
+ self._orphaned_apertures + self._orphaned_doors + self._shade_meshes
3957
+
3958
+ @staticmethod
3959
+ def validate(model, check_function='check_for_extension', check_args=None,
3960
+ json_output=False):
3961
+ """Get a string of a validation report given a specific check_function.
3962
+
3963
+ Args:
3964
+ model: A Honeybee Model object for which validation will be performed.
3965
+ This can also be the file path to a HBJSON or a JSON string
3966
+ representation of a Honeybee Model. These latter two options may
3967
+ be useful if the type of validation issue with the Model is
3968
+ one that prevents serialization.
3969
+ check_function: Text for the name of a check function on this Model
3970
+ that will be used to generate the validation report. For example,
3971
+ check_all or check_rooms_solid. (Default: check_for_extension),
3972
+ check_args: An optional list of arguments to be passed to the
3973
+ check_function. If None, all default values for the arguments
3974
+ will be used. (Default: None).
3975
+ json_output: Boolean to note whether the output validation report
3976
+ should be formatted as a JSON object instead of plain text.
3977
+ """
3978
+ # process the input model if it's not already serialized
3979
+ report = ''
3980
+ if isinstance(model, str):
3981
+ try:
3982
+ if model.startswith('{'):
3983
+ model = Model.from_dict(json.loads(model))
3984
+ elif os.path.isfile(model):
3985
+ model = Model.from_file(model)
3986
+ else:
3987
+ report = 'Input Model for validation is not a Model object, ' \
3988
+ 'file path to a Model or a Model HBJSON string.'
3989
+ except Exception as e:
3990
+ report = str(e)
3991
+ elif not isinstance(model, Model):
3992
+ report = 'Input Model for validation is not a Model object, ' \
3993
+ 'file path to a Model or a Model HBJSON string.'
3994
+
3995
+ if report == '': # get the function to call to do checks
3996
+ if '.' in check_function: # nested attribute
3997
+ attributes = check_function.split('.') # get all the sub-attributes
3998
+ check_func = model
3999
+ for attribute in attributes:
4000
+ if check_func is None:
4001
+ continue
4002
+ check_func = getattr(check_func, attribute, None)
4003
+ else:
4004
+ check_func = getattr(model, check_function, None)
4005
+ assert check_func is not None, \
4006
+ 'Honeybee Model class has no method {}'.format(check_function)
4007
+ # process the arguments and options
4008
+ args = [] if check_args is None else [] + list(check_args)
4009
+ kwargs = {'raise_exception': False}
4010
+
4011
+ # create the report
4012
+ if not json_output: # create a plain text report
4013
+ # add the versions of things into the validation message
4014
+ c_ver = folders.honeybee_core_version_str
4015
+ s_ver = folders.honeybee_schema_version_str
4016
+ ver_msg = 'Validating Model using honeybee-core=={} and ' \
4017
+ 'honeybee-schema=={}'.format(c_ver, s_ver)
4018
+ # run the check function
4019
+ if report == '':
4020
+ kwargs['detailed'] = False
4021
+ report = check_func(*args, **kwargs)
4022
+ # format the results of the check
4023
+ if report == '':
4024
+ full_msg = ver_msg + '\nCongratulations! Your Model is valid!'
4025
+ else:
4026
+ full_msg = ver_msg + \
4027
+ '\nYour Model is invalid for the following reasons:\n' + report
4028
+ return full_msg
4029
+ else:
4030
+ # add the versions of things into the validation message
4031
+ out_dict = {
4032
+ 'type': 'ValidationReport',
4033
+ 'app_name': 'Honeybee',
4034
+ 'app_version': folders.honeybee_core_version_str,
4035
+ 'schema_version': folders.honeybee_schema_version_str,
4036
+ 'fatal_error': report
4037
+ }
4038
+ if report == '':
4039
+ kwargs['detailed'] = True
4040
+ errors = check_func(*args, **kwargs)
4041
+ out_dict['errors'] = errors
4042
+ out_dict['valid'] = True if len(out_dict['errors']) == 0 else False
4043
+ else:
4044
+ out_dict['errors'] = []
4045
+ out_dict['valid'] = False
4046
+ return json.dumps(out_dict, indent=4)
4047
+
4048
+ @staticmethod
4049
+ def clean_irrational_geometry(model_dict):
4050
+ """Remove irrational geometry objects from a honeybee Model dictionary.
4051
+
4052
+ This can be useful to run prior to serializing the Model object from a
4053
+ dictionary if it was produced from a source other than the Python
4054
+ core libraries, in which case the dictionary is necessarily rational
4055
+ and serializable. This is because not all honeybee-schema bindings
4056
+ enforce fundamental definitions of geometry types upon initialization
4057
+ of the geometry objects, leading to exceptions when an attempt is made
4058
+ to serialize them to Python. Furthermore, it is possible that the honeybee
4059
+ Model dictionary did not originate from any schema bindings at all, in
4060
+ which case it is highly recommended that this method be run.
4061
+
4062
+ Typical irrational geometry cases that are removed by this method include.
4063
+
4064
+ * Apertures/Doors with less than 3 vertices or holes less than 3 vertices.
4065
+ * Faces with less than 3 vertices or holes with less than 3 vertices.
4066
+ * Rooms that have no Face geometry.
4067
+ * Shade Face3Ds with less than 3 vertices or holes with less than 3 vertices.
4068
+ * ShadeMesh Mesh3Ds with no faces or faces with less than 3 vertices.
4069
+ """
4070
+ # clean all of the Room geometry in the model dictionary
4071
+ if 'rooms' in model_dict and model_dict['rooms'] is not None:
4072
+ for ri in range(len(model_dict['rooms']) - 1, -1, -1):
4073
+ r_dict = model_dict['rooms'][ri]
4074
+ # clean all of the Face geometry
4075
+ Model._clean_irrational_geo_with_shade(r_dict['faces'])
4076
+ for f_dict in r_dict['faces']:
4077
+ # clean all of the Aperture/Door geometry
4078
+ if 'apertures' in f_dict and f_dict['apertures'] is not None:
4079
+ Model._clean_irrational_geo_with_shade(f_dict['apertures'])
4080
+ if 'doors' in f_dict and f_dict['doors'] is not None:
4081
+ Model._clean_irrational_geo_with_shade(f_dict['doors'])
4082
+ # clean the assigned shade geometry
4083
+ if 'outdoor_shades' in r_dict and r_dict['outdoor_shades'] is not None:
4084
+ Model._clean_irrational_face3ds(r_dict['outdoor_shades'])
4085
+ if 'indoor_shades' in r_dict and r_dict['indoor_shades'] is not None:
4086
+ Model._clean_irrational_face3ds(r_dict['indoor_shades'])
4087
+ if len(r_dict['faces']) == 0: # the entire Room is irrational
4088
+ model_dict['rooms'].pop(ri)
4089
+ # clean all of the orphaned geometry in the model dictionary
4090
+ if 'orphaned_faces' in model_dict and model_dict['orphaned_faces'] is not None:
4091
+ Model._clean_irrational_geo_with_shade(model_dict['orphaned_faces'])
4092
+ if 'orphaned_apertures' in model_dict and \
4093
+ model_dict['orphaned_apertures'] is not None:
4094
+ Model._clean_irrational_geo_with_shade(model_dict['orphaned_apertures'])
4095
+ if 'orphaned_doors' in model_dict and model_dict['orphaned_doors'] is not None:
4096
+ Model._clean_irrational_geo_with_shade(model_dict['orphaned_doors'])
4097
+ if 'orphaned_shades' in model_dict and model_dict['orphaned_shades'] is not None:
4098
+ Model._clean_irrational_face3ds(model_dict['orphaned_shades'])
4099
+ # clean all of the shade mesh geometry in the model dictionary
4100
+ if 'shade_meshes' in model_dict and model_dict['shade_meshes'] is not None:
4101
+ for smi in range(len(model_dict['shade_meshes']) - 1, -1, -1):
4102
+ sm_dict = model_dict['shade_meshes'][smi]['geometry']
4103
+ for mfi in range(len(sm_dict['faces']) - 1, -1, -1):
4104
+ mf = sm_dict['faces'][mfi]
4105
+ if len(mf) not in (3, 4):
4106
+ sm_dict['faces'].pop(mfi)
4107
+ else:
4108
+ for ind in mf:
4109
+ try:
4110
+ sm_dict['vertices'][ind]
4111
+ except IndexError:
4112
+ sm_dict['faces'].pop(mfi)
4113
+ break
4114
+ if len(sm_dict['faces']) == 0:
4115
+ model_dict['shade_meshes'].pop(smi)
4116
+
4117
+ @staticmethod
4118
+ def _clean_irrational_geo_with_shade(geo_obj_dicts):
4119
+ """Clean irrational Face3Ds out of a list of honeybee geometry objects."""
4120
+ Model._clean_irrational_face3ds(geo_obj_dicts)
4121
+ for f_dict in geo_obj_dicts:
4122
+ if 'outdoor_shades' in f_dict and f_dict['outdoor_shades'] is not None:
4123
+ Model._clean_irrational_face3ds(f_dict['outdoor_shades'])
4124
+ if 'indoor_shades' in f_dict and f_dict['indoor_shades'] is not None:
4125
+ Model._clean_irrational_face3ds(f_dict['indoor_shades'])
4126
+
4127
+ @staticmethod
4128
+ def _clean_irrational_face3ds(geo_obj_dicts):
4129
+ """Clean irrational Face3Ds out of a list of honeybee geometry objects."""
4130
+ for fi in range(len(geo_obj_dicts) - 1, -1, -1):
4131
+ f_dict = geo_obj_dicts[fi]
4132
+ face_3d = f_dict['geometry']
4133
+ if len(face_3d['boundary']) < 3:
4134
+ geo_obj_dicts.pop(fi)
4135
+ continue
4136
+ elif 'holes' in face_3d:
4137
+ face_3d['holes'] = [h for h in face_3d['holes'] if len(h) >= 3]
4138
+
4139
+ @staticmethod
4140
+ def conversion_factor_to_meters(units):
4141
+ """Get the conversion factor to meters based on input units.
4142
+
4143
+ Args:
4144
+ units: Text for the units. Choose from the following:
4145
+
4146
+ * Meters
4147
+ * Millimeters
4148
+ * Feet
4149
+ * Inches
4150
+ * Centimeters
4151
+
4152
+ Returns:
4153
+ A number for the conversion factor, which should be multiplied by
4154
+ all distance units taken from Rhino geometry in order to convert
4155
+ them to meters.
4156
+ """
4157
+ return conversion_factor_to_meters(units)
4158
+
4159
+ def _self_adj_check(self, obj_type, hb_obj, bc_ids, room_ids, bc_set, detailed):
4160
+ """Check that an adjacent object is referencing itself or its own room.
4161
+
4162
+ A check will also be performed to ensure the adjacent object doesn't already
4163
+ have an adjacent pair in the model.
4164
+ """
4165
+ bc_objs = hb_obj.boundary_condition.boundary_condition_objects
4166
+ bc_obj, bc_room = bc_objs[0], bc_objs[-1]
4167
+ bc_ids.append(bc_obj)
4168
+ room_ids.append(bc_room)
4169
+ msgs = []
4170
+ # first ensure that the object is not referencing itself
4171
+ if hb_obj.identifier == bc_obj:
4172
+ parent_msg = 'with parent "{}" '.format(hb_obj._top_parent().full_id) \
4173
+ if hb_obj.has_parent else ''
4174
+ msg = '{} "{}" {}cannot reference itself in its Surface boundary ' \
4175
+ 'condition.'.format(obj_type, hb_obj.full_id, parent_msg)
4176
+ msg = self._validation_message_child(
4177
+ msg, hb_obj, detailed, '000201',
4178
+ error_type='Self-Referential Adjacency')
4179
+ msgs.append(msg)
4180
+ # then ensure that the object is not referencing its own room
4181
+ if hb_obj.has_parent and hb_obj.parent.has_parent:
4182
+ if hb_obj.parent.parent.identifier == bc_room:
4183
+ msg = '{} "{}" and its adjacent object "{}" cannot be a part of the ' \
4184
+ 'same Room "{}".'.format(obj_type, hb_obj.full_id, bc_obj, bc_room)
4185
+ msg = self._validation_message_child(
4186
+ msg, hb_obj, detailed, '000202',
4187
+ error_type='Intra-Room Adjacency')
4188
+ msgs.append(msg)
4189
+ # lastly make sure the adjacent object doesn't already have an adjacency
4190
+ if bc_obj in bc_set:
4191
+ parent_msg1 = 'with parent "{}" '.format(hb_obj._top_parent().full_id) \
4192
+ if hb_obj.has_parent else ''
4193
+ parent_msg2 = ' with parent "{}" '.format(bc_room) if len(bc_objs) > 1 else ''
4194
+ msg = '{} "{}" {}is adjacent to object "{}"{}, which has another adjacent ' \
4195
+ 'object in the Model.'.format(
4196
+ obj_type, hb_obj.full_id, parent_msg1, bc_obj, parent_msg2)
4197
+ msg = self._validation_message_child(
4198
+ msg, hb_obj, detailed, '000203',
4199
+ error_type='Object with Multiple Adjacencies')
4200
+ msgs.append(msg)
4201
+ else:
4202
+ bc_set.add(bc_obj)
4203
+ return msgs if detailed else ''.join(msgs)
4204
+
4205
+ def _missing_adj_msg(self, messages, hb_obj, bc_obj,
4206
+ obj_type='Face', bc_obj_type='Face', detailed=False):
4207
+ parent_msg = 'with parent "{}" '.format(hb_obj._top_parent().full_id) \
4208
+ if hb_obj.has_parent else ''
4209
+ msg = '{} "{}" {}has an adjacent {} that is missing from the model: ' \
4210
+ '{}'.format(obj_type, hb_obj.full_id, parent_msg, bc_obj_type, bc_obj)
4211
+ msg = self._validation_message_child(
4212
+ msg, hb_obj, detailed, '000204', error_type='Missing Adjacency')
4213
+ if detailed:
4214
+ messages.append([msg])
4215
+ else:
4216
+ messages.append(msg)
4217
+
4218
+ @staticmethod
4219
+ def _missing_adj_check(id_checking_function, bc_ids):
4220
+ """Check whether adjacencies are missing from a model."""
4221
+ try:
4222
+ id_checking_function(bc_ids)
4223
+ return []
4224
+ except ValueError as e:
4225
+ id_pattern = re.compile('\"([^"]*)\"')
4226
+ return [obj_id for obj_id in id_pattern.findall(str(e))]
4227
+
4228
+ @staticmethod
4229
+ def _adj_objects(hb_obj):
4230
+ """Check that an adjacent object is referencing itself."""
4231
+ bc_objs = hb_obj.boundary_condition.boundary_condition_objects
4232
+ return bc_objs[0], bc_objs[-1]
4233
+
4234
+ @staticmethod
4235
+ def _remove_by_ids(objs, obj_ids):
4236
+ """Remove items from a list using a list of object IDs."""
4237
+ if obj_ids == []:
4238
+ return objs
4239
+ new_objs = []
4240
+ if obj_ids is not None:
4241
+ obj_id_set = set(obj_ids)
4242
+ for obj in objs:
4243
+ if obj.identifier not in obj_id_set:
4244
+ new_objs.append(obj)
4245
+ return new_objs
4246
+
4247
+ def __add__(self, other):
4248
+ new_model = self.duplicate()
4249
+ new_model.add_model(other)
4250
+ return new_model
4251
+
4252
+ def __iadd__(self, other):
4253
+ self.add_model(other)
4254
+ return self
4255
+
4256
+ def __copy__(self):
4257
+ new_model = Model(
4258
+ self.identifier,
4259
+ [room.duplicate() for room in self._rooms],
4260
+ [face.duplicate() for face in self._orphaned_faces],
4261
+ [shade.duplicate() for shade in self._orphaned_shades],
4262
+ [aperture.duplicate() for aperture in self._orphaned_apertures],
4263
+ [door.duplicate() for door in self._orphaned_doors],
4264
+ [shade_mesh.duplicate() for shade_mesh in self._shade_meshes],
4265
+ self.units, self.tolerance, self.angle_tolerance)
4266
+ new_model._display_name = self._display_name
4267
+ new_model._user_data = None if self.user_data is None else self.user_data.copy()
4268
+ new_model._properties._duplicate_extension_attr(self._properties)
4269
+ return new_model
4270
+
4271
+ def __repr__(self):
4272
+ return 'Model: %s' % self.display_name