test-patient-generator 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +15 -0
- data/Rakefile +18 -0
- data/lib/test-patient-generator.rb +18 -0
- data/lib/tpg/ext/coded.rb +40 -0
- data/lib/tpg/ext/conjunction.rb +23 -0
- data/lib/tpg/ext/data_criteria.rb +159 -0
- data/lib/tpg/ext/derivation_operator.rb +49 -0
- data/lib/tpg/ext/population_criteria.rb +12 -0
- data/lib/tpg/ext/precondition.rb +23 -0
- data/lib/tpg/ext/range.rb +144 -0
- data/lib/tpg/ext/record.rb +6 -0
- data/lib/tpg/ext/subset_operator.rb +7 -0
- data/lib/tpg/ext/temporal_reference.rb +66 -0
- data/lib/tpg/ext/value.rb +112 -0
- data/lib/tpg/generation/exporter.rb +118 -0
- data/lib/tpg/generation/generator.rb +169 -0
- data/lib/tpg/generation/randomizer.rb +255 -0
- data/public/cda.xsl +1 -2
- metadata +66 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
module HQMF
|
2
|
+
# Generates a Range to define the timing of a data_criteria
|
3
|
+
class TemporalReference
|
4
|
+
#
|
5
|
+
#
|
6
|
+
# @param [Array] base_patients
|
7
|
+
# @return
|
8
|
+
def generate(base_patients)
|
9
|
+
if reference.id == "MeasurePeriod"
|
10
|
+
matching_time = Generator::hqmf.measure_period.clone
|
11
|
+
else
|
12
|
+
# First generate patients for the data criteria that this temporal reference points to
|
13
|
+
data_criteria = Generator::hqmf.data_criteria(reference.id)
|
14
|
+
base_patients = data_criteria.generate(base_patients)
|
15
|
+
|
16
|
+
# Now that the data criteria is defined, we can set our relative time to those generated results
|
17
|
+
matching_time = data_criteria.generation_range.first.clone
|
18
|
+
end
|
19
|
+
|
20
|
+
# TODO add nils where necessary. Ranges should be unbounded, despite the relative time's potential bounds (unless the type specifies)
|
21
|
+
if range
|
22
|
+
offset = range.try(:clone)
|
23
|
+
|
24
|
+
case type
|
25
|
+
when "DURING"
|
26
|
+
# TODO differentiate between this and CONCURRENT
|
27
|
+
when "SBS" # Starts before start
|
28
|
+
offset.low.value.insert(0, "-")
|
29
|
+
when "SAS" # Starts after start
|
30
|
+
offset.low = offset.high
|
31
|
+
offset.high = nil
|
32
|
+
when "SBE" # Starts before end
|
33
|
+
offset.low = offset.high
|
34
|
+
offset.high = nil
|
35
|
+
offset.low.value.insert(0, "-")
|
36
|
+
matching_time.low = matching_time.high
|
37
|
+
when "SAE" # Starts after end
|
38
|
+
|
39
|
+
when "EBS" # Ends before start
|
40
|
+
|
41
|
+
when "EAS" # Ends after start
|
42
|
+
|
43
|
+
when "EBE" # Ends before end
|
44
|
+
|
45
|
+
when "EAE" # Ends after end
|
46
|
+
|
47
|
+
when "SDU" # Starts during
|
48
|
+
matching_time.high.value = nil
|
49
|
+
when "EDU" # Ends during
|
50
|
+
|
51
|
+
when "ECW" # Ends concurrent with
|
52
|
+
|
53
|
+
when "SCW" # Starts concurrent with
|
54
|
+
|
55
|
+
when "CONCURRENT"
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
matching_time = Range.merge_ranges(offset, matching_time)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Note we return the possible times to the calling data criteria, not patients
|
63
|
+
[matching_time]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module HQMF
|
2
|
+
class Value
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
# Perform a deep copy of this Value.
|
6
|
+
#
|
7
|
+
# @return A deep copy of this Value.
|
8
|
+
def clone
|
9
|
+
Value.new(type.try(:clone), unit.try(:clone), value.try(:clone), inclusive?, derived?, expression.try(:clone))
|
10
|
+
end
|
11
|
+
|
12
|
+
#
|
13
|
+
def format
|
14
|
+
unit_mapping = {"a" => "years", "mo" => "months", "wk" => "weeks", "d" => "days"}
|
15
|
+
pretty_unit = unit_mapping[unit] if unit
|
16
|
+
pretty_unit ||= unit
|
17
|
+
|
18
|
+
{ "scalar" => value, "units" => pretty_unit }
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
#
|
23
|
+
# @param [Value] value1
|
24
|
+
# @param [Value] value2
|
25
|
+
# @return
|
26
|
+
def self.merge_values(value1, value2)
|
27
|
+
# If only one value in the range is defined, we have no work to do
|
28
|
+
return value1 if value2.nil?
|
29
|
+
return value2 if value1.nil?
|
30
|
+
|
31
|
+
if value1.type == "PQ" && value2.type == "PQ"
|
32
|
+
# TODO Combine the value of two PQs. This will be tough if there is a case of time units and no relative TS
|
33
|
+
|
34
|
+
elsif value1.type == "TS" && value2.type == "TS"
|
35
|
+
# Convert the time strings that we have into actual time objects
|
36
|
+
|
37
|
+
else
|
38
|
+
# One PQ and one TS
|
39
|
+
pq = value1.type == "PQ" ? value1 : value2
|
40
|
+
ts = value1.type == "TS" ? value1 : value2
|
41
|
+
|
42
|
+
# Create a Ruby object to represent the TS
|
43
|
+
year = ts.value[0,4]
|
44
|
+
month = ts.value[4,2]
|
45
|
+
day = ts.value[6,2]
|
46
|
+
time = Time.new(year, month, day)
|
47
|
+
|
48
|
+
# Advance that time forward the amount the PQ specifies. Convert units to symbols for advance function.
|
49
|
+
pq.value += 1 unless pq.inclusive?
|
50
|
+
unit_mapping = {"a" => :years, "mo" => :months, "wk" => :weeks, "d" => :days}
|
51
|
+
time = time.advance({unit_mapping[pq.unit] => pq.value.to_i})
|
52
|
+
|
53
|
+
# Form up the modified TS with expected YYYYMMDD formatting (avoid YYYYMD)
|
54
|
+
year = time.year
|
55
|
+
month = time.month < 10 ? "0#{time.month}" : time.month
|
56
|
+
day = time.day < 10 ? "0#{time.day}" : time.day
|
57
|
+
|
58
|
+
Value.new("TS", value1.unit, "#{year}#{month}#{day}", value1.inclusive? && value2.inclusive?, false, false)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.time_to_ts(time)
|
63
|
+
year = time.year
|
64
|
+
month = time.month < 10 ? "0#{time.month}" : time.month
|
65
|
+
day = time.day < 10 ? "0#{time.day}" : time.day
|
66
|
+
|
67
|
+
"#{year}#{month}#{day}"
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
#
|
72
|
+
# @return
|
73
|
+
def to_seconds
|
74
|
+
to_time_object.to_i
|
75
|
+
end
|
76
|
+
|
77
|
+
#
|
78
|
+
#
|
79
|
+
# @return
|
80
|
+
def to_time_object
|
81
|
+
year = value[0,4].to_i
|
82
|
+
month = value[4,2].to_i
|
83
|
+
day = value[6,2].to_i
|
84
|
+
hour = 0
|
85
|
+
minute = 0
|
86
|
+
second = 0
|
87
|
+
if (value.length > 8)
|
88
|
+
hour = value[8,2].to_i
|
89
|
+
minute = value[10,2].to_i
|
90
|
+
second = value[12,2].to_i
|
91
|
+
end
|
92
|
+
|
93
|
+
Time.new(year, month, day, hour, minute, second)
|
94
|
+
end
|
95
|
+
|
96
|
+
def <=>(value)
|
97
|
+
# So far there has only been a need to compare TS Values
|
98
|
+
if type == "TS" && value.type == "TS"
|
99
|
+
time = to_time_object
|
100
|
+
other = value.value
|
101
|
+
|
102
|
+
if time < other
|
103
|
+
-1
|
104
|
+
elsif time > other
|
105
|
+
1
|
106
|
+
else
|
107
|
+
0
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module TPG
|
2
|
+
class Exporter
|
3
|
+
# Export a list of patients to a zip file. Contains nothing but patient records.
|
4
|
+
#
|
5
|
+
# @param [Array] patients All of the patients that will be exported.
|
6
|
+
# @param [String] format The desired format for the patients to be in.
|
7
|
+
# @return A zip file containing all given patients in the requested format.
|
8
|
+
def self.zip(patients, format, concept_map=nil)
|
9
|
+
file = Tempfile.new("patients-#{Time.now.to_i}")
|
10
|
+
|
11
|
+
Zip::ZipOutputStream.open(file.path) do |z|
|
12
|
+
xslt = Nokogiri::XSLT(File.read("public/cda.xsl"))
|
13
|
+
patients.each do |patient|
|
14
|
+
next_entry_path = patient_filename(patient)
|
15
|
+
|
16
|
+
if format == "c32"
|
17
|
+
z.put_next_entry("#{next_entry_path}.xml")
|
18
|
+
z << HealthDataStandards::Export::C32.export(patient)
|
19
|
+
elsif format == "ccr"
|
20
|
+
z.put_next_entry("#{next_entry_path}.xml")
|
21
|
+
z << HealthDataStandards::Export::CCR.export(patient)
|
22
|
+
elsif format == "ccda"
|
23
|
+
z.put_next_entry("#{next_entry_path}.xml")
|
24
|
+
z << HealthDataStandards::Export::CCDA.export(patient)
|
25
|
+
elsif format == "html"
|
26
|
+
z.put_next_entry("#{next_entry_path}.html")
|
27
|
+
z << html_contents(patient, concept_map)
|
28
|
+
elsif format == "json"
|
29
|
+
z.put_next_entry("#{next_entry_path}.json")
|
30
|
+
z << JSON.pretty_generate(JSON.parse(patient.to_json))
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
file.close
|
36
|
+
file
|
37
|
+
end
|
38
|
+
|
39
|
+
# Export QRDA Category 1 patients to a zip file. Contents are organized with a directory for each measure containing one patient for validation.
|
40
|
+
#
|
41
|
+
# @param [Hash] measure_patients Measures mapped to the patient that was generated for it.
|
42
|
+
# @return A zip file containing all of the QRDA Category 1 patients that were passed in.
|
43
|
+
def self.zip_qrda_patients(measure_patients)
|
44
|
+
file = Tempfile.new("patients-#{Time.now.to_i}")
|
45
|
+
|
46
|
+
Zip::ZipOutputStream.open(file.path) do |zip|
|
47
|
+
xslt = Nokogiri::XSLT(File.read("public/cda.xsl"))
|
48
|
+
measure_patients.each do |measure, patient|
|
49
|
+
# Create a directory for this measure and insert the HTML for this patient.
|
50
|
+
zip.put_next_entry(File.join(measure, "#{patient_filename(patient)}.html"))
|
51
|
+
zip << html_contents(patient)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
file.close
|
56
|
+
file
|
57
|
+
end
|
58
|
+
|
59
|
+
# Export a list of patients to a zip file. Contains the proper formatting of a patient bundle for Cypress,
|
60
|
+
# i.e. a bundle JSON file with four subdirectories for c32, ccr, html, and JSON formatting for patients.
|
61
|
+
#
|
62
|
+
# @param [Array] patients All of the patients that will be exported.
|
63
|
+
# @param [String] version The version to mark the bundle.json file of this archive.
|
64
|
+
# @return A bundle containing all of the QRDA Category 1 patients that were passed in.
|
65
|
+
def self.zip_bundle(patients, name, version)
|
66
|
+
file = Tempfile.new("patients-#{Time.now.to_i}")
|
67
|
+
|
68
|
+
Zip::ZipOutputStream.open(file.path) do |zip|
|
69
|
+
# Generate the bundle file
|
70
|
+
zip.put_next_entry("bundle.json")
|
71
|
+
zip << {name: name, version: version}.to_json
|
72
|
+
|
73
|
+
xslt = Nokogiri::XSLT(File.read("public/cda.xsl"))
|
74
|
+
patients.each_with_index do |patient, index|
|
75
|
+
filename = "#{index}_#{patient_filename(patient)}"
|
76
|
+
|
77
|
+
# Define path names
|
78
|
+
c32_path = File.join("patients", "c32", "#{filename}.xml")
|
79
|
+
ccr_path = File.join("patients", "ccr", "#{filename}.xml")
|
80
|
+
html_path = File.join("patients", "html", "#{filename}.html")
|
81
|
+
json_path = File.join("patients", "json", "#{filename}.json")
|
82
|
+
|
83
|
+
# For each patient add a C32, CCR, HTML, and JSON file.
|
84
|
+
zip.put_next_entry(c32_path)
|
85
|
+
zip << HealthDataStandards::Export::C32.export(patient)
|
86
|
+
zip.put_next_entry(ccr_path)
|
87
|
+
zip << HealthDataStandards::Export::CCR.export(patient)
|
88
|
+
zip.put_next_entry(html_path)
|
89
|
+
zip << html_contents(patient)
|
90
|
+
zip.put_next_entry(json_path)
|
91
|
+
zip << JSON.pretty_generate(JSON.parse(patient.to_json))
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
file.close
|
96
|
+
file
|
97
|
+
end
|
98
|
+
|
99
|
+
# Generate the HTML output for a Record from Health Data Standards.
|
100
|
+
#
|
101
|
+
# @param [Record] patient The Record for which we're generating HTML content.
|
102
|
+
# @return HTML content to be exported for a Record.
|
103
|
+
def self.html_contents(patient, concept_map=nil)
|
104
|
+
HealthDataStandards::Export::HTML.export(patient, concept_map)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Join the first and last name with an underscore and replace any other punctuation that might interfere with file names.
|
108
|
+
#
|
109
|
+
# @param [Record] patient The patient for whom we're generating a filename.
|
110
|
+
# @return A string that can be used safely as a filename.
|
111
|
+
def self.patient_filename(patient)
|
112
|
+
safe_first_name = patient.first.gsub("'", "")
|
113
|
+
safe_last_name = patient.last.gsub("'", "")
|
114
|
+
|
115
|
+
"#{safe_first_name}_#{safe_last_name}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
module HQMF
|
2
|
+
# The generator will create as many patients as possible to exhaustively test the logic of a given clinical quality measure.
|
3
|
+
class Generator
|
4
|
+
# TODO - This is a hack and a half. Need a better way to resolve data_criteria from any point in the tree.
|
5
|
+
class << self
|
6
|
+
attr_accessor :hqmf
|
7
|
+
attr_accessor :value_sets
|
8
|
+
end
|
9
|
+
|
10
|
+
# @param [HQMF::Document] hqmf A model representing the logic of a given HQMF document.
|
11
|
+
# @param [Hash] value_sets All of the value sets referenced by this particular HQMF document.
|
12
|
+
def initialize(hqmf, value_sets)
|
13
|
+
@patients = []
|
14
|
+
Generator.hqmf = hqmf
|
15
|
+
Generator.value_sets = value_sets
|
16
|
+
end
|
17
|
+
|
18
|
+
# Generate patients from lists of DataCriteria. This is originally created for QRDA Category 1 validation testing,
|
19
|
+
# i.e. a single patient will be generated per measure with an entry for every data criteria involved in the measure.
|
20
|
+
#
|
21
|
+
# @param [Hash] measure_needs A hash of measure IDs mapped to a list of all their data criteria.
|
22
|
+
# @param [Hash] measure_value_sets A hash of measure IDs mapped to hashes of value sets used by the DataCriteria in measure_needs.
|
23
|
+
# @return [Hash] A hash of measure IDs mapped to a Record that includes all the given data criteria (values and times are arbitrary).
|
24
|
+
def self.generate_qrda_patients(measure_needs, measure_value_sets)
|
25
|
+
return {} if measure_needs.nil?
|
26
|
+
|
27
|
+
measure_patients = {}
|
28
|
+
measure_needs.each do |measure, all_data_criteria|
|
29
|
+
# Prune out all data criteria that create similar entries. Category 1 validation is only checking for ability to access information
|
30
|
+
# so to minimize time we only want to include each kind of data once.
|
31
|
+
unique_data_criteria = []
|
32
|
+
all_data_criteria.each do |data_criteria|
|
33
|
+
index = unique_data_criteria.index {|dc| dc.code_list_id == data_criteria.code_list_id && dc.negation_code_list_id == data_criteria.negation_code_list_id && dc.field_values == data_criteria.field_values && dc.status == data_criteria.status}
|
34
|
+
unique_data_criteria << data_criteria if index.nil?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Create a patient that includes an entry for every data criteria included in this measure.
|
38
|
+
patient = Generator.create_base_patient
|
39
|
+
unique_data_criteria.each do |data_criteria|
|
40
|
+
# Ignore data criteria that are really just containers.
|
41
|
+
next if data_criteria.derivation_operator.present?
|
42
|
+
|
43
|
+
# Generate a random time for this data criteria and apply it to the patient.
|
44
|
+
time = Randomizer.randomize_range(patient.birthdate, nil)
|
45
|
+
data_criteria.modify_patient(patient, time, measure_value_sets[measure])
|
46
|
+
end
|
47
|
+
patient.measure_ids ||= []
|
48
|
+
patient.measure_ids << measure
|
49
|
+
patient.type = "qrda"
|
50
|
+
measure_patients[measure] = Generator.finalize_patient(patient)
|
51
|
+
end
|
52
|
+
|
53
|
+
measure_patients
|
54
|
+
end
|
55
|
+
|
56
|
+
# Generate patients from an HQMF file and its matching value sets file. These patients are designed to test all
|
57
|
+
# paths through the logic of this particular clinical quality measure.
|
58
|
+
def generate_patients
|
59
|
+
base_patients = [Generator.create_base_patient]
|
60
|
+
generated_patients = []
|
61
|
+
|
62
|
+
# Gather all available populations. Each kind of population (e.g. IPP, DENOM) can have many multiples (e.g. IPP_1, IPP_2)
|
63
|
+
populations = []
|
64
|
+
["IPP", "DENOM", "NUMER", "EXCL", "DENEXCEP"].each do |population|
|
65
|
+
i = 1
|
66
|
+
populations << population
|
67
|
+
while Generator.hqmf.population_criteria("#{population}_#{i}").present? do
|
68
|
+
populations << "#{population}_#{i}"
|
69
|
+
i += 1
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
populations = ["EXCL_1"]
|
74
|
+
|
75
|
+
populations.each do |population|
|
76
|
+
criteria = Generator.hqmf.population_criteria(population)
|
77
|
+
|
78
|
+
# We don't need to do anything for populations with nothing specified
|
79
|
+
next if criteria.nil? || !criteria.preconditions.present?
|
80
|
+
criteria.generate(base_patients)
|
81
|
+
|
82
|
+
# Mark the patient we just created with its expected population. Then extend the Record to be augmented by the next population.
|
83
|
+
base_patients.collect! do |patient|
|
84
|
+
generated_patients.push(Generator.finalize_patient(patient))
|
85
|
+
Generator.extend_patient(patient)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
generated_patients
|
90
|
+
end
|
91
|
+
|
92
|
+
# Create a patient with trivial demographic information and no coded entries.
|
93
|
+
#
|
94
|
+
# @return A Record with a blank slate
|
95
|
+
def self.create_base_patient(initial_attributes = nil)
|
96
|
+
patient = Record.new
|
97
|
+
|
98
|
+
if initial_attributes.nil?
|
99
|
+
patient = Randomizer.randomize_demographics(patient)
|
100
|
+
else
|
101
|
+
initial_attributes.each {|attribute, value| patient.send("#{attribute}=", value)}
|
102
|
+
end
|
103
|
+
patient.medical_record_number = "#{patient.first} #{patient.last}".hash.abs
|
104
|
+
|
105
|
+
patient
|
106
|
+
end
|
107
|
+
|
108
|
+
# Take an existing patient with some coded entries on them and redefine their trivial demographic information
|
109
|
+
#
|
110
|
+
# @param [Record] base_patient The patient that we're using as a base to create a new one
|
111
|
+
# @return A new Record with an identical medical history to the given patient but new trivial demographic information
|
112
|
+
def self.extend_patient(base_patient)
|
113
|
+
patient = base_patient.clone()
|
114
|
+
Randomizer.randomize_demographics(patient)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Fill in any missing details that should be filled in on a patient. These include: age, gender, and first name.
|
118
|
+
#
|
119
|
+
# @param [Record] patient The patient for whom we are about to fill in remaining demographic information.
|
120
|
+
# @return A patient with guaranteed complete information necessary for standard formats.
|
121
|
+
def self.finalize_patient(patient)
|
122
|
+
if patient.birthdate.nil?
|
123
|
+
patient.birthdate = Randomizer.randomize_birthdate(patient)
|
124
|
+
patient.birthdate = Time.now.to_i
|
125
|
+
end
|
126
|
+
|
127
|
+
if patient.gender.nil?
|
128
|
+
# Set gender
|
129
|
+
patient.gender = "F"
|
130
|
+
#rand(2) == 0 ? patient.gender = "M" : patient.gender = "F"
|
131
|
+
patient.first = Randomizer.randomize_first_name(patient.gender)
|
132
|
+
end
|
133
|
+
|
134
|
+
patient
|
135
|
+
end
|
136
|
+
|
137
|
+
#
|
138
|
+
#
|
139
|
+
# @param [String] type
|
140
|
+
# @return
|
141
|
+
def self.classify_entry(type)
|
142
|
+
# The possible matches per patientAPI function can be found in hqmf-parser's README
|
143
|
+
case type
|
144
|
+
when :allProcedures
|
145
|
+
"procedures"
|
146
|
+
when :proceduresPerformed
|
147
|
+
"procedures"
|
148
|
+
when :procedureResults
|
149
|
+
"lab_results"
|
150
|
+
when :laboratoryTests
|
151
|
+
"vital_signs"
|
152
|
+
when :allMedications
|
153
|
+
"medications"
|
154
|
+
when :activeDiagnoses
|
155
|
+
"conditions"
|
156
|
+
when :inactiveDiagnoses
|
157
|
+
"conditions"
|
158
|
+
when :resolvedDiagnoses
|
159
|
+
"conditions"
|
160
|
+
when :allProblems
|
161
|
+
"conditions"
|
162
|
+
when :allDevices
|
163
|
+
"medical_equipment"
|
164
|
+
else
|
165
|
+
type.to_s
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|