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.
- data/README.md +34 -6
- data/Rakefile +2 -0
- data/VERSION +1 -1
- data/js/{map-reduce-utils.js → map_reduce_utils.js} +50 -85
- data/js/{underscore-min.js → underscore_min.js} +3 -2
- data/lib/qme/database_access.rb +30 -0
- data/lib/qme/importer/code_system_helper.rb +3 -1
- data/lib/qme/importer/entry.rb +12 -22
- data/lib/qme/importer/generic_importer.rb +89 -30
- data/lib/qme/importer/patient_importer.rb +37 -21
- data/lib/qme/importer/property_matcher.rb +7 -2
- data/lib/qme/importer/section_importer.rb +31 -9
- data/lib/qme/map/map_reduce_builder.rb +14 -12
- data/lib/qme/map/map_reduce_executor.rb +34 -98
- data/lib/qme/map/measure_calculation_job.rb +33 -0
- data/lib/qme/measure/database_loader.rb +18 -25
- data/lib/qme/measure/measure_loader.rb +70 -37
- data/lib/qme/measure/properties_builder.rb +184 -0
- data/lib/qme/measure/properties_converter.rb +27 -0
- data/lib/qme/quality_measure.rb +41 -0
- data/lib/qme/quality_report.rb +61 -0
- data/lib/quality-measure-engine.rb +19 -16
- data/lib/tasks/fixtures.rake +91 -0
- data/lib/tasks/measure.rake +55 -36
- data/lib/tasks/mongo.rake +13 -19
- data/lib/tasks/patient_random.rake +2 -3
- metadata +174 -102
- data/lib/qme/mongo_helpers.rb +0 -15
@@ -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
|
-
|
9
|
-
#
|
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
|
-
|
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
|
-
|
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
|
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[:
|
74
|
-
|
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
|
69
|
-
# map functions are stored separately
|
70
|
-
#
|
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
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|