Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DPL-916 PBMC Donor Pooling create plate #1582

Merged
merged 95 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
aceec0e
Added creator class to LRC PBMC Pools purpose
yoldas Feb 13, 2024
9f2313f
Added automatic state changer class to LRC PBMC Pools purpose
yoldas Feb 13, 2024
e5c7d03
Added work completion request type to LRC PBMC Pools purpose
yoldas Feb 13, 2024
bac22b6
Added pooling lookup table, same as Cardinal
yoldas Feb 14, 2024
a23b111
Add creator_class configuration for LRC PBMC Pools purpose
yoldas Feb 23, 2024
e1a4003
Added pooling algorithm for donor pooling labware creator
yoldas Feb 23, 2024
d92ae65
Fixed split_single_group_by_donor_ids call
yoldas Feb 23, 2024
a6726d2
Added transfer requests and tag_depth info
yoldas Feb 23, 2024
fa56710
Formatted labware creator code comments
yoldas Feb 23, 2024
dcccca8
Add donor pooling template for receving source plate barcodes
yoldas Feb 23, 2024
dd1033b
Add donor pooling plate purpose config factory
yoldas Feb 23, 2024
f8483ac
Prettier
yoldas Feb 23, 2024
cb5599e
Merge branch 'develop' into 1390-dpl-916-pbmc-donor-pooling-create-plate
yoldas Feb 23, 2024
529cace
Merged transfer_hash methods
yoldas Feb 24, 2024
2e0a199
Added initializer to load the lookup table
yoldas Feb 24, 2024
3d96ccf
Add attribute readers for barcodes and minimal barcodes
yoldas Feb 25, 2024
05de79a
Rubocop
yoldas Feb 25, 2024
acc4d5c
Add test for source_plates method
yoldas Feb 25, 2024
8279d4d
Add test for source_wells_for_pooling method
yoldas Feb 25, 2024
c4287ba
Fix config name in number_of_pools method
yoldas Feb 25, 2024
1ebd41b
Add test for number_of_pools method
yoldas Feb 25, 2024
9afc083
Add tests for barcodes= and group_by_study_and_project methods
yoldas Feb 25, 2024
9988999
Add test for unique_donor_ids method
yoldas Feb 25, 2024
60fbf63
Fix unique_donor_ids method
yoldas Feb 25, 2024
7421274
Add test for split_single_group_by_donor_ids method
yoldas Feb 25, 2024
0aebf5c
Fix split_single_group_by_donor_ids method
yoldas Feb 25, 2024
71b9c70
Rename donor_id split methods
yoldas Feb 25, 2024
c1fd115
Move helper methods to a separate module
yoldas Feb 26, 2024
de63cdb
Fix distribute_groups_across_pools method
yoldas Feb 26, 2024
7e957e1
Check nil in split_single_group_by_unique_donor_ids
yoldas Feb 26, 2024
81e71a9
Fix distribute_groups_across_pools call in build_pools
yoldas Feb 26, 2024
6b791e3
Add test for build_pools method
yoldas Feb 26, 2024
f84278c
Add tests for tag depth and transfer request attributes
yoldas Feb 27, 2024
37b5db7
Add test for transfer_material_from_parent! method
yoldas Feb 27, 2024
580e4b7
Add test for pools method
yoldas Feb 27, 2024
52dd3c5
Prettier
yoldas Feb 27, 2024
b3bbe22
Change module name from helper to calculator
yoldas Feb 28, 2024
96a79e3
Rename file for donor pooling calculator
yoldas Feb 28, 2024
4c97d08
Update the include with the pooling calculator module name
yoldas Feb 28, 2024
c2fbbaa
Add donor pooling validations
yoldas Feb 28, 2024
d35641d
Include validator in labware creator
yoldas Feb 28, 2024
2d9f022
Prettier
yoldas Feb 28, 2024
616f4c4
Updated message for number_of_pools_must_not_exceed_configured validator
yoldas Feb 28, 2024
d5cdca5
Remove redundant include from validator
yoldas Feb 28, 2024
c8ffdb9
Add comments to validation methods
yoldas Feb 28, 2024
05bd224
Prettier
yoldas Feb 28, 2024
c74e242
Add tests for validations
yoldas Feb 28, 2024
bd90134
Add test for wells_with_aliquots_must_have_donor_id
yoldas Mar 1, 2024
d484671
Add validation wells_with_aliquots_must_have_donor_id
yoldas Mar 1, 2024
61ec788
Rename labware creator argument to max_number_of_source_plates
yoldas Mar 7, 2024
7acfec8
Fix typo in labware_wells method doc
yoldas Mar 7, 2024
a3fda01
Use number_of_pools and default_number_of_pools from purpose_config
yoldas Mar 10, 2024
407f630
Add pooling and default_number_pools to labware creator arguments
yoldas Mar 10, 2024
698d887
Updated docs with config:generate
yoldas Mar 10, 2024
127c487
Load pooling configurations with config task
yoldas Mar 10, 2024
89045f9
Add labware creator arguments to donor_pooling_plate_purpose_config f…
yoldas Mar 10, 2024
649eb89
Update test with default_number_of_pools from purpose config
yoldas Mar 10, 2024
595b8cb
Reset Settings.poolings in spec_config to allow testing with custom s…
yoldas Mar 10, 2024
542c209
Add config loader for poolings
yoldas Mar 10, 2024
75f0db3
Add donor_pooling_config factory for testing pooling configurations f…
yoldas Mar 10, 2024
2ca3b52
Add interim scrna donor pooling config YAML file (lookup table; copy …
yoldas Mar 10, 2024
58f224d
Add fixtures for testing with pooling configurations
yoldas Mar 10, 2024
08bd157
Add test for poolings config loader
yoldas Mar 10, 2024
c312ead
Add well_at_location method to Plate
yoldas Mar 10, 2024
b491741
Use plate.well_at_location method instead of get_well_for_plate_location
yoldas Mar 10, 2024
37e3b6a
Remove get_well_for_plate_location method from labware creator
yoldas Mar 10, 2024
f209901
Remove test for get_well_for_plate_location method
yoldas Mar 10, 2024
2a89e43
Update config rake task stdout progress with pooling config
yoldas Mar 10, 2024
7bc4277
Add method to compute all tag depths at once into a hash
yoldas Mar 10, 2024
254c788
Use pre-computed tag depths (as strings) in request_hash
yoldas Mar 10, 2024
918f710
Remove per well tag_depth method because they are pre-computed now
yoldas Mar 10, 2024
4440b33
Replace tag_depth test with tag_depth_hash test
yoldas Mar 10, 2024
a4c43cf
Fix caching the result of tag_depth_hash computation
yoldas Mar 10, 2024
7ed1788
Use less strong wording in the view as suggested in code-review
yoldas Mar 10, 2024
3bb5088
Update instructions with the conditions of pooling
yoldas Mar 10, 2024
a815052
Apply doc suggestion for DonorPoolingCalculator
yoldas Mar 10, 2024
67693fe
Add to doc: only one aliquot is expected per well
yoldas Mar 10, 2024
e8dbe18
Remove redundant nil-check
yoldas Mar 10, 2024
ec22680
Use counter instead of empty check for the while loop condition
yoldas Mar 10, 2024
68b1e83
Prettier
yoldas Mar 10, 2024
80b1bea
Add examples in the comments for methods
yoldas Mar 10, 2024
7224c0d
Add missing source plate barcodes to the error message
yoldas Mar 10, 2024
d02883c
Update test with missing source plate barcodes
yoldas Mar 10, 2024
711ac1c
Rubocop
yoldas Mar 10, 2024
9d08807
Separate assigment statement from the chain
yoldas Mar 10, 2024
4ce1bcc
Update error message strings
yoldas Mar 10, 2024
4386930
Remove redundant source_plate param from locations_with_missing_donor_id
yoldas Mar 10, 2024
efc8360
Add a method to map source wells to their plates
yoldas Mar 10, 2024
f458b48
Ensure validator uses the same filtered wells as the labware creator
yoldas Mar 10, 2024
8590d76
Add test for source_wells_to_plates method
yoldas Mar 10, 2024
651445c
Update code documentation of locations_with_missing_donor_id method
yoldas Mar 10, 2024
fe38028
Remove pooling config CSV file
yoldas Mar 10, 2024
e3960c4
Remove CSV config file loader
yoldas Mar 10, 2024
169891b
Prettier
yoldas Mar 11, 2024
d4b8573
Merge branch 'develop' into 1390-dpl-916-pbmc-donor-pooling-create-plate
yoldas Mar 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions app/models/concerns/labware_creators/donor_pooling_calculator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# frozen_string_literal: true

# This module contains algorithms to allocate source wells into a target number of pools.
module LabwareCreators::DonorPoolingCalculator
extend ActiveSupport::Concern

yoldas marked this conversation as resolved.
Show resolved Hide resolved
# Splits wells into groups by study and project. Wells are grouped based on the
# study and project of the first aliquot in each well (only one aliquot is
# expected per well). Returns an array of groups, where each group is an array
# of wells with the same study and project.
#
# If the input group is [w1, w2, w3, w4, w5, w6, w7, w8, w9]
# where w1, w2, w3, w4, w5, w6, w7, w8, and w9 are wells with (study_id, project_id),
#
# w1(1,1)
# w2(1,2)
# w3(1,3)
# w4(1,1)
# w5(1,2)
# w6(1,3)
# w7(1,1)
# w8(2,1)
# w9(2,2)
#
# the result will be:
# [[w1, w4, w7], [w2, w5], [w3, w6], [w8], [w9]]
#
# @param group [Array<Well>] The group of wells to be split.
# @return [Array<Array<Well>>] An array of well groups.
def split_single_group_by_study_and_project(group)
group.group_by { |well| [well.aliquots.first.study.id, well.aliquots.first.project.id] }.values
end

# Splits groups ensuring unique donor_ids within each group. Iterates over
# each group, creating subgroups with wells from a unique donor. The first
# occurrences of unique donor_ids are grouped, then the second occurrences,
# and so on. This prevents combining samples with the same donor_id. The
# result is flattened to a single array of subgroups.
#
# If the input groups are [[w1, w2, w3, w4], [w5, w6, w7], [w8, w9]]
# where w1, w2, w3, w4, w5, w6, w7, w8, and w9 are wells with (donor_id),
#
# w1(1)
# w2(2)
# w3(3)
# w4(1)
# w5(4)
# w6(4)
# w7(5)
# w8(6)
# w9(7)
#
# the result will be:
# [[w1, w2, w3], [w4], [w5, w7], [w6], [w8, w9]]
#
# Note that the input groups are not mixed. donor_ids are unique within each
# result subgroup.
#
# @param groups [Array<Array<Well>>] Array of well groups to be split.
# @return [Array<Array<Well>>] Array of subgroups split by donor ID.
def split_groups_by_unique_donor_ids(groups)
groups.flat_map { |group| split_single_group_by_unique_donor_ids(group) }
end

# Splits a single group of wells by donor_ids. This method is used by the
# 'split_groups_by_unique_donor_ids' method. It iteratively segregates wells with
# the first encountered instance of each unique donor_id into a separate
# subgroup. This process continues until there are no wells left in the
# original group. The result is a collection of subgroups, each containing
# wells from distinct donors.
#
# If the input group is [w1, w2, w3, w4, w5, w6, w7, w8, w9]
# where w1, w2, w3, w4, w5, w6, w7, w8, and w9 are wells with (donor_id),
#
# w1(1)
# w2(2)
# w3(3)
# w4(1)
# w5(2)
# w6(4)
# w7(5)
# w8(5)
# w9(5)
#
# the result will be:
# [[w1, w2, w3, w6, w7], [w4, w5, w8], [w9]]
#
# @param group [Array<Well>] The group of wells to split.
# @return [Array<Array<Well>>] An array of subgroups, each containing wells
# from different donors.
def split_single_group_by_unique_donor_ids(group)
group = group.dup
output = []
wells_moved = 0
wells_total = group.size
while wells_moved < wells_total
subgroup = []
unique_donor_ids(group).each do |donor_id|
wells_moved += 1
index = group.index { |well| well.aliquots.first.sample.sample_metadata.donor_id == donor_id }
subgroup << group.delete_at(index)
end
output << subgroup
end
output
end
KatyTaylor marked this conversation as resolved.
Show resolved Hide resolved

# Returns the unique donor_ids from a group of wells. Used by the
# 'split_single_group_by_unique_donor_ids' method.
#
# If the input group is [w1, w2, w3, w4, w5, w6, w7, w8, w9]
# where w1, w2, w3, w4, w5, w6, w7, w8, and w9 are wells with (donor_id),
#
# w1(1)
# w2(2)
# w3(3)
# w4(1)
# w5(2)
# w6(4)
# w7(5)
# w8(5)
# w9(5)
#
# the result will be:
# [1, 2, 3, 4, 5]
#
# @param group [Array<Well>] The group of wells from which to retrieve donor_ids.
# @return [Array<String>] An array of unique donor_ids.
def unique_donor_ids(group)
group.map { |well| well.aliquots.first.sample.sample_metadata.donor_id }.uniq
end

# Distributes samples across pools based on group sizes. It sorts the groups
# by size and splits the largest group into two until the number of groups
# equals the number of pools or until all groups have a size of 1. The input
# groups are the result of applying conditions, hence they cannot be mixed.
#
# If the request number of pools is 6 and the input groups are
# [[1, 2, 3], [4, 5], [6, 7, 8, 9]] where the numbers denote wells,
#
# the result will be:
# [[3], [1], [2], [4, 5], [6, 7], [8, 9]]
#
# for which the steps are:
# [[1, 2, 3], [4, 5], [6, 7, 8, 9]] -> 3 pools (input)
# [[4, 5], [6, 7], [8, 9], [1, 2, 3]] -> 4 pools
# [[3], [4, 5], [6, 7], [8, 9], [1, 2]] -> 5 pools
# [[3], [1], [2], [4, 5], [6, 7], [8, 9]] -> 6 pools (output)
#
# @param groups [Array<Array<Well>>] Array of well groups to be distributed.
# @return [Array<Array<Well>>] Array of distributed groups.
def distribute_groups_across_pools(groups, number_of_pools)
groups = groups.dup
yoldas marked this conversation as resolved.
Show resolved Hide resolved
groups.sort_by!(&:size)
while groups.any? && groups.last.size > 1 && groups.size < number_of_pools
largest = groups.pop # last
splits = largest.each_slice((largest.size / 2.0).ceil).to_a
groups.concat(splits).sort_by!(&:size)
end
groups
end
end
130 changes: 130 additions & 0 deletions app/models/concerns/labware_creators/donor_pooling_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

# This module contains validations for donor pooling.
module LabwareCreators::DonorPoolingValidator
extend ActiveSupport::Concern

included do
validate :source_barcodes_must_be_entered
validate :source_barcodes_must_be_different
validate :source_plates_must_exist
validate :wells_with_aliquots_must_have_donor_id
validate :number_of_pools_must_not_exceed_configured
end

SOURCE_BARCODES_MUST_BE_ENTERED = 'At least one source plate must be scanned.'

SOURCE_BARCODES_MUST_BE_DIFFERENT = 'You must not scan the same barcode more than once.'

SOURCE_PLATES_MUST_EXIST =
'Source plates not found: %s. ' \
'Please check you scanned the correct source plates. '

NUMBER_OF_POOLS_MUST_NOT_EXCEED_CONFIGURED =
'The calculated number of pools (%s) is higher than the number of pools ' \
'(%s) configured. This is due to constraints such as: ' \
'* samples with different Studies or Projects cannot be combined ' \
'* multiple samples from the same donor cannot be combined. ' \
'Please check you have scanned the correct set of source plates.'

WELLS_WITH_ALIQUOTS_MUST_HAVE_DONOR_ID =
'All samples must have the donor_id specified. ' \
'Wells missing donor_id (on sample metadata): %s'

# Validates that at least one source barcode has been entered. If no barcodes
# are entered, an error is added to the :source_barcodes attribute.
#
# @return [void]
def source_barcodes_must_be_entered
return if minimal_barcodes.size >= 1

errors.add(:source_barcodes, SOURCE_BARCODES_MUST_BE_ENTERED)
end

# Validates that all source barcodes are unique. If any barcodes are
# duplicated, an error is added to the :source_barcodes attribute.
#
# @return [void]
def source_barcodes_must_be_different
return if minimal_barcodes.size == minimal_barcodes.uniq.size

errors.add(:source_barcodes, SOURCE_BARCODES_MUST_BE_DIFFERENT)
end

# Validates that all source plates corresponding to the minimal barcodes exist.
# If the number of source plates does not match the number of minimal barcodes,
# an error is added to the :source_plates attribute.
#
# @return [void]
def source_plates_must_exist
return if source_plates.size == minimal_barcodes.size

formatted_string = (minimal_barcodes - source_plates.map(&:human_barcode)).join(', ')

errors.add(:source_plates, format(SOURCE_PLATES_MUST_EXIST, formatted_string))
end

# Validates that the number of calculated pools does not exceed the configured
# number of pools. If the number of calculated pools is greater, an error is
# added to the :source_plates attribute.
#
# @return [void]
def number_of_pools_must_not_exceed_configured
# Don't add this error if there are already errors about missing donor_ids.
invalid_wells_hash = locations_with_missing_donor_id
return if invalid_wells_hash.any?

return if pools.size <= number_of_pools

errors.add(:source_plates, format(NUMBER_OF_POOLS_MUST_NOT_EXCEED_CONFIGURED, pools.size, number_of_pools))
end

# Validates that all wells with aliquots must have a donor_id.
# It uses the locations_with_missing_donor_id method to find any wells that are
# missing a donor_id. If any such wells are found, it adds an error message to
# the source_plates attribute, formatted with the barcodes of the plates and
# the wells that are missing a donor_id.
#
# @return [void]
def wells_with_aliquots_must_have_donor_id
invalid_wells_hash = locations_with_missing_donor_id
return if invalid_wells_hash.empty?

formatted_string = invalid_wells_hash.map { |barcode, locations| "#{barcode}: #{locations.join(', ')}" }.join(' ')
errors.add(:source_plates, format(WELLS_WITH_ALIQUOTS_MUST_HAVE_DONOR_ID, formatted_string))
end

private

# Checks each source well for pooling for missing donor_id. Returns a hash
# with keys as the barcodes of source plates and values as arrays of well
# locations with missing donor_id. If a plate has no wells with missing
# donor_id, it is not included in the returned hash. This method is used by
# the wells_with_aliquots_must_have_donor_id method to generate an error
# message.
#
# @return [Hash] A hash mapping source plate barcodes to arrays of invalid
# well locations.
def locations_with_missing_donor_id
# source_wells_for_pooling contains filtered wells from source plates
invalid_wells = source_wells_for_pooling.select { |well| missing_donor_id?(well) }
invalid_wells.each_with_object({}) do |well, hash|
plate_barcode = source_wells_to_plates[well].human_barcode # find the plate barcode
hash[plate_barcode] ||= []
hash[plate_barcode] << well.location
end
end
KatyTaylor marked this conversation as resolved.
Show resolved Hide resolved

# Checks if a well is missing a donor_id. If there is an aliquot, it checks
# if the associated sample_metadata has a donor_id. If the donor_id is
# missing, it returns true. Otherwise, it returns false.
#
# @param well [Well] The well to check.
# @return [Boolean] True if the well is missing a donor_id, false otherwise.
def missing_donor_id?(well)
aliquot = well.aliquots&.first
return false unless aliquot

(aliquot.sample.sample_metadata.donor_id || '').to_s.strip.blank?
end
end
Loading
Loading