quality-measure-engine 0.2.0 → 0.8.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,33 @@
1
+ module QME
2
+ module MapReduce
3
+ # A Resque job that allows for measure calculation by a Resque worker. Can be created as follows:
4
+ #
5
+ # MapReduce::MeasureCalculationJob.create(:measure_id => '0221', :sub_id => 'a', :effective_date => 1291352400)
6
+ #
7
+ # This will return a uuid which can be used to check in on the status of a job. More details on this can be found
8
+ # at the {Resque Stats project page}[https://github.com/quirkey/resque-status].
9
+ #
10
+ # MeasureCalculationJob will check to see if a measure has been calculated before running the calculation. It does
11
+ # this by creating a QME::QualityReport and asking if it has been calculated. If so, it will complete the job without
12
+ # running the MapReduce job.
13
+ #
14
+ # When a measure needs calculation, the job will create a QME::MapReduce::Executor and interact with it to calculate
15
+ # the report.
16
+ class MeasureCalculationJob < Resque::JobWithStatus
17
+ def perform
18
+ qr = QualityReport.new(options['measure_id'], options['sub_id'], 'effective_date' => options['effective_date'])
19
+ if qr.calculated?
20
+ completed("#{options['measure_id']}#{options['sub_id']} has already been calculated")
21
+ else
22
+ map = QME::MapReduce::Executor.new(options['measure_id'], options['sub_id'], 'effective_date' => options['effective_date'])
23
+ tick('Starting MapReduce')
24
+ map.map_records_into_measure_groups
25
+ tick('MapReduce complete')
26
+ tick('Calculating group totals')
27
+ result = map.count_records_in_measure_groups
28
+ completed("#{options['measure_id']}#{options['sub_id']}: p#{result['population']}, d#{result['denominator']}, n#{result['numerator']}, e#{result['exclusions']}")
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -5,25 +5,11 @@ module QME
5
5
 
6
6
  # Utility class for working with JSON files and the database
7
7
  class Loader
8
- # Create a new Loader. Database host and port may be set using the
9
- # environment variables TEST_DB_HOST and TEST_DB_PORT which default
10
- # to localhost and 27017 respectively.
8
+ include DatabaseAccess
9
+ # Create a new Loader.
11
10
  # @param [String] db_name the name of the database to use
12
- def initialize(db_name)
13
- @db_name = db_name
14
- @db_host = ENV['TEST_DB_HOST'] || 'localhost'
15
- @db_port = ENV['TEST_DB_PORT'] ? ENV['TEST_DB_PORT'].to_i : 27017
16
- end
17
-
18
- # Lazily creates a connection to the database and initializes the
19
- # JavaScript environment
20
- # @return [Mongo::Connection]
21
- def get_db
22
- if @db==nil
23
- @db = Mongo::Connection.new(@db_host, @db_port).db(@db_name)
24
- QME::MongoHelpers.initialize_javascript_frameworks(@db)
25
- end
26
- @db
11
+ def initialize(db_name = nil)
12
+ determine_connection_information(db_name)
27
13
  end
28
14
 
29
15
  # Load a measure from the filesystem and save it in the database.
@@ -43,8 +29,7 @@ module QME
43
29
  # @param [String] collection_name name of the database collection
44
30
  # @param [Hash] json the JSON hash to save in the database
45
31
  def save(collection_name, json)
46
- collection = get_db.create_collection(collection_name)
47
- collection.save(json)
32
+ get_db[collection_name].save(json)
48
33
  end
49
34
 
50
35
  # Drop a database collection
@@ -53,14 +38,22 @@ module QME
53
38
  get_db.drop_collection(collection_name)
54
39
  end
55
40
 
41
+ def save_system_js_fn(name, fn)
42
+ get_db['system.js'].save(
43
+ {
44
+ "_id" => name,
45
+ "value" => BSON::Code.new(fn)
46
+ }
47
+ )
48
+ end
56
49
 
57
50
  # Save a bundle to the db, this will save the bundle meta data, javascript extension functions and measures to
58
- # the db in their respective loacations
51
+ # the db in their respective locations
59
52
  # @param [String] bundle_dir the bundle directory
60
53
  # @param [String] bundle_collection the collection to save the bundle meta_data and extension functions to
61
54
  # @param [String] measure_collection the collection to save the measures to, defaults to measures
62
- def save_bundle(bundle_dir,bundle_collection, measure_collection = 'measures')
63
- bundle = QME::Measure::Loader.load_bundle(bundle_dir)
55
+ def save_bundle(bundle_dir, measure_dir, bundle_collection='bundles', measure_collection = 'measures')
56
+ bundle = QME::Measure::Loader.load_bundle(bundle_dir, measure_dir)
64
57
  bundle[:bundle_data][:measures] = []
65
58
  b_id = save(bundle_collection,bundle[:bundle_data])
66
59
  measures = bundle[:measures]
@@ -70,8 +63,8 @@ module QME
70
63
  bundle[:bundle_data][:measures] << m_id
71
64
  end
72
65
  save(bundle_collection,bundle[:bundle_data])
73
- bundle[:bundle_data][:extensions].each do |ext|
74
- get_db.eval(ext)
66
+ bundle[:extensions].each do |name, fn|
67
+ save_system_js_fn(name, fn)
75
68
  end
76
69
  bundle
77
70
  end
@@ -2,6 +2,7 @@ gem 'rubyzip'
2
2
  require 'json'
3
3
  require 'zip/zip'
4
4
  require 'zip/zipfilesystem'
5
+ require File.join(File.dirname(__FILE__),'properties_builder')
5
6
 
6
7
  module QME
7
8
  module Measure
@@ -42,13 +43,22 @@ module QME
42
43
  measure_file = File.join(component_dir, collection_def['root'])
43
44
  measures = []
44
45
  collection_def['combinations'].each do |combination|
46
+ combination['metadata'] ||= {}
47
+ if !combination['map_fn']
48
+ raise "Missing map function in #{collection_file} (sub_id #{combination['metadata']['sub_id']})"
49
+ end
45
50
  map_file = File.join(component_dir, combination['map_fn'])
46
51
  measure = load_measure_file(measure_file, map_file)
52
+
47
53
  # add inline metadata to top level of definition
48
- combination['metadata'] ||= {}
49
54
  combination['metadata'].each do |key, value|
50
55
  measure[key] = value
51
56
  end
57
+ if measure['properties'] && !measure['measure']
58
+ # load the measure properties if they weren't already added
59
+ measure['measure'] = get_measure_properties(measure)
60
+ end
61
+
52
62
  # add inline measure-specific properties to definition
53
63
  combination['measure'] ||= {}
54
64
  measure['measure'] ||= {}
@@ -65,9 +75,10 @@ module QME
65
75
  measures
66
76
  end
67
77
 
68
- # For ease of development, measure definition JSON files and JavaScript
69
- # map functions are stored separately in the file system, this function
70
- # combines the two and returns the result
78
+ # For ease of development and change mananegment, measure definition JSON
79
+ # files, property lists and JavaScript map functions are stored separately
80
+ # in the file system, this function combines the three components and
81
+ # returns the result.
71
82
  # @param [String] measure_file path to the measure file
72
83
  # @param [String] map_fn_file path to the map function file
73
84
  # @return [Hash] a JSON hash of the measure with embedded map function.
@@ -75,9 +86,35 @@ module QME
75
86
  map_fn = File.read(map_fn_file)
76
87
  measure = JSON.parse(File.read(measure_file))
77
88
  measure['map_fn'] = map_fn
89
+ if measure['properties']
90
+ measure['measure'] = get_measure_properties(measure)
91
+ end
78
92
  measure
79
93
  end
80
94
 
95
+ # Load the measure properties from an external file, converting from JSONified XLS if
96
+ # necessary
97
+ def self.get_measure_properties(measure)
98
+ merged_props = {}
99
+ # normalize field value to be an array of file names
100
+ if !(measure['properties'].respond_to?(:each))
101
+ measure['properties'] = [measure['properties']]
102
+ end
103
+ measure['properties'].each do |file|
104
+ measure_props_file = File.join(ENV['MEASURE_PROPS'], file)
105
+ measure_props = JSON.parse(File.read(measure_props_file))
106
+ if measure_props['measure']
107
+ # copy measure properties over
108
+ merged_props.merge!(measure_props['measure'])
109
+ else
110
+ # convert JSONified XLS to properties format and add to measure
111
+ converted_props = QME::Measure::PropertiesBuilder.build_properties(measure_props, measure_props_file)['measure']
112
+ merged_props.merge!(converted_props)
113
+ end
114
+ end
115
+ merged_props
116
+ end
117
+
81
118
  # Load a JSON file from the specified directory
82
119
  # @param [String] dir_path path to the directory containing the JSON file
83
120
  # @param [String] filename the JSON file
@@ -89,53 +126,49 @@ module QME
89
126
 
90
127
 
91
128
  #Load a bundle from a directory
92
- #@param [String] bundel_path path to directory containing the bundle information
93
-
94
- def self.load_bundle(bundle_path)
95
- bundle = {};
96
- bundle_file = File.join(bundle_path,'bundle.js')
97
-
98
- bundle[:bundle_data] = File.exists?(bundle_file) ? JSON.parse(File.read(bundle_file)) : JSON.parse("{}")
99
- bundle[:bundle_data][:extensions] = load_bundle_extensions(bundle_path)
100
- bundle[:measures] = []
101
- Dir.glob(File.join(bundle_path, 'measures', '*')).each do |measure_dir|
102
- load_measure(measure_dir).each do |measure|
103
- bundle[:measures] << measure
129
+ # @param [String] bundle_path path to directory containing the bundle information
130
+ def self.load_bundle(bundle_path, measure_dir)
131
+ begin
132
+ bundle = {};
133
+ bundle_file = File.join(bundle_path,'bundle.json')
134
+ license_file = File.join(bundle_path, 'license.html')
135
+
136
+ bundle[:bundle_data] = File.exists?(bundle_file) ? JSON.parse(File.read(bundle_file)) : JSON.parse("{}")
137
+ bundle[:bundle_data][:license] = File.exists?(license_file) ? File.read(license_file) : ""
138
+ bundle[:extensions] = load_bundle_extensions(bundle_path)
139
+ bundle[:bundle_data][:extensions] = bundle[:extensions].keys
140
+ bundle[:measures] = []
141
+ Dir.glob(File.join(bundle_path, measure_dir, '*')).each do |measure_dir|
142
+ load_measure(measure_dir).each do |measure|
143
+ bundle[:measures] << measure
144
+ end
104
145
  end
146
+ bundle
147
+ rescue Exception => e
148
+ print e.backtrace.join("\n")
149
+ throw e
105
150
  end
106
- bundle
107
151
  end
108
152
 
109
153
 
110
154
  # Load all of the extenson functions that will be available to map reduce functions from the bundle dir
111
155
  # This will load from bundle_path/js and from ext directories in the individule measures directories
112
156
  # like bundle_path/measures/0001/ext/
113
- #@param [String] bundle_path the path to the bundle directory
157
+ # @param [String] bundle_path the path to the bundle directory
158
+ # @return [Hash] name, function
114
159
  def self.load_bundle_extensions(bundle_path)
115
- extensions = []
116
- Dir.glob(File.join(bundle_path, 'js', '*.js')).each do |js_file|
160
+ extensions = {}
161
+ process_extension = lambda do |js_file|
162
+ fn_name = File.basename(js_file, ".js")
117
163
  raw_js = File.read(js_file)
118
- extensions << raw_js
119
- end
120
- Dir.glob(File.join(bundle_path, 'measures', '*','ext', '*.js')).each do |js_file|
121
- raw_js = File.read(js_file)
122
- extensions << raw_js
164
+ extensions[fn_name] = raw_js
123
165
  end
166
+ Dir.glob(File.join(File.dirname(__FILE__), '../../..', 'js', '*.js')).each &process_extension
167
+ Dir.glob(File.join(bundle_path, 'js', '*.js')).each &process_extension
168
+ Dir.glob(File.join(bundle_path, 'measures', '*','ext', '*.js')).each &process_extension
124
169
  extensions
125
170
  end
126
171
 
127
-
128
- def self.load_from_zip(archive)
129
- unzip_path = "./tmp/#{Time.new.to_i}/"
130
- FileUtils.mkdir_p(unzip_path)
131
- all_measures = []
132
- Zip::ZipFile.foreach(archive) do |zipfile|
133
- fname = unzip_path+ zipfile.name
134
- FileUtils.rm fname, :force=>true
135
- zipfile.extract(fname)
136
- end
137
- load_bundle(unzip_path)
138
- end
139
172
  end
140
173
  end
141
174
  end
@@ -0,0 +1,184 @@
1
+ module QME
2
+ module Measure
3
+
4
+ # Utility class for converting JSONified XLS file to measure properties
5
+ class PropertiesBuilder
6
+
7
+ GROUPING = 'GROUPING'
8
+ STANDARD_TAXONOMY = 'standard_taxonomy'
9
+ STANDARD_CODE_LIST = 'standard_code_list'
10
+ STANDARD_CONCEPT = 'standard_concept'
11
+ STANDARD_CONCEPT_ID = 'standard_concept_id'
12
+ STANDARD_CATEGORY = 'standard_category'
13
+ QDS_DATA_TYPE = 'QDS_data_type'
14
+ PROPERTIES_TO_IGNORE = %w(birthdate measurement_enddate measurement_period)
15
+
16
+ # Convert JSONified measure XLS to measure properties format
17
+ def self.build_properties(xls_json, xls_file)
18
+ xls_json = patch_properties(xls_json, xls_file)
19
+
20
+ # build groupings
21
+ grouped_json = build_groups(xls_json)
22
+
23
+ properties = {}
24
+ grouped_json.each do |value|
25
+ property_value = build_property(value)
26
+ if !PROPERTIES_TO_IGNORE.include?(property_value[STANDARD_CONCEPT])
27
+ properties[property_name(value)] = property_value
28
+ end
29
+ end
30
+
31
+ {'measure' => properties}
32
+ end
33
+
34
+ # Build the top-level JSON hash for a measure property
35
+ def self.build_property(parent)
36
+ property = {}
37
+ property[STANDARD_CONCEPT] = build_underscore_separated_name(parent[STANDARD_CONCEPT])
38
+ property[STANDARD_CATEGORY] = build_underscore_separated_name(parent[STANDARD_CATEGORY])
39
+ property[STANDARD_CONCEPT_ID] = parent[STANDARD_CONCEPT_ID]
40
+ property['qds_data_type'] = build_underscore_separated_name(parent[QDS_DATA_TYPE])
41
+ property['qds_id'] = parent['QDS_id']
42
+ property['type'] = 'array'
43
+ property['items'] = build_item_def(parent)
44
+ property['codes'] = build_code_list(parent)
45
+ property
46
+ end
47
+
48
+ # Build the definition of a property item
49
+ def self.build_item_def(property)
50
+ if property['value']
51
+ {
52
+ 'type' => 'object',
53
+ 'properties' => {
54
+ 'value' => {
55
+ 'description' => property['value']['description'],
56
+ 'type' => property['value']['type']
57
+ },
58
+ 'date' => {
59
+ 'description' => property['value']['date_description'],
60
+ 'type' => 'number',
61
+ 'format' => 'utc-sec'
62
+ }
63
+ }
64
+ }
65
+ elsif property['range']
66
+ {
67
+ 'type' => 'object',
68
+ 'properties' => {
69
+ 'start' => {
70
+ 'description' => property['range']['start_description'],
71
+ 'type' => 'number',
72
+ 'format' => 'utc-sec'
73
+ },
74
+ 'end' => {
75
+ 'description' => property['range']['end_description'],
76
+ 'type' => 'number',
77
+ 'format' => 'utc-sec'
78
+ }
79
+ }
80
+ }
81
+ else
82
+ {
83
+ 'type' => 'number',
84
+ 'format' => 'utc-sec'
85
+ }
86
+ end
87
+ end
88
+
89
+ # Build the set of code lists for a property
90
+ def self.build_code_list(property)
91
+ if property['child_nodes']
92
+ property['child_nodes'].collect { |item| build_code_def(item) }
93
+ else
94
+ [build_code_def(property)]
95
+ end
96
+ end
97
+
98
+ # Build a single code list JSON hash for a property
99
+ def self.build_code_def(property)
100
+ {
101
+ 'set' => property['standard_taxonomy'],
102
+ 'version' => property['standard_taxonomy_version'],
103
+ STANDARD_CONCEPT => build_underscore_separated_name(property[STANDARD_CONCEPT]),
104
+ STANDARD_CONCEPT_ID => property[STANDARD_CONCEPT_ID],
105
+ 'qds_id' => property['QDS_id'],
106
+ 'values' => extract_code_values(property['standard_code_list'], property['standard_taxonomy'])
107
+ }
108
+ end
109
+
110
+ # Extract the code values from their string form into an array of strings, one per code
111
+ def self.extract_code_values(string_list, set)
112
+ return [] if string_list==nil || string_list.length==0
113
+ string_list.split(',').collect do |entry|
114
+ if set=='CPT' && entry.include?('-')
115
+ # special handling for ranges in CPT code sets, e.g. 10010-10015
116
+ eval(entry.strip.gsub('-','..')).to_a.collect { |i| i.to_s }
117
+ else
118
+ entry.strip
119
+ end
120
+ end.flatten
121
+ end
122
+
123
+ # Generate a property name from the standard concept and qds data type
124
+ def self.property_name(property)
125
+ build_underscore_separated_name(property[STANDARD_CONCEPT], property[QDS_DATA_TYPE])
126
+ end
127
+
128
+ # Break all the supplied strings into separate words and return the resulting list as a
129
+ # new string with each word separated with '_'
130
+ def self.build_underscore_separated_name(*components)
131
+ name = []
132
+ components.each do |component|
133
+ name.concat component.gsub(/\W/,' ').split.collect { |word| word.strip.downcase }
134
+ end
135
+ name.join '_'
136
+ end
137
+
138
+ # build an array of grouped properties
139
+ def self.build_groups(xls_json)
140
+ grouped_json = []
141
+ exclude_child_ids = []
142
+
143
+ # add grouped members
144
+ groups = xls_json.values.select { |value| value[STANDARD_TAXONOMY]!=nil && value[STANDARD_TAXONOMY].upcase==GROUPING }
145
+ groups.each do |group|
146
+ group_child_ids = group[STANDARD_CODE_LIST].split(',').collect { |id| id.strip }
147
+ children = xls_json.values.select do |child|
148
+ group_child_ids.include?(child[STANDARD_CONCEPT_ID].strip)
149
+ end
150
+ group['child_nodes'] = children
151
+ grouped_json << group
152
+ if group['group_type']==nil || group['group_type']!='abstract'
153
+ exclude_child_ids.concat group_child_ids
154
+ end
155
+ end
156
+
157
+ # add remaining non-grouped members
158
+ xls_json.values.each do |value|
159
+ if value[STANDARD_TAXONOMY]!=GROUPING && !exclude_child_ids.include?(value[STANDARD_CONCEPT_ID].strip)
160
+ grouped_json<<value
161
+ end
162
+ end
163
+ grouped_json
164
+ end
165
+
166
+ # Patch the supplied property_json hash with the contents of a patch file named
167
+ # "#{property_file}.patch" and return the result
168
+ def self.patch_properties(property_json, property_file)
169
+ patch_file = "#{property_file}.patch"
170
+
171
+ if File.exists? patch_file
172
+ patch_json = JSON.parse(File.read(patch_file))
173
+
174
+ property_json = property_json.merge(patch_json) do |key, old, new|
175
+ old.merge(new)
176
+ end
177
+ end
178
+ property_json
179
+ end
180
+
181
+ end
182
+
183
+ end
184
+ end