test-patient-generator 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ # Extensions to the Record model in health-data-standards to add needed functionality for test patient generation
2
+ class Record
3
+ field :type, type: String
4
+ field :measure_ids, type: Array
5
+ field :source_data_criteria, type: Array
6
+ end
@@ -0,0 +1,7 @@
1
+ module HQMF
2
+ class SubsetOperator
3
+ def generate(base_patients)
4
+
5
+ end
6
+ end
7
+ end
@@ -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