app/models/plate.rb
# frozen_string_literal: true
require 'lab_where_client'
# https://github.com/sanger/sequencescape/raw/master/docs/images/plate.jpg
#
# A plate is a piece of labware made up of a number of {Well wells}. This class represents the physical piece of
# plastic.
#
# - {PlatePurpose}: describes the role a plate has in the lab. In some cases a plate's purpose may change as it gets
# processed.
# - {Well}: Plates can have multiple wells (most often 96 or 384) each of which can contain multiple samples.
# - {PlateType}: Identifies the plates form factor, typically provided to robots to ensure tips are positioned
# correctly.
#
class Plate < Labware # rubocop:todo Metrics/ClassLength
include Api::PlateIO::Extensions
include ModelExtensions::Plate
include Transfer::Associations
include Transfer::State::PlateState
include Asset::Ownership::Owned
include Plate::FluidigmBehaviour
include SubmissionPool::Association::Plate
include PlateCreation::CreationChild
include Barcode::Barcodeable
extend QcFile::Associations
class_attribute :default_plate_size
# Shouldn't actually be falling back to this, but its here just in case
self.sample_partial = 'assets/samples_partials/plate_samples'
self.per_page = 50
self.default_plate_size = 96
self.receptacle_class = 'Well'
has_qc_files
belongs_to :plate_purpose, inverse_of: :plates
belongs_to :purpose, foreign_key: :plate_purpose_id
has_many :wells, inverse_of: :plate, foreign_key: :labware_id do # rubocop:todo Metrics/BlockLength
# rubocop:todo Metrics/MethodLength
def construct! # rubocop:todo Metrics/AbcSize
transaction do
plate = proxy_association.owner
plate
.maps
.in_row_major_order
.ids
.map { |location_id| { map_id: location_id } }
.tap do |wells|
plate.wells.import(wells)
ids = plate.wells.ids
well_type = Well.base_class.name
# These would usually be handled in after create callbacks, however
# import does not fire these, and we want to create them in bulk anyway
WellAttribute.import(ids.map { |well| { well_id: well } })
Uuid.import(
ids.map { |well| { resource_id: well, resource_type: well_type, external_id: Uuid.generate_uuid } }
)
# Warren::Message::Short keeps track of the class (Well) and id, and gets sent after
# the transaction completes. This avoids us needing to instantiate wells, keeping the memory footprint
# down.
ids.each { |id| Warren::Message::Short.new(class_name: 'Well', id: id).queue(Warren.handler) }
end
end
end
# rubocop:enable Metrics/MethodLength
# Returns the wells with their pool identifier included
# @note This is a method defined on the well association, and can be considered a scope.
# Ie. You can do plate.wells.with_pool_id.located_at(['A1'])
# It is a little special in that its behaviour is dependent on the {PlatePurpose#pool_wells plate purpose}.
# In practice this lets wells on PlatePurpose::Initial to pull their information from the requests, whereas
# other purposes jump back to the stock wells.
# Given this is usually called by the transfers themselves, prior to the transfer of the aliquots
# it can't be replaced purely by using request on aliquot (as it is partly responsible for setting that)
# however we can almost certainly simplify this whole process somewhat.
def with_pool_id
proxy_association.owner.plate_purpose.pool_wells(self)
end
#
# Returns a hash of wells, indexed by the well name (map_description)
#
# @return [Hash] eg. { 'A1' => #<Well map_description: 'A1'>, 'B1' => #<Well map_description: 'B1'> }
def indexed_by_location
@index_well_cache ||= index_by(&:map_description)
end
end
has_many :well_requests_as_target, through: :wells, source: :requests_as_target
has_many :well_requests_as_source, through: :wells, source: :requests_as_source
has_many :orders_as_target, -> { distinct }, through: :well_requests_as_target, source: :order
# We use stock well associations here as stock_wells is already used to generate some kind of hash.
has_many :stock_requests, -> { distinct }, through: :stock_well_associations, source: :requests
has_many :stock_well_associations, -> { distinct }, through: :wells, source: :stock_wells
has_many :stock_orders, -> { distinct }, through: :stock_requests, source: :order
has_many :extraction_attributes, foreign_key: 'target_id'
has_many :siblings, through: :parents, source: :children
# Transfer requests into a plate are the requests leading into the wells of said plate.
has_many :transfer_requests, through: :wells, source: :transfer_requests_as_target
has_many :transfer_request_collections, -> { distinct }, through: :transfer_requests_as_source
# The default state for a plate comes from the plate purpose
delegate :default_state, to: :plate_purpose, allow_nil: true
# Used to unify interface with TubeRacks. Returns a list of all receptacles {Well wells}
# with position information included for aid performance
def receptacles_with_position
wells.includes(:map)
end
# The state of a plate loosely defines what has happened to it. In most cases it is determined
# by aggregating the state of transfer requests into the wells, although exact behaviour is determined
# by the {PlatePurpose}. State typically only works for pipeline application plates. In general:
#
# - pending: The plate has been registered, but it empty.
# - started: The plate contains samples, but required further processing
# - passed: Work on the plate is complete, and it can be transferred to another target
# - failed: The plate failed QC and can not be progressed further
# - cancelled: The plate is no longer required and should be ignored.
#
# @return [String] Name of the state the plate is in
def state
plate_purpose.state_of(self)
end
# Modifies the recorded volume information of all wells on a plate by volume_change
# @param volume_change [Numeric] The adjustment to apply to all wells (in ul).
# Negative values reduce the target volume, positive values increase it.
#
# @return [Void]
def update_volume(volume_change)
ActiveRecord::Base.transaction { wells.each { |well| well.update_volume(volume_change) } }
end
#
# Counts the number of wells containing one or more aliquots.
# @note Does not take into account the {Sample#empty_supplier_sample_name} flag on older samples
#
# @return [Integer] The number of wells with samples
def occupied_well_count
wells.with_contents.count
end
#
# Called when cherrypicking is completed to allow the plate to trigger any callbacks,
# such as broadcasting Fluidigm plates to the warehouse.
# This behaviour varies based on the PlatePurpose
#
# @return [Void]
def cherrypick_completed
plate_purpose.cherrypick_completed(self)
end
# The type of the barcode is delegated to the plate purpose because that governs the number of wells
delegate :barcode_type, to: :plate_purpose, allow_nil: true
delegate :asset_shape, to: :plate_purpose, allow_nil: true
delegate :dilution_factor, :dilution_factor=, to: :plate_metadata
# Submissions on requests out of the plate
# May not have been started yet
has_many :waiting_submissions, -> { distinct }, through: :well_requests_as_source, source: :submission
def submission_ids
@submission_ids ||= in_progress_submissions.ids
end
def submission_ids_as_source
@submission_ids_as_source ||= waiting_submissions.ids
end
# Prioritised the submissions that have been made from the plate
# then falls back onto the ones under which the plate was made
def all_submission_ids
submission_ids_as_source.presence || submission_ids
end
def submissions
waiting_submissions.presence || in_progress_submissions
end
def iteration
iter =
siblings # assets sharing the same parent
.where(plate_purpose_id: plate_purpose_id, sti_type: sti_type) # of the same purpose and type
.where("#{self.class.table_name}.created_at <= ?", created_at) # created before or at the same time
.count(:id) # count the siblings.
iter.zero? ? nil : iter # Maintains compatibility with legacy version
end
def comments
@comments ||= CommentsProxy::Plate.new(self)
end
def priority
waiting_submissions.maximum(:priority) || in_progress_submissions.maximum(:priority) || 0
end
before_create :set_plate_name_and_size
scope :with_sample, ->(sample) { includes(:contained_samples).where(samples: { id: sample }) }
scope :with_requests, ->(requests) { includes(wells: :requests).where(requests: { id: requests }).distinct }
scope :output_by_batch, ->(batch) { joins(wells: { requests_as_target: :batch }).where(batches: { id: batch }) }
scope :with_wells, ->(wells) { joins(:wells).where(receptacles: { id: wells.map(&:id) }).distinct }
has_many :descendant_plates,
class_name: 'Plate',
through: :links_as_ancestor,
foreign_key: :ancestor_id,
source: :descendant
has_many :descendant_tubes,
class_name: 'Tube',
through: :links_as_ancestor,
foreign_key: :ancestor_id,
source: :descendant
has_many :descendant_lanes,
class_name: 'Lane::Labware',
through: :links_as_ancestor,
foreign_key: :ancestor_id,
source: :descendant
has_many :tag_layouts, dependent: :destroy
scope :with_descendants_owned_by,
->(user) { joins(descendant_plates: :plate_owner).where(plate_owners: { user_id: user }).distinct }
scope :source_plates, -> { joins(:plate_purpose).where('plate_purposes.id = plate_purposes.source_purpose_id') }
scope :with_wells_and_requests,
-> {
eager_load(
wells: [
:uuid_object,
:map,
{
requests_as_target: [
{ initial_study: :uuid_object },
{ initial_project: :uuid_object },
{ asset: { aliquots: :sample } }
]
}
]
)
}
def self.search_for_plates(params)
with_faculty_sponsor_ids(params[:faculty_sponsor_ids] || nil)
.with_study_id(params[:study_id] || nil)
.with_plate_purpose_ids(params[:plate_purpose_ids] || nil)
.created_between(params[:start_date], params[:end_date])
.filter_by_barcode(params[:barcodes] || nil)
.distinct
end
scope :with_faculty_sponsor_ids,
->(faculty_sponsor_ids) {
if faculty_sponsor_ids.present?
joins(studies: { study_metadata: :faculty_sponsor }).where(faculty_sponsors: { id: faculty_sponsor_ids })
end
}
scope :with_study_id, ->(study_id) { joins(:studies).where(studies: { id: study_id }) if study_id.present? }
scope :with_plate_purpose_ids,
->(plate_purpose_ids) { where(plate_purpose_id: plate_purpose_ids) if plate_purpose_ids.present? }
# TODO: When on Ruby 2.6 try using endless ranges
scope :created_between,
->(start_date, end_date) {
where(created_at: (start_date.midnight..(end_date || Time.current).end_of_day)) if start_date.present?
}
def maps
Map.where_plate_size(size).where_plate_shape(asset_shape)
end
def find_well_by_name(well_name)
wells.loaded? ? wells.indexed_by_location[well_name] : wells.located_at_position(well_name).first
end
alias find_well_by_map_description find_well_by_name
def plate_rows
('A'..('A'.getbyte(0) + height - 1).chr.to_s).to_a
end
def plate_columns
(1..width)
end
def plate_type
labware_type&.name || Sequencescape::Application.config.plate_default_type
end
def plate_type=(plate_type)
self.labware_type = PlateType.find_by(name: plate_type)
end
def details
purpose.try(:name) || 'Unknown plate purpose'
end
def self.plate_ids_from_requests(requests)
with_requests(requests).pluck(:id)
end
def stock_plate?
return true if plate_purpose.nil?
plate_purpose.stock_plate? && plate_purpose.attached?(self)
end
#
# Attempts to find the 'stock_plate' for the plate. However this is a fairly
# nebulous concept. Often it means the plate that first entered a pipeline,
# but in other cases it can be the XP plate part way through the process. Further
# complication comes from tubes which pool across multiple plates, where identifying
# a single stock plate is meaningless. In other scenarios, you split plates out again
# and the asset link graph is insufficient.
#
# JG: 2021-02-11:
# See https://github.com/sanger/sequencescape/issues/3040 for more information
#
# @deprecate Do not use this for new behaviour.
#
#
# @return [Plate, nil] The stock plate if found
#
def stock_plate
@stock_plate ||= stock_plate? ? self : lookup_stock_plate
end
deprecate stock_plate: 'Stock plate is nebulous and can easily lead to unexpected behaviour'
def self.create_with_barcode!(*args, &block)
attributes = args.extract_options!
attributes[:sanger_barcode] ||= PlateBarcode.create_barcode
create!(attributes, &block)
end
def number_of_blank_samples
wells.with_blank_samples.count
end
def scored?
wells.any?(&:get_gel_pass)
end
def buffer_required?
wells.any?(&:buffer_required?)
end
#
# Given a list of well map_descriptions (eg. A1), returns those not present on the plate
#
# @param [Array] positions Array of positions to test
#
# @return [Array] Array of invalid positions
#
def invalid_positions(positions)
(positions.uniq - unique_positions_on_plate).sort
end
def unique_positions_on_plate
maps.distinct.pluck(:description)
end
def name_for_label
name
end
extend Metadata
has_metadata {}
def height
asset_shape.plate_height(size)
end
def width
asset_shape.plate_width(size)
end
# This method returns a map from the wells on the plate to their stock well.
def stock_wells # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
# Optimisation: if the plate is a stock plate then it's wells are it's stock wells!]
if stock_plate?
wells.with_pool_id.index_with { |w| [w] }
else
wells
.include_stock_wells
.with_pool_id
.each_with_object({}) do |w, store|
storted_stock_wells = w.stock_wells.sort_by { |sw| sw.map.column_order }
store[w] = storted_stock_wells unless storted_stock_wells.empty?
end
.tap { |stock_wells_hash| raise "No stock plate associated with #{id}" if stock_wells_hash.empty? }
end
end
def convert_to(new_purpose)
update!(plate_purpose: new_purpose)
end
def compatible_purposes
PlatePurpose.compatible_with_purpose(purpose)
end
def well_hash
@well_hash ||= wells.include_map.includes(:well_attribute).index_by(&:map_description)
end
def update_qc_values_with_parser(parser)
ActiveRecord::Base.transaction do
qc_assay = QcAssay.new
parser.each_well_and_parameters do |position, well_updates|
# We might have a nil well if a plate was only partially cherrypicked
well = well_hash[position] || next
well_updates.each do |attribute, value|
QcResult.create!(
asset: well,
key: attribute,
unit_value: value,
assay_type: parser.assay_type,
assay_version: parser.assay_version,
qc_assay: qc_assay
)
end
end
end
true
end
# Finds the product line (= team) of the requests coming out of this plate's 'stock plate'.
# Written at a time when requests weren't recorded on the aliquot, so could be re-written in a less convoluted way.
def team
ProductLine
.joins(
[
'INNER JOIN request_types ON request_types.product_line_id = product_lines.id',
'INNER JOIN requests ON requests.request_type_id = request_types.id',
'INNER JOIN well_links ON well_links.source_well_id = requests.asset_id AND well_links.type = "stock"',
'INNER JOIN receptacles AS re ON re.id = well_links.target_well_id'
]
)
.find_by(['re.labware_id = ?', id])
.try(:name) || 'UNKNOWN'
end
alias friendly_name human_barcode
def subject_type
'plate'
end
# Plates use a different counter to tubes, and prior to the foreign barcodes update
# this method would have fallen back to Barcodable#generate tubes, and potentially generated
# an invalid plate barcode. In the future we probably want to scrap this approach entirely,
# and generate all barcodes in the plate style. (That is, as part of the factory on, eg. plate purpose)
def generate_barcode
raise StandardError,
"#generate_barcode has been called on plate, which wasn't supposed to happen, and probably indicates a bug."
end
def sanger_barcode=(barcode)
barcodes << barcode
end
def after_comment_addition(comment)
comments.add_comment_to_submissions(comment)
end
def related_studies
studies
end
def wells_in_row_order
wells.loaded? ? wells.sort_by(&:row_order) : wells.in_row_major_order
end
def wells_in_column_order
wells.loaded? ? wells.sort_by(&:column_order) : wells.in_column_major_order
end
# When Cherrypicking, especially on the Hamilton, control plates get placed
# on a seperate bed. ControlPlates overide this.
# @return [false]
def pick_as_control?
false
end
private
def lookup_stock_plate
spp = PlatePurpose.considered_stock_plate.pluck(:id)
ancestors.order(id: :desc).find_by(plate_purpose_id: spp)
end
def set_plate_name_and_size
self.name = "Plate #{human_barcode}" if name.blank?
self.size = default_plate_size if size.nil?
end
end