quality-measure-engine 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.
- data/.gitignore +6 -0
- data/Gemfile +18 -0
- data/Gemfile.lock +50 -0
- data/README.md +24 -0
- data/Rakefile +26 -0
- data/VERSION +1 -0
- data/fixtures/complex_measure.json +36 -0
- data/fixtures/result_example.json +6 -0
- data/lib/patches/v8.rb +20 -0
- data/lib/qme/map/map_reduce_builder.rb +169 -0
- data/lib/qme/map/map_reduce_executor.rb +31 -0
- data/lib/qme/query/json_document_builder.rb +124 -0
- data/lib/quality_measure_engine.rb +11 -0
- data/measures/0032/0032_NQF_Cervical_Cancer_Screening.json +171 -0
- data/measures/0032/patients/denominator1.json +10 -0
- data/measures/0032/patients/denominator2.json +10 -0
- data/measures/0032/patients/numerator1.json +11 -0
- data/measures/0032/patients/population1.json +9 -0
- data/measures/0032/patients/population2.json +11 -0
- data/measures/0032/result/result.json +6 -0
- data/measures/0043/0043_NQF_PneumoniaVaccinationStatusForOlderAdults.json +71 -0
- data/measures/0043/patients/denominator.json +11 -0
- data/measures/0043/patients/numerator.json +11 -0
- data/measures/0043/patients/population.json +10 -0
- data/measures/0043/result/result.json +6 -0
- data/quality-measure-engine.gemspec +102 -0
- data/schema/result.json +28 -0
- data/schema/schema.json +143 -0
- data/spec/qme/map/map_reduce_builder_spec.rb +64 -0
- data/spec/qme/measures_spec.rb +50 -0
- data/spec/qme/query/json_document_builder_spec.rb +56 -0
- data/spec/schema_spec.rb +21 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/validate_measures_spec.rb +21 -0
- metadata +221 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
source :gemcutter
|
2
|
+
|
3
|
+
gem 'mongo'
|
4
|
+
gem 'mongomatic'
|
5
|
+
gem 'bson_ext'
|
6
|
+
gem 'rake'
|
7
|
+
gem 'therubyracer', :require => 'v8'
|
8
|
+
|
9
|
+
group :test do
|
10
|
+
gem 'rspec'
|
11
|
+
gem 'jsonschema'
|
12
|
+
gem 'awesome_print', :require => 'ap'
|
13
|
+
end
|
14
|
+
|
15
|
+
group :build do
|
16
|
+
gem 'jeweler'
|
17
|
+
end
|
18
|
+
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
activesupport (3.0.1)
|
5
|
+
awesome_print (0.2.1)
|
6
|
+
bson (1.1)
|
7
|
+
bson_ext (1.1.1)
|
8
|
+
diff-lcs (1.1.2)
|
9
|
+
gemcutter (0.6.1)
|
10
|
+
git (1.2.5)
|
11
|
+
jeweler (1.4.0)
|
12
|
+
gemcutter (>= 0.1.0)
|
13
|
+
git (>= 1.2.5)
|
14
|
+
rubyforge (>= 2.0.0)
|
15
|
+
json_pure (1.4.6)
|
16
|
+
jsonschema (2.0.0)
|
17
|
+
mongo (1.1)
|
18
|
+
bson (>= 1.0.5)
|
19
|
+
mongomatic (0.5.8)
|
20
|
+
activesupport (>= 2.3.5)
|
21
|
+
bson (= 1.1)
|
22
|
+
mongo (= 1.1)
|
23
|
+
rake (0.8.7)
|
24
|
+
rspec (2.0.0)
|
25
|
+
rspec-core (= 2.0.0)
|
26
|
+
rspec-expectations (= 2.0.0)
|
27
|
+
rspec-mocks (= 2.0.0)
|
28
|
+
rspec-core (2.0.0)
|
29
|
+
rspec-expectations (2.0.0)
|
30
|
+
diff-lcs (>= 1.1.2)
|
31
|
+
rspec-mocks (2.0.0)
|
32
|
+
rspec-core (= 2.0.0)
|
33
|
+
rspec-expectations (= 2.0.0)
|
34
|
+
rubyforge (2.0.4)
|
35
|
+
json_pure (>= 1.1.7)
|
36
|
+
therubyracer (0.7.5)
|
37
|
+
|
38
|
+
PLATFORMS
|
39
|
+
ruby
|
40
|
+
|
41
|
+
DEPENDENCIES
|
42
|
+
awesome_print
|
43
|
+
bson_ext
|
44
|
+
jeweler
|
45
|
+
jsonschema
|
46
|
+
mongo
|
47
|
+
mongomatic
|
48
|
+
rake
|
49
|
+
rspec
|
50
|
+
therubyracer
|
data/README.md
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
This project will provide a library that can ingest HITSP C32's and ASTM CCR's and extract values needed to compute heath quality measures for a population. It will then be able to query over a population to compute how many people within a population conform to the measure.
|
2
|
+
|
3
|
+
Environment
|
4
|
+
-----------
|
5
|
+
|
6
|
+
This project currently uses Ruby 1.9.2 and is built using [Bundler](http://gembundler.com/). To get all of the dependencies for the project, first install bundler:
|
7
|
+
|
8
|
+
gem install bundler
|
9
|
+
|
10
|
+
Then run bundler to grab all of the necessay gems:
|
11
|
+
|
12
|
+
bundle install
|
13
|
+
|
14
|
+
Testing
|
15
|
+
-------
|
16
|
+
|
17
|
+
This project uses [RSpec](http://github.com/rspec/rspec-core) for testing. To run the suite, just enter the following:
|
18
|
+
|
19
|
+
rake spec
|
20
|
+
|
21
|
+
Project Practices
|
22
|
+
------------------
|
23
|
+
|
24
|
+
Please try to follow our [Coding Style Guides](http://github.com/eedrummer/styleguide). Additionally, we will be using git in a pattern similar to [Vincent Driessen's workflow](http://nvie.com/posts/a-successful-git-branching-model/). While feature branches are encouraged, they are not required to work on the project.
|
data/Rakefile
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'rspec/core/rake_task'
|
2
|
+
require 'jeweler'
|
3
|
+
|
4
|
+
RSpec::Core::RakeTask.new do |t|
|
5
|
+
t.rspec_opts = ["-c", "-f progress", "-r ./spec/spec_helper.rb"]
|
6
|
+
t.pattern = 'spec/**/*_spec.rb'
|
7
|
+
end
|
8
|
+
|
9
|
+
Jeweler::Tasks.new do |gem|
|
10
|
+
gem.name = "quality-measure-engine"
|
11
|
+
gem.summary = "A library for extracting quality measure information from HITSP C32's and ASTM CCR's"
|
12
|
+
gem.description = "A library for extracting quality measure information from HITSP C32's and ASTM CCR's"
|
13
|
+
gem.email = "talk@projectpophealth.org"
|
14
|
+
gem.homepage = "http://github.com/pophealth/quality-measure-engine"
|
15
|
+
gem.authors = ["Marc Hadley", "Andy Gregorowicz"]
|
16
|
+
|
17
|
+
gem.add_dependency 'mongo', '~> 1.1'
|
18
|
+
gem.add_dependency 'mongomatic', '~> 0.5.8'
|
19
|
+
gem.add_dependency 'therubyracer', '~> 0.7.5'
|
20
|
+
gem.add_dependency 'bson_ext', '~> 1.1.1'
|
21
|
+
|
22
|
+
gem.add_development_dependency "jsonschema", "~> 2.0.0"
|
23
|
+
gem.add_development_dependency "rspec", "~> 2.0.0"
|
24
|
+
gem.add_development_dependency "awesome_print", "~> 0.2.1"
|
25
|
+
gem.add_development_dependency "jeweler", "~> 1.4.0"
|
26
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
@@ -0,0 +1,36 @@
|
|
1
|
+
{
|
2
|
+
"id": "0043",
|
3
|
+
"name": "Pneumonia Vaccination Status for Older Adults",
|
4
|
+
"steward": "NCQA",
|
5
|
+
"population": {
|
6
|
+
"and": [
|
7
|
+
{
|
8
|
+
"category": "Patient Characteristic",
|
9
|
+
"title": "Age > 17 before measure period",
|
10
|
+
"query": {"age": {"_gt": 17}}
|
11
|
+
},
|
12
|
+
{
|
13
|
+
"category": "Patient Characteristic",
|
14
|
+
"title": "Age < 75 before measure period",
|
15
|
+
"query": {"age": {"_lt": 75}}
|
16
|
+
},
|
17
|
+
{
|
18
|
+
"or": [
|
19
|
+
{
|
20
|
+
"category": "Patient Characteristic",
|
21
|
+
"title": "Male",
|
22
|
+
"query": {"sex": "male"}
|
23
|
+
},
|
24
|
+
{
|
25
|
+
"category": "Patient Characteristic",
|
26
|
+
"title": "Female",
|
27
|
+
"query": {"sex": "female"}
|
28
|
+
}
|
29
|
+
]
|
30
|
+
}
|
31
|
+
]
|
32
|
+
},
|
33
|
+
"denominator": {},
|
34
|
+
"numerator": {},
|
35
|
+
"exception": {}
|
36
|
+
}
|
data/lib/patches/v8.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# Patching V8::Object because it is the entry point for conversions
|
2
|
+
# between Ruby and JavaScript types. We are using seconds since the
|
3
|
+
# epoch to represent dates. On 32 bit architectures, for recent dates
|
4
|
+
# this will be too large for Fixnum and become a Bignum. Ruby Racer
|
5
|
+
# worn't properly convert a Bignum to JavaScript, but it will work
|
6
|
+
# just fine for a Float. Because of this, we will convert all Bignums
|
7
|
+
# passed into a JavaScript context to a Float
|
8
|
+
module V8
|
9
|
+
class Object
|
10
|
+
alias :old_index_setter :'[]='
|
11
|
+
|
12
|
+
def []=(key, value)
|
13
|
+
if value.kind_of?(Bignum)
|
14
|
+
old_index_setter(key, value.to_f)
|
15
|
+
else
|
16
|
+
old_index_setter(key, value)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
module QME
|
2
|
+
module MapReduce
|
3
|
+
class Builder
|
4
|
+
attr_reader :id, :parameters
|
5
|
+
|
6
|
+
YEAR_IN_SECONDS = 365*24*60*60
|
7
|
+
|
8
|
+
def initialize(measure_def, params)
|
9
|
+
@measure_def = measure_def
|
10
|
+
@id = measure_def['id']
|
11
|
+
@parameters = {}
|
12
|
+
measure_def['parameters'] ||= {}
|
13
|
+
measure_def['parameters'].each do |parameter, value|
|
14
|
+
if !params.has_key?(parameter.intern)
|
15
|
+
raise "No value supplied for measure parameter: #{parameter}"
|
16
|
+
end
|
17
|
+
@parameters[parameter.intern] = params[parameter.intern]
|
18
|
+
end
|
19
|
+
ctx = V8::Context.new
|
20
|
+
ctx['year']=YEAR_IN_SECONDS
|
21
|
+
@parameters.each do |key, param|
|
22
|
+
ctx[key]=param
|
23
|
+
end
|
24
|
+
measure_def['calculated_dates'] ||= {}
|
25
|
+
measure_def['calculated_dates'].each do |parameter, value|
|
26
|
+
@parameters[parameter.intern]=ctx.eval(value)
|
27
|
+
end
|
28
|
+
@property_prefix = 'this.measures["'+@id+'"].'
|
29
|
+
end
|
30
|
+
|
31
|
+
def map_function
|
32
|
+
"function () {\n" +
|
33
|
+
" var value = {i: 0, d: 0, n: 0, e: 0};\n" +
|
34
|
+
" if #{population} {\n" +
|
35
|
+
" value.i++;\n" +
|
36
|
+
" if #{denominator} {\n" +
|
37
|
+
" value.d++;\n" +
|
38
|
+
" if #{numerator} {\n" +
|
39
|
+
" value.n++;\n" +
|
40
|
+
" } else if #{exception} {\n" +
|
41
|
+
" value.e++;\n" +
|
42
|
+
" value.d--;\n" +
|
43
|
+
" }\n" +
|
44
|
+
" }\n" +
|
45
|
+
" }\n" +
|
46
|
+
" emit(null, value);\n" +
|
47
|
+
"};\n"
|
48
|
+
end
|
49
|
+
|
50
|
+
REDUCE_FUNCTION = <<END_OF_REDUCE_FN
|
51
|
+
function (key, values) {
|
52
|
+
var total = {i: 0, d: 0, n: 0, e: 0};
|
53
|
+
for (var i = 0; i < values.length; i++) {
|
54
|
+
total.i += values[i].i;
|
55
|
+
total.d += values[i].d;
|
56
|
+
total.n += values[i].n;
|
57
|
+
total.e += values[i].e;
|
58
|
+
}
|
59
|
+
return total;
|
60
|
+
};
|
61
|
+
END_OF_REDUCE_FN
|
62
|
+
|
63
|
+
def reduce_function
|
64
|
+
REDUCE_FUNCTION
|
65
|
+
end
|
66
|
+
|
67
|
+
def population
|
68
|
+
javascript(@measure_def['population'])
|
69
|
+
end
|
70
|
+
|
71
|
+
def denominator
|
72
|
+
javascript(@measure_def['denominator'])
|
73
|
+
end
|
74
|
+
|
75
|
+
def numerator
|
76
|
+
javascript(@measure_def['numerator'])
|
77
|
+
end
|
78
|
+
|
79
|
+
def exception
|
80
|
+
javascript(@measure_def['exception'])
|
81
|
+
end
|
82
|
+
|
83
|
+
def javascript(expr)
|
84
|
+
if expr.has_key?('query')
|
85
|
+
# leaf node
|
86
|
+
query = expr['query']
|
87
|
+
triple = leaf_expr(query)
|
88
|
+
property_name = munge_property_name(triple[0])
|
89
|
+
'('+property_name+triple[1]+triple[2]+')'
|
90
|
+
elsif expr.size==1
|
91
|
+
operator = expr.keys[0]
|
92
|
+
result = logical_expr(operator, expr[operator])
|
93
|
+
operator = result.shift
|
94
|
+
js = '('
|
95
|
+
result.each_with_index do |operand,index|
|
96
|
+
if index>0
|
97
|
+
js+=operator
|
98
|
+
end
|
99
|
+
js+=operand
|
100
|
+
end
|
101
|
+
js+=')'
|
102
|
+
js
|
103
|
+
elsif expr.size==0
|
104
|
+
'(false)'
|
105
|
+
else
|
106
|
+
throw "Unexpected number of keys in: #{expr}"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def munge_property_name(name)
|
111
|
+
if name=='birthdate'
|
112
|
+
'this.'+name
|
113
|
+
else
|
114
|
+
@property_prefix+name
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def logical_expr(operator, args)
|
119
|
+
operands = args.collect { |arg| javascript(arg) }
|
120
|
+
[get_operator(operator)].concat(operands)
|
121
|
+
end
|
122
|
+
|
123
|
+
def leaf_expr(query)
|
124
|
+
property_name = query.keys[0]
|
125
|
+
property_value_expression = query[property_name]
|
126
|
+
if property_value_expression.kind_of?(Hash)
|
127
|
+
operator = property_value_expression.keys[0]
|
128
|
+
value = property_value_expression[operator]
|
129
|
+
[property_name, get_operator(operator), get_value(value)]
|
130
|
+
else
|
131
|
+
[property_name, '==', get_value(property_value_expression)]
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def get_operator(operator)
|
136
|
+
case operator
|
137
|
+
when '_eql'
|
138
|
+
'=='
|
139
|
+
when '_gt'
|
140
|
+
'>'
|
141
|
+
when '_gte'
|
142
|
+
'>='
|
143
|
+
when '_lt'
|
144
|
+
'<'
|
145
|
+
when '_lte'
|
146
|
+
'<='
|
147
|
+
when 'and'
|
148
|
+
'&&'
|
149
|
+
when 'or'
|
150
|
+
'||'
|
151
|
+
else
|
152
|
+
throw "Unknown operator: #{operator}"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def get_value(value)
|
157
|
+
if value.kind_of?(String) && value[0]=='@'
|
158
|
+
@parameters[value[1..-1].intern].to_s
|
159
|
+
elsif value.kind_of?(String)
|
160
|
+
'"'+value+'"'
|
161
|
+
elsif value==nil
|
162
|
+
'null'
|
163
|
+
else
|
164
|
+
value.to_s
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module QME
|
2
|
+
module MapReduce
|
3
|
+
class Executor
|
4
|
+
def initialize(db)
|
5
|
+
@db = db
|
6
|
+
end
|
7
|
+
|
8
|
+
def measure_def(measure_id)
|
9
|
+
measures = @db.collection('measures')
|
10
|
+
measures.find({'id'=> "#{measure_id}"}).to_a[0]
|
11
|
+
end
|
12
|
+
|
13
|
+
def measure_result(measure_id, parameter_values)
|
14
|
+
|
15
|
+
measure = Builder.new(measure_def(measure_id), parameter_values)
|
16
|
+
|
17
|
+
records = @db.collection('records')
|
18
|
+
results = records.map_reduce(measure.map_function, measure.reduce_function)
|
19
|
+
result = results.find.to_a[0]
|
20
|
+
value = result['value']
|
21
|
+
|
22
|
+
{
|
23
|
+
:population=>value['i'].to_i,
|
24
|
+
:denominator=> value['d'].to_i,
|
25
|
+
:numerator=> value['n'].to_i,
|
26
|
+
:exceptions=> value['e'].to_i
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module QME
|
2
|
+
module Query
|
3
|
+
class JSONDocumentBuilder
|
4
|
+
attr_accessor :parameters, :calculated_dates
|
5
|
+
|
6
|
+
# Creates the JSONDocumentBuilder. Will calculate dates if parameters
|
7
|
+
# are passed in.
|
8
|
+
def initialize(measure_json, parameters={})
|
9
|
+
@measure_json = measure_json
|
10
|
+
@parameters = parameters
|
11
|
+
@measure_id = measure_json['id']
|
12
|
+
|
13
|
+
if ! parameters.empty?
|
14
|
+
calculate_dates
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Calculates all dates necessary to create a query for this measure
|
19
|
+
# This will be run by the constructor if params were passed in
|
20
|
+
def calculate_dates
|
21
|
+
ctx = V8::Context.new
|
22
|
+
@parameters.each_pair do |key, value|
|
23
|
+
ctx[key] = value
|
24
|
+
end
|
25
|
+
|
26
|
+
ctx['year'] = 365 * 24 * 60 * 60 # TODO: Replace this with a js file that has all constants
|
27
|
+
|
28
|
+
@calculated_dates = {}
|
29
|
+
@measure_json["calculated_dates"].each_pair do |key, value|
|
30
|
+
@calculated_dates[key] = ctx.eval(value)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def numerator_query
|
35
|
+
create_query(@measure_json['numerator']).merge(denominator_query)
|
36
|
+
end
|
37
|
+
|
38
|
+
def denominator_query
|
39
|
+
create_query(@measure_json['denominator']).merge(initial_population_query)
|
40
|
+
end
|
41
|
+
|
42
|
+
def initial_population_query
|
43
|
+
create_query(@measure_json['population'])
|
44
|
+
end
|
45
|
+
|
46
|
+
def exclusions_query
|
47
|
+
create_query(@measure_json['exception'])
|
48
|
+
end
|
49
|
+
|
50
|
+
# Creates the appropriate JSON document to query MongoDB based on
|
51
|
+
# a measure definition passed in. This method calls itself recursively
|
52
|
+
# to walk the tree and get all possible logical operators available
|
53
|
+
# in a measure definition
|
54
|
+
def create_query(definition_json, args={})
|
55
|
+
if definition_json.has_key?('and')
|
56
|
+
definition_json['and'].each do |operand|
|
57
|
+
create_query(operand, args)
|
58
|
+
end
|
59
|
+
elsif definition_json.has_key?('or')
|
60
|
+
operands = []
|
61
|
+
definition_json['or'].each do |operand|
|
62
|
+
operands << create_query(operand)
|
63
|
+
end
|
64
|
+
args['$or'] = operands
|
65
|
+
elsif definition_json.has_key?('query')
|
66
|
+
process_query(definition_json['query'], args)
|
67
|
+
end
|
68
|
+
|
69
|
+
args
|
70
|
+
end
|
71
|
+
|
72
|
+
# Called by create_query to process leaf nodes in a measure
|
73
|
+
# definition
|
74
|
+
def process_query(definition_json, args)
|
75
|
+
if definition_json.size > 1
|
76
|
+
raise 'A query should have only one property'
|
77
|
+
end
|
78
|
+
|
79
|
+
query_property = definition_json.keys.first
|
80
|
+
document_key = transform_query_property(query_property)
|
81
|
+
document_value = nil
|
82
|
+
query_value = definition_json[query_property]
|
83
|
+
if query_value.kind_of?(Hash)
|
84
|
+
if query_value.size > 1
|
85
|
+
raise 'A query value should only have one property'
|
86
|
+
end
|
87
|
+
|
88
|
+
document_value = {query_value.keys.first.gsub('_', '$') =>
|
89
|
+
substitute_variables(query_value.values.first)}
|
90
|
+
if args[document_key]
|
91
|
+
args[document_key].merge!(document_value)
|
92
|
+
else
|
93
|
+
args[document_key] = document_value
|
94
|
+
end
|
95
|
+
else
|
96
|
+
document_value = substitute_variables(query_value)
|
97
|
+
args[document_key] = document_value
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Takes a query property name and transforms it into
|
102
|
+
# a name of a key in a MongoDB document
|
103
|
+
def transform_query_property(property_name)
|
104
|
+
#TODO What do we do with special case fields - the stuff we are keeping at the patient level?
|
105
|
+
if ['birthdate'].include?(property_name)
|
106
|
+
property_name
|
107
|
+
else
|
108
|
+
"measures.#{@measure_id}.#{property_name}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Finds strings that start with "@" and replaces them
|
113
|
+
# with the calculated date
|
114
|
+
def substitute_variables(value)
|
115
|
+
if value.kind_of?(String) && value[0] == '@'
|
116
|
+
variable_name = value[1..-1]
|
117
|
+
@calculated_dates[variable_name]
|
118
|
+
else
|
119
|
+
value
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|