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,27 @@
|
|
1
|
+
require 'roo'
|
2
|
+
|
3
|
+
module QME
|
4
|
+
module Measure
|
5
|
+
|
6
|
+
# Utility class for converting NQF XLS files to JSON
|
7
|
+
class Converter
|
8
|
+
|
9
|
+
#Convert an NQF XLS file to a hash
|
10
|
+
def self.from_xls(file)
|
11
|
+
xls =Excelx.new(file)
|
12
|
+
xls.default_sheet='Measure_QDS'
|
13
|
+
result = {}
|
14
|
+
(xls.header_line+1..xls.last_row).each do |row|
|
15
|
+
entry = {}
|
16
|
+
(xls.first_column..xls.last_column).each do |column|
|
17
|
+
entry[xls.cell(xls.header_line, column)] = xls.cell(row, column)
|
18
|
+
end
|
19
|
+
result[entry['QDS_id']] = entry
|
20
|
+
end
|
21
|
+
result
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module QME
|
2
|
+
class QualityMeasure
|
3
|
+
include DatabaseAccess
|
4
|
+
extend DatabaseAccess
|
5
|
+
determine_connection_information
|
6
|
+
|
7
|
+
# Return a list of the measures in the database
|
8
|
+
# @return [Hash] an hash of measure definitions
|
9
|
+
def self.all
|
10
|
+
result = {}
|
11
|
+
measures = get_db.collection('measures')
|
12
|
+
measures.find().each do |measure|
|
13
|
+
id = measure['id']
|
14
|
+
sub_id = measure['sub_id']
|
15
|
+
measure_id = "#{id}#{sub_id}.json"
|
16
|
+
result[measure_id] ||= measure
|
17
|
+
end
|
18
|
+
result
|
19
|
+
end
|
20
|
+
|
21
|
+
# Creates a new QualityMeasure
|
22
|
+
# @param [String] measure_id value of the measure's id field
|
23
|
+
# @param [String] sub_id value of the measure's sub_id field, may be nil for measures with only a single numerator and denominator
|
24
|
+
def initialize(measure_id, sub_id = nil)
|
25
|
+
@measure_id = measure_id
|
26
|
+
@sub_id = sub_id
|
27
|
+
determine_connection_information
|
28
|
+
end
|
29
|
+
|
30
|
+
# Retrieve a measure definition from the database
|
31
|
+
# @return [Hash] a JSON hash of the encoded measure
|
32
|
+
def definition
|
33
|
+
measures = get_db.collection('measures')
|
34
|
+
if @sub_id
|
35
|
+
measures.find_one({'id' => @measure_id, 'sub_id' => @sub_id})
|
36
|
+
else
|
37
|
+
measures.find_one({'id' => @measure_id})
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module QME
|
2
|
+
# A class that allows you to create and obtain the results of running a
|
3
|
+
# quality measure against a set of patient records.
|
4
|
+
class QualityReport
|
5
|
+
include DatabaseAccess
|
6
|
+
extend DatabaseAccess
|
7
|
+
determine_connection_information
|
8
|
+
|
9
|
+
# Gets rid of all calculated QualityReports by dropping the patient_cache
|
10
|
+
# and query_cache collections
|
11
|
+
def self.destroy_all
|
12
|
+
determine_connection_information
|
13
|
+
get_db.collection("query_cache").drop
|
14
|
+
get_db.collection("patient_cache").drop
|
15
|
+
end
|
16
|
+
|
17
|
+
# Creates a new QualityReport
|
18
|
+
# @param [String] measure_id value of the measure's id field
|
19
|
+
# @param [String] sub_id value of the measure's sub_id field, may be nil
|
20
|
+
# for measures with only a single numerator and denominator
|
21
|
+
# @param [Hash] parameter_values slots in the measure definition that need to
|
22
|
+
# be filled in.
|
23
|
+
def initialize(measure_id, sub_id, parameter_values)
|
24
|
+
@measure_id = measure_id
|
25
|
+
@sub_id = sub_id
|
26
|
+
@parameter_values = parameter_values
|
27
|
+
determine_connection_information
|
28
|
+
end
|
29
|
+
|
30
|
+
# Determines whether the quality report has been calculated for the given
|
31
|
+
# measure and parameters
|
32
|
+
# @return [true|false]
|
33
|
+
def calculated?
|
34
|
+
! result().nil?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Kicks off a background job to calculate the measure
|
38
|
+
# @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
|
+
end
|
43
|
+
|
44
|
+
# Returns the status of a measure calculation job
|
45
|
+
# @param job_id the id of the job to check on
|
46
|
+
# @return [Hash] containing status information on the measure calculation job
|
47
|
+
def status(job_id)
|
48
|
+
Resque::Status.get(job_id)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Gets the result of running a quality measure
|
52
|
+
# @return [Hash] measure groups (like numerator) as keys, counts as values or nil if
|
53
|
+
# the measure has not yet been calculated
|
54
|
+
def result
|
55
|
+
cache = get_db.collection("query_cache")
|
56
|
+
query = {:measure_id => @measure_id, :sub_id => @sub_id,
|
57
|
+
:effective_date => @parameter_values['effective_date']}
|
58
|
+
cache.find_one(query)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -1,29 +1,32 @@
|
|
1
1
|
Bundler.require(:default)
|
2
2
|
|
3
|
-
|
3
|
+
require 'resque/job_with_status'
|
4
4
|
|
5
|
-
|
6
|
-
|
5
|
+
require_relative 'qme/database_access'
|
6
|
+
require_relative 'qme/quality_measure'
|
7
7
|
|
8
|
-
|
8
|
+
require_relative 'qme/map/map_reduce_builder'
|
9
|
+
require_relative 'qme/map/map_reduce_executor'
|
10
|
+
require_relative 'qme/map/measure_calculation_job'
|
9
11
|
|
10
|
-
|
12
|
+
require_relative 'qme/quality_report'
|
13
|
+
|
14
|
+
require_relative 'qme/randomizer/patient_randomizer'
|
11
15
|
|
12
|
-
require
|
13
|
-
require LIB + '/qme/importer/property_matcher'
|
14
|
-
require LIB + '/qme/importer/patient_importer'
|
15
|
-
require LIB + '/qme/importer/code_system_helper'
|
16
|
-
require LIB + '/qme/importer/hl7_helper'
|
16
|
+
require 'singleton'
|
17
17
|
|
18
|
-
|
19
|
-
|
18
|
+
require_relative 'qme/importer/entry'
|
19
|
+
require_relative 'qme/importer/property_matcher'
|
20
|
+
require_relative 'qme/importer/patient_importer'
|
21
|
+
require_relative 'qme/importer/code_system_helper'
|
22
|
+
require_relative 'qme/importer/hl7_helper'
|
20
23
|
|
21
|
-
|
24
|
+
require_relative 'qme/importer/section_importer'
|
25
|
+
require_relative 'qme/importer/generic_importer'
|
22
26
|
|
23
27
|
require 'json'
|
24
28
|
require 'mongo'
|
25
29
|
require 'nokogiri'
|
26
30
|
|
27
|
-
|
28
|
-
|
29
|
-
|
31
|
+
require_relative 'qme/measure/measure_loader'
|
32
|
+
require_relative 'qme/measure/database_loader'
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'erb'
|
3
|
+
|
4
|
+
fixtures_dir = ENV['FIXTURE_DIR'] || File.join('fixtures', 'measures')
|
5
|
+
output_dir = ENV['HTML_OUTPUT_DIR'] || File.join('tmp', 'html')
|
6
|
+
|
7
|
+
def epoch_pp(seconds_since_epoch)
|
8
|
+
if seconds_since_epoch.kind_of? Fixnum
|
9
|
+
Time.at(seconds_since_epoch).utc.strftime('%B %d, %Y')
|
10
|
+
else
|
11
|
+
seconds_since_epoch
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def extract_dates(thingy, dates)
|
16
|
+
if thingy.kind_of? Fixnum
|
17
|
+
dates[thingy] = epoch_pp(thingy)
|
18
|
+
elsif thingy.kind_of? Array
|
19
|
+
thingy.each {|inner_thingy| extract_dates(inner_thingy, dates)}
|
20
|
+
elsif thingy.kind_of? Hash
|
21
|
+
thingy.values.each {|inner_thingy| extract_dates(inner_thingy, dates)}
|
22
|
+
else
|
23
|
+
# Must be a string
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
namespace :fixtures do
|
28
|
+
|
29
|
+
# NOTE: This will create invalid JSON fixture files, since JSON does not have the ability add comments.
|
30
|
+
# However, most JSON parsers handle this just fine.
|
31
|
+
desc 'Place a comment header at the top of each fixture file with a table of seconds since epoch to month, day, year'
|
32
|
+
task :time_comments do
|
33
|
+
Dir.glob(File.join(fixtures_dir,'*','patients','*.json')) do |file|
|
34
|
+
fixture_person = JSON.parse(File.read(file))
|
35
|
+
dates = {}
|
36
|
+
extract_dates(fixture_person, dates)
|
37
|
+
|
38
|
+
fixture = File.open(file, 'w')
|
39
|
+
fixture.write("//\n// Dates used in this fixture\n//\n")
|
40
|
+
dates.each_pair do |key, value|
|
41
|
+
fixture.write("// #{key} - #{value}\n")
|
42
|
+
end
|
43
|
+
fixture.write(JSON.pretty_generate(fixture_person))
|
44
|
+
fixture.close
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
desc 'Generate HTML versions of the patient fixtures'
|
49
|
+
task :html_doc do
|
50
|
+
patient_template = File.read(File.join(File.dirname(__FILE__), 'patient_fixture.erb'))
|
51
|
+
patient_erb = ERB.new(patient_template)
|
52
|
+
|
53
|
+
result_template = File.read(File.join(File.dirname(__FILE__), 'result.erb'))
|
54
|
+
result_erb = ERB.new(result_template)
|
55
|
+
|
56
|
+
fixtures_path = Pathname.new(fixtures_dir)
|
57
|
+
output_path = Pathname.new(output_dir)
|
58
|
+
|
59
|
+
Dir.glob(File.join(fixtures_dir,'*','patients','*.json')) do |file|
|
60
|
+
@patient = JSON.parse(File.read(file))
|
61
|
+
@filename = file
|
62
|
+
html = patient_erb.result(binding)
|
63
|
+
|
64
|
+
fixture_path = Pathname.new(file)
|
65
|
+
relative_fixture_path = fixture_path.relative_path_from(fixtures_path)
|
66
|
+
measure_output_path = output_path + relative_fixture_path
|
67
|
+
FileUtils.mkdir_p(measure_output_path.dirname)
|
68
|
+
File.open(measure_output_path.sub_ext('.html'), 'w') do |html_out|
|
69
|
+
html_out.write(html)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
Dir.glob(File.join(fixtures_dir,'*','result.json')) do |file|
|
74
|
+
result_json = JSON.parse(File.read(file))
|
75
|
+
if result_json["results"]
|
76
|
+
@results = result_json["results"]
|
77
|
+
else
|
78
|
+
@results = [result_json]
|
79
|
+
end
|
80
|
+
html = result_erb.result(binding)
|
81
|
+
|
82
|
+
result_path = Pathname.new(file)
|
83
|
+
relative_result_path = result_path.relative_path_from(fixtures_path)
|
84
|
+
result_output_path = output_path + relative_result_path
|
85
|
+
|
86
|
+
File.open(result_output_path.sub_ext('.html'), 'w') do |html_out|
|
87
|
+
html_out.write(html)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/lib/tasks/measure.rake
CHANGED
@@ -5,19 +5,18 @@ require 'zlib'
|
|
5
5
|
gem 'rubyzip'
|
6
6
|
require 'zip/zip'
|
7
7
|
require 'zip/zipfilesystem'
|
8
|
-
|
9
|
-
|
10
8
|
require File.join(path,'../quality-measure-engine')
|
11
9
|
|
12
|
-
|
13
10
|
measures_dir = ENV['MEASURE_DIR'] || 'measures'
|
14
|
-
puts "Loading measures from #{measures_dir}"
|
15
|
-
|
16
11
|
bundle_dir = ENV['BUNDLE_DIR'] || './'
|
12
|
+
xls_dir = ENV['XLS_DIR'] || 'xls'
|
13
|
+
db_name = ENV['DB_NAME'] || 'test'
|
14
|
+
|
17
15
|
namespace :measures do
|
18
16
|
|
19
17
|
desc 'Build all measures to tmp directory'
|
20
18
|
task :build do
|
19
|
+
puts "Loading measures from #{measures_dir}"
|
21
20
|
dest_dir = File.join('.', 'tmp')
|
22
21
|
Dir.mkdir(dest_dir) if !Dir.exist?(dest_dir)
|
23
22
|
Dir.glob(File.join(measures_dir, '*')).each do |measure_dir|
|
@@ -34,43 +33,63 @@ namespace :measures do
|
|
34
33
|
end
|
35
34
|
end
|
36
35
|
|
37
|
-
|
38
|
-
|
39
36
|
desc "run the map_test tool"
|
40
37
|
task :map_tool do
|
38
|
+
puts "Loading measures from #{measures_dir}"
|
41
39
|
require File.join(path,"../../map_test/map_test.rb")
|
42
40
|
end
|
43
41
|
|
44
|
-
|
45
|
-
|
46
|
-
|
42
|
+
desc 'Take a snapshot of the current measures, system.js and bundles collections and store as a ZIP file'
|
43
|
+
task :snapshot do
|
44
|
+
tmp = File.join('.', 'tmp')
|
45
|
+
dest_dir = File.join(tmp, 'bundle')
|
46
|
+
FileUtils.rm_r dest_dir, :force=>true
|
47
|
+
FileUtils.mkdir_p(dest_dir)
|
48
|
+
system("mongodump --db #{db_name} --collection bundles --out - > #{dest_dir}/bundles.bson")
|
49
|
+
system("mongodump --db #{db_name} --collection system.js --out - > #{dest_dir}/system.js.bson")
|
50
|
+
system("mongodump --db #{db_name} --collection measures --out - > #{dest_dir}/measures.bson")
|
51
|
+
read_me = <<EOF
|
52
|
+
Load the included files into Mongo as follows:
|
53
|
+
|
54
|
+
mongorestore --db dbname --drop measures.bson
|
55
|
+
mongorestore --db dbname --drop bundles.bson
|
56
|
+
mongorestore --db dbname --drop system.js.bson
|
57
|
+
|
58
|
+
Where dbname is the name of the database you want to load the measures into. For a
|
59
|
+
production system this will typically be pophealth-production. For a development
|
60
|
+
system it will typically be pophealth-development.
|
61
|
+
|
62
|
+
Note that the existing contents of the destination database's measures, system.js and
|
63
|
+
bundles collections will be lost.
|
64
|
+
EOF
|
65
|
+
File.open(File.join(dest_dir, 'README.txt'), 'w') {|f| f.write(read_me) }
|
47
66
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
67
|
+
archive = File.join(tmp, 'bundle.zip')
|
68
|
+
puts "Snapshot saved to #{archive}"
|
69
|
+
FileUtils.rm archive, :force=>true
|
70
|
+
|
71
|
+
Zip::ZipFile.open(archive, 'w') do |zipfile|
|
72
|
+
Dir["#{dest_dir}/*"].each do |file|
|
73
|
+
zipfile.add(File.basename(file),file)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
FileUtils.rm_r dest_dir, :force=>true
|
77
|
+
end
|
78
|
+
|
79
|
+
desc "convert NQF Excel spreadsheets to JSON"
|
80
|
+
task :convert do
|
81
|
+
require LIB + '/qme/measure/properties_builder'
|
82
|
+
require LIB + '/qme/measure/properties_converter'
|
83
|
+
dest_dir = File.join('.', 'tmp')
|
84
|
+
Dir.mkdir(dest_dir) if !Dir.exist?(dest_dir)
|
85
|
+
Dir.glob(File.join(xls_dir, '*.xlsx')).each do |measure|
|
86
|
+
properties = QME::Measure::Converter.from_xls(measure)
|
87
|
+
json = JSON.pretty_generate(properties)
|
88
|
+
file_name = File.join(dest_dir, "#{File.basename(measure)}.json")
|
89
|
+
file = File.new(file_name, "w")
|
90
|
+
file.write(json)
|
91
|
+
file.close
|
92
|
+
end
|
74
93
|
end
|
75
94
|
|
76
95
|
end
|
data/lib/tasks/mongo.rake
CHANGED
@@ -2,19 +2,21 @@ path = File.dirname(__FILE__)
|
|
2
2
|
path = path.index('lib') == 0 ? "./#{path}" : path
|
3
3
|
require 'mongo'
|
4
4
|
require 'json'
|
5
|
+
require 'resque'
|
5
6
|
require File.join(path,'../quality-measure-engine')
|
6
7
|
|
7
8
|
measures_dir = ENV['MEASURE_DIR'] || 'measures'
|
8
9
|
bundle_dir = ENV['BUNDLE_DIR'] || '.'
|
9
10
|
fixtures_dir = ENV['FIXTURE_DIR'] || File.join('fixtures', 'measures')
|
10
11
|
db_name = ENV['DB_NAME'] || 'test'
|
11
|
-
loader = QME::Database::Loader.new(
|
12
|
+
loader = QME::Database::Loader.new()
|
12
13
|
|
13
14
|
namespace :mongo do
|
14
15
|
|
15
16
|
desc 'Removed cached measure results'
|
16
17
|
task :drop_cache do
|
17
18
|
loader.drop_collection('query_cache')
|
19
|
+
loader.drop_collection('patient_cache')
|
18
20
|
end
|
19
21
|
|
20
22
|
desc 'Remove the patient records collection'
|
@@ -23,22 +25,11 @@ namespace :mongo do
|
|
23
25
|
end
|
24
26
|
|
25
27
|
desc 'Remove the measures and bundles collection'
|
26
|
-
task :drop_bundle
|
28
|
+
task :drop_bundle do
|
27
29
|
loader.drop_collection('bundles')
|
28
|
-
end
|
29
|
-
|
30
|
-
desc 'Remove the measures collection'
|
31
|
-
task :drop_measures => :drop_cache do
|
32
30
|
loader.drop_collection('measures')
|
33
31
|
end
|
34
32
|
|
35
|
-
desc 'Remove all measures and reload'
|
36
|
-
task :reload_measures => :drop_measures do
|
37
|
-
Dir.glob(File.join(measures_dir, '*')).each do |measure_dir|
|
38
|
-
loader.save_measure(measure_dir, 'measures')
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
33
|
desc 'Remove all patient records and reload'
|
43
34
|
task :reload_records => :drop_records do
|
44
35
|
load_files(loader, File.join(fixtures_dir,'*','patients','*.json'), 'records')
|
@@ -46,21 +37,24 @@ namespace :mongo do
|
|
46
37
|
|
47
38
|
desc 'Remove all patient records and reload'
|
48
39
|
task :reload_bundle => [:drop_bundle] do
|
49
|
-
loader.save_bundle(bundle_dir,
|
40
|
+
loader.save_bundle(bundle_dir, measures_dir)
|
50
41
|
end
|
51
42
|
|
52
43
|
desc 'Clear database and road each measure and its sample patient files'
|
53
|
-
task :reload => [:reload_records, :
|
44
|
+
task :reload => [:reload_records, :reload_bundle]
|
54
45
|
|
55
46
|
desc 'Seed the query cache by calculating the results for all measures'
|
56
47
|
task :seed_cache, [:year, :month, :day] do |t, args|
|
48
|
+
db = loader.get_db
|
49
|
+
patient_cache = db['patient_cache']
|
50
|
+
patient_cache.create_index([['value.measure_id', Mongo::ASCENDING],
|
51
|
+
['value.sub_id', Mongo::ASCENDING],
|
52
|
+
['value.effective_date', Mongo::ASCENDING]])
|
57
53
|
year = args.year.to_i>0 ? args.year.to_i : 2010
|
58
54
|
month = args.month.to_i>0 ? args.month.to_i : 9
|
59
55
|
day = args.day.to_i>0 ? args.day.to_i : 19
|
60
|
-
|
61
|
-
|
62
|
-
result = map.measure_result(measure_def['id'], measure_def['sub_id'], :effective_date=>Time.gm(year, month, day).to_i)
|
63
|
-
puts "#{measure_def['id']}#{measure_def['sub_id']}: p#{result[:population]}, d#{result[:denominator]}, n#{result[:numerator]}, e#{result[:exclusions]}"
|
56
|
+
QME::QualityMeasure.all.each_value do |measure_def|
|
57
|
+
QME::MapReduce::MeasureCalculationJob.create(:measure_id => measure_def['id'], :sub_id => measure_def['sub_id'], :effective_date => Time.gm(year, month, day).to_i)
|
64
58
|
end
|
65
59
|
end
|
66
60
|
|