# coding=utf-8
"""Module for parsing OpenStudio measures and setting measure arguments."""
from __future__ import division
import os
import xml.etree.ElementTree as ElementTree
[docs]
class Measure(object):
"""Object to hold all properties of an OpenStudio measure, including arguments.
Args:
folder: Path to the folder in which the measure exists. This folder
must contain a measure.rb and a measure.xml file. Other files are
optional.
Properties:
* folder
* metadata_file
* program_file
* resources_folder
* identifier
* display_name
* description
* type
* arguments
"""
__slots__ = ('_folder', '_metadata_file', '_program_file', '_resources_folder',
'_identifier', '_display_name', '_description', '_type', '_arguments')
def __init__(self, folder):
"""Initialize Measure."""
# check to be sure that the required files are all there
assert os.path.isdir(folder), \
'No directory was found at {}'.format(folder)
self._folder = os.path.abspath(folder)
self._metadata_file = os.path.join(self._folder, 'measure.xml')
assert os.path.isfile(self._metadata_file), \
'No Measure XML file was found at {}'.format(self._metadata_file)
self._program_file = os.path.join(self._folder, 'measure.rb')
assert os.path.isfile(self._program_file), \
'No Measure Ruby file was found at {}'.format(self._program_file)
resources_folder = os.path.join(self._folder, 'resources')
self._resources_folder = None
if os.path.isdir(resources_folder):
self._resources_folder = resources_folder
# parse the XML file to extract the measure properties and arguments
self._parse_metadata_file()
@property
def folder(self):
"""Get the path to the folder in which the measure exists."""
return self._folder
@property
def metadata_file(self):
"""Get the path to the measure.xml file within the measure folder.
This file contains metadata about the measure and this is where many of
the properties on this object originate from.
"""
return self._metadata_file
@property
def program_file(self):
"""Get the path to the measure.rb file within the measure folder.
This file contains the Ruby code that is executed whenever the measure
is run by the OpenStudio CLI.
"""
return self._program_file
@property
def resources_folder(self):
"""Get the path to the folder for resource Ruby file if it exists.
This folder contains Ruby file dependencies that are used in the program_file.
"""
return self._resources_folder
@property
def identifier(self):
"""Get text for the identifier of the measure.
This is also called the "name" in the measure.xml file.
"""
return self._identifier
@property
def display_name(self):
"""Get text for the human-readable display name of the measure.
This is called the "display_name" in the measure.xml file.
"""
return self._display_name
@property
def description(self):
"""Get text for describing what the measure does."""
return self._description
@property
def type(self):
"""Get text for the type of measure this is. This is always one of 3 values.
* ModelMeasure - for measures that operate on the .osm model.
* EnergyPlusMeasure - for measures that operate on the .idf file.
* ReportingMeasure - for measures that run after the simulation is finished.
"""
return self._type
@property
def arguments(self):
"""Get a tuple of MeasureArgument objects for the measure input arguments.
The value property of these objects can be set in order to specify input
arguments for the measure.
"""
return tuple(self._arguments)
[docs]
@classmethod
def from_dict(cls, data, folder='.'):
"""Initialize a Measure from a dictionary.
Args:
data: A dictionary in the format below.
folder: Path to a destination folder to save the measure files. (Default '.')
.. code-block:: python
{
"type": "Measure",
"identifier": string, # Measure identifier
"xml_data": string, # XML file data as string
"rb_data": string, # Ruby file data as string
"resource_data": {}, # Dictionary of strings for any resource ruby files
"argument_values": [], # List of values for each of the measure arguments
}
"""
assert data['type'] == 'Measure', \
'Expected Measure dictionary. Got {}.'.format(data['type'])
fp = os.path.join(folder, data['identifier'])
if not os.path.isdir(fp):
os.makedirs(fp)
# write out the contents of the measure
xml_fp = os.path.join(fp, 'measure.xml')
cls._decompress_to_file(data['xml_data'], xml_fp)
rb_fp = os.path.join(fp, 'measure.rb')
cls._decompress_to_file(data['rb_data'], rb_fp)
if 'resource_data' in data and data['resource_data'] is not None:
resource_path = os.path.join(fp, 'resources')
os.makedirs(resource_path)
for f_name, res in data['resource_data'].items():
res_fp = os.path.join(resource_path, f_name)
cls._decompress_to_file(res, res_fp)
# create the measure object and assign the arguments
new_measure = cls(fp)
for arg, val in zip(new_measure.arguments, data['argument_values']):
if val is not None:
arg.value = val
return new_measure
[docs]
def to_dict(self):
"""Convert Measure to a dictionary."""
# create a base dictionary with the XML and Ruby file data, and the arguments
base = {
'type': 'Measure',
'identifier': self.identifier,
'xml_data': self._compress_file(self.metadata_file),
'rb_data': self._compress_file(self.program_file),
'argument_values': [arg._value for arg in self._arguments]
}
# add any resource files to the dictionary if they exist
if self.resources_folder:
base['resource_data'] = {}
for rb_file in os.listdir(self.resources_folder):
path = os.path.join(self.folder, rb_file)
base['resource_data'][rb_file] = self._compress_file(path)
return base
[docs]
def to_osw_dict(self, full_path=False):
"""Get a Python dictionary that can be written to an OSW JSON.
Specifically, this dictionary can be appended to the "steps" key of the
OpenStudio Workflow (.osw) JSON dictionary in order to include the measure
in the workflow.
Note that this method does not perform any checks to validate that the
Measure has all required values and only arguments with values will be
included in the dictionary. Validation should be done separately with
the validate method.
Args:
full_path: Boolean to note whether the full path to the measure should
be written under the 'measure_dir_name' key or just the measure
base name. (Default: False)
"""
meas_dir = self.folder if full_path else os.path.basename(self.folder)
base = {'measure_dir_name': meas_dir, 'arguments': {}}
for arg in self._arguments:
if arg.value is not None:
base['arguments'][arg.identifier] = arg.value
return base
[docs]
def validate(self, raise_exception=True):
"""Check if all required arguments have values needed for simulation.
Args:
raise_exception: If True, an exception will be raised if there's a
required argument and there is no value. Otherwise, False will
be returned for this case and True will be returned if all
is correct.
"""
for arg in self._arguments:
if not arg.validate(raise_exception):
return False
return True
[docs]
@staticmethod
def sort_measures(measures):
"""Sort measures according to the order they will be executed by OpenStudio CLI.
ModelMeasures will be first, followed by EnergyPlusMeasures, followed by
ReportingMeasures.
"""
m_dict = {'ModelMeasure': [], 'EnergyPlusMeasure': [], 'ReportingMeasure': []}
for measure in measures:
m_dict[measure.type].append(measure)
return m_dict['ModelMeasure'] + m_dict['EnergyPlusMeasure'] + \
m_dict['ReportingMeasure']
def _parse_metadata_file(self):
"""Parse measure properties from the measure.xml file."""
# create an element tree object
tree = ElementTree.parse(self._metadata_file)
root = tree.getroot()
# parse the measure properties from the element tree
self._identifier = root.find('name').text
self._display_name = root.find('display_name').text
self._description = root.find('description').text
self._type = None
for atr in root.find('attributes'):
if atr.find('name').text == 'Measure Type':
self._type = atr.find('value').text
# parse the measure arguments
self._arguments = []
arg_info = root.find('arguments')
if arg_info is not None:
for arg in arg_info:
arg_obj = MeasureArgument(arg)
if arg_obj.model_dependent:
# TODO: Figure out how to implement model-dependent arguments
raise NotImplementedError(
'Model dependent arguments are not yet supported and measure '
'argument is "{}" model dependent.'.format(arg_obj.identifier))
self._arguments.append(arg_obj)
@staticmethod
def _compress_file(filepath):
"""Compress file contents to a string."""
# TODO: Research better ways to compress the file
with open(filepath, 'r') as input_file:
content = input_file.read()
return content
@staticmethod
def _decompress_to_file(value, filepath):
"""Write file contents to a file."""
with open(filepath, 'w') as output_file:
output_file.write(value)
def __len__(self):
return len(self._arguments)
def __getitem__(self, key):
return self._arguments[key]
def __iter__(self):
return iter(self._arguments)
[docs]
def ToString(self):
return self.__repr__()
def __repr__(self):
return 'Measure: {}'.format(self.display_name)
[docs]
class MeasureArgument(object):
"""Object representing a single measure argument.
Args:
xml_element: A Python XML Element object taken from the <arguments> section
of the measure.xml file.
Properties:
* identifier
* display_name
* value
* default_value
* type
* type_text
* required
* description
* model_dependent
* valid_choices
"""
PYTHON_TYPES = {
'Double': float,
'Integer': int,
'Boolean': bool,
'String': str,
'Choice': str
}
__slots__ = ('_identifier', '_display_name', '_value', '_default_value',
'_type', '_type_text', '_required', '_description',
'_model_dependent', '_valid_choices')
def __init__(self, xml_element):
"""Initialize MeasureArgument."""
# parse the required properties of the argument
self._identifier = xml_element.find('name').text
self._type_text = xml_element.find('type').text
self._type = self.PYTHON_TYPES[self._type_text]
required = xml_element.find('required').text
self._required = True if required == 'true' else False
# set up the argument value and default value
self._value = None # will be set by user
self._default_value = None # will be overridden if it is present
if xml_element.find('default_value') is not None and \
xml_element.find('default_value').text is not None:
d_val = xml_element.find('default_value').text
if self._type_text == 'Boolean':
self._default_value = True if d_val.lower() == 'true' else False
else: # just use the type to cast the text
self._default_value = self._type(d_val)
# parse the optional properties of the argument
self._display_name = xml_element.find('display_name').text \
if xml_element.find('display_name') is not None else None
self._description = xml_element.find('description').text \
if xml_element.find('description') is not None else None
model_dependent = xml_element.find('model_dependent').text
self._model_dependent = True if model_dependent == 'true' else False
# parse any choice arguments if they exist
self._valid_choices = None
if self._type_text == 'Choice':
try:
self._valid_choices = tuple(choice.find('value').text
for choice in xml_element.find('choices'))
except TypeError as e:
raise ValueError(
'The measure is invalid. Choice argument was found without any '
'available choices.\n{}'.format(e))
@property
def identifier(self):
"""Get text for the identifier of the argument.
This is also called the "name" in the measure.xml file.
"""
return self._identifier
@property
def display_name(self):
"""Get text for the human-readable display name of the argument.
This is called the "display_name" in the measure.xml file.
"""
return self._display_name
@property
def value(self):
"""Get or set the value for the argument.
If not set, this will be equal to the default_value and, if no default
value is included for this argument, it will be None.
"""
if self._value is not None:
return self._value
return self._default_value
@value.setter
def value(self, val):
if val is not None:
try:
val = self._type(val)
except Exception:
raise TypeError('Value for measure argument "{}" must be a {}. '
'Got {}'.format(self.identifier, self._type, type(val)))
if self._valid_choices:
assert val in self._valid_choices, 'Choice measure argument "{}" ' \
'must be one of the following:\n{}\nGot {}'.format(
self.identifier, self._valid_choices, val)
self._value = val
@property
def default_value(self):
"""Get the default value for the argument.
This may be None if no default value has been included.
"""
return self._default_value
@property
def type(self):
"""Get the Python type of argument this is (eg. float, str, int)."""
return self._type
@property
def type_text(self):
"""Get a text string for the argument type as it appears in the measure.xml.
(eg. 'Double', 'String', 'Boolean').
"""
return self._type_text
@property
def required(self):
"""Get a boolean for whether this argument is required to run the measure."""
return self._required
@property
def description(self):
"""Get text for describing what the measure does if it exists."""
return self._description
@property
def model_dependent(self):
"""Get a boolean for whether this argument is dependent on the model."""
return self._model_dependent
@property
def valid_choices(self):
"""Get a list of text for valid inputs for choice arguments.
This will be None if the argument type is not Choice.
"""
return self._valid_choices
[docs]
def validate(self, raise_exception=True):
"""If this argument is required, check that it has a value.
If this argument is not required, this method will always return True.
Args:
raise_exception: If True, an exception will be raised if this argument
is required and there is no value. Otherwise, False will be returned
for this case and True will be returned if all is correct.
"""
if self.required and self.value is None:
if self._valid_choices != (None,):
if raise_exception:
raise ValueError('Measure argument "{}" is required and missing '
'a value'.format(self.identifier))
return False
return True
[docs]
def ToString(self):
return self.__repr__()
def __repr__(self):
return '{} <{}> value: {}'.format(self.display_name, self.type_text, self.value)