app/models/aliquot.rb
# frozen_string_literal: true
# An aliquot can be considered to be an amount of a material in a liquid. The material could be the DNA
# of a sample, or it might be a library (a combination of the DNA sample and a {Tag tag}).
# A note on tags:
# Aliquots can have up to two tags attached, the i7 (tag) and the i5(tag2)
# Tags are short DNA sequences which can be used to track samples following pooling.
# If two samples with the same tags are pooled together it becomes impossible to
# distinguish between them.
# To avoid this we have an index which ensures unique tags are maintained per pool.
# (Limitation: This restriction assumes that each oligo sequence is represented only once
# in the database. This is not the case, so additional slower checks are required where cross
# tag group pools are possible)
# MySQL indexes treat NULL values as non identical, so -1 (UNASSIGNED_TAG) is used to represent
# an untagged well.
# We have some performance optimizations in place to avoid trying to look up tag -1
# @see Tag
class Aliquot < ApplicationRecord # rubocop:todo Metrics/ClassLength
include Uuid::Uuidable
include Api::Messages::FlowcellIO::AliquotExtensions
include Api::Messages::QcResultIO::AliquotExtensions
include AliquotIndexer::AliquotScopes
include Api::AliquotIO::Extensions
include DataForSubstitution
self.lazy_uuid_generation = true
# TagClash = Class.new(ActiveRecord::RecordInvalid)
TagClash = Class.new(StandardError)
# An aliquot can represent a library, which is a processed sample that has been fragmented. In which case it
# has a receptacle that held the library aliquot and has an insert size describing the fragment positions.
class InsertSize < Range
alias from first
alias to last
end
TAG_COUNT_NAMES = %w[Untagged Single Dual].freeze
# It may have a tag but not necessarily. If it does, however, that tag needs to be unique within the receptacle.
# To ensure that there can only be one untagged aliquot present in a receptacle we use a special value for tag_id,
# rather than NULL which does not work in MySQL. It also works because the unassigned tag ID never gets matched
# for a Tag and so the result is nil!
UNASSIGNED_TAG = -1
# An aliquot is held within a receptacle
belongs_to :receptacle, inverse_of: :aliquots
belongs_to :tag, optional: true
belongs_to :tag2, class_name: 'Tag', optional: true
# An aliquot can belong to a study and a project.
belongs_to :study
belongs_to :project
# An aliquot is an amount of a sample
belongs_to :sample
# It may have a bait library but not necessarily.
belongs_to :bait_library, optional: true
belongs_to :primer_panel
# It can belong to a library asset
belongs_to :library, class_name: 'Receptacle', optional: true
belongs_to :request
composed_of :insert_size,
mapping: [%w[insert_size_from from], %w[insert_size_to to]],
class_name: 'Aliquot::InsertSize',
allow_nil: true
has_one :aliquot_index, dependent: :destroy
convert_labware_to_receptacle_for :library, :receptacle
before_validation { |aliquot| aliquot.tag_id ||= UNASSIGNED_TAG unless aliquot.tag_id? || tag }
before_validation { |aliquot| aliquot.tag2_id ||= UNASSIGNED_TAG unless aliquot.tag2_id? || tag2 }
broadcast_with_warren
scope :include_summary, -> { includes([:sample, { tag: :tag_group }, { tag2: :tag_group }]) }
scope :in_tag_order,
-> {
joins(
'LEFT OUTER JOIN tags AS tag1s ON tag1s.id = aliquots.tag_id,
LEFT OUTER JOIN tags AS tag2s ON tag2s.id = aliquots.tag2_id'
).order('tag1s.map_id ASC, tag2s.map_id ASC')
}
scope :untagged, -> { where(tag_id: UNASSIGNED_TAG, tag2_id: UNASSIGNED_TAG) }
scope :any_tags, -> { where.not(tag_id: UNASSIGNED_TAG).or(where.not(tag2_id: UNASSIGNED_TAG)) }
delegate :library_name, to: :library, allow_nil: true
# returns a hash, where keys are cost_codes and values are number of aliquots related to particular cost code
# {'cost_code_1' => 20, 'cost_code_2' => 3, 'cost_code_3' => 8 }
# this one does not work, as project is not always there:
# joins(project: :project_metadata).group("project_metadata.project_cost_code").count
def self.count_by_project_cost_code
joins('LEFT JOIN projects ON aliquots.project_id = projects.id')
.joins('LEFT JOIN project_metadata ON project_metadata.project_id = projects.id')
.group('project_metadata.project_cost_code')
.count
end
# Returns a list of attributes which must be the same for two Aliquots to be considered
# {#equivalent?} Generated dynamically to avoid accidental introduction of false positives
# when new columns are added
def self.equivalent_attributes
@equivalent_attributes ||= attribute_names - %w[id receptacle_id created_at updated_at]
end
def aliquot_index_value
aliquot_index.try(:aliquot_index)
end
def created_with_request_options
{
fragment_size_required_from: insert_size_from,
fragment_size_required_to: insert_size_to,
library_type: library_type
}
end
# Validating the uniqueness of tags in rails was causing issues, as it was resulting the in the
# preform_transfer_of_contents in transfer request to fail, without any visible sign that something had gone wrong.
# This essentially meant that tag clashes would result in sample dropouts.
# (presumably because << triggers save not save!)
def no_tag1?
tag_id == UNASSIGNED_TAG || (tag_id.nil? && tag.nil?)
end
def tag1?
!no_tag1?
end
def no_tag2?
tag2_id == UNASSIGNED_TAG || (tag2_id.nil? && tag2.nil?)
end
def tag2?
!no_tag2?
end
def tags?
!no_tags?
end
def no_tags?
no_tag1? && no_tag2?
end
def tags_combination
[tag.try(:oligo), tag2.try(:oligo)]
end
def tags_and_tag_depth_combination
[tag.try(:oligo), tag2.try(:oligo), tag_depth]
end
def tag_count_name
TAG_COUNT_NAMES[tag_count]
end
# Optimization: Avoids us hitting the database for untagged aliquots
def tag
super unless tag_id == UNASSIGNED_TAG
end
def tag2
super unless tag2_id == UNASSIGNED_TAG
end
def set_library(force: false)
self.library = receptacle if library.nil? || force
end
# Cloning an aliquot should unset the receptacle ID because otherwise it won't get reassigned. We should
# also reset the timestamp information as this is a new aliquot really.
# Any options passed in as parameters will override the aliquot defaults
def dup(params = {})
super().tap { |cloned_aliquot| cloned_aliquot.assign_attributes(params) }
end
def update_quality(suboptimal_quality)
self.suboptimal = suboptimal_quality
save!
end
# rubocop:todo Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
def matches?(object) # rubocop:todo Metrics/CyclomaticComplexity
# NOTE: This function is directional, and assumes that the downstream aliquot
# is checking the upstream aliquot
case
when sample_id != object.sample_id
false # The samples don't match
when object.library_id.present? && (library_id != object.library_id)
false # Our libraries don't match.
when object.bait_library_id.present? && (bait_library_id != object.bait_library_id)
false # We have different bait libraries
when (no_tag1? && object.tag1?) || (no_tag2? && object.tag2?)
# rubocop:todo Layout/LineLength
raise StandardError, 'Tag missing from downstream aliquot' # The downstream aliquot is untagged, but is tagged upstream. Something is wrong!
# rubocop:enable Layout/LineLength
when object.no_tags?
true # The upstream aliquot was untagged, we don't need to check tags
else
# rubocop:todo Layout/LineLength
(object.no_tag1? || (tag_id == object.tag_id)) && (object.no_tag2? || (tag2_id == object.tag2_id)) # Both aliquots are tagged, we need to check if they match
# rubocop:enable Layout/LineLength
end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
# Unlike the above methods, which allow untagged to match with tagged, this looks for exact matches only
# only id, timestamps and receptacles are excluded
def equivalent?(other)
Aliquot.equivalent_attributes.all? { |attrib| send(attrib) == other.send(attrib) }
end
private
def tag_count
# Find the most highly tagged aliquot
return 2 if tag2?
return 1 if tag1?
0
end
end