lib/hqmf-parser/2.0/document.rb in health-data-standards-3.6.1 vs lib/hqmf-parser/2.0/document.rb in health-data-standards-3.7.0

- old
+ new

@@ -1,280 +1,304 @@ module HQMF2 # Class representing an HQMF document class Document + include HQMF2::Utilities, HQMF2::DocumentUtilities + NAMESPACES = { 'cda' => 'urn:hl7-org:v3', + 'xsi' => 'http://www.w3.org/2001/XMLSchema-instance', + 'qdm' => 'urn:hhs-qdm:hqmf-r2-extensions:v1' } - include HQMF2::Utilities - NAMESPACES = {'cda' => 'urn:hl7-org:v3', 'xsi' => 'http://www.w3.org/2001/XMLSchema-instance'} + attr_reader :measure_period, :id, :hqmf_set_id, :hqmf_version_number, :populations, :attributes, + :source_data_criteria - attr_reader :measure_period, :id, :hqmf_set_id, :hqmf_version_number, :populations, :attributes, :source_data_criteria - - # Create a new HQMF2::Document instance by parsing at file at the supplied path - # @param [String] path the path to the HQMF document - def initialize(hqmf_contents) - @doc = @entry = Document.parse(hqmf_contents) - @id = attr_val('cda:QualityMeasureDocument/cda:id/@extension') - @hqmf_set_id = attr_val('cda:QualityMeasureDocument/cda:setId/@extension') - @hqmf_version_number = attr_val('cda:QualityMeasureDocument/cda:versionNumber/@value').to_i - measure_period_def = @doc.at_xpath('cda:QualityMeasureDocument/cda:controlVariable/cda:measurePeriod/cda:value', NAMESPACES) - if measure_period_def - @measure_period = EffectiveTime.new(measure_period_def) - end - - # Extract measure attributes - @attributes = @doc.xpath('/cda:QualityMeasureDocument/cda:subjectOf/cda:measureAttribute', NAMESPACES).collect do |attribute| - id = attribute.at_xpath('./cda:id/@root', NAMESPACES).try(:value) - code = attribute.at_xpath('./cda:code/@code', NAMESPACES).try(:value) - name = attribute.at_xpath('./cda:code/cda:displayName/@value', NAMESPACES).try(:value) - value = attribute.at_xpath('./cda:value/@value', NAMESPACES).try(:value) + # Create a new HQMF2::Document instance by parsing the given HQMF contents + # @param [String] containing the HQMF contents to be parsed + def initialize(hqmf_contents, use_default_measure_period = true) + setup_default_values(hqmf_contents, use_default_measure_period) - id_obj = nil - if attribute.at_xpath('./cda:id', NAMESPACES) - id_obj = HQMF::Identifier.new(attribute.at_xpath('./cda:id/@xsi:type', NAMESPACES).try(:value), id, attribute.at_xpath('./cda:id/@extension', NAMESPACES).try(:value)) - end + extract_criteria - code_obj = nil; - if attribute.at_xpath('./cda:code', NAMESPACES) - nullFlavor = attribute.at_xpath('./cda:code/@nullFlavor', NAMESPACES).try(:value) - oText = attribute.at_xpath('./cda:code/cda:originalText', NAMESPACES) ? attribute.at_xpath('./cda:code/cda:originalText/@value', NAMESPACES).try(:value) : nil - code_obj = HQMF::Coded.new(attribute.at_xpath('./cda:code/@xsi:type', NAMESPACES).try(:value) || 'CD', - attribute.at_xpath('./cda:code/@codeSystem', NAMESPACES).try(:value), - code, - attribute.at_xpath('./cda:code/@valueSet', NAMESPACES).try(:value), - name, - nullFlavor, - oText) - - - # Mapping for nil values to align with 1.0 parsing - if code == nil - code = nullFlavor - end - - if name == nil - name = oText - end - - end - - value_obj = nil - if attribute.at_xpath('./cda:value', NAMESPACES) - type = attribute.at_xpath('./cda:value/@xsi:type', NAMESPACES).try(:value) - case type - when 'II' - value_obj = HQMF::Identifier.new(type, attribute.at_xpath('./cda:value/@root', NAMESPACES).try(:value), attribute.at_xpath('./cda:value/@extension', NAMESPACES).try(:value)) - if value == nil - value = attribute.at_xpath('./cda:value/@extension', NAMESPACES).try(:value) - end - when 'ED' - value_obj = HQMF::ED.new(type, value, attribute.at_xpath('./cda:value/@mediaType', NAMESPACES).try(:value)) - when 'CD' - value_obj = HQMF::Coded.new('CD', attribute.at_xpath('./cda:value/@codeSystem', NAMESPACES).try(:value), attribute.at_xpath('./cda:value/@code', NAMESPACES).try(:value), attribute.at_xpath('./cda:value/@valueSet', NAMESPACES).try(:value), attribute.at_xpath('./cda:value/cda:displayName/@value', NAMESPACES).try(:value)) - else - value_obj = value.present? ? HQMF::GenericValueContainer.new(type, value) : HQMF::AnyValue.new(type) - end - end - - # Handle the cms_id - if name == "eMeasure Identifier" - @cms_id = "CMS#{value}v#{@hqmf_version_number}" - end - - HQMF::Attribute.new(id, code, value, nil, name, id_obj, code_obj, value_obj) - end - - # Extract the data criteria - @data_criteria = [] - @source_data_criteria = [] - @doc.xpath('cda:QualityMeasureDocument/cda:component/cda:dataCriteriaSection/cda:entry', NAMESPACES).each do |entry| - criteria = DataCriteria.new(entry) - if criteria.is_source_data_criteria - @source_data_criteria << criteria - else - @data_criteria << criteria - end - end - # Extract the population criteria and population collections - @populations = [] - @population_criteria = [] - - population_counters = {} - ids_by_hqmf_id = {} - - @doc.xpath('cda:QualityMeasureDocument/cda:component/cda:populationCriteriaSection', NAMESPACES).each_with_index do |population_def, population_index| - population = {} + pop_helper = HQMF2::DocumentPopulationHelper.new(@entry, @doc, self, @id_generator, @reference_ids) + @populations, @population_criteria = pop_helper.extract_populations_and_criteria - stratifier_id_def = population_def.at_xpath('cda:templateId/cda:item[@root="'+HQMF::Document::STRATIFIED_POPULATION_TEMPLATE_ID+'"]/@controlInformationRoot', NAMESPACES) - population['stratification'] = stratifier_id_def.value if stratifier_id_def - - { - HQMF::PopulationCriteria::IPP => 'initialPopulationCriteria', - HQMF::PopulationCriteria::DENOM => 'denominatorCriteria', - HQMF::PopulationCriteria::NUMER => 'numeratorCriteria', - HQMF::PopulationCriteria::DENEXCEP => 'denominatorExceptionCriteria', - HQMF::PopulationCriteria::DENEX => 'denominatorExclusionCriteria', - HQMF::PopulationCriteria::STRAT => 'stratifierCriteria', - HQMF::PopulationCriteria::MSRPOPL => 'measurePopulationCriteria' - }.each_pair do |criteria_id, criteria_element_name| - criteria_def = population_def.at_xpath("cda:component[cda:#{criteria_element_name}]", NAMESPACES) - - if criteria_def - - criteria = PopulationCriteria.new(criteria_def, self) - - # check to see if we have an identical population criteria. - # this can happen since the hqmf 2.0 will export a DENOM, NUMER, etc for each population, even if identical. - # if we have identical, just re-use it rather than creating DENOM_1, NUMER_1, etc. - identical = @population_criteria.select {|pc| pc.to_model.base_json.to_json == criteria.to_model.base_json.to_json} - - if (identical.empty?) - # this section constructs a human readable id. The first IPP will be IPP, the second will be IPP_1, etc. This allows the populations to be - # more readable. The alternative would be to have the hqmf ids in the populations, which would work, but is difficult to read the populations. - if ids_by_hqmf_id["#{criteria.hqmf_id}-#{population['stratification']}"] - criteria.create_human_readable_id(ids_by_hqmf_id[criteria.hqmf_id]) - else - if population_counters[criteria_id] - population_counters[criteria_id] += 1 - criteria.create_human_readable_id("#{criteria_id}_#{population_counters[criteria_id]}") - else - population_counters[criteria_id] = 0 - criteria.create_human_readable_id(criteria_id) - end - ids_by_hqmf_id["#{criteria.hqmf_id}-#{population['stratification']}"] = criteria.id - end - - - @population_criteria << criteria - population[criteria_id] = criteria.id - else - population[criteria_id] = identical.first.id - end - end - end - - - id_def = population_def.at_xpath('cda:id/@extension', NAMESPACES) - population['id'] = id_def ? id_def.value : "Population#{population_index}" - title_def = population_def.at_xpath('cda:title/@value', NAMESPACES) - population['title'] = title_def ? title_def.value : "Population #{population_index}" - observation_section = @doc.xpath('cda:QualityMeasureDocument/cda:component/cda:measureObservationsSection', NAMESPACES) - if !observation_section.empty? - population['OBSERV'] = 'OBSERV' - end - @populations << population + # Remove any data criteria from the main data criteria list that already has an equivalent member + # and no references to it. The goal of this is to remove any data criteria that should not + # be purely a source. + @data_criteria.reject! do |dc| + criteria_covered_by_criteria?(dc) end - - - #look for observation data in separate section but create a population for it if it exists - observation_section = @doc.xpath('cda:QualityMeasureDocument/cda:component/cda:measureObservationsSection', NAMESPACES) - if !observation_section.empty? - observation_section.xpath("cda:definition",NAMESPACES).each do |criteria_def| - criteria_id = "OBSERV" - population = {} - criteria = PopulationCriteria.new(criteria_def, self) - criteria.type="OBSERV" - # this section constructs a human readable id. The first IPP will be IPP, the second will be IPP_1, etc. This allows the populations to be - # more readable. The alternative would be to have the hqmf ids in the populations, which would work, but is difficult to read the populations. - if ids_by_hqmf_id["#{criteria.hqmf_id}"] - criteria.create_human_readable_id(ids_by_hqmf_id[criteria.hqmf_id]) - else - if population_counters[criteria_id] - population_counters[criteria_id] += 1 - criteria.create_human_readable_id("#{criteria_id}_#{population_counters[criteria_id]}") - else - population_counters[criteria_id] = 0 - criteria.create_human_readable_id(criteria_id) - end - ids_by_hqmf_id["#{criteria.hqmf_id}"] = criteria.id - end - - @population_criteria << criteria - - population[criteria_id] = criteria.id - @populations << population - end - end - end - + # Get the title of the measure # @return [String] the title def title @doc.at_xpath('cda:QualityMeasureDocument/cda:title/@value', NAMESPACES).inner_text end - + # Get the description of the measure # @return [String] the description def description description = @doc.at_xpath('cda:QualityMeasureDocument/cda:text/@value', NAMESPACES) - description==nil ? '' : description.inner_text + description.nil? ? '' : description.inner_text end - + # Get all the population criteria defined by the measure # @return [Array] an array of HQMF2::PopulationCriteria def all_population_criteria @population_criteria end - + # Get a specific population criteria by id. # @param [String] id the population identifier # @return [HQMF2::PopulationCriteria] the matching criteria, raises an Exception if not found def population_criteria(id) find(@population_criteria, :id, id) end - + # Get all the data criteria defined by the measure # @return [Array] an array of HQMF2::DataCriteria describing the data elements used by the measure def all_data_criteria @data_criteria end - + # Get a specific data criteria by id. # @param [String] id the data criteria identifier # @return [HQMF2::DataCriteria] the matching data criteria, raises an Exception if not found def data_criteria(id) find(@data_criteria, :id, id) end - - # Parse an XML document from the supplied contents + + # Adds data criteria to the Document's criteria list + # needed so data criteria can be added to a document from other objects + def add_data_criteria(dc) + @data_criteria << dc + end + + # Finds a data criteria by it's local variable name + def find_criteria_by_lvn(local_variable_name) + find(@data_criteria, :local_variable_name, local_variable_name) + end + + # Get ids of data criteria directly referenced by others + # @return [Array] an array of ids of directly referenced data criteria + def all_reference_ids + @reference_ids + end + + # Adds id of a data criteria to the list of reference ids + def add_reference_id(id) + @reference_ids << id + end + + # Parse an XML document from the supplied contents # @return [Nokogiri::XML::Document] def self.parse(hqmf_contents) - doc = hqmf_contents.kind_of?(Nokogiri::XML::Document) ? hqmf_contents : Nokogiri::XML(hqmf_contents) + doc = hqmf_contents.is_a?(Nokogiri::XML::Document) ? hqmf_contents : Nokogiri::XML(hqmf_contents) doc.root.add_namespace_definition('cda', 'urn:hl7-org:v3') doc end - + + # Generates this classes hqmf-model equivalent def to_model + dcs = all_data_criteria.collect(&:to_model) + pcs = all_population_criteria.collect(&:to_model) + sdc = source_data_criteria.collect(&:to_model) + HQMF::Document.new(@id, @id, @hqmf_set_id, @hqmf_version_number, @cms_id, + title, description, pcs, dcs, sdc, + @attributes, @measure_period, @populations) + end - dcs = all_data_criteria.collect {|dc| dc.to_model} - pcs = all_population_criteria.collect {|pc| pc.to_model} - sdc = source_data_criteria.collect{|dc| dc.to_model} - dcs = update_data_criteria(dcs, sdc) - HQMF::Document.new(id, id, hqmf_set_id, hqmf_version_number, @cms_id, title, description, pcs, dcs, sdc, attributes, measure_period.to_model, populations) + # Finds an element within the collection given that has an instance variable or method of "attribute" with a value + # of "value" + def find(collection, attribute, value) + collection.find { |e| e.send(attribute) == value } end - # Update the data criteria to handle variables properly - def update_data_criteria(data_criteria, source_data_criteria) - # step through each criteria and look for groupers (type derived) with one child - data_criteria.map do |criteria| - if criteria.type == "derived".to_sym && criteria.children_criteria.length == 1 - source_data_criteria.each do |source_criteria| - if source_criteria.title == criteria.children_criteria[0] - criteria.children_criteria = source_criteria.children_criteria - #if criteria.is_same_type?(source_criteria) - criteria.update_copy( source_criteria.hard_status, source_criteria.title, source_criteria.description, - source_criteria.derivation_operator, source_criteria.definition )#, occur_letter ) - end - end - end - criteria - end + private + + # Handles setup of the base values of the document, defined here as ones that are either + # obtained from the xml directly or with limited parsing + def setup_default_values(hqmf_contents, use_default_measure_period) + @id_generator = IdGenerator.new + @doc = @entry = Document.parse(hqmf_contents) + + @id = attr_val('cda:QualityMeasureDocument/cda:id/@extension') || + attr_val('cda:QualityMeasureDocument/cda:id/@root').upcase + @hqmf_set_id = attr_val('cda:QualityMeasureDocument/cda:setId/@extension') || + attr_val('cda:QualityMeasureDocument/cda:setId/@root').upcase + @hqmf_version_number = attr_val('cda:QualityMeasureDocument/cda:versionNumber/@value') + + # TODO: -- figure out if this is the correct thing to do -- probably not, but is + # necessary to get the bonnie comparison to work. Currently + # defaulting measure period to a period of 1 year from 2012 to 2013 this is overriden during + # calculation with correct year information . Need to investigate parsing mp from meaures. + @measure_period = extract_measure_period_or_default(use_default_measure_period) + + # Extract measure attributes + # TODO: Review + @attributes = @doc.xpath('/cda:QualityMeasureDocument/cda:subjectOf/cda:measureAttribute', NAMESPACES) + .collect do |attribute| + read_attribute(attribute) + end + + @data_criteria = [] + @source_data_criteria = [] + @data_criteria_references = {} + @occurrences_map = {} + + # Used to keep track of referenced data criteria ids + @reference_ids = [] end + # Extracts a measure period from the document or returns the default measure period + # (if the default value is set to true). + def extract_measure_period_or_default(default) + if default + mp_low = HQMF::Value.new('TS', nil, '201201010000', nil, nil, nil) + mp_high = HQMF::Value.new('TS', nil, '201212312359', nil, nil, nil) + mp_width = HQMF::Value.new('PQ', 'a', '1', nil, nil, nil) + HQMF::EffectiveTime.new(mp_low, mp_high, mp_width) + else + measure_period_def = @doc.at_xpath('cda:QualityMeasureDocument/cda:controlVariable/cda:measurePeriod/cda:value', + NAMESPACES) + EffectiveTime.new(measure_period_def).to_model if measure_period_def + end + end + + # Handles parsing the attributes of the document + def read_attribute(attribute) + id = attribute.at_xpath('./cda:id/@root', NAMESPACES).try(:value) + code = attribute.at_xpath('./cda:code/@code', NAMESPACES).try(:value) + name = attribute.at_xpath('./cda:code/cda:displayName/@value', NAMESPACES).try(:value) + value = attribute.at_xpath('./cda:value/@value', NAMESPACES).try(:value) + + id_obj = nil + if attribute.at_xpath('./cda:id', NAMESPACES) + id_obj = HQMF::Identifier.new(attribute.at_xpath('./cda:id/@xsi:type', NAMESPACES).try(:value), + id, + attribute.at_xpath('./cda:id/@extension', NAMESPACES).try(:value)) + end + + code_obj = nil + if attribute.at_xpath('./cda:code', NAMESPACES) + code_obj, null_flavor, o_text = handle_attribute_code(attribute, code, name) + + # Mapping for nil values to align with 1.0 parsing + code = null_flavor if code.nil? + name = o_text if name.nil? + + end + + value_obj = nil + value_obj = handle_attribute_value(attribute, value) if attribute.at_xpath('./cda:value', NAMESPACES) + + # Handle the cms_id + @cms_id = "CMS#{value}v#{@hqmf_version_number.to_i}" if name.include? 'eMeasure Identifier' + + HQMF::Attribute.new(id, code, value, nil, name, id_obj, code_obj, value_obj) + end + + # Extracts the code used by a particular attribute + def handle_attribute_code(attribute, code, name) + null_flavor = attribute.at_xpath('./cda:code/@nullFlavor', NAMESPACES).try(:value) + o_text = attribute.at_xpath('./cda:code/cda:originalText/@value', NAMESPACES).try(:value) + code_obj = HQMF::Coded.new(attribute.at_xpath('./cda:code/@xsi:type', NAMESPACES).try(:value) || 'CD', + attribute.at_xpath('./cda:code/@codeSystem', NAMESPACES).try(:value), + code, + attribute.at_xpath('./cda:code/@valueSet', NAMESPACES).try(:value), + name, + null_flavor, + o_text) + [code_obj, null_flavor, o_text] + end + + # Extracts the value used by a particular attribute + def handle_attribute_value(attribute, value) + type = attribute.at_xpath('./cda:value/@xsi:type', NAMESPACES).try(:value) + case type + when 'II' + if value.nil? + value = attribute.at_xpath('./cda:value/@extension', NAMESPACES).try(:value) + end + HQMF::Identifier.new(type, + attribute.at_xpath('./cda:value/@root', NAMESPACES).try(:value), + attribute.at_xpath('./cda:value/@extension', NAMESPACES).try(:value)) + when 'ED' + HQMF::ED.new(type, value, attribute.at_xpath('./cda:value/@mediaType', NAMESPACES).try(:value)) + when 'CD' + HQMF::Coded.new('CD', + attribute.at_xpath('./cda:value/@codeSystem', NAMESPACES).try(:value), + attribute.at_xpath('./cda:value/@code', NAMESPACES).try(:value), + attribute.at_xpath('./cda:value/@valueSet', NAMESPACES).try(:value), + attribute.at_xpath('./cda:value/cda:displayName/@value', NAMESPACES).try(:value)) + else + value.present? ? HQMF::GenericValueContainer.new(type, value) : HQMF::AnyValue.new(type) + end + end + + def extract_criteria + # Extract the data criteria + extracted_criteria = [] + @doc.xpath('cda:QualityMeasureDocument/cda:component/cda:dataCriteriaSection/cda:entry', NAMESPACES) + .each do |entry| + extracted_criteria << entry + end + + # Extract the source data criteria from data criteria + @source_data_criteria, collapsed_source_data_criteria = SourceDataCriteriaHelper.get_source_data_criteria_list( + extracted_criteria, @data_criteria_references, @occurrences_map) + + extracted_criteria.each do |entry| + criteria = DataCriteria.new(entry, @data_criteria_references, @occurrences_map) + handle_data_criteria(criteria, collapsed_source_data_criteria) + @data_criteria << criteria + end + end + + def handle_data_criteria(criteria, collapsed_source_data_criteria) + # Sometimes there are multiple criteria with the same ID, even though they're different; in the HQMF + # criteria refer to parent criteria via outboundRelationship, using an extension (aka ID) and a root; + # we use just the extension to follow the reference, and build the lookup hash using that; since they + # can repeat, we wind up overwriting some content. This becomes important when we want, for example, + # the code_list_id and we overwrite the parent with the code_list_id with a child with the same ID + # without the code_list_id. As a temporary approach, we only overwrite a data criteria reference if + # it doesn't have a code_list_id. As a longer term approach we may want to use the root for lookups. + if criteria && (@data_criteria_references[criteria.id].try(:code_list_id).nil?) + @data_criteria_references[criteria.id] = criteria + end + if collapsed_source_data_criteria.key?(criteria.id) + candidate = find(all_data_criteria, :id, collapsed_source_data_criteria[criteria.id]) + # derived criteria should not be collapsed... they do not have enough info to be collapsed and may cross into the wrong criteria + # only add the collapsed as a source for derived if it is stripped of any temporal references, fields, etc. to make sure we don't cross into an incorrect source + if ((criteria.definition != 'derived') || (!candidate.nil? && SourceDataCriteriaHelper.already_stripped?(candidate))) + criteria.instance_variable_set(:@source_data_criteria, collapsed_source_data_criteria[criteria.id]) + end + end + + handle_variable(criteria, collapsed_source_data_criteria) if criteria.variable + handle_specific_source_data_criteria_reference(criteria) + @reference_ids.concat(criteria.children_criteria) + if criteria.temporal_references + criteria.temporal_references.each do |tr| + @reference_ids << tr.reference.id if tr.reference.id != HQMF::Document::MEASURE_PERIOD_ID + end + end + end - private - - def find(collection, attribute, value) - collection.find {|e| e.send(attribute)==value} + # For specific occurrence data criteria, make sure the source data criteria reference points + # to the correct source data criteria. + def handle_specific_source_data_criteria_reference(criteria) + original_sdc = find(@source_data_criteria, :id, criteria.source_data_criteria) + updated_sdc = find(@source_data_criteria, :id, criteria.id) + if !updated_sdc.nil? && !criteria.specific_occurrence.nil? && (original_sdc.nil? || original_sdc.specific_occurrence.nil?) + criteria.instance_variable_set(:@source_data_criteria, criteria.id) + end + return if original_sdc.nil? + if (criteria.specific_occurrence && !original_sdc.specific_occurrence) + original_sdc.instance_variable_set(:@specific_occurrence, criteria.specific_occurrence) + original_sdc.instance_variable_set(:@specific_occurrence_const, criteria.specific_occurrence_const) + original_sdc.instance_variable_set(:@code_list_id, criteria.code_list_id) + end end + end end