Skip to content

Commit

Permalink
feat(model): Set up validation to be checked on a per-extension basis
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey committed Nov 27, 2024
1 parent b375845 commit 9a6d117
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 28 deletions.
129 changes: 104 additions & 25 deletions honeybee/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2050,6 +2050,73 @@ def comparison_report(self, other_model, ignore_deleted=False, ignore_added=Fals
compare_dict['deleted_objects'] = deleted
return compare_dict

def check_for_extension(self, extension_name='All',
raise_exception=True, detailed=False):
"""Check that the Model is valid for a specific Honeybee extension.
This process will typically include both honeybee-core checks as well
as checks that apply only to the extension. However, any checks that
are not relevant for the specified extension will be ignored.
Note that the specified Honeybee extension must be installed in order
for this method to run successfully.
Args:
extension_name: Text for the name of the extension to be checked.
The value input here is case-insensitive such that "radiance"
and "Radiance" will both result in the model being checked for
validity with honeybee-radiance. This value can also be set to
"All" in order to run checks for all installed extensions. Some
common honeybee extension names that can be input here if they
are installed include:
* Radiance
* EnergyPlus
* OpenStudio
* DesignBuilder
* DOE2
* IES
* IDAICE
Note that EnergyPlus, OpenStudio, and DesignBuilder are all set
to run the same checks with honeybee-energy.
raise_exception: Boolean to note whether a ValueError should be raised
if any errors are found. If False, this method will simply
return a text string with all errors that were found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A text string with all errors that were found or a list if detailed is True.
This string (or list) will be empty if no errors were found.
"""
# set up defaults to ensure the method runs correctly
detailed = False if raise_exception else detailed
extension_name = extension_name.lower()
if extension_name == 'all':
return self.check_all(raise_exception, detailed)
energy_extensions = ('energyplus', 'openstudio', 'designbuilder')
if extension_name in energy_extensions:
extension_name = 'energy'

# check the extension attributes
assert self.tolerance != 0, \
'Model must have a non-zero tolerance in order to perform geometry checks.'
assert self.angle_tolerance != 0, \
'Model must have a non-zero angle_tolerance to perform geometry checks.'
msgs = self._properties._check_for_extension(extension_name, detailed)
if detailed:
msgs = [m for m in msgs if isinstance(m, list)]

# output a final report of errors or raise an exception
full_msgs = [msg for msg in msgs if msg]
if detailed:
return [m for msg in full_msgs for m in msg]
full_msg = '\n'.join(full_msgs)
if raise_exception and len(full_msgs) != 0:
raise ValueError(full_msg)
return full_msg

def check_all(self, raise_exception=True, detailed=False):
"""Check all of the aspects of the Model for possible errors.
Expand Down Expand Up @@ -2082,35 +2149,13 @@ def check_all(self, raise_exception=True, detailed=False):
ang_tol = self.angle_tolerance

# perform checks for duplicate identifiers, which might mess with other checks
msgs.append(self.check_duplicate_room_identifiers(False, detailed))
msgs.append(self.check_duplicate_face_identifiers(False, detailed))
msgs.append(self.check_duplicate_sub_face_identifiers(False, detailed))
msgs.append(self.check_duplicate_shade_identifiers(False, detailed))
msgs.append(self.check_duplicate_shade_mesh_identifiers(False, detailed))
msgs.append(self.check_all_duplicate_identifiers(False, detailed))

# perform several checks for the Honeybee schema geometry rules
msgs.append(self.check_planar(tol, False, detailed))
msgs.append(self.check_self_intersecting(tol, False, detailed))
# perform checks for degenerate rooms with a test that removes colinear vertices
for room in self.rooms:
try:
new_room = room.duplicate() # duplicate to avoid editing the original
new_room.remove_colinear_vertices_envelope(tol)
except ValueError as e:
deg_msg = str(e)
if detailed:
deg_msg = [{
'type': 'ValidationError',
'code': '000107',
'error_type': 'Degenerate Room Volume',
'extension_type': 'Core',
'element_type': 'Room',
'element_id': [room.identifier],
'element_name': [room.display_name],
'message': deg_msg
}]
msgs.append(deg_msg)
msgs.append(self.check_degenerate_rooms(tol, False, detailed))

# perform geometry checks related to parent-child relationships
msgs.append(self.check_sub_faces_valid(tol, ang_tol, False, detailed))
msgs.append(self.check_sub_faces_overlapping(tol, False, detailed))
Expand All @@ -2124,7 +2169,7 @@ def check_all(self, raise_exception=True, detailed=False):
msgs.append(self.check_all_air_boundaries_adjacent(False, detailed))

# check the extension attributes
ext_msgs = self._properties._check_extension_attr(detailed)
ext_msgs = self._properties._check_all_extension_attr(detailed)
if detailed:
ext_msgs = [m for m in ext_msgs if isinstance(m, list)]
msgs.extend(ext_msgs)
Expand All @@ -2138,6 +2183,40 @@ def check_all(self, raise_exception=True, detailed=False):
raise ValueError(full_msg)
return full_msg

def check_all_duplicate_identifiers(self, raise_exception=True, detailed=False):
"""Check that there are no duplicate identifiers for any geometry objects.
This includes Rooms, Faces, Apertures, Doors, Shades, and ShadeMeshes.
Args:
raise_exception: Boolean to note whether a ValueError should be raised
if any Model errors are found. If False, this method will simply
return a text string with all errors that were found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A text string with all errors that were found or a list if detailed is True.
This string (or list) will be empty if no errors were found.
"""
# set up defaults to ensure the method runs correctly
detailed = False if raise_exception else detailed
msgs = []
# perform checks for duplicate identifiers
msgs.append(self.check_duplicate_room_identifiers(False, detailed))
msgs.append(self.check_duplicate_face_identifiers(False, detailed))
msgs.append(self.check_duplicate_sub_face_identifiers(False, detailed))
msgs.append(self.check_duplicate_shade_identifiers(False, detailed))
msgs.append(self.check_duplicate_shade_mesh_identifiers(False, detailed))
# output a final report of errors or raise an exception
full_msgs = [msg for msg in msgs if msg]
if detailed:
return [m for msg in full_msgs for m in msg]
full_msg = '\n'.join(full_msgs)
if raise_exception and len(full_msgs) != 0:
raise ValueError(full_msg)
return full_msg

def check_duplicate_room_identifiers(self, raise_exception=True, detailed=False):
"""Check that there are no duplicate Room identifiers in the model.
Expand Down
48 changes: 46 additions & 2 deletions honeybee/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,52 @@ def apply_properties_from_dict(self, data):
raise Exception(
'Failed to apply {} properties to the Model: {}'.format(atr, e))

def _check_extension_attr(self, detailed=False):
"""Check the attributes of extensions.
def _check_for_extension(self, extension_name, detailed=False):
"""Check the validity of the model for a specific extension.
Args:
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
extension_name: Text for the name of the extension to be checked.
This value should always be lowercase to match the name of the
extension package. Some common honeybee extension names that can
be input here if they are installed include:
* radiance
* energy
* doe2
* ies
* idaice
"""
msgs = []
for atr in self._extension_attributes:
check_msg = None
try:
var = getattr(self, atr)
except AttributeError as e:
raise ImportError(
'Extension for {} is not installed or has not been set up '
'for model validation.\n{}'.format(var, e))
if not hasattr(var, 'check_for_extension'):
raise NotImplementedError(
'Extension for {} does not have validation routines.'.format(var))
try:
check_msg = var.check_for_extension(
raise_exception=False, detailed=detailed)
if detailed and check_msg is not None:
msgs.append(check_msg)
elif check_msg != '':
f_msg = 'Attributes for {} are invalid.\n{}'.format(atr, check_msg)
msgs.append(f_msg)
except Exception as e:
import traceback
traceback.print_exc()
raise Exception('Failed to check_for_extension '
'for {}: {}'.format(var, e))
return msgs

def _check_all_extension_attr(self, detailed=False):
"""Check the attributes of all extensions.
This method should be called within the check_all method of the Model object
to ensure that the check_all functions of any extension model properties
Expand Down
21 changes: 21 additions & 0 deletions honeybee/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -1493,8 +1493,29 @@ def check_degenerate(self, tolerance=0.01, raise_exception=True, detailed=False)
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
# if the room has the correct number of faces, test the envelope geometry
if len(self._faces) >= 4 and self.volume > tolerance:
try:
test_room = self.duplicate() # duplicate to avoid editing the original
test_room.remove_colinear_vertices_envelope(tolerance)
except ValueError as e:
deg_msg = str(e)
if raise_exception:
raise ValueError(e)
if detailed:
deg_msg = [{
'type': 'ValidationError',
'code': '000107',
'error_type': 'Degenerate Room Volume',
'extension_type': 'Core',
'element_type': 'Room',
'element_id': [self.identifier],
'element_name': [self.display_name],
'message': deg_msg
}]
return deg_msg
return [] if detailed else ''
# otherwise, report the room as invalid
msg = 'Room "{}" is degenerate with zero volume. It should be deleted'.format(
self.full_id)
return self._validation_message(
Expand Down
1 change: 0 additions & 1 deletion tests/face_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,6 @@ def test_apertures_by_ratio_gridded():
assert sum([ap.area for ap in face.apertures]) == pytest.approx(face.area * 0.4, rel=1e-3)



def test_apertures_by_width_height_rectangle():
"""Test the adding of apertures by width/height."""
pts = (Point3D(0, 0, 0), Point3D(0, 0, 3), Point3D(10, 0, 3), Point3D(10, 0, 0))
Expand Down

0 comments on commit 9a6d117

Please sign in to comment.