quality-measure-engine 1.0.2 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -4,6 +4,7 @@ gemspec :development_group => :test
4
4
 
5
5
  gem 'bson_ext', :platforms => :mri
6
6
  gem 'rake'
7
+ gem 'pry', :require => true
7
8
 
8
9
  group :test do
9
10
  gem 'cover_me', '>= 1.0.0.rc5', :platforms => :ruby_19
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.2
1
+ 1.0.3
@@ -122,7 +122,9 @@ function() {
122
122
  exclusions: false, antinumerator: false, patient_id: record._id,
123
123
  medical_record_id: record.patient_id,
124
124
  first: record.first, last: record.last, gender: record.gender,
125
- birthdate: record.birthdate, test_id: record.test_id};
125
+ birthdate: record.birthdate, test_id: record.test_id,
126
+ provider_performances: record.provider_performances,
127
+ race: record.race, ethnicity: record.ethnicity};
126
128
  if (population()) {
127
129
  value.population = true;
128
130
  if (denominator()) {
@@ -0,0 +1,5 @@
1
+ class String
2
+ def to_boolean
3
+ ['1', 'true', 't'].include?(self.downcase)
4
+ end
5
+ end
@@ -12,7 +12,8 @@ module QME
12
12
  '2.16.840.1.113883.6.104' => 'ICD-9-CM',
13
13
  '2.16.840.1.113883.6.90' => 'ICD-10-CM',
14
14
  '2.16.840.1.113883.6.14' => 'HCPCS',
15
- '2.16.840.1.113883.6.59' => 'CVX'
15
+ '2.16.840.1.113883.6.59' => 'CVX',
16
+ '2.16.840.1.113883.6.238' => 'CDC-RE'
16
17
  }
17
18
 
18
19
  # Returns the name of a code system given an oid
@@ -22,23 +23,18 @@ module QME
22
23
  CODE_SYSTEMS[oid] || "Unknown"
23
24
  end
24
25
 
25
- @@oid_map = nil
26
-
27
26
  # Returns the oid for a code system given a codesystem name
28
27
  # @param [String] the name of the code system
29
28
  # @return [String] the oid of the code system
30
- def self.oid_for_code_system(codesystem)
31
- if(!@@oid_map)
32
- @@oid_map = {}
33
- CODE_SYSTEMS.each_pair do |oid, codesystem|
34
- # STDERR.puts "Adding #{oid}, #{codesystem}"
35
- @@oid_map[codesystem] = oid
36
- end
37
- end
38
- # STDERR.puts "@@oid_map[#{codesystem}] = #{@@oid_map[codesystem]}"
39
- return @@oid_map[codesystem]
29
+ def self.oid_for_code_system(code_system)
30
+ CODE_SYSTEMS.invert[code_system]
40
31
  end
41
32
 
33
+ # Returns the whole map of OIDs to code systems
34
+ # @terurn [Hash] oids as keys, code system names as values
35
+ def self.code_systems
36
+ CODE_SYSTEMS
37
+ end
42
38
  end
43
39
  end
44
40
  end
@@ -14,7 +14,10 @@ module QME
14
14
  entry = Entry.new
15
15
  if event['code']
16
16
  entry.add_code(event['code'], event['code_set'])
17
+ elsif event['codes']
18
+ entry.instance_eval { @codes = event['codes'] }
17
19
  end
20
+
18
21
  entry.time = event['time']
19
22
  if event['value']
20
23
  entry.set_value(event['value'], event['unit'])
@@ -121,6 +121,8 @@ module QME
121
121
  patient_record['gender'] = patient_hash['gender']
122
122
  patient_record['patient_id'] = patient_hash['patient_id']
123
123
  patient_record['birthdate'] = patient_hash['birthdate']
124
+ patient_record['race'] = patient_hash['race']
125
+ patient_record['ethnicity'] = patient_hash['ethnicity']
124
126
  patient_record['addresses'] = patient_hash['addresses']
125
127
  event_hash = {}
126
128
  patient_hash['events'].each do |key, value|
@@ -209,6 +211,10 @@ module QME
209
211
  patient['birthdate'] = HL7Helper.timestamp_to_integer(birthdate_in_hl7ts)
210
212
  gender_node = doc.at_xpath('/cda:ClinicalDocument/cda:recordTarget/cda:patientRole/cda:patient/cda:administrativeGenderCode')
211
213
  patient['gender'] = gender_node['code']
214
+ race_node = doc.at_xpath('/cda:ClinicalDocument/cda:recordTarget/cda:patientRole/cda:patient/cda:raceCode')
215
+ patient['race'] = race_node['code'] if race_node
216
+ ethnicity_node = doc.at_xpath('/cda:ClinicalDocument/cda:recordTarget/cda:patientRole/cda:patient/cda:ethnicGroupCode')
217
+ patient['ethnicity'] = ethnicity_node['code'] if ethnicity_node
212
218
  id_node = doc.at_xpath('/cda:ClinicalDocument/cda:recordTarget/cda:patientRole/cda:id')
213
219
  patient['patient_id'] = id_node['extension']
214
220
  end
@@ -46,6 +46,8 @@ module QME
46
46
  if value
47
47
  if @property_description['items']['properties']['value']['type'] == 'number'
48
48
  value = value.to_f
49
+ elsif @property_description['items']['properties']['value']['type'] == 'boolean'
50
+ value = value.to_boolean
49
51
  end
50
52
 
51
53
  matching_values << {'date' => entry.as_point_in_time, 'value' => value}
@@ -0,0 +1,94 @@
1
+ require "date"
2
+ require "date/delta"
3
+
4
+ module QME
5
+ module Importer
6
+ class ProviderImporter
7
+ include Singleton
8
+
9
+ # Extract Healthcare Providers from C32
10
+ #
11
+ # @param [Nokogiri::XML::Document] doc It is expected that the root node of this document
12
+ # will have the "cda" namespace registered to "urn:hl7-org:v3"
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")
17
+
18
+ providers = performers.map do |performer|
19
+ provider = {}
20
+ entity = performer.xpath(performer, "./cda:assignedEntity")
21
+ name = entity.xpath("./cda:assignedPerson/cda:name")
22
+ provider[:title] = extract_data(name, "./cda:prefix")
23
+ provider[:given_name] = extract_data(name, "./cda:given")
24
+ provider[:family_name] = extract_data(name, "./cda:family")
25
+ provider[:phone] = extract_data(entity, "./cda:telecom/@value") { |text| text.gsub("tel:", "") }
26
+ provider[:organization] = extract_data(entity, "./cda:representedOrganization/cda:name")
27
+ provider[:specialty] = extract_data(entity, "./cda:code/@code")
28
+ time = performer.xpath(performer, "./cda:time")
29
+ provider[:start] = extract_date(time, "./cda:low/@value")
30
+ provider[:end] = extract_date(time, "./cda:high/@value")
31
+ # NIST sample C32s use different OID for NPI vs C83, support both
32
+ npi = extract_data(entity, "./cda:id[@root='2.16.840.1.113883.4.6' or @root='2.16.840.1.113883.3.72.5.2']/@extension")
33
+ if ProviderImporter::valid_npi?(npi)
34
+ provider[:npi] = npi
35
+ else
36
+ puts "Warning: Invalid NPI (#{npi})"
37
+ end
38
+ provider
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def extract_date(subject,query)
45
+ date = extract_data(subject,query)
46
+ date ? Date.parse(date).to_time.to_i : nil
47
+ end
48
+
49
+ # Returns nil if result is an empty string, block allows text munging of result if there is one
50
+ def extract_data(subject, query)
51
+ result = subject.xpath(query).text
52
+ if result == ""
53
+ nil
54
+ elsif block_given?
55
+ yield(result)
56
+ else
57
+ result
58
+ end
59
+ end
60
+
61
+ # validate the NPI, should be 10 or 15 digits total with the final digit being a
62
+ # checksum using the Luhn algorithm with additional special handling as described in
63
+ # https://www.cms.gov/NationalProvIdentStand/Downloads/NPIcheckdigit.pdf
64
+ def self.valid_npi?(npi)
65
+ return false if npi.length != 10 and npi.length != 15
66
+ return false if npi.gsub(/\d/, '').length > 0 # npi must be all digits
67
+ return false if npi.length == 15 and (npi =~ /^80840/)==nil # 15 digit npi must start with 80840
68
+
69
+ # checksum is always calculated as if 80840 prefix is present
70
+ if npi.length==10
71
+ npi = '80840'+npi
72
+ end
73
+
74
+ return luhn_checksum(npi[0,14])==npi[14]
75
+ end
76
+
77
+ def self.luhn_checksum(num)
78
+ double = {'0' => 0, '1' => 2, '2' => 4, '3' => 6, '4' => 8, '5' => 1, '6' => 3, '7' => 5, '8' => 7, '9' => 9}
79
+ sum = 0
80
+ num.reverse!
81
+ num.split("").each_with_index do |char, i|
82
+ if (i%2)==0
83
+ sum+=double[char]
84
+ else
85
+ sum+=char.to_i
86
+ end
87
+ end
88
+ sum = (9*sum)%10
89
+
90
+ return sum.to_s
91
+ end
92
+ end
93
+ end
94
+ end
@@ -28,15 +28,31 @@ module QME
28
28
  query = {'value.measure_id' => @measure_id, 'value.sub_id' => @sub_id,
29
29
  'value.effective_date' => @parameter_values['effective_date'],
30
30
  'value.test_id' => @parameter_values['test_id']}
31
+
32
+ query.merge!(filter_parameters)
33
+
31
34
  result = {:measure_id => @measure_id, :sub_id => @sub_id,
32
35
  :effective_date => @parameter_values['effective_date'],
33
- :test_id => @parameter_values['test_id']}
34
- %w(population denominator numerator antinumerator exclusions).each do |measure_group|
35
- patient_cache.find(query.merge("value.#{measure_group}" => true)) do |cursor|
36
- result[measure_group] = cursor.count
37
- end
38
- end
39
- get_db.collection("query_cache").save(result)
36
+ :test_id => @parameter_values['test_id'], :filters => @parameter_values['filters']}
37
+
38
+ aggregate = patient_cache.group({cond: query,
39
+ initial: {population: 0, denominator: 0, numerator: 0, antinumerator: 0, exclusions: 0, considered: 0},
40
+ reduce: "function(record,sums) { for (var key in sums) { sums[key] += (record['value'][key] || key == 'considered') ? 1 : 0 } }"}).first
41
+
42
+ aggregate ||= {population: 0, denominator: 0, numerator: 0, antinumerator: 0, exclusions: 0}
43
+ aggregate.each {|key, value| aggregate[key] = value.to_i}
44
+ result.merge!(aggregate)
45
+
46
+ # need to time the old way agains the single query to verify that the single query is more performant
47
+ # %w(population denominator numerator antinumerator exclusions).each do |measure_group|
48
+ # patient_cache.find(query.merge("value.#{measure_group}" => true)) do |cursor|
49
+ # result[measure_group] = cursor.count
50
+ # end
51
+ # end
52
+
53
+ result.merge!(execution_time: (Time.now.to_i - @parameter_values['start_time'].to_i)) if @parameter_values['start_time']
54
+
55
+ get_db.collection("query_cache").save(result, safe: true)
40
56
  result
41
57
  end
42
58
 
@@ -52,6 +68,47 @@ module QME
52
68
  :finalize => measure.finalize_function,
53
69
  :query => {:test_id => @parameter_values['test_id']})
54
70
  end
71
+
72
+ # This method runs the MapReduce job for the measure and a specific patient.
73
+ # This will create a document in the patient_cache collection. This document
74
+ # will state the measure groups that the record belongs to, such as numerator, etc.
75
+ def map_record_into_measure_groups(patient_id)
76
+ qm = QualityMeasure.new(@measure_id, @sub_id)
77
+ measure = Builder.new(get_db, qm.definition, @parameter_values)
78
+ records = get_db.collection('records')
79
+ records.map_reduce(measure.map_function, "function(key, values){return values;}",
80
+ :out => {:reduce => 'patient_cache'},
81
+ :finalize => measure.finalize_function,
82
+ :query => {:patient_id => patient_id, :test_id => @parameter_values['test_id']})
83
+ end
84
+
85
+ def filter_parameters
86
+ results = {}
87
+ conditions = []
88
+ if(filters = @parameter_values['filters'])
89
+ if (filters['providers'] && filters['providers'].size > 0)
90
+ providers = filters['providers'].map {|provider_id| BSON::ObjectId(provider_id) if provider_id }
91
+ conditions << provider_queries(providers, @parameter_values['effective_date'])
92
+ end
93
+ if (filters['races'] && filters['races'].size > 0)
94
+ conditions << {'value.race.code' => {'$in' => filters['races']}}
95
+ end
96
+ if (filters['ethnicities'] && filters['ethnicities'].size > 0)
97
+ conditions << {'value.ethnicity.code' => {'$in' => filters['ethnicities']}}
98
+ end
99
+ if (filters['genders'] && filters['genders'].size > 0)
100
+ conditions << {'value.gender' => {'$in' => filters['genders']}}
101
+ end
102
+ end
103
+ results.merge!({'$and'=>conditions}) if conditions.length > 0
104
+ results
105
+ end
106
+ def provider_queries(provider_ids, effective_date)
107
+ {'$or' => [provider_query(provider_ids, effective_date,effective_date), provider_query(provider_ids, nil,effective_date), provider_query(provider_ids, effective_date,nil)]}
108
+ end
109
+ def provider_query(provider_ids, start_before, end_after)
110
+ {'value.provider_performances' => {'$elemMatch' => {'provider_id' => {'$in' => provider_ids}, 'start_date'=> {'$lt'=>start_before}, 'end_date'=> {'$gt'=>end_after} } }}
111
+ end
55
112
  end
56
113
  end
57
114
  end
@@ -15,20 +15,34 @@ module QME
15
15
  # the report.
16
16
  class MeasureCalculationJob < Resque::JobWithStatus
17
17
  def perform
18
+ MeasureCalculationJob.calculate(options)
19
+ end
20
+
21
+ def self.calculate(options)
18
22
  test_id = options['test_id'] ? BSON::ObjectId(options['test_id']) : nil
19
- qr = QualityReport.new(options['measure_id'], options['sub_id'], 'effective_date' => options['effective_date'], 'test_id' => test_id)
23
+ qr = QualityReport.new(options['measure_id'], options['sub_id'], 'effective_date' => options['effective_date'], 'test_id' => test_id, 'filters' => options['filters'])
20
24
  if qr.calculated?
21
- completed("#{options['measure_id']}#{options['sub_id']} has already been calculated")
25
+ completed("#{options['measure_id']}#{options['sub_id']} has already been calculated") if respond_to? :completed
22
26
  else
23
- map = QME::MapReduce::Executor.new(options['measure_id'], options['sub_id'], 'effective_date' => options['effective_date'], 'test_id' => test_id)
24
- tick('Starting MapReduce')
25
- map.map_records_into_measure_groups
26
- tick('MapReduce complete')
27
- tick('Calculating group totals')
27
+ map = QME::MapReduce::Executor.new(options['measure_id'], options['sub_id'], 'effective_date' => options['effective_date'], 'test_id' => test_id, 'filters' => options['filters'], 'start_time' => Time.now.to_i)
28
+
29
+ if !qr.patients_cached?
30
+ tick('Starting MapReduce') if respond_to? :tick
31
+ map.map_records_into_measure_groups
32
+ tick('MapReduce complete') if respond_to? :tick
33
+ end
34
+
35
+ tick('Calculating group totals') if respond_to? :tick
28
36
  result = map.count_records_in_measure_groups
29
- completed("#{options['measure_id']}#{options['sub_id']}: p#{result['population']}, d#{result['denominator']}, n#{result['numerator']}, e#{result['exclusions']}")
37
+ completed("#{options['measure_id']}#{options['sub_id']}: p#{result['population']}, d#{result['denominator']}, n#{result['numerator']}, e#{result['exclusions']}") if respond_to? :completed
30
38
  end
39
+
31
40
  end
41
+
42
+ # This can be uncommented and changed to put the jobs on a separate queue.
43
+ # def self.queue
44
+ # :statused
45
+ # end
32
46
  end
33
47
  end
34
48
  end
@@ -13,6 +13,37 @@ module QME
13
13
  get_db.collection("query_cache").drop
14
14
  get_db.collection("patient_cache").drop
15
15
  end
16
+
17
+ # Removes the cached results for the patient with the supplied id and
18
+ # recalculates as necessary
19
+ def self.update_patient_results(id)
20
+ determine_connection_information
21
+
22
+ # TODO: need to wait for any outstanding calculations to complete and then prevent
23
+ # any new ones from starting until we are done.
24
+
25
+ # drop any cached measure result calculations for the modified patient
26
+ get_db.collection("patient_cache").remove('value.medical_record_id' => id)
27
+
28
+ # get a list of cached measure results for a single patient
29
+ sample_patient = get_db.collection('patient_cache').find_one()
30
+ if sample_patient
31
+ cached_results = get_db.collection('patient_cache').find({'value.patient_id' => sample_patient['value']['patient_id']})
32
+
33
+ # for each cached result (a combination of measure_id, sub_id, effective_date and test_id)
34
+ cached_results.each do |measure|
35
+ # recalculate patient_cache value for modified patient
36
+ value = measure['value']
37
+ map = QME::MapReduce::Executor.new(value['measure_id'], value['sub_id'],
38
+ 'effective_date' => value['effective_date'], 'test_id' => value['test_id'])
39
+ map.map_record_into_measure_groups(id)
40
+ end
41
+ end
42
+
43
+ # remove the query totals so they will be recalculated using the new results for
44
+ # the modified patient
45
+ get_db.collection("query_cache").drop
46
+ end
16
47
 
17
48
  # Creates a new QualityReport
18
49
  # @param [String] measure_id value of the measure's id field
@@ -33,13 +64,25 @@ module QME
33
64
  def calculated?
34
65
  ! result().nil?
35
66
  end
67
+
68
+ # Determines whether the patient mapping for the quality report has been
69
+ # completed
70
+ def patients_cached?
71
+ ! patient_result().nil?
72
+ end
36
73
 
37
74
  # Kicks off a background job to calculate the measure
38
75
  # @return a unique id for the measure calculation job
39
- def calculate
40
- MapReduce::MeasureCalculationJob.create(:measure_id => @measure_id, :sub_id => @sub_id,
41
- :effective_date => @parameter_values['effective_date'],
42
- :test_id => @parameter_values['test_id'])
76
+ def calculate(asynchronous=true)
77
+ options = {'measure_id' => @measure_id, 'sub_id' => @sub_id,
78
+ 'effective_date' => @parameter_values['effective_date'],
79
+ 'test_id' => @parameter_values['test_id'],
80
+ 'filters' => QME::QualityReport.normalize_filters(@parameter_values['filters'])}
81
+ if (asynchronous)
82
+ MapReduce::MeasureCalculationJob.create(options)
83
+ else
84
+ MapReduce::MeasureCalculationJob.calculate(options)
85
+ end
43
86
  end
44
87
 
45
88
  # Returns the status of a measure calculation job
@@ -57,7 +100,27 @@ module QME
57
100
  query = {:measure_id => @measure_id, :sub_id => @sub_id,
58
101
  :effective_date => @parameter_values['effective_date'],
59
102
  :test_id => @parameter_values['test_id']}
103
+ if @parameter_values['filters']
104
+ query.merge!({filters: QME::QualityReport.normalize_filters(@parameter_values['filters'])})
105
+ else
106
+ query.merge!({filters: nil})
107
+ end
108
+
60
109
  cache.find_one(query)
61
110
  end
111
+
112
+ # make sure all filter id arrays are sorted
113
+ def self.normalize_filters(filters)
114
+ filters.each {|key, value| value.sort_by! {|v| (v.is_a? Hash) ? "#{v}" : v} if value.is_a? Array} unless filters.nil?
115
+ end
116
+
117
+ def patient_result
118
+ cache = get_db.collection("patient_cache")
119
+ query = {'value.measure_id' => @measure_id, 'value.sub_id' => @sub_id,
120
+ 'value.effective_date' => @parameter_values['effective_date'],
121
+ 'value.test_id' => @parameter_values['test_id']}
122
+ cache.find_one(query)
123
+ end
124
+
62
125
  end
63
126
  end
@@ -46,6 +46,40 @@ module QME
46
46
  def gender
47
47
  @genders[rand(@genders.length)]
48
48
  end
49
+
50
+ # Picks a race based on 2010 census estimates
51
+ # Pacific Islander 0.2%
52
+ # American Indian 0.9%
53
+ # Asian 4.8%
54
+ # Black persons 12.6%
55
+ # Hispanic 16.3%
56
+ # White 63.7%
57
+ def race_and_ethnicity
58
+ race_percent = rand(999)
59
+ case race_percent
60
+ when 0..1
61
+ # pacific islander
62
+ {race: '2076-8', ethnicity: '2186-5'}
63
+ when 2..10
64
+ # american indian
65
+ {race: '1002-5', ethnicity: '2186-5'}
66
+ when 11..58
67
+ # asian
68
+ {race: '2028-9', ethnicity: '2186-5'}
69
+ when 59..184
70
+ # black
71
+ {race: '2054-5', ethnicity: '2186-5'}
72
+ when 185..347
73
+ # hispanic
74
+ {race: '2106-3', ethnicity: '2135-2'}
75
+ when 348..984
76
+ # white (not hispanic)
77
+ {race: '2106-3', ethnicity: '2186-5'}
78
+ when 985..999
79
+ # other
80
+ {race: '2131-1', ethnicity: '2186-5'}
81
+ end
82
+ end
49
83
 
50
84
  # Pick a forename at random appropriate for the supplied gender
51
85
  # @param [String] gender the gender 'M' or 'F'
@@ -2,6 +2,8 @@ require "bundler/setup"
2
2
 
3
3
  require 'resque/job_with_status'
4
4
 
5
+ require_relative 'qme/ext/string'
6
+
5
7
  require_relative 'qme/database_access'
6
8
  require_relative 'qme/quality_measure'
7
9
 
@@ -24,6 +26,7 @@ require_relative 'qme/importer/hl7_helper'
24
26
 
25
27
  require_relative 'qme/importer/section_importer'
26
28
  require_relative 'qme/importer/generic_importer'
29
+ require_relative 'qme/importer/provider_importer'
27
30
 
28
31
  require 'json'
29
32
  require 'mongo'
metadata CHANGED
@@ -2,7 +2,7 @@
2
2
  name: quality-measure-engine
3
3
  version: !ruby/object:Gem::Version
4
4
  prerelease:
5
- version: 1.0.2
5
+ version: 1.0.3
6
6
  platform: ruby
7
7
  authors:
8
8
  - Marc Hadley
@@ -12,7 +12,7 @@ autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
14
 
15
- date: 2011-09-29 00:00:00 -04:00
15
+ date: 2011-12-08 00:00:00 -05:00
16
16
  default_executable:
17
17
  dependencies:
18
18
  - !ruby/object:Gem::Dependency
@@ -157,12 +157,14 @@ extra_rdoc_files: []
157
157
 
158
158
  files:
159
159
  - lib/qme/database_access.rb
160
+ - lib/qme/ext/string.rb
160
161
  - lib/qme/importer/code_system_helper.rb
161
162
  - lib/qme/importer/entry.rb
162
163
  - lib/qme/importer/generic_importer.rb
163
164
  - lib/qme/importer/hl7_helper.rb
164
165
  - lib/qme/importer/patient_importer.rb
165
166
  - lib/qme/importer/property_matcher.rb
167
+ - lib/qme/importer/provider_importer.rb
166
168
  - lib/qme/importer/section_importer.rb
167
169
  - lib/qme/map/map_reduce_builder.rb
168
170
  - lib/qme/map/map_reduce_executor.rb
@@ -211,7 +213,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
211
213
  requirements: []
212
214
 
213
215
  rubyforge_project:
214
- rubygems_version: 1.5.0
216
+ rubygems_version: 1.6.2
215
217
  signing_key:
216
218
  specification_version: 3
217
219
  summary: A library for extracting quality measure information from HITSP C32's and ASTM CCR's