sanger/limber

View on GitHub
app/models/labware_creators/merged_plate.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
98%
# frozen_string_literal: true

module LabwareCreators
  # Merges plates together into a single child plate, and de-duplicates aliquots if they are identical.
  class MergedPlate < StampedPlate
    include LabwareCreators::CustomPage
    include SupportParent::PlateOnly

    attr_reader :child, :barcodes, :minimal_barcodes

    self.attributes += [{ barcodes: [] }]
    self.page = 'merged_plate'

    validates :api, :purpose_uuid, :parent_uuid, :user_uuid, presence: true
    validate :all_source_barcodes_must_be_entered
    validate :source_plates_can_be_merged
    validate :source_barcodes_must_be_different
    validate :source_plates_must_have_expected_purposes

    delegate :size, :number_of_columns, :number_of_rows, to: :labware

    def labware_wells
      source_plates.flat_map(&:wells)
    end

    #
    # Returns the source plate purposes for use in the help view.
    #
    # @return [Array] Purpose name strings.
    #
    def expected_source_purposes
      Settings.purposes.dig(@purpose_uuid, :merged_plate).source_purposes
    end

    #
    # Returns specific help text to display to the user for this specific use case.
    #
    # @return [String] Some descriptive text.
    #
    def help_text
      Settings.purposes.dig(@purpose_uuid, :merged_plate).help_text
    end

    def barcodes=(barcodes)
      @barcodes = barcodes

      # Removes empty strings from barcodes, for validation and strips off whitespace
      @minimal_barcodes = barcodes.compact_blank.map(&:strip)
    end

    private

    def create_plate_from_parent!
      api.pooled_plate_creation.create!(
        child_purpose: purpose_uuid,
        user: user_uuid,
        parents: source_plates.map(&:uuid)
      )
    end

    def source_plates
      @source_plates ||=
        Sequencescape::Api::V2::Plate.find_all(
          { barcode: minimal_barcodes },
          includes: 'purpose,parents,wells.aliquots.request,wells.requests_as_source'
        )
    end

    # Returns the attributes for a transfer request from
    # source_well to the same location on child_plate
    # Unlike request_hash on StampedPlate sets the merge_equivalent_aliquots to true
    def request_hash(source_well, child_plate, additional_parameters)
      super.merge('merge_equivalent_aliquots' => true)
    end

    # validation to check the number of barcodes scanned matches the number of expected purposes from the configuration
    def all_source_barcodes_must_be_entered
      return if minimal_barcodes.size == expected_source_purposes.size

      msg = 'Please scan in all the required source plate barcodes.'
      errors.add(:base, msg)
    end

    # Validation to check all source plates can be merged.
    # Currently we allow this if there is a maximum of one request associated with each well.
    # One library request covers both forks of the process, and is recorded on the aliquots, thus
    # if two aliquots are associated with the same request, they are part of the same process and can be
    # merged. Behind the scenes Sequencescape also checks a number of other attributes, such as
    # library_type and primer_panel_id, but as these are both set by the request, we can be pretty
    # confident that they'll match in this case.
    # The suboptimal flag is also included in the check, as this is exposed via the API, and forms
    # part of Sequencescape's de-duplication. In most cases this will be identical, however can differ
    # if users begin processing plates while the qc report is still running.
    # @see Sequencescape::Api::V2::Aliquot#equivalent_attributes for more information
    # Theoretically we can allow merging of plates with two distinct requests, as long as they have two
    # different tag sets. See #merge_index for details
    def source_plates_can_be_merged
      expected_merges = expected_merges(source_plates)

      return unless expected_merges.values.any? { |v| v.uniq.many? }

      errors.add(
        :source_plates,
        'have different requests or suboptimal status and can not be merged, ' \
          'please check you have scanned the correct set of source plates.'
      )
    end

    #
    # Build up a hash of all merges that we expect to resolve
    #
    # @param [Array] source_plates Array of {Sequencescape::Api::V2::Plate} to be merged
    #
    # @return [Hash] Hash of each well/tag combination as a key, with an array of associated aliquots information
    #                [request_id, suboptimal]
    #                @example { 'A1' => [['1', false],['1', false]] }
    #
    def expected_merges(source_plates)
      # Given the small set size, arrays are actually faster here than sets
      merges = Hash.new { |h, i| h[i] = [] }
      source_plates.each do |plate|
        plate.each_well_and_aliquot do |well, aliquot|
          merges[merge_index(well, aliquot)] << aliquot.equivalent_attributes
        end
      end
      merges
    end

    # All aliquots with the same merge_index should be capable of being merged.
    # Currently this is restricted to *all* aliquots in the same well location.
    # If this was changed to:
    # ```
    #  [well.position, aliquot.tag_pair]
    # ```
    # it would allow merging of plates for different requests, as long as their
    # tag groups are different. This functionality is currently disabled to prevent
    # it being used accidentally.
    def merge_index(well, _aliquot)
      well.position
    end

    # Validation to check the user hasn't scanned the same barcode multiple times
    def source_barcodes_must_be_different
      return unless minimal_barcodes.any? { |e| minimal_barcodes.count(e) > 1 }

      msg = 'should not have the same barcode, please check you scanned all the plates.'
      errors.add(:source_plates, msg)
    end

    # Validation to check the user hasn't accidentally created multiple plates with the same purpose
    def source_plates_must_have_expected_purposes
      actual_purposes = source_plates.map { |sp| sp.purpose[:name] }
      return if actual_purposes.sort == expected_source_purposes.sort

      msg = 'do not have the expected types, check whether the right set of plate types have been made.'
      errors.add(:source_plates, msg)
    end
  end
end