Skip to content

Commit

Permalink
feat(model): Refactor validation to be much more robust
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey committed Dec 3, 2024
1 parent 9a6d117 commit 5728edd
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 113 deletions.
210 changes: 120 additions & 90 deletions honeybee/cli/validate.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"""honeybee validation commands."""
import sys
import logging
import json as py_json
import click

from honeybee.model import Model
from honeybee.config import folders

_logger = logging.getLogger(__name__)

Expand All @@ -19,9 +17,13 @@ def validate():
@click.argument('model-file', type=click.Path(
exists=True, file_okay=True, dir_okay=False, resolve_path=True))
@click.option(
'--check-all/--room-overlaps', ' /-ro', help='Flag to note whether the output '
'validation report should validate all possible issues with the model or only '
'the Room collisions should be checked.', default=True, show_default=True)
'--extension', '-e', help='Text for the name of the extension to be checked. '
'The value input 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 include: '
'Radiance, EnergyPlus, DOE2, IES, IDAICE',
type=str, default='All', show_default=True)
@click.option(
'--plain-text/--json', ' /-j', help='Flag to note whether the output validation '
'report should be formatted as a JSON object instead of plain text. If set to JSON, '
Expand All @@ -37,7 +39,7 @@ def validate():
'--output-file', '-f', help='Optional file to output the full report '
'of the validation. By default it will be printed out to stdout',
type=click.File('w'), default='-')
def validate_model_cli(model_file, check_all, plain_text, output_file):
def validate_model_cli(model_file, extension, plain_text, output_file):
"""Validate all properties of a Model file against Honeybee schema.
This includes checking basic compliance with the 5 rules of honeybee geometry
Expand All @@ -62,133 +64,161 @@ def validate_model_cli(model_file, check_all, plain_text, output_file):
"""
try:
json = not plain_text
room_overlaps = not check_all
validate_model(model_file, room_overlaps, json, output_file)
validate_model(model_file, extension, json, output_file)
except Exception as e:
_logger.exception('Model validation failed.\n{}'.format(e))
sys.exit(1)
else:
sys.exit(0)


def validate_model(model_file, room_overlaps=False, json=False, output_file=None,
check_all=True, plain_text=True):
def validate_model(model_file, extension='All', json=False, output_file=None,
plain_text=True):
"""Validate all properties of a Model file against the Honeybee schema.
This includes checking basic compliance with the 5 rules of honeybee geometry
as well as checks for all extension attributes.
Args:
model_file: Full path to a Honeybee Model file.
room_overlaps: Boolean to note whether the output validation report
should only validate the Room collisions (True) or all possible
issues with the model should be checked (False). (Default: False).
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
* DOE2
* IES
* IDAICE
json: Boolean to note whether the output validation report should be
formatted as a JSON object instead of plain text.
output_file: Optional file to output the string of the visualization
file contents. If None, the string will simply be returned from
this method.
"""
if not json:
# re-serialize the Model to make sure no errors are found
c_ver = folders.honeybee_core_version_str
s_ver = folders.honeybee_schema_version_str
ver_msg = 'Validating Model using honeybee-core=={} and ' \
'honeybee-schema=={}'.format(c_ver, s_ver)
print(ver_msg)
parsed_model = Model.from_file(model_file)
print('Re-serialization passed.')
# perform several other checks for geometry rules and others
if not room_overlaps:
report = parsed_model.check_all(raise_exception=False, detailed=False)
else:
report = parsed_model.check_room_volume_collisions(raise_exception=False)
print('Model checks completed.')
# check the report and write the summary of errors
if report == '':
full_msg = ver_msg + '\nCongratulations! Your Model is valid!'
else:
full_msg = ver_msg + \
'\nYour Model is invalid for the following reasons:\n' + report
if output_file is None:
return full_msg
else:
output_file.write(full_msg)
formatted as a JSON object instead of plain text. (Default: False).
output_file: Optional file to output the full report of the validation.
If None, the string will simply be returned from this method.
"""
report = Model.validate(model_file, 'check_for_extension', [extension], json)
if output_file is None:
return report
else:
out_dict = {
'type': 'ValidationReport',
'app_name': 'Honeybee',
'app_version': folders.honeybee_core_version_str,
'schema_version': folders.honeybee_schema_version_str
}
try:
parsed_model = Model.from_file(model_file)
out_dict['fatal_error'] = ''
if not room_overlaps:
errors = parsed_model.check_all(raise_exception=False, detailed=True)
else:
errors = parsed_model.check_room_volume_collisions(
raise_exception=False, detailed=True)
out_dict['errors'] = errors
out_dict['valid'] = True if len(out_dict['errors']) == 0 else False
except Exception as e:
out_dict['fatal_error'] = str(e)
out_dict['errors'] = []
out_dict['valid'] = False
if output_file is None:
return py_json.dumps(out_dict, indent=4)
else:
output_file.write(py_json.dumps(out_dict, indent=4))


@validate.command('room-volumes')
output_file.write(report)


@validate.command('rooms-solid')
@click.argument('model-file', type=click.Path(
exists=True, file_okay=True, dir_okay=False, resolve_path=True))
@click.option(
'--output-file', '-f', help='Optional file to output the JSON strings of '
'ladybug_geometry LineSegment3Ds that represent naked and non-manifold edges. '
'By default it will be printed out to stdout', type=click.File('w'), default='-')
def validate_room_volumes_cli(model_file, output_file):
'--plain-text/--json', ' /-j', help='Flag to note whether the output validation '
'report should be formatted as a JSON object instead of plain text. If set to JSON, '
'the output object will contain several attributes. An attribute called '
'"fatal_error" is a text string containing an exception if the Model failed to '
'serialize and will be an empty string if serialization was successful. An '
'attribute called "errors" will contain a list of JSON objects for each '
'invalid issue. A boolean attribute called "valid" will note whether the Model '
'is valid or not.',
default=True, show_default=True)
@click.option(
'--output-file', '-f', help='Optional file to output the full report '
'of the validation. By default it will be printed out to stdout.',
type=click.File('w'), default='-')
def validate_rooms_solid_cli(model_file, plain_text, output_file):
"""Validate whether all Room volumes in a model are solid.
The returned result can include a list of all naked and non-manifold edges
preventing closed room volumes when --json is used. This is helpful for visually
identifying issues in geometry that are preventing the room volume from
validating as closed.
\b
Args:
model_file: Full path to a Honeybee Model file.
"""
try:
json = not plain_text
validate_rooms_solid(model_file, json, output_file)
except Exception as e:
_logger.exception('Model room volume validation failed.\n{}'.format(e))
sys.exit(1)
else:
sys.exit(0)


def validate_rooms_solid(model_file, json=False, output_file=None, plain_text=True):
"""Get a list of all naked and non-manifold edges preventing closed room volumes.
This is helpful for visually identifying issues in geometry that are preventing
the room volume from reading as closed.
Args:
model_file: Full path to a Honeybee Model file.
json: Boolean to note whether the output validation report should be
formatted as a JSON object instead of plain text. (Default: False).
output_file: Optional file to output the full report of the validation.
If None, the string will simply be returned from this method.
"""
report = Model.validate(model_file, 'check_rooms_solid', json_output=json)
if output_file is None:
return report
else:
output_file.write(report)


@validate.command('room-collisions')
@click.argument('model-file', type=click.Path(
exists=True, file_okay=True, dir_okay=False, resolve_path=True))
@click.option(
'--plain-text/--json', ' /-j', help='Flag to note whether the output validation '
'report should be formatted as a JSON object instead of plain text. If set to JSON, '
'the output object will contain several attributes. An attribute called '
'"fatal_error" is a text string containing an exception if the Model failed to '
'serialize and will be an empty string if serialization was successful. An '
'attribute called "errors" will contain a list of JSON objects for each '
'invalid issue. A boolean attribute called "valid" will note whether the Model '
'is valid or not.', default=True, show_default=True)
@click.option(
'--output-file', '-f', help='Optional file to output the full report '
'of the validation. By default it will be printed out to stdout.',
type=click.File('w'), default='-')
def validate_room_collisions_cli(model_file, plain_text, output_file):
"""Validate whether all Room volumes in a model are solid.
The returned result can include a list of all naked and non-manifold edges
preventing closed room volumes when --json is used. This is helpful for visually
identifying issues in geometry that are preventing the room volume from
validating as closed.
\b
Args:
model_file: Full path to a Honeybee Model file.
"""
try:
validate_room_volumes(model_file, output_file)
json = not plain_text
validate_room_collisions(model_file, json, output_file)
except Exception as e:
_logger.exception('Model room volume validation failed.\n{}'.format(e))
sys.exit(1)
else:
sys.exit(0)


def validate_room_volumes(model_file, output_file=None):
def validate_room_collisions(model_file, json=False, output_file=None, plain_text=True):
"""Get a list of all naked and non-manifold edges preventing closed room volumes.
This is helpful for visually identifying issues in geometry that are preventing
the room volume from reading as closed.
Args:
model_file: Full path to a Honeybee Model file.
output_file: Optional file to output the string of the visualization
file contents. If None, the string will simply be returned from
this method.
json: Boolean to note whether the output validation report should be
formatted as a JSON object instead of plain text. (Default: False).
output_file: Optional file to output the full report of the validation.
If None, the string will simply be returned from this method.
"""
# re-serialize the Model and collect all naked and non-manifold edges
parsed_model = Model.from_file(model_file)
problem_edges = []
for room in parsed_model.rooms:
if not room.geometry.is_solid:
problem_edges.extend(room.geometry.naked_edges)
problem_edges.extend(room.geometry.non_manifold_edges)
# write the new model out to the file or stdout
prob_array = [lin.to_dict() for lin in problem_edges]
report = Model.validate(model_file, 'check_room_volume_collisions', json_output=json)
if output_file is None:
return py_json.dumps(prob_array)
return report
else:
output_file.write(py_json.dumps(prob_array))
output_file.write(report)
82 changes: 82 additions & 0 deletions honeybee/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3396,6 +3396,88 @@ def _all_objects(self):
return self._rooms + self._orphaned_faces + self._orphaned_shades + \
self._orphaned_apertures + self._orphaned_doors + self._shade_meshes

@staticmethod
def validate(model, check_function='check_for_extension', check_args=None,
json_output=False):
"""Get a string of a validation report given a specific check_function.
Args:
model: A Honeybee Model object for which validation will be performed.
This can also be the file path to a HBJSON or a JSON string
representation of a Honeybee Model. These latter two options may
be useful if the type of validation issue with the Model is
one that prevents serialization.
check_function: Text for the name of a check function on this Model
that will be used to generate the validation report. For example,
check_all or check_rooms_solid. (Default: check_for_extension),
check_args: An optional list of arguments to be passed to the
check_function. If None, all default values for the arguments
will be used. (Default: None).
json_output: Boolean to note whether the output validation report
should be formatted as a JSON object instead of plain text.
"""
# first get the function to call on this class
check_func = getattr(Model, check_function, None)
assert check_func is not None, \
'Honeybee Model class has no method {}'.format(check_function)

# process the input model if it's not already serialized
report = ''
if isinstance(model, str):
try:
if model.startswith('{'):
model = Model.from_dict(json.loads(model))
elif os.path.isfile(model):
model = Model.from_file(model)
else:
report = 'Input Model for validation is not a Model object, ' \
'file path to a Model or a Model HBJSON string.'
except Exception as e:
report = str(e)
elif not isinstance(model, Model):
report = 'Input Model for validation is not a Model object, ' \
'file path to a Model or a Model HBJSON string.'
# process the arguments and options
args = [model] if check_args is None else [model] + list(check_args)
kwargs = {'raise_exception': False}

# create the report
if not json_output: # create a plain text report
# add the versions of things into the validation message
c_ver = folders.honeybee_core_version_str
s_ver = folders.honeybee_schema_version_str
ver_msg = 'Validating Model using honeybee-core=={} and ' \
'honeybee-schema=={}'.format(c_ver, s_ver)
# run the check function
if isinstance(args[0], Model):
kwargs['detailed'] = False
report = check_func(*args, **kwargs)
# format the results of the check
if report == '':
full_msg = ver_msg + '\nCongratulations! Your Model is valid!'
else:
full_msg = ver_msg + \
'\nYour Model is invalid for the following reasons:\n' + report
return full_msg
else:
# add the versions of things into the validation message
out_dict = {
'type': 'ValidationReport',
'app_name': 'Honeybee',
'app_version': folders.honeybee_core_version_str,
'schema_version': folders.honeybee_schema_version_str,
'fatal_error': report
}
if report == '':
kwargs['detailed'] = True
errors = check_func(*args, **kwargs)
out_dict['errors'] = errors
out_dict['valid'] = True if len(out_dict['errors']) == 0 else False
else:
out_dict['errors'] = []
out_dict['valid'] = False
return json.dumps(out_dict, indent=4)

@staticmethod
def conversion_factor_to_meters(units):
"""Get the conversion factor to meters based on input units.
Expand Down
Loading

0 comments on commit 5728edd

Please sign in to comment.