test-patient-generator 1.0.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.
@@ -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