quality-measure-engine 1.0.2 → 1.0.3

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/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