quality-measure-engine 1.0.4 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +3 -2
- data/Rakefile +0 -8
- data/VERSION +1 -1
- data/js/map_reduce_utils.js +1 -1
- data/lib/qme/database_access.rb +4 -0
- data/lib/qme/ext/record.rb +49 -0
- data/lib/qme/importer/entry.rb +1 -1
- data/lib/qme/importer/generic_importer.rb +24 -47
- data/lib/qme/importer/measure_properties_generator.rb +39 -0
- data/lib/qme/importer/provider_importer.rb +7 -4
- data/lib/qme/map/map_reduce_builder.rb +1 -1
- data/lib/qme/map/map_reduce_executor.rb +29 -25
- data/lib/qme/randomizer/patient_randomization_job.rb +5 -6
- data/lib/qme/randomizer/random_patient_creator.rb +47 -0
- data/lib/quality-measure-engine.rb +5 -8
- data/lib/tasks/patient_random.rake +3 -3
- metadata +99 -107
- data/lib/qme/ext/string.rb +0 -5
- data/lib/qme/importer/code_system_helper.rb +0 -41
- data/lib/qme/importer/hl7_helper.rb +0 -27
- data/lib/qme/importer/patient_importer.rb +0 -228
- data/lib/qme/importer/section_importer.rb +0 -138
data/Gemfile
CHANGED
@@ -5,11 +5,12 @@ gemspec :development_group => :test
|
|
5
5
|
gem 'mongo', '1.5.1'
|
6
6
|
gem 'bson_ext', '1.5.1', :platforms => :mri
|
7
7
|
gem 'rake'
|
8
|
-
gem 'pry', :require => true
|
8
|
+
#gem 'pry', :require => true
|
9
|
+
#gem 'health-data-standards', :git => 'https://github.com/projectcypress/health-data-standards.git', :branch => 'master'
|
10
|
+
gem 'health-data-standards', '0.7.0'
|
9
11
|
|
10
12
|
group :test do
|
11
13
|
gem 'cover_me', '>= 1.0.0.rc5', :platforms => :ruby_19
|
12
|
-
gem 'metric_fu'
|
13
14
|
gem 'sinatra'
|
14
15
|
end
|
15
16
|
|
data/Rakefile
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
require 'rspec/core/rake_task'
|
2
2
|
require 'yard'
|
3
|
-
require 'metric_fu'
|
4
3
|
require 'resque/tasks'
|
5
4
|
|
6
5
|
ENV['MEASURE_DIR'] = ENV['MEASURE_DIR'] || File.join('fixtures', 'measure_defs')
|
@@ -29,11 +28,4 @@ end
|
|
29
28
|
task :coverage do
|
30
29
|
Rake::Task['spec'].invoke
|
31
30
|
Rake::Task['cover_me:report'].invoke
|
32
|
-
end
|
33
|
-
|
34
|
-
MetricFu::Configuration.run do |config|
|
35
|
-
#define which metrics you want to use
|
36
|
-
config.metrics = [:roodi, :reek, :churn, :flog, :flay]
|
37
|
-
config.graphs = [:flog, :flay]
|
38
|
-
config.flay ={:dirs_to_flay => []} #Flay doesn't seem to be handling CLI arguments well... so this config squashes them
|
39
31
|
end
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.0
|
1
|
+
1.1.0
|
data/js/map_reduce_utils.js
CHANGED
@@ -120,7 +120,7 @@ function() {
|
|
120
120
|
root.map = function(record, population, denominator, numerator, exclusion) {
|
121
121
|
var value = {population: false, denominator: false, numerator: false,
|
122
122
|
exclusions: false, antinumerator: false, patient_id: record._id,
|
123
|
-
medical_record_id: record.
|
123
|
+
medical_record_id: record.medical_record_number,
|
124
124
|
first: record.first, last: record.last, gender: record.gender,
|
125
125
|
birthdate: record.birthdate, test_id: record.test_id,
|
126
126
|
provider_performances: record.provider_performances,
|
data/lib/qme/database_access.rb
CHANGED
@@ -0,0 +1,49 @@
|
|
1
|
+
# Extensions to the Record model in health-data-standards to support
|
2
|
+
# quality measure calculation
|
3
|
+
class Record
|
4
|
+
extend ActiveSupport::Memoizable
|
5
|
+
|
6
|
+
def procedure_results
|
7
|
+
results.to_a + vital_signs.to_a + procedures.to_a
|
8
|
+
end
|
9
|
+
|
10
|
+
def laboratory_tests
|
11
|
+
results.to_a + vital_signs.to_a
|
12
|
+
end
|
13
|
+
|
14
|
+
def all_meds
|
15
|
+
medications.to_a + immunizations.to_a
|
16
|
+
end
|
17
|
+
|
18
|
+
def active_diagnosis
|
19
|
+
conditions.any_of({:status => 'active'}, {:status => nil}).to_a +
|
20
|
+
social_history.any_of({:status => 'active'}, {:status => nil}).to_a
|
21
|
+
end
|
22
|
+
|
23
|
+
def inactive_diagnosis
|
24
|
+
conditions.any_of({:status => 'inactive'}, {:status => nil}).to_a +
|
25
|
+
social_history.any_of({:status => 'inactive'}, {:status => nil}).to_a
|
26
|
+
end
|
27
|
+
|
28
|
+
def resolved_diagnosis
|
29
|
+
conditions.any_of({:status => 'resolved'}, {:status => nil}).to_a +
|
30
|
+
social_history.any_of({:status => 'resolved'}, {:status => nil}).to_a
|
31
|
+
end
|
32
|
+
|
33
|
+
def all_problems
|
34
|
+
conditions.to_a + social_history.to_a
|
35
|
+
end
|
36
|
+
|
37
|
+
def all_devices
|
38
|
+
conditions.to_a + procedures.to_a + care_goals.to_a + medical_equipment.to_a
|
39
|
+
end
|
40
|
+
|
41
|
+
memoize :procedure_results
|
42
|
+
memoize :laboratory_tests
|
43
|
+
memoize :all_meds
|
44
|
+
memoize :active_diagnosis
|
45
|
+
memoize :inactive_diagnosis
|
46
|
+
memoize :resolved_diagnosis
|
47
|
+
memoize :all_problems
|
48
|
+
memoize :all_devices
|
49
|
+
end
|
data/lib/qme/importer/entry.rb
CHANGED
@@ -91,7 +91,7 @@ module QME
|
|
91
91
|
# Creates a Hash for this Entry
|
92
92
|
# @return [Hash] a Hash representing the Entry
|
93
93
|
def to_hash
|
94
|
-
entry_hash = {}
|
94
|
+
entry_hash = {'_id' => BSON::ObjectId.new}
|
95
95
|
entry_hash['codes'] = @codes
|
96
96
|
unless @value.empty?
|
97
97
|
entry_hash['value'] = @value
|
@@ -23,13 +23,12 @@ module QME
|
|
23
23
|
#
|
24
24
|
# @param [Hash] patient_hash representation of a patient
|
25
25
|
# @return [Hash] measure information
|
26
|
-
def parse(
|
26
|
+
def parse(patient)
|
27
27
|
measure_info = {}
|
28
28
|
@definition['measure'].each_pair do |property, description|
|
29
29
|
raise "No standard_category for #{property}" if !description['standard_category']
|
30
30
|
matcher = PropertyMatcher.new(description)
|
31
|
-
|
32
|
-
entry_list = enrty_filter.call(patient_hash)
|
31
|
+
entry_list = filter_for_property(description['standard_category'], description['qds_data_type'], patient)
|
33
32
|
if ! entry_list.empty?
|
34
33
|
matched_list = matcher.match(entry_list)
|
35
34
|
measure_info[property] = matched_list if matched_list.length > 0
|
@@ -40,89 +39,67 @@ module QME
|
|
40
39
|
|
41
40
|
private
|
42
41
|
|
43
|
-
def
|
44
|
-
Proc.new do |patient_hash|
|
45
|
-
sections.map do |section|
|
46
|
-
if patient_hash[section]
|
47
|
-
patient_hash[section]
|
48
|
-
else
|
49
|
-
[]
|
50
|
-
end
|
51
|
-
end.flatten
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
def create_section_and_status_filter(status, *sections)
|
56
|
-
section_filter = create_section_filter(*sections)
|
57
|
-
Proc.new do |patient_hash|
|
58
|
-
entry_list = section_filter.call(patient_hash)
|
59
|
-
entry_list.select do |entry|
|
60
|
-
entry.status.nil? || entry.status == status
|
61
|
-
end
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
def filter_for_property(standard_category, qds_data_type)
|
42
|
+
def filter_for_property(standard_category, qds_data_type, patient)
|
66
43
|
# Currently unsupported categories: negation_rationale, risk_category_assessment
|
67
44
|
case standard_category
|
68
45
|
when 'encounter'
|
69
|
-
|
46
|
+
patient.encounters
|
70
47
|
when 'procedure'
|
71
48
|
case qds_data_type
|
72
49
|
when 'procedure_performed'
|
73
|
-
|
50
|
+
patient.procedures
|
74
51
|
when 'procedure_adverse_event', 'procedure_intolerance'
|
75
|
-
|
52
|
+
patient.allergies
|
76
53
|
when 'procedure_result'
|
77
|
-
|
54
|
+
patient.procedure_results
|
78
55
|
end
|
79
56
|
when 'risk_category_assessment'
|
80
|
-
|
57
|
+
patient.procedures
|
81
58
|
when 'communication'
|
82
|
-
|
59
|
+
patient.procedures
|
83
60
|
when 'laboratory_test'
|
84
|
-
|
61
|
+
patient.laboratory_tests
|
85
62
|
when 'physical_exam'
|
86
|
-
|
63
|
+
patient.vital_signs
|
87
64
|
when 'medication'
|
88
65
|
case qds_data_type
|
89
66
|
when 'medication_dispensed', 'medication_order', 'medication_active', 'medication_administered'
|
90
|
-
|
67
|
+
patient.all_meds
|
91
68
|
when 'medication_allergy', 'medication_intolerance', 'medication_adverse_event'
|
92
|
-
|
69
|
+
patient.allergies
|
93
70
|
end
|
94
71
|
when 'diagnosis_condition_problem'
|
95
72
|
case qds_data_type
|
96
73
|
when 'diagnosis_active'
|
97
|
-
|
74
|
+
patient.active_diagnosis
|
98
75
|
when 'diagnosis_inactive'
|
99
|
-
|
76
|
+
patient.inactive_diagnosis
|
100
77
|
when 'diagnosis_resolved'
|
101
|
-
|
78
|
+
patient.resolved_diagnosis
|
102
79
|
end
|
103
80
|
when 'symptom'
|
104
|
-
|
81
|
+
patient.all_problems
|
105
82
|
when 'individual_characteristic'
|
106
|
-
|
83
|
+
patient.all_problems
|
107
84
|
when 'device'
|
108
85
|
case qds_data_type
|
109
86
|
when 'device_applied'
|
110
|
-
|
87
|
+
patient.all_devices
|
111
88
|
when 'device_allergy'
|
112
|
-
|
89
|
+
patient.allergies
|
113
90
|
end
|
114
91
|
when 'care_goal'
|
115
|
-
|
92
|
+
patient.care_goals
|
116
93
|
when 'diagnostic_study'
|
117
|
-
|
94
|
+
patient.procedures
|
118
95
|
when 'substance'
|
119
|
-
|
96
|
+
patient.allergies
|
120
97
|
else
|
121
98
|
unless self.class.warnings.include?(standard_category)
|
122
99
|
puts "Warning: Unsupported standard_category (#{standard_category})"
|
123
100
|
self.class.warnings << standard_category
|
124
101
|
end
|
125
|
-
|
102
|
+
[]
|
126
103
|
end
|
127
104
|
end
|
128
105
|
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module QME
|
2
|
+
module Importer
|
3
|
+
class MeasurePropertiesGenerator
|
4
|
+
include Singleton
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@measure_importers = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
# Adds a GenericImporter that will be used to extract
|
11
|
+
# information about a measure from a patient Record.
|
12
|
+
def add_measure(measure_id, importer)
|
13
|
+
@measure_importers[measure_id] = importer
|
14
|
+
end
|
15
|
+
|
16
|
+
# Generates denormalized measure information
|
17
|
+
# Measure information is contained in a Hash that hash
|
18
|
+
# the measure id as a key, and the denormalized
|
19
|
+
# measure information as a value
|
20
|
+
#
|
21
|
+
# @param [Record] patient - populated patient record
|
22
|
+
# @returns Hash with denormalized measure information
|
23
|
+
def generate_properties(patient)
|
24
|
+
measures = {}
|
25
|
+
@measure_importers.each_pair do |measure_id, importer|
|
26
|
+
measures[measure_id] = importer.parse(patient)
|
27
|
+
end
|
28
|
+
measures
|
29
|
+
end
|
30
|
+
|
31
|
+
# The same as generate_properties but addes the denormalized
|
32
|
+
# measure information into the Record and saves it.
|
33
|
+
def generate_properties!(patient)
|
34
|
+
patient['measures'] = generate_properties(patient)
|
35
|
+
patient.save!
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -11,16 +11,19 @@ module QME
|
|
11
11
|
# @param [Nokogiri::XML::Document] doc It is expected that the root node of this document
|
12
12
|
# will have the "cda" namespace registered to "urn:hl7-org:v3"
|
13
13
|
# @return [Array] an array of providers found in the document
|
14
|
-
def extract_providers(doc)
|
15
|
-
|
16
|
-
performers = doc.xpath("//cda:documentationOf/cda:serviceEvent/cda:performer")
|
14
|
+
def extract_providers(doc, use_encounters=false)
|
17
15
|
|
16
|
+
|
17
|
+
xpath_base = use_encounters ? "//cda:encounter/cda:performer" : "//cda:documentationOf/cda:serviceEvent/cda:performer"
|
18
|
+
|
19
|
+
performers = doc.xpath(xpath_base)
|
20
|
+
|
18
21
|
providers = performers.map do |performer|
|
19
22
|
provider = {}
|
20
23
|
entity = performer.xpath(performer, "./cda:assignedEntity")
|
21
24
|
name = entity.xpath("./cda:assignedPerson/cda:name")
|
22
25
|
provider[:title] = extract_data(name, "./cda:prefix")
|
23
|
-
provider[:given_name] = extract_data(name, "./cda:given")
|
26
|
+
provider[:given_name] = extract_data(name, "./cda:given[1]")
|
24
27
|
provider[:family_name] = extract_data(name, "./cda:family")
|
25
28
|
provider[:phone] = extract_data(entity, "./cda:telecom/@value") { |text| text.gsub("tel:", "") }
|
26
29
|
provider[:organization] = extract_data(entity, "./cda:representedOrganization/cda:name")
|
@@ -97,7 +97,7 @@ module QME
|
|
97
97
|
var tmp = [];
|
98
98
|
for(var i=0; i<patient.provider_performances.length; i++) {
|
99
99
|
var value = patient.provider_performances[i];
|
100
|
-
if (value['start_date'] <= #{@params['effective_date']} && (value['end_date'] >= #{@params['effective_date']} || value['end_date'] == null))
|
100
|
+
if ((value['start_date'] <= #{@params['effective_date']} || value['start_date'] == null) && (value['end_date'] >= #{@params['effective_date']} || value['end_date'] == null))
|
101
101
|
tmp.push(value);
|
102
102
|
}
|
103
103
|
if (tmp.length == 0) tmp = null;
|
@@ -32,36 +32,24 @@ module QME
|
|
32
32
|
base_query.merge!(filter_parameters)
|
33
33
|
|
34
34
|
query = base_query.clone
|
35
|
-
|
36
|
-
query.merge!({'value.manual_exclusion'=>{'$
|
35
|
+
|
36
|
+
query.merge!({'value.manual_exclusion' => {'$in' => [nil, false]}})
|
37
37
|
|
38
38
|
result = {:measure_id => @measure_id, :sub_id => @sub_id,
|
39
39
|
:effective_date => @parameter_values['effective_date'],
|
40
40
|
:test_id => @parameter_values['test_id'], :filters => @parameter_values['filters']}
|
41
41
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
aggregate
|
51
|
-
aggregate.each {|key, value| aggregate[key] = value.to_i}
|
52
|
-
aggregate['exclusions'] += patient_cache.find(base_query.merge({'value.manual_exclusion'=>true})).count
|
42
|
+
# need to time the old way agains the single query to verify that the single query is more performant
|
43
|
+
aggregate = {"population"=>0, "denominator"=>0, "numerator"=>0, "antinumerator"=>0, "exclusions"=>0}
|
44
|
+
%w(population denominator numerator antinumerator exclusions).each do |measure_group|
|
45
|
+
patient_cache.find(query.merge("value.#{measure_group}" => true)) do |cursor|
|
46
|
+
aggregate[measure_group] = cursor.count
|
47
|
+
end
|
48
|
+
end
|
49
|
+
aggregate["considered"] = patient_cache.find(query).count
|
50
|
+
aggregate["exclusions"] += patient_cache.find(base_query.merge({'value.manual_exclusion'=>true})).count
|
53
51
|
result.merge!(aggregate)
|
54
52
|
|
55
|
-
# # need to time the old way agains the single query to verify that the single query is more performant
|
56
|
-
# aggregate = {population: 0, denominator: 0, numerator: 0, antinumerator: 0, exclusions: 0}
|
57
|
-
# %w(population denominator numerator antinumerator exclusions).each do |measure_group|
|
58
|
-
# patient_cache.find(query.merge("value.#{measure_group}" => true)) do |cursor|
|
59
|
-
# aggregate[measure_group] = cursor.count
|
60
|
-
# end
|
61
|
-
# end
|
62
|
-
# aggregate[:considered] = patient_cache.find(query).count
|
63
|
-
# result.merge!(aggregate)
|
64
|
-
|
65
53
|
result.merge!(execution_time: (Time.now.to_i - @parameter_values['start_time'].to_i)) if @parameter_values['start_time']
|
66
54
|
|
67
55
|
get_db.collection("query_cache").save(result, safe: true)
|
@@ -92,11 +80,27 @@ module QME
|
|
92
80
|
records.map_reduce(measure.map_function, "function(key, values){return values;}",
|
93
81
|
:out => {:reduce => 'patient_cache'},
|
94
82
|
:finalize => measure.finalize_function,
|
95
|
-
:query => {:
|
83
|
+
:query => {:medical_record_number => patient_id, :test_id => @parameter_values['test_id']})
|
96
84
|
apply_manual_exclusions
|
97
85
|
end
|
98
86
|
|
99
|
-
# This
|
87
|
+
# This method runs the MapReduce job for the measure and a specific patient.
|
88
|
+
# This will *not* create a document in the patient_cache collection, instead the
|
89
|
+
# result is returned directly.
|
90
|
+
def get_patient_result(patient_id)
|
91
|
+
qm = QualityMeasure.new(@measure_id, @sub_id)
|
92
|
+
measure = Builder.new(get_db, qm.definition, @parameter_values)
|
93
|
+
records = get_db.collection('records')
|
94
|
+
result = records.map_reduce(measure.map_function, "function(key, values){return values;}",
|
95
|
+
:out => {:inline => true},
|
96
|
+
:raw => true,
|
97
|
+
:finalize => measure.finalize_function,
|
98
|
+
:query => {:medical_record_number => patient_id, :test_id => @parameter_values['test_id']})
|
99
|
+
raise result['err'] if result['ok']!=1
|
100
|
+
result['results'][0]['value']
|
101
|
+
end
|
102
|
+
|
103
|
+
# This collects the set of manual exclusions from the manual_exclusions collections
|
100
104
|
# and sets a flag in each cached patient result for patients that have been excluded from the
|
101
105
|
# current measure
|
102
106
|
def apply_manual_exclusions
|
@@ -24,21 +24,20 @@ module QME
|
|
24
24
|
QME::QualityMeasure.all.each_value do |measure_def|
|
25
25
|
measure_id = measure_def['id']
|
26
26
|
if !processed_measures[measure_id]
|
27
|
-
QME::Importer::
|
28
|
-
processed_measures[measure_id]=true
|
27
|
+
QME::Importer::MeasurePropertiesGenerator.instance.add_measure(measure_id, QME::Importer::GenericImporter.new(measure_def))
|
28
|
+
processed_measures[measure_id] = true
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
32
|
-
loader = QME::Database::Loader.new()
|
33
32
|
tick('Generating patients')
|
34
33
|
count.times do |i|
|
35
34
|
at(i, count, "Generating patient #{i} of #{count}")
|
36
35
|
template = templates[rand(templates.length)]
|
37
36
|
generator = QME::Randomizer::Patient.new(template)
|
38
37
|
json = JSON.parse(generator.get())
|
39
|
-
|
40
|
-
|
41
|
-
|
38
|
+
patient_record = RandomPatientCreator.parse_hash(json)
|
39
|
+
patient_record.test_id = test_id
|
40
|
+
patient_record.save!
|
42
41
|
end
|
43
42
|
|
44
43
|
completed
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module QME
|
2
|
+
module Randomizer
|
3
|
+
class RandomPatientCreator
|
4
|
+
|
5
|
+
# Parses a patient hash containing demographic and event information
|
6
|
+
#
|
7
|
+
# @param [Hash] patient_hash patient data
|
8
|
+
# @return [Record] a representation of the patient that can be inserted into MongoDB
|
9
|
+
def self.parse_hash(patient_hash)
|
10
|
+
patient_record = Record.new
|
11
|
+
patient_record.first = patient_hash['first']
|
12
|
+
patient_record.last = patient_hash['last']
|
13
|
+
patient_record.gender = patient_hash['gender']
|
14
|
+
patient_record.medical_record_number = patient_hash['medical_record_number']
|
15
|
+
patient_record.birthdate = patient_hash['birthdate']
|
16
|
+
patient_record.race = patient_hash['race']
|
17
|
+
patient_record.ethnicity = patient_hash['ethnicity']
|
18
|
+
# pophealth needs languages... please do not remove
|
19
|
+
patient_record.languages = patient_hash['languages']
|
20
|
+
#patient_record['addresses'] = patient_hash['addresses']
|
21
|
+
patient_hash['events'].each do |key, value|
|
22
|
+
patient_record.send("#{key}=".to_sym, parse_events(value))
|
23
|
+
end
|
24
|
+
patient_record['measures'] = QME::Importer::MeasurePropertiesGenerator.instance.generate_properties(patient_record)
|
25
|
+
|
26
|
+
patient_record
|
27
|
+
end
|
28
|
+
|
29
|
+
# Parses a list of event hashes into an array of Entry objects
|
30
|
+
#
|
31
|
+
# @param [Array] event_list list of event hashes
|
32
|
+
# @return [Array] array of Entry objects
|
33
|
+
def self.parse_events(event_list)
|
34
|
+
event_list.collect do |event|
|
35
|
+
if event.class==String.class
|
36
|
+
# skip String elements in the event list, patient randomization templates
|
37
|
+
# introduce String elements to simplify tailing-comma handling when generating
|
38
|
+
# JSON using ERb
|
39
|
+
nil
|
40
|
+
else
|
41
|
+
Entry.from_event_hash(event)
|
42
|
+
end
|
43
|
+
end.compact
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|