openstudio_measure_tester 0.1.0

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.
@@ -0,0 +1,502 @@
1
+ ########################################################################################################################
2
+ # OpenStudio(R), Copyright (c) 2008-2018, Alliance for Sustainable Energy, LLC. All rights reserved.
3
+ #
4
+ # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
5
+ # following conditions are met:
6
+ #
7
+ # (1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following
8
+ # disclaimer.
9
+ #
10
+ # (2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
11
+ # following disclaimer in the documentation and/or other materials provided with the distribution.
12
+ #
13
+ # (3) Neither the name of the copyright holder nor the names of any contributors may be used to endorse or promote
14
+ # products derived from this software without specific prior written permission from the respective party.
15
+ #
16
+ # (4) Other than as required in clauses (1) and (2), distributions in any form of modifications or other derivative
17
+ # works may not use the "OpenStudio" trademark, "OS", "os", or any other confusingly similar designation without
18
+ # specific prior written permission from Alliance for Sustainable Energy, LLC.
19
+ #
20
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
21
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER, THE UNITED STATES GOVERNMENT, OR ANY CONTRIBUTORS BE LIABLE FOR
23
+ # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24
+ # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
25
+ # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
+ ########################################################################################################################
28
+
29
+ module OpenStudioMeasureTester
30
+ class OpenStudioStyle
31
+ attr_reader :results
32
+ attr_reader :measure_messages
33
+
34
+ CHECKS = [
35
+ {
36
+ regex: /OpenStudio::Ruleset::ModelUserScript/,
37
+ check_type: :if_exists,
38
+ message: 'OpenStudio::Ruleset::ModelUserScript is deprecated, use OpenStudio::Measure::ModelMeasure instead.',
39
+ type: :deprecated,
40
+ severity: :error,
41
+ file_type: :measure
42
+ }, {
43
+ regex: /OpenStudio::Ruleset::OSRunner/,
44
+ check_type: :if_exists,
45
+ message: 'OpenStudio::Ruleset::OSRunner is deprecated, use OpenStudio::Measure::OSRunner.new(OpenStudio::WorkflowJSON.new) instead.',
46
+ type: :deprecated,
47
+ severity: :error,
48
+ file_type: :measure
49
+ }, {
50
+ regex: /OpenStudio::Ruleset::OSRunner/,
51
+ check_type: :if_exists,
52
+ message: 'OpenStudio::Ruleset::OSRunner is deprecated, use OpenStudio::Measure::OSRunner.new(OpenStudio::WorkflowJSON.new) instead.',
53
+ type: :deprecated,
54
+ severity: :error,
55
+ file_type: :test
56
+ }, {
57
+ regex: /OpenStudio::Ruleset::OSArgumentVector/,
58
+ check_type: :if_exists,
59
+ message: 'OpenStudio::Ruleset::OSArgumentVector is deprecated, use OpenStudio::Measure::OSArgumentVector instead.',
60
+ type: :deprecated,
61
+ severity: :error,
62
+ file_type: :measure
63
+ }, {
64
+ regex: /OpenStudio::Ruleset::OSArgument/,
65
+ check_type: :if_exists,
66
+ message: 'OpenStudio::Ruleset::OSArgument is deprecated, use OpenStudio::Measure::OSArgument instead.',
67
+ type: :deprecated,
68
+ severity: :error,
69
+ file_type: :measure
70
+ }, {
71
+ regex: /OpenStudio::Ruleset::OSArgumentMap/,
72
+ check_type: :if_exists,
73
+ message: 'OpenStudio::Ruleset::OSArgumentMap is deprecated, use OpenStudio::Measure.convertOSArgumentVectorToMap(arguments) instead.',
74
+ type: :deprecated,
75
+ severity: :error,
76
+ file_type: :measure
77
+ }, {
78
+ regex: /def description(.*?)end/m,
79
+ check_type: :if_missing,
80
+ message: '\'def description\' is missing.',
81
+ type: :syntax,
82
+ severity: :error,
83
+ file_type: :measure
84
+ }, {
85
+ regex: /def modeler_description(.*?)end/m,
86
+ check_type: :if_missing,
87
+ message: '\'def modeler_description\' is missing.',
88
+ type: :syntax,
89
+ severity: :error,
90
+ file_type: :measure
91
+ }, {
92
+ regex: /require .openstudio_measure_tester\/test_helper./,
93
+ check_type: :if_missing,
94
+ message: "Must include 'require 'openstudio_measure_tester/test_helper'' in Test file to report coverage correctly.",
95
+ type: :syntax,
96
+ severity: :error,
97
+ file_type: :test
98
+ }, {
99
+ regex: /MiniTest::Unit::TestCase/,
100
+ check_type: :if_exists,
101
+ message: "MiniTest::Unit::TestCase is deprecated. Use MiniTest::Test.",
102
+ type: :syntax,
103
+ severity: :warning,
104
+ file_type: :test
105
+ }, {
106
+ regex: /require .openstudio\/ruleset /,
107
+ check_type: :if_exists,
108
+ message: "Require openstudio/ruleset/* is deprecated. Use require 'openstudio/measure/*'",
109
+ type: :syntax,
110
+ severity: :warning,
111
+ file_type: :test
112
+ }
113
+ ].freeze
114
+
115
+ # Pass in the measures_glob with the filename (typically measure.rb)
116
+ def initialize(measures_glob)
117
+ @measures_glob = measures_glob
118
+ @results = {by_measure: {}}
119
+
120
+ # Individual measure messages
121
+ @measure_messages = []
122
+
123
+ # Load in the method infoExtractor which will load measure info (helpful comment huh?)
124
+ # https://github.com/NREL/OpenStudio/blob/e7aa6be05a714814983d68ea840ca61383e9ef54/openstudiocore/src/measure/OSMeasureInfoGetter.cpp#L254
125
+ eval(::OpenStudio::Measure.infoExtractorRubyFunction)
126
+
127
+ Dir[@measures_glob].each do |measure|
128
+ measure_dir = File.dirname(measure)
129
+
130
+ # initialize the measure name from the directory until the measure_hash is loaded.
131
+ # The test_measure method can fail but still report errors, so we need a place to store the results.
132
+ @results[:by_measure].merge!(test_measure(measure_dir))
133
+ end
134
+
135
+ aggregate_results
136
+ end
137
+
138
+ def test_measure(measure_dir)
139
+ # reset the instance variables for this measure
140
+ @measure_messages.clear
141
+ # initialize the measure name from the directory until the measure_hash is loaded.
142
+ # The test_measure method can fail but still report errors, so we need a place to store the results.
143
+ @measure_classname = measure_dir.split('/').last
144
+
145
+ measure_missing = false
146
+ unless Dir.exist? measure_dir
147
+ log_message("Could not find measure directory: '#{measure_dir}'.", :general, :error)
148
+ measure_missing = true
149
+ end
150
+
151
+ unless File.exist? "#{measure_dir}/measure.rb"
152
+ log_message("Could not find measure.rb in '#{measure_dir}'.", :general, :error)
153
+ measure_missing = true
154
+ end
155
+
156
+ unless File.exist? "#{measure_dir}/measure.xml"
157
+ log_message("Could not find measure.xml in '#{measure_dir}'.", :general, :error)
158
+ measure_missing = true
159
+ end
160
+
161
+ unless measure_missing
162
+ measure = OpenStudio::BCLMeasure.load(measure_dir)
163
+ if measure.empty?
164
+ log_message("Failed to load measure '#{measure_dir}'", :general, :error)
165
+ else
166
+ measure = measure.get
167
+ measure_info = infoExtractor(measure, OpenStudio::Model::OptionalModel.new, OpenStudio::OptionalWorkspace.new)
168
+
169
+ measure_hash = generate_measure_hash(measure_dir, measure, measure_info)
170
+ measure_hash.merge!(get_attributes_from_measure(measure_dir, measure_hash[:class_name]))
171
+
172
+ @measure_classname = measure_hash[:class_name]
173
+ # At this point, the measure.rb file is ensured to exist
174
+
175
+ # run static checks on the files in the measure directory
176
+ run_regex_checks(measure_dir)
177
+
178
+ validate_measure_hash(measure_hash)
179
+ end
180
+ end
181
+
182
+ # pp @measure_messages
183
+
184
+ # calculate the info, warnings, errors and return the measure data
185
+ # TODO: break out the issues by file
186
+ return {
187
+ @measure_classname.to_sym => {
188
+ measure_info: @measure_messages.nil? ? 0 : @measure_messages.count{|h| h[:severity] == :info},
189
+ measure_warnings: @measure_messages.nil? ? 0 : @measure_messages.count{|h| h[:severity] == :warning},
190
+ measure_errors: @measure_messages.nil? ? 0 : @measure_messages.count{|h| h[:severity] == :error},
191
+ issues: @measure_messages.clone
192
+ }
193
+ }
194
+ end
195
+
196
+ def log_message(message, type = :syntax, severity = :info)
197
+ new_message = {
198
+ message: message,
199
+ type: type,
200
+ severity: severity
201
+ }
202
+ @measure_messages << new_message
203
+ end
204
+
205
+ # read the data in the results and sum up the total number of issues for all measures
206
+ def aggregate_results
207
+ total_info = 0
208
+ total_warnings = 0
209
+ total_errors = 0
210
+ @results[:by_measure].each_pair do |k, v|
211
+ total_info += v[:measure_info]
212
+ total_warnings += v[:measure_warnings]
213
+ total_errors += v[:measure_errors]
214
+ end
215
+ @results[:total_info] = total_info
216
+ @results[:total_warnings] = total_warnings
217
+ @results[:total_errors] = total_errors
218
+ end
219
+
220
+ def save_results
221
+ FileUtils.mkdir 'openstudio_style' unless Dir.exist? 'openstudio_style'
222
+ File.open("openstudio_style/openstudio_style.json", 'w') do |file|
223
+ file << JSON.pretty_generate(@results)
224
+ end
225
+ end
226
+
227
+ def run_regex_checks(measure_dir)
228
+ def check(data, check)
229
+ if check[:check_type] == :if_exists
230
+ if data =~ check[:regex]
231
+ log_message(check[:message], check[:type], check[:severity])
232
+ end
233
+ elsif check[:check_type] == :if_missing
234
+ if data !~ check[:regex]
235
+ log_message(check[:message], check[:type], check[:severity])
236
+ end
237
+ end
238
+ end
239
+
240
+ file_data = File.read("#{measure_dir}/measure.rb")
241
+ test_files = Dir["#{measure_dir}/**/*_[tT]est.rb"]
242
+ CHECKS.each do |check|
243
+ if check[:file_type] == :test
244
+ test_files.each do |test_file|
245
+ puts test_file
246
+ test_filedata = File.read(test_file)
247
+ check(test_filedata, check)
248
+ end
249
+ elsif check[:file_type] == :measure
250
+ check(file_data, check)
251
+ end
252
+ end
253
+ end
254
+
255
+ # check the name of the measure and make sure that there are no unwanted characters
256
+ #
257
+ # @param name_type [String] type of name that is being validated (e.g. Display Name, Class Name, etc)
258
+ # @param name [String] name to validate
259
+ # @param name [Symbol] severity, [:info, :warning, :error]
260
+ # @param options [Hash] Additional checks
261
+ # @option options [Boolean] :ensure_camelcase
262
+ # @option options [Boolean] :ensure_snakecase
263
+ def validate_name(name_type, name, severity = :info, options = {})
264
+ clean_name = name
265
+
266
+ # Check for parenthetical names
267
+ if clean_name =~ /\(.+?\)/
268
+ log_message("#{name_type} '#{name}' appears to have units. Set units in the setUnits method.", severity)
269
+ end
270
+
271
+ if clean_name =~ /\?|\.|\#/
272
+ log_message("#{name_type} '#{name}' cannot contain ?#.[] characters.", :syntax, severity)
273
+ end
274
+
275
+ if options[:ensure_camelcase]
276
+ # convert to snake and then back to camelcase to check if formatted correctly
277
+ if clean_name != clean_name.strip
278
+ log_message("#{name_type} '#{name}' has leading or trailing spaces.", :syntax, severity)
279
+ end
280
+
281
+ if clean_name != clean_name.to_snakecase.to_camelcase
282
+ log_message("#{name_type} '#{name}' is not CamelCase.", :syntax, severity)
283
+ end
284
+ end
285
+
286
+ if options[:ensure_snakecase]
287
+ # no leading/trailing spaces
288
+ if clean_name != clean_name.strip
289
+ log_message("#{name_type} '#{name}' has leading or trailing spaces.", :syntax, severity)
290
+ end
291
+
292
+ if clean_name != clean_name.to_snakecase
293
+ log_message("#{name_type} '#{name}' is not snake_case.", :syntax, severity)
294
+ end
295
+ end
296
+ end
297
+
298
+ # Validate the measure hash to make sure that it is meets the style guide. This will also perform the selection
299
+ # of which data to use for the "actual metadata"
300
+ def validate_measure_hash(measure_hash)
301
+ validate_name('Measure name', measure_hash[:name], :error, ensure_snakecase: true)
302
+ validate_name('Class name', measure_hash[:class_name], :error, ensure_camelcase: true)
303
+ # check if @measure_name (which is the directory name) is snake cased
304
+
305
+ validate_name('Measure directory name',
306
+ measure_hash[:measure_dir].split('/').last,
307
+ :warning,
308
+ ensure_snakecase: true)
309
+
310
+ log_message('Could not find measure description in XML.', :structure, :warning) if measure_hash[:description].empty?
311
+ log_message('Could not find modeler description in XML.', :structure, :warning) unless measure_hash[:modeler_description]
312
+ log_message('Could not find display_name in measure.', :structure, :warning) unless measure_hash[:display_name]
313
+ log_message('Could not find measure name in measure.', :structure, :warning) unless measure_hash[:name]
314
+
315
+ # def name is the display name in the XML!
316
+ if measure_hash[:values_from_file][:display_name].empty?
317
+ log_message('Could not find "def name" in measure.rb', :structure, :error)
318
+ elsif measure_hash[:display_name] != measure_hash[:values_from_file][:display_name]
319
+ log_message("'def name' in measure.rb differs from <display_name> in XML. Run openstudio measure -u .", :structure, :error)
320
+ end
321
+
322
+ if measure_hash[:values_from_file][:name] != measure_hash[:name]
323
+ log_message("Measure class as snake_case name does not match <name> in XML. Run openstudio measure -u .", :structure, :error)
324
+ end
325
+
326
+ if measure_hash[:values_from_file][:description].empty?
327
+ log_message('Could not find "def description" in measure.rb', :structure, :error)
328
+ elsif measure_hash[:description] != measure_hash[:values_from_file][:description]
329
+ log_message('Description in measure.rb differs from description in XML', :structure, :error)
330
+ end
331
+
332
+ if measure_hash[:values_from_file][:description].empty?
333
+ log_message('Could not find "def modeler_description" in measure.rb', :structure, :error) if measure_hash[:values_from_file][:description].empty?
334
+ elsif measure_hash[:description] != measure_hash[:values_from_file][:description]
335
+ log_message('Modeler description in measure.rb differs from modeler description in XML', :structure, :error)
336
+ end
337
+
338
+ measure_hash[:arguments].each do |arg|
339
+ validate_name('Argument display name', arg[:display_name], :error)
340
+ # {
341
+ # :name => "relative_building_rotation",
342
+ # :display_name =>
343
+ # "Number of Degrees to Rotate Building (positive value is clockwise).",
344
+ # :description => "",
345
+ # :type => "Double",
346
+ # :required => true,
347
+ # :model_dependent => false,
348
+ # :default_value => 90.0
349
+ # }
350
+ end
351
+ end
352
+
353
+ def get_attributes_from_measure(measure_dir, class_name)
354
+ result = {
355
+ values_from_file: {
356
+ name: '',
357
+ display_name: '',
358
+ description: '',
359
+ modeler_description: ''
360
+ }
361
+ }
362
+
363
+ begin
364
+ # file exists because the init checks for its existence
365
+ require "#{measure_dir}/measure.rb"
366
+ measure = Object.const_get(class_name).new
367
+ rescue LoadError => e
368
+ log_message("Could not load measure into memory with error #{e.message}", :initialize, :error)
369
+ return result
370
+ end
371
+
372
+ result[:values_from_file][:name] = class_name.to_snakecase
373
+ result[:values_from_file][:display_name] = measure.name
374
+ result[:values_from_file][:description] = measure.description
375
+ result[:values_from_file][:modeler_description] = measure.modeler_description
376
+
377
+ result
378
+ end
379
+
380
+ ###################################################################################################################
381
+ # These methods are copied from the measure_manager.rb file in OpenStudio. Once the measure_manager.rb file
382
+ # is shipped with OpenStudio, we can deprecate the copying over.
383
+ #
384
+ # https://github.com/NREL/OpenStudio/blob/7865ba413ef52e8c41b8b95d6643d68eb949f1c4/openstudiocore/src/cli/measure_manager.rb#L355
385
+ ###################################################################################################################
386
+ def generate_measure_hash(measure_dir, measure, measure_info)
387
+ result = {}
388
+ result[:measure_dir] = measure_dir
389
+ result[:name] = measure.name
390
+ result[:directory] = measure.directory.to_s
391
+ if measure.error.is_initialized
392
+ result[:error] = measure.error.get
393
+ end
394
+ result[:uid] = measure.uid
395
+ result[:uuid] = measure.uuid.to_s
396
+ result[:version_id] = measure.versionId
397
+ result[:version_uuid] = measure.versionUUID.to_s
398
+ version_modified = measure.versionModified
399
+ if version_modified.is_initialized
400
+ result[:version_modified] = version_modified.get.toISO8601
401
+ else
402
+ result[:version_modified] = nil
403
+ end
404
+ result[:xml_checksum] = measure.xmlChecksum
405
+ result[:name] = measure.name
406
+ result[:display_name] = measure.displayName
407
+ result[:class_name] = measure.className
408
+ result[:description] = measure.description
409
+ result[:modeler_description] = measure.modelerDescription
410
+ result[:tags] = []
411
+ measure.tags.each {|tag| result[:tags] << tag}
412
+
413
+ result[:outputs] = []
414
+ begin
415
+ # this is an OS 2.0 only method
416
+ measure.outputs.each do |output|
417
+ out = {}
418
+ out[:name] = output.name
419
+ out[:display_name] = output.displayName
420
+ out[:short_name] = output.shortName.get if output.shortName.is_initialized
421
+ out[:description] = output.description
422
+ out[:type] = output.type
423
+ out[:units] = output.units.get if output.units.is_initialized
424
+ out[:model_dependent] = output.modelDependent
425
+ result[:outputs] << out
426
+ end
427
+ rescue StandardError
428
+ end
429
+
430
+ attributes = []
431
+ measure.attributes.each do |a|
432
+ value_type = a.valueType
433
+ if value_type == 'Boolean'.to_AttributeValueType
434
+ attributes << {name: a.name, display_name: a.displayName(true).get, value: a.valueAsBoolean}
435
+ elsif value_type == 'Double'.to_AttributeValueType
436
+ attributes << {name: a.name, display_name: a.displayName(true).get, value: a.valueAsDouble}
437
+ elsif value_type == 'Integer'.to_AttributeValueType
438
+ attributes << {name: a.name, display_name: a.displayName(true).get, value: a.valueAsInteger}
439
+ elsif value_type == 'Unsigned'.to_AttributeValueType
440
+ attributes << {name: a.name, display_name: a.displayName(true).get, value: a.valueAsUnsigned}
441
+ elsif value_type == 'String'.to_AttributeValueType
442
+ attributes << {name: a.name, display_name: a.displayName(true).get, value: a.valueAsString}
443
+ end
444
+ end
445
+ result[:attributes] = attributes
446
+
447
+ result[:arguments] = measure_info ? get_arguments_from_measure_info(measure_info) : []
448
+
449
+ result
450
+ end
451
+
452
+ def get_arguments_from_measure_info(measure_info)
453
+ result = []
454
+
455
+ measure_info.arguments.each do |argument|
456
+ type = argument.type
457
+
458
+ arg = {}
459
+ arg[:name] = argument.name
460
+ arg[:display_name] = argument.displayName
461
+ arg[:description] = argument.description.to_s
462
+ arg[:type] = argument.type.valueName
463
+ arg[:required] = argument.required
464
+ arg[:model_dependent] = argument.modelDependent
465
+
466
+ if type == 'Boolean'.to_OSArgumentType
467
+ arg[:default_value] = argument.defaultValueAsBool if argument.hasDefaultValue
468
+
469
+ elsif type == 'Double'.to_OSArgumentType
470
+ arg[:units] = argument.units.get if argument.units.is_initialized
471
+ arg[:default_value] = argument.defaultValueAsDouble if argument.hasDefaultValue
472
+
473
+ elsif type == 'Quantity'.to_OSArgumentType
474
+ arg[:units] = argument.units.get if argument.units.is_initialized
475
+ arg[:default_value] = argument.defaultValueAsQuantity.value if argument.hasDefaultValue
476
+
477
+ elsif type == 'Integer'.to_OSArgumentType
478
+ arg[:units] = argument.units.get if argument.units.is_initialized
479
+ arg[:default_value] = argument.defaultValueAsInteger if argument.hasDefaultValue
480
+
481
+ elsif type == 'String'.to_OSArgumentType
482
+ arg[:default_value] = argument.defaultValueAsString if argument.hasDefaultValue
483
+
484
+ elsif type == 'Choice'.to_OSArgumentType
485
+ arg[:default_value] = argument.defaultValueAsString if argument.hasDefaultValue
486
+ arg[:choice_values] = []
487
+ argument.choiceValues.each {|value| arg[:choice_values] << value}
488
+ arg[:choice_display_names] = []
489
+ argument.choiceValueDisplayNames.each {|value| arg[:choice_display_names] << value}
490
+
491
+ elsif type == 'Path'.to_OSArgumentType
492
+ arg[:default_value] = argument.defaultValueAsPath.to_s if argument.hasDefaultValue
493
+
494
+ end
495
+
496
+ result << arg
497
+ end
498
+
499
+ result
500
+ end
501
+ end
502
+ end