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.
- 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
|