test-patient-generator 1.0.2 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,83 +1,38 @@
1
1
  module HQMF
2
2
  class Value
3
- include Comparable
4
-
5
- # Perform a deep copy of this Value.
3
+ # Translate a Ruby time object to an HL7 timestamp string (YYYYMMDD).
6
4
  #
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" => unit }
5
+ # @return An HL7 timestamp string (YYYYMMDD) equivalent to time.
6
+ def self.time_to_ts(time)
7
+ time.strftime("%Y%m%d%H%M%S")
19
8
  end
20
9
 
21
- #
10
+ # Translate an HQMF Value object into a shape that HealthDataStandards understands.
22
11
  #
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)
12
+ # @return A Value formatted for storing a HealthDataStandards Record.
13
+ def format
14
+ if type == "PQ"
15
+ { "scalar" => value, "units" => unit }
16
+ elsif type == "TS"
17
+ to_seconds
59
18
  end
60
19
  end
61
20
 
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
-
21
+ # Translate the time stored in this Value into epoch seconds.
70
22
  #
71
- #
72
- # @return
23
+ # @return Epoch seconds equivalent to the time stored in this Value.
73
24
  def to_seconds
74
- to_time_object.to_i
25
+ return nil unless type == "TS"
26
+
27
+ to_time_object.utc.to_i
75
28
  end
76
29
 
77
- #
30
+ # Translate the time represented by this Value into a Ruby time object.
78
31
  #
79
- # @return
32
+ # @return A Ruby time object equivalent to the time represented by this Value.
80
33
  def to_time_object
34
+ return nil unless type == "TS"
35
+
81
36
  year = value[0,4].to_i
82
37
  month = value[4,2].to_i
83
38
  day = value[6,2].to_i
@@ -90,23 +45,7 @@ module HQMF
90
45
  second = value[12,2].to_i
91
46
  end
92
47
 
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
48
+ Time.gm(year, month, day, hour, minute, second)
110
49
  end
111
50
  end
112
51
  end
@@ -36,11 +36,12 @@ module TPG
36
36
  file
37
37
  end
38
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.
39
+ # Export QRDA Category 1 patients to a zip file.
40
+ # Contents are organized with a directory for each measure containing one patient for validation.
40
41
  #
41
42
  # @param [Hash] measure_patients Measures mapped to the patient that was generated for it.
42
43
  # @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
+ def self.zip_qrda_html_patients(measure_patients)
44
45
  file = Tempfile.new("patients-#{Time.now.to_i}")
45
46
 
46
47
  Zip::ZipOutputStream.open(file.path) do |zip|
@@ -55,40 +56,21 @@ module TPG
55
56
  file.close
56
57
  file
57
58
  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.
59
+
60
+ # Export QRDA Category 1 patients to a zip file.
61
+ # Contents are organized with a directory for each measure containing one patient for validation.
61
62
  #
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)
63
+ # @param [Hash] measure_patients Measures mapped to the patient that was generated for it.
64
+ # @return A zip file containing all of the QRDA Category 1 patients that were passed in.
65
+ def self.zip_qrda_cat_1_patients(measure_patients, measures)
66
66
  file = Tempfile.new("patients-#{Time.now.to_i}")
67
67
 
68
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))
69
+ measure_patients.each do |nqf_id, patient|
70
+ measure_defs = measures.find {|m| m.id == nqf_id}
71
+ # Create a directory for this measure and insert the HTML for this patient.
72
+ zip.put_next_entry(File.join(measure_defs.hqmf_id, "#{patient_filename(patient)}.xml"))
73
+ zip << QrdaGenerator::Export::Cat1.export(patient, [measure_defs], Time.gm(2011, 1, 1), Time.gm(2011, 12, 31))
92
74
  end
93
75
  end
94
76
 
@@ -1,49 +1,33 @@
1
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
2
  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
3
  # Generate patients from lists of DataCriteria. This is originally created for QRDA Category 1 validation testing,
19
4
  # i.e. a single patient will be generated per measure with an entry for every data criteria involved in the measure.
20
5
  #
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.
6
+ # @param [Hash] measure_needs A hash of measure IDs mapped to a list of all their data criteria in JSON.
23
7
  # @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)
8
+ def self.generate_qrda_patients(measure_needs)
25
9
  return {} if measure_needs.nil?
26
10
 
27
11
  measure_patients = {}
28
12
  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
13
+ # Define a list of unique data criteria and matching value sets to create a patient for this measure.
14
+ unique_data_criteria = select_unique_data_criteria(all_data_criteria)
15
+ oids = select_unique_oids(all_data_criteria)
16
+ value_sets = create_oid_dictionary(oids)
36
17
 
37
18
  # Create a patient that includes an entry for every data criteria included in this measure.
38
19
  patient = Generator.create_base_patient
39
20
  unique_data_criteria.each do |data_criteria|
40
21
  # Ignore data criteria that are really just containers.
41
22
  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])
23
+
24
+ # Prepare and apply our parameters for modifying the patient based on the data criteria.
25
+ time = select_valid_time_range(patient, data_criteria)
26
+ apply_field_defaults(data_criteria, time)
27
+ data_criteria.modify_patient(patient, time, value_sets)
46
28
  end
29
+
30
+ # Add final data for the patient, e.g. that they were designed for the measure, possibly a birthdate, etc.
47
31
  patient.measure_ids ||= []
48
32
  patient.measure_ids << measure
49
33
  patient.type = "qrda"
@@ -53,45 +37,9 @@ module HQMF
53
37
  measure_patients
54
38
  end
55
39
 
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
40
  # Create a patient with trivial demographic information and no coded entries.
93
41
  #
94
- # @return A Record with a blank slate
42
+ # @return A Record with a blank slate.
95
43
  def self.create_base_patient(initial_attributes = nil)
96
44
  patient = Record.new
97
45
 
@@ -100,20 +48,10 @@ module HQMF
100
48
  else
101
49
  initial_attributes.each {|attribute, value| patient.send("#{attribute}=", value)}
102
50
  end
103
- patient.medical_record_number = Digest::MD5.hexdigest("#{patient.first} #{patient.last}")
104
51
 
105
52
  patient
106
53
  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
-
54
+
117
55
  # Fill in any missing details that should be filled in on a patient. These include: age, gender, and first name.
118
56
  #
119
57
  # @param [Record] patient The patient for whom we are about to fill in remaining demographic information.
@@ -125,19 +63,155 @@ module HQMF
125
63
  end
126
64
 
127
65
  if patient.gender.nil?
128
- # Set gender
129
66
  patient.gender = "F"
130
- #rand(2) == 0 ? patient.gender = "M" : patient.gender = "F"
131
- patient.first = Randomizer.randomize_first_name(patient.gender)
67
+ patient.first ||= Randomizer.randomize_first_name(patient.gender)
132
68
  end
133
69
 
134
70
  patient
135
71
  end
136
-
72
+
73
+ # Select all unique data criteria from a list. Category 1 validation is only checking for ability to access information
74
+ # so to minimize time we only want to include each kind of data once.
137
75
  #
76
+ # @param [Array] all_data_criteria A list of HQMF::DataCriteria to be sifted through.
77
+ # @return The unique list of data criteria extracted from all_data_criteria
78
+ def self.select_unique_data_criteria(all_data_criteria)
79
+ all_data_criteria.flatten!
80
+ all_data_criteria.uniq!
81
+
82
+ unique_data_criteria = []
83
+ all_data_criteria.each do |data_criteria|
84
+ 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}
85
+ unique_data_criteria << data_criteria if index.nil?
86
+ end
87
+
88
+ unique_data_criteria
89
+ end
90
+
91
+ #
138
92
  #
139
- # @param [String] type
93
+ # @param [Array] oids
140
94
  # @return
95
+ def self.create_oid_dictionary(oids)
96
+ value_sets = []
97
+ HealthDataStandards::SVS::ValueSet.any_in(oid: oids).each do |value_set|
98
+ code_sets = value_set.concepts.map { |concept| {"code_set" => concept.code_system_name, "codes" => [concept.code]} }
99
+ value_sets << {"code_sets" => code_sets, "oid" => value_set.oid, "concept" => value_set.display_name}
100
+ end
101
+
102
+ value_sets
103
+ end
104
+
105
+ #
106
+ #
107
+ # @param [Array] all_data_criteria
108
+ # @return
109
+ def self.select_unique_oids(all_data_criteria)
110
+ oids = []
111
+ all_data_criteria.each do |dc|
112
+ oids << dc.code_list_id if dc.code_list_id.present?
113
+ oids << dc.negation_code_list_id if dc.negation_code_list_id.present?
114
+ oids << dc.value.code_list_id if dc.value.present? && dc.value.type == "CD"
115
+
116
+ dc.field_values.each {|name, field| oids << field.code_list_id if field.present? && field.type == "CD"} if dc.field_values.present?
117
+ end
118
+
119
+ oids << "2.16.840.1.113883.3.117.1.7.1.70"
120
+ oids << "2.16.840.1.113883.3.117.2.7.1.14"
121
+
122
+ oids.flatten!
123
+ oids.uniq!
124
+ oids.compact
125
+ end
126
+
127
+ # Create a random time range for an entry to occur. It is guaranteed to be within the lifespan of the patient and will last no longer than a day.
128
+ #
129
+ # @param [Record] patient The patient for whom this range is being generated.
130
+ # @param [HQMF::DataCriteria] data_criteria The data criteria for which we're creating an entry.
131
+ # @return A time range that can be used to create an entry for this data criteria.
132
+ def self.select_valid_time_range(patient, data_criteria)
133
+ earliest_time = patient.birthdate
134
+ latest_time = patient.deathdate
135
+
136
+ # Make sure all ranges occur within the bounds of birth and death. If this data criteria is deciding one of those two, place this range outside of our 35 year range for entries.
137
+ if data_criteria.property.present?
138
+ if data_criteria.property == :birthtime
139
+ earliest_time = HQMF::Randomizer.randomize_birthdate(patient)
140
+ latest_time = earliest_time.advance(days: 1)
141
+ elsif data_criteria.property == :expired
142
+ earliest_time = Time.now
143
+ latest_time = earliest_time.advance(days: 1)
144
+ end
145
+ end
146
+
147
+ time = Randomizer.randomize_range(earliest_time, latest_time, {days: 1})
148
+ end
149
+
150
+ #
151
+ #
152
+ # @param [HQMF::DataCriteria] date_criteria
153
+ # @return
154
+ def self.apply_field_defaults(data_criteria, time)
155
+ return nil if data_criteria.field_values.nil?
156
+
157
+ # Some fields come in with no value or marked as AnyValue (i.e. any value is acceptable, there just must be one). If that's the case, we pick a default here.
158
+ data_criteria.field_values.each do |name, field|
159
+ if field.is_a? HQMF::AnyValue
160
+ if ["ADMISSION_DATETIME", "START_DATETIME", "INCISION_DATETIME"].include? name
161
+ data_criteria.field_values[name] = time.low
162
+ elsif ["DISCHARGE_DATETIME", "STOP_DATETIME", "REMOVAL_DATETIME"].include? name
163
+ data_criteria.field_values[name] = time.high
164
+ elsif name == "REASON"
165
+ # If we're not explicitly given a code (e.g. HQMF dictates there must be a reason but any is ok), we assign a random one (birth)
166
+ data_criteria.field_values[name] = Coded.for_code_list("2.16.840.1.113883.3.117.1.7.1.70", "birth")
167
+ elsif name == "ORDINAL"
168
+ # If we're not explicitly given a code (e.g. HQMF dictates there must be a reason but any is ok), we assign it to be not principle
169
+ data_criteria.field_values[name] = Coded.for_code_list("2.16.840.1.113883.3.117.2.7.1.14", "principle")
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ # Takes an Array of meassures and builds a Hash keyed by NQF ID with the values being an Array of data criteria.
176
+ #
177
+ # @param [Array] measures A list of HQMF::Documents for which patients will be generated.
178
+ # @return A hash of measure IDs for which we're generating patients, mapped to an array of HQMF::DataCriteria.
179
+ def self.determine_measure_needs(measures)
180
+ measure_needs = {}
181
+ measures.each do |measure|
182
+ measure_needs[measure.id] = measure.all_data_criteria
183
+ end
184
+
185
+ measure_needs
186
+ end
187
+
188
+ # Parses a JSON representation of a measure from a Bonnie Bundle into an hqmf-parser ready format.
189
+ #
190
+ # @param [Hash] measure JSON representation of a measure
191
+ # @return Tweaked JSON that has fields in the places hqmf-parser expects
192
+ def self.parse_measure(measure_json)
193
+ # HQMF Parser expects just a hash of ID => data_criteria, so translate to that format here.
194
+ translated_data_criteria = {}
195
+ measure_json["data_criteria"].each { |data_criteria| translated_data_criteria[data_criteria.keys.first] = data_criteria.values.first }
196
+ measure_json["data_criteria"] = translated_data_criteria
197
+
198
+ # HQMF::Documents have fields for hqmf_id and id, but not NQF ID. We'll store NQF_ID in ID.
199
+ measure_json["id"] = measure_json["nqf_id"]
200
+ measure_json["source_data_criteria"] = []
201
+
202
+ measure = HQMF::Document.from_json(measure_json)
203
+ measure.all_data_criteria.each do |data_criteria|
204
+ data_criteria.values ||= []
205
+ data_criteria.values << data_criteria.value if data_criteria.value && data_criteria.value.type != "ANYNonNull"
206
+ end
207
+
208
+ measure
209
+ end
210
+
211
+ # Map all patient api coded entry types from HQMF data criteria to Record sections.
212
+ #
213
+ # @param [String] type The type of the coded entry requried by a data criteria.
214
+ # @return The section type for the given patient api function type
141
215
  def self.classify_entry(type)
142
216
  # The possible matches per patientAPI function can be found in hqmf-parser's README
143
217
  case type
@@ -146,7 +220,7 @@ module HQMF
146
220
  when :proceduresPerformed
147
221
  "procedures"
148
222
  when :procedureResults
149
- "lab_results"
223
+ "procedures"
150
224
  when :laboratoryTests
151
225
  "vital_signs"
152
226
  when :allMedications
@@ -166,4 +240,4 @@ module HQMF
166
240
  end
167
241
  end
168
242
  end
169
- end
243
+ end