sanger/sequencescape

View on GitHub
app/models/map.rb

Summary

Maintainability
B
5 hrs
Test Coverage
A
94%
# frozen_string_literal: true
# Map identifies a {Well wells} position on a {Plate}. It is not related to
# the ruby #map method.
class Map < ApplicationRecord
  validates :description, :asset_size, :location_id, :row_order, :column_order, :asset_shape, presence: true
  validates :asset_size, :row_order, :column_order, numericality: true

  # @!attribute description
  #   @return [String] the name of the well. In most cases this will be in the
  #                    along the lines of A1 or H12
  # @!attribute asset_size
  #   @return [Integer] the plate size for which the {Map} corresponds
  # @!attribute row_order
  #   @return [Integer] zero indexed order of the well when sorted by row
  # @!attribute column_order
  #   @return [Integer] zero indexed order of the well when sorted by column
  module Coordinate
    # TODO: These methods are only valid for standard plates. Moved them here to make that more explicit
    # (even if its not strictly appropriate) They could do with refactoring/removing.

    # A hash representing the dimensions of different types of plates.
    # The keys are the total number of wells in the plate, and the values are
    # arrays, where the first element is the number of columns and the second
    # element is the number of rows.
    #
    # @note
    #   - 96 represents a 96-well plate, arranged in 12 columns and 8 rows.
    #   - 384 represents a 384-well plate, arranged in 24 columns and 16 rows.
    #   - 16 represents a 16-well Chromium Chip, which has 8 columns and 2 rows.
    #     Although a 16-well Chromium Chip does not have 3:2 ratio to be a
    #     standard plate, i.e. it has 4:1 ratio, the methods here still apply.
    # @return [Hash{Integer => Array<Integer>}] the dimensions of the plates
    PLATE_DIMENSIONS = Hash.new { |_h, _k| [] }.merge(96 => [12, 8], 384 => [24, 16], 16 => [8, 2])

    # Seems to expect row to be zero-indexed but column to be 1 indexed
    def self.location_from_row_and_column(row, column, _ = nil, __ = nil)
      "#{('A'.getbyte(0) + row).chr}#{column}"
    end

    def self.description_to_horizontal_plate_position(well_description, plate_size)
      return nil unless Map.valid_well_description_and_plate_size?(well_description, plate_size)

      split_well = Map.split_well_description(well_description)
      width = plate_width(plate_size)
      return nil if width.nil?

      (width * split_well[:row]) + split_well[:col]
    end

    def self.description_to_vertical_plate_position(well_description, plate_size)
      return nil unless Map.valid_well_description_and_plate_size?(well_description, plate_size)

      split_well = Map.split_well_description(well_description)
      length = plate_length(plate_size)
      return nil if length.nil?

      (length * (split_well[:col] - 1)) + split_well[:row] + 1
    end

    def self.horizontal_plate_position_to_description(well_position, plate_size)
      return nil unless Map.valid_plate_position_and_plate_size?(well_position, plate_size)

      width = plate_width(plate_size)
      return nil if width.nil?

      horizontal_position_to_description(well_position, width)
    end

    def self.vertical_plate_position_to_description(well_position, plate_size)
      return nil unless Map.valid_plate_position_and_plate_size?(well_position, plate_size)

      length = plate_length(plate_size)
      return nil if length.nil?

      vertical_position_to_description(well_position, length)
    end

    def self.descriptions_for_row(row, size)
      (1..plate_width(size)).map { |column| "#{row}#{column}" }
    end

    def self.descriptions_for_column(column, size)
      (0...plate_length(size)).map { |row| Map.location_from_row_and_column(row, column) }
    end

    def self.plate_width(plate_size)
      PLATE_DIMENSIONS[plate_size].first
    end

    def self.plate_length(plate_size)
      PLATE_DIMENSIONS[plate_size].last
    end

    def self.vertical_position_to_description(well_position, length)
      desc_letter = (((well_position - 1) % length) + 65).chr
      desc_number = ((well_position - 1) / length) + 1
      (desc_letter + (desc_number.to_s))
    end

    def self.horizontal_position_to_description(well_position, width)
      desc_letter = (((well_position - 1) / width) + 65).chr
      desc_number = ((well_position - 1) % width) + 1
      (desc_letter + (desc_number.to_s))
    end

    def self.horizontal_to_vertical(well_position, plate_size)
      alternate_position(well_position, plate_size, :width, :length)
    end

    def self.vertical_to_horizontal(well_position, plate_size)
      alternate_position(well_position, plate_size, :length, :width)
    end

    def self.location_from_index(index, size)
      horizontal_plate_position_to_description(index + 1, size)
    end

    class << self
      # Given the well position described in terms of a direction (vertical or horizontal) this function
      # will map it to the alternate positional representation, i.e. a vertical position will be mapped
      # to a horizontal one.  It does this with the divisor and multiplier, which will be reversed for
      # the alternate.
      #
      # NOTE: I don't like this, it just makes things clearer than it was!
      # NOTE: I hate the nil returns but external code would take too long to change this time round
      def alternate_position(well_position, size, *dimensions)
        return nil unless Map.valid_well_position?(well_position)

        divisor, multiplier = dimensions.map { |n| send(:"plate_#{n}", size) }
        return nil if divisor.nil? || multiplier.nil?

        column, row = (well_position - 1).divmod(divisor)
        return nil unless (0...multiplier).cover?(column)
        return nil unless (0...divisor).cover?(row)

        (row * multiplier) + column + 1
      end
      private :alternate_position
    end
  end

  module Sequential
    def self.location_from_row_and_column(row, column, width, size)
      digit_count = Math.log10(size + 1).ceil
      "S%0#{digit_count}d" % [((row) * width) + column]
    end

    def self.location_from_index(index, size)
      digit_count = Math.log10(size + 1).ceil
      "S%0#{digit_count}d" % [index + 1]
    end
  end

  scope :for_position_on_plate,
        ->(position, plate_size, asset_shape) {
          where(row_order: position - 1, asset_size: plate_size, asset_shape_id: asset_shape.id)
        }

  scope :where_description, ->(*descriptions) { where(description: descriptions.flatten) }
  scope :where_plate_size, ->(size) { where(asset_size: size) }
  scope :where_plate_shape, ->(asset_shape) { where(asset_shape_id: asset_shape) }
  scope :where_vertical_plate_position, ->(*positions) { where(column_order: positions.map { |v| v - 1 }) }
  scope :for_plate, ->(plate) { where_plate_size(plate.size).where_plate_shape(plate.asset_shape) }

  belongs_to :asset_shape, class_name: 'AssetShape'
  delegate :standard?, to: :asset_shape

  def self.valid_plate_size?(plate_size)
    plate_size.is_a?(Integer) && plate_size > 0
  end

  def self.valid_plate_position_and_plate_size?(well_position, plate_size)
    return false unless valid_well_position?(well_position)
    return false unless valid_plate_size?(plate_size)
    return false if well_position > plate_size

    true
  end

  def self.valid_well_description_and_plate_size?(well_description, plate_size)
    return false if well_description.blank?
    return false unless valid_plate_size?(plate_size)

    true
  end

  def self.valid_well_position?(well_position)
    well_position.is_a?(Integer) && well_position > 0
  end

  def vertical_plate_position
    column_order + 1
  end

  def height
    asset_shape.plate_height(asset_size)
  end

  def width
    asset_shape.plate_width(asset_size)
  end

  ##
  # Column of particular map location. Zero indexed integer
  def column
    row_order % width
  end

  ##
  # Row of particular map location. Zero indexed integer
  def row
    column_order % height
  end

  def horizontal_plate_position
    row_order + 1
  end

  def snp_id
    raise StandardError, 'Only standard maps can be converted to SNP' unless map.standard?

    horizontal_plate_position
  end

  def self.location_from_row_and_column(row, column)
    "#{('A'.getbyte(0) + row).chr}#{column}"
  end

  def self.horizontal_to_vertical(well_position, plate_size, _plate_shape = nil)
    Map::Coordinate.horizontal_to_vertical(well_position, plate_size)
  end

  def self.vertical_to_horizontal(well_position, plate_size, _plate_shape = nil)
    Map::Coordinate.vertical_to_horizontal(well_position, plate_size)
  end

  def self.map_96wells
    Map.where(asset_size: 96)
  end

  def self.map_384wells
    Map.where(asset_size: 384)
  end

  def self.split_well_description(well_description)
    { row: well_description.getbyte(0) - 65, col: well_description[1, well_description.size].to_i }
  end

  # Stip any leading zeros from the well name
  # eg. A01 => A1
  def self.strip_description(description)
    description.sub(/0(\d)$/, '\1')
  end

  def self.pad_description(map)
    split_description = split_well_description(map.description)
    return "#{map.description[0].chr}0#{split_description[:col]}" if split_description[:col] < 10

    map.description
  end

  scope :in_row_major_order, -> { order('row_order ASC') }
  scope :in_reverse_row_major_order, -> { order('row_order DESC') }
  scope :in_column_major_order, -> { order('column_order ASC') }
  scope :in_reverse_column_major_order, -> { order('column_order DESC') }

  class << self
    # Walking in column major order goes by the columns: A1, B1, C1, ... A2, B2, ...
    def walk_plate_in_column_major_order(size, asset_shape = nil)
      asset_shape ||= AssetShape.default_id
      where(asset_size: size, asset_shape_id: asset_shape)
        .order(:column_order)
        .each { |position| yield(position, position.column_order) }
    end
    alias walk_plate_vertically walk_plate_in_column_major_order

    # Walking in row major order goes by the rows: A1, A2, A3, ... B1, B2, B3 ....
    def walk_plate_in_row_major_order(size, asset_shape = nil)
      asset_shape ||= AssetShape.default_id
      where(asset_size: size, asset_shape_id: asset_shape)
        .order(:row_order)
        .each { |position| yield(position, position.row_order) }
    end
    alias walk_plate_horizontally walk_plate_in_row_major_order
  end
end