openstudio_measure_tester 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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