diff --git a/Gemfile.lock b/Gemfile.lock index 5051fffc5e..0437a39463 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,6 +301,8 @@ GEM nio4r (2.7.3) nokogiri (1.16.7-arm64-darwin) racc (~> 1.4) + nokogiri (1.16.7-x86_64-darwin) + racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) parallel (1.24.0) @@ -565,6 +567,7 @@ GEM PLATFORMS arm64-darwin arm64-darwin-23 + x86_64-darwin-21 x86_64-linux DEPENDENCIES diff --git a/app/models/tag_set.rb b/app/models/tag_set.rb index 5bbee37cd5..2650596d03 100644 --- a/app/models/tag_set.rb +++ b/app/models/tag_set.rb @@ -6,6 +6,9 @@ class TagSet < ApplicationRecord # For dual index tags, tag_group is i7 oligos and tag2_group is i5 oligos belongs_to :tag_group, class_name: 'TagGroup', optional: false + + # In order to support a unified access to dual and single index sets, + # it allows tag2_group to be null. belongs_to :tag2_group, class_name: 'TagGroup', optional: true # We can assume adapter_type is the same for both tag groups @@ -15,7 +18,16 @@ class TagSet < ApplicationRecord validates :name, presence: true, uniqueness: true validate :tag_group_adapter_types_must_match + scope :dual_index, -> { where.not(tag2_group: nil) } + scope :visible, + -> { joins(:tag_group, :tag2_group).where(tag_group: { visible: true }, tag2_group: { visible: true }) } + + # The scoping retrieves the visible tag sets and makes sure they are dual index. + scope :visible_dual_index, -> { dual_index.visible } + # Dynamic method to determine the visibility of a tag_set based on the visibility of its tag_groups + # TagSet has a method to check if itself is visible by checking + # the visibility of both tag_group and (if not null) tag2_group. def visible tag_group.visible && (tag2_group.nil? || tag2_group.visible) end diff --git a/app/sequencescape_excel/sequencescape_excel/specialised_field/dual_index_tag_set.rb b/app/sequencescape_excel/sequencescape_excel/specialised_field/dual_index_tag_set.rb new file mode 100644 index 0000000000..bd5d448d3e --- /dev/null +++ b/app/sequencescape_excel/sequencescape_excel/specialised_field/dual_index_tag_set.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SequencescapeExcel + module SpecialisedField + ## + # DualIndexTagSet + class DualIndexTagSet + include Base + include ValueRequired + + validate :dual_index_tag_set + + def tag_set_id + @tag_set_id ||= ::TagSet.dual_index.visible.find_by(name: value)&.id + end + + private + + # Check the Dual Index Tag Set with a visible tag_group and tag2_group exists here + # Check the TagSet/TagWell combination in DualIndexTagWell + def dual_index_tag_set + return if tag_set_id.present? + + errors.add(:base, "could not find a visible dual index Tag Set with name '#{value}'.") + end + end + end +end diff --git a/app/sequencescape_excel/sequencescape_excel/specialised_field/dual_index_tag_well.rb b/app/sequencescape_excel/sequencescape_excel/specialised_field/dual_index_tag_well.rb new file mode 100644 index 0000000000..bfc58042a3 --- /dev/null +++ b/app/sequencescape_excel/sequencescape_excel/specialised_field/dual_index_tag_well.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module SequencescapeExcel + module SpecialisedField + ## + # DualIndexTagWell + class DualIndexTagWell + include Base + include ValueRequired + + # ValueToUpcase converts the `value` to uppercase + # For exqmple the `value` used in the well_index method would be 'A1' instead of 'a1' + # This is important because the description_to_vertical_plate_position method + # returns a different value for 'A1' vs 'a1', where the upcase version is correct + include ValueToUpcase + + attr_accessor :sf_dual_index_tag_set + + validates :well_index, presence: { message: 'is not valid' } + validates :tag, presence: { message: 'does not have associated i7 tag' }, if: :well_index + validates :tag2, presence: { message: 'does not have associated i5 tag' }, if: :well_index + + PLATE_SIZE = 96 + + def update(_attributes = {}) + return unless valid? + + raise StandardError, 'Tag aliquot mismatch' unless asset.aliquots.one? + + # For dual index tags, tag is a i7 oligo and tag2 is a i5 oligo + asset.aliquots.first.update(tag: tag, tag2: tag2) + end + + def link(other_fields) + self.sf_dual_index_tag_set = other_fields[SequencescapeExcel::SpecialisedField::DualIndexTagSet] + end + + # From the validation in DualIndexTagSet, we know this tag set is a valid dual index tag set + # with a visible tag group and visible tag2 group + def dual_index_tag_set + @dual_index_tag_set = TagSet.find(sf_dual_index_tag_set.tag_set_id) if sf_dual_index_tag_set&.tag_set_id + end + + def tag_group_id + @tag_group_id ||= ::TagGroup.find_by(id: dual_index_tag_set.tag_group_id, visible: true).id + end + + def tag2_group_id + @tag2_group_id ||= ::TagGroup.find_by(id: dual_index_tag_set.tag2_group_id, visible: true).id + end + + private + + # This assumes that the tags within a tag group for dual index tags are listed in 'column' order, + # i.e. the first tag is the one in the first column, the second tag is the one in the second column, etc. + # therefore description_to_vertical_plate_position is used to get the correct map_id + # A1 --> 1 + # B1 --> 2 + # ... + # H12 --> 96 + def well_index + @well_index = Map::Coordinate.description_to_vertical_plate_position(value, PLATE_SIZE) + end + + # i7 tag + def tag + Tag.find_by(tag_group_id: tag_group_id, map_id: well_index) + end + + # i5 tag + def tag2 + Tag.find_by(tag_group_id: tag2_group_id, map_id: well_index) + end + end + end +end diff --git a/app/sequencescape_excel/sequencescape_excel/specialised_field/value_to_upcase.rb b/app/sequencescape_excel/sequencescape_excel/specialised_field/value_to_upcase.rb new file mode 100644 index 0000000000..9ebd92423b --- /dev/null +++ b/app/sequencescape_excel/sequencescape_excel/specialised_field/value_to_upcase.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SequencescapeExcel + module SpecialisedField + ## + # ValueToUpcase + module ValueToUpcase + def value=(value) + @value = value.upcase if value.present? + end + end + end +end diff --git a/config/sample_manifest_excel/columns.yml b/config/sample_manifest_excel/columns.yml index 6bd16407f1..c045eb2e81 100644 --- a/config/sample_manifest_excel/columns.yml +++ b/config/sample_manifest_excel/columns.yml @@ -153,7 +153,7 @@ chromium_tag_well: showErrorMessage: true errorStyle: :stop errorTitle: "Tag well" - error: "Tag Index must a well." + error: "Tag Index must be a well." conditional_formattings: empty_cell: is_number: @@ -202,6 +202,43 @@ library_type_long_read: conditional_formattings: empty_cell: is_error: +dual_index_tag_set: + heading: TAG PLATE NAME + unlocked: true + validation: + options: + type: :list + formula1: "$A$1:$A$2" + allowBlank: false + showInputMessage: true + promptTitle: "Dual Index Tag Plate Name" + prompt: "Input the name of a valid dual index tag plate." + range_name: :dual_index_tag_sets + conditional_formattings: + empty_cell: +dual_index_tag_well: + heading: DUAL INDEX TAG WELL + unlocked: true + validation: + options: + type: :textLength + operator: :lessThanOrEqual + formula1: "3" + allowBlank: false + showInputMessage: true + promptTitle: "Dual Index Tag well" + prompt: "The name of the well, eg. A1 which supplied the dual index tag" + showErrorMessage: true + errorStyle: :stop + errorTitle: "Dual index tag well" + error: "Dual Index Tag must be a well." + conditional_formattings: + empty_cell: + is_number: + len: + formula: + operator: ">" + operand: 3 reference_genome: heading: REFERENCE GENOME unlocked: true @@ -461,7 +498,7 @@ dna_source: empty_cell: is_error: date_of_sample_collection: - heading: DATE OF SAMPLE COLLECTION (YYY-MM-DD) + heading: DATE OF SAMPLE COLLECTION (YYYY-MM-DD) unlocked: true validation: options: diff --git a/config/sample_manifest_excel/manifest_types.yml b/config/sample_manifest_excel/manifest_types.yml index 4c29a4c456..919f7fd337 100644 --- a/config/sample_manifest_excel/manifest_types.yml +++ b/config/sample_manifest_excel/manifest_types.yml @@ -127,7 +127,7 @@ plate_rnachip: - :sample_ebi_accession_number - :donor_id plate_library: - heading: "Library Plate" + heading: "Library Plate - custom tags" asset_type: "library_plate" columns: - :sanger_plate_id @@ -184,6 +184,63 @@ plate_library: - :sample_ebi_accession_number - :donor_id - :primer_panel +plate_dual_index_tag_library: + heading: "Library Plate - dual index tag plate" + asset_type: "library_plate" + columns: + - :sanger_plate_id + - :well + - :sanger_sample_id + - :supplier_name + - :dual_index_tag_set + - :dual_index_tag_well + - :reference_genome + - :library_type + - :insert_size_from + - :insert_size_to + - :cohort + - :volume + - :concentration + - :gender + - :country_of_origin + - :geographical_region + - :ethnicity + - :dna_source + - :date_of_sample_collection + - :date_of_sample_extraction + - :sample_extraction_method + - :sample_purified + - :purification_method + - :concentration_determined_by + - :sample_storage_conditions + - :mother + - :father + - :sibling + - :gc_content + - :sample_public_name + - :sample_taxon_id + - :sample_common_name + - :sample_description + - :sample_strain_att + - :sample_type + - :genotype + - :phenotype + - :age + - :developmental_stage + - :cell_type + - :disease_state + - :compound + - :dose + - :immunoprecipitate + - :growth_condition + - :rnai + - :organism_part + - :time_point + - :treatment + - :subject + - :disease + - :sample_ebi_accession_number + - :donor_id plate_chromium_library: heading: "Chromium Library Plate" asset_type: "library_plate" diff --git a/config/sample_manifest_excel/ranges.yml b/config/sample_manifest_excel/ranges.yml index 196f6a2175..cf00acbd07 100644 --- a/config/sample_manifest_excel/ranges.yml +++ b/config/sample_manifest_excel/ranges.yml @@ -61,6 +61,10 @@ chromium_tag_groups: identifier: :name scope: :chromium scope_on: TagGroup +dual_index_tag_sets: + identifier: :name + scope: :visible_dual_index + scope_on: TagSet gc_content: options: - "Neutral" diff --git a/spec/data/sample_manifest_excel/columns.yml b/spec/data/sample_manifest_excel/columns.yml index 8199b9d64c..3b916a8016 100644 --- a/spec/data/sample_manifest_excel/columns.yml +++ b/spec/data/sample_manifest_excel/columns.yml @@ -168,6 +168,43 @@ library_type: conditional_formattings: empty_cell: is_error: +dual_index_tag_set: + heading: TAG PLATE NAME + unlocked: true + validation: + options: + type: :list + formula1: "$A$1:$A$2" + allowBlank: false + showInputMessage: true + promptTitle: "Dual Index Tag Plate Name" + prompt: "Input the name of a valid dual index tag plate." + range_name: :dual_index_tag_sets + conditional_formattings: + empty_cell: +dual_index_tag_well: + heading: DUAL INDEX TAG WELL + unlocked: true + validation: + options: + type: :textLength + operator: :lessThanOrEqual + formula1: "3" + allowBlank: false + showInputMessage: true + promptTitle: "Dual Index Tag well" + prompt: "The name of the well, eg. A1, which is supplied in the dual index tag plate" + showErrorMessage: true + errorStyle: :stop + errorTitle: "Dual index tag well" + error: "Dual Index Tag must be a well." + conditional_formattings: + empty_cell: + is_number: + len: + formula: + operator: ">" + operand: 3 reference_genome: heading: REFERENCE GENOME unlocked: true diff --git a/spec/data/sample_manifest_excel/manifest_types.yml b/spec/data/sample_manifest_excel/manifest_types.yml index d6c5c5aeae..154aeb701f 100644 --- a/spec/data/sample_manifest_excel/manifest_types.yml +++ b/spec/data/sample_manifest_excel/manifest_types.yml @@ -174,6 +174,63 @@ plate_chromium_library: - :disease - :sample_ebi_accession_number - :donor_id +plate_dual_index_tag_library: + heading: "Library Plate - dual index tag plate" + asset_type: "library_plate" + columns: + - :sanger_plate_id + - :well + - :sanger_sample_id + - :supplier_name + - :dual_index_tag_set + - :dual_index_tag_well + - :reference_genome + - :library_type + - :insert_size_from + - :insert_size_to + - :cohort + - :volume + - :concentration + - :gender + - :country_of_origin + - :geographical_region + - :ethnicity + - :dna_source + - :date_of_sample_collection + - :date_of_sample_extraction + - :sample_extraction_method + - :sample_purified + - :purification_method + - :concentration_determined_by + - :sample_storage_conditions + - :mother + - :father + - :sibling + - :gc_content + - :sample_public_name + - :sample_taxon_id + - :sample_common_name + - :sample_description + - :sample_strain_att + - :sample_type + - :genotype + - :phenotype + - :age + - :developmental_stage + - :cell_type + - :disease_state + - :compound + - :dose + - :immunoprecipitate + - :growth_condition + - :rnai + - :organism_part + - :time_point + - :treatment + - :subject + - :disease + - :sample_ebi_accession_number + - :donor_id plate_bioscan: heading: "Bioscan Plate" asset_type: "plate" diff --git a/spec/data/sample_manifest_excel/ranges.yml b/spec/data/sample_manifest_excel/ranges.yml index a718de4877..bb9488d666 100644 --- a/spec/data/sample_manifest_excel/ranges.yml +++ b/spec/data/sample_manifest_excel/ranges.yml @@ -55,6 +55,10 @@ chromium_tag_groups: identifier: :name scope: :chromium scope_on: TagGroup +dual_index_tag_sets: + identifier: :name + scope: :visible_dual_index + scope_on: TagSet gc_content: options: - "Neutral" diff --git a/spec/features/sample_manifests/create_manifest_spec.rb b/spec/features/sample_manifests/create_manifest_spec.rb index 503cebbe1d..26d47963e8 100644 --- a/spec/features/sample_manifests/create_manifest_spec.rb +++ b/spec/features/sample_manifests/create_manifest_spec.rb @@ -66,7 +66,7 @@ def load_manifest_spec it 'indicate the purpose field is used for plates only' do visit(new_sample_manifest_path) - within('#sample_manifest_template') { expect(page).to have_css('option', count: 24) } + within('#sample_manifest_template') { expect(page).to have_css('option', count: 25) } select(created_purpose.name, from: 'Purpose') expect(page).to have_text('Used for plate manifests only') end diff --git a/spec/models/tag_set_spec.rb b/spec/models/tag_set_spec.rb index e515b9308f..243f9917a7 100644 --- a/spec/models/tag_set_spec.rb +++ b/spec/models/tag_set_spec.rb @@ -50,6 +50,46 @@ end end + describe 'scopes' do + describe '.dual_index' do + context 'when there are single index tag sets' do + let!(:tag_set1) { create(:tag_set) } + let!(:tag_set2) { create(:tag_set) } + let!(:tag_set3) { create(:tag_set, tag2_group: nil) } + + it 'does not return single index tag sets' do + expect(described_class.dual_index).not_to include(tag_set3) + end + + it 'returns dual index tag sets' do + expect(described_class.dual_index).to include(tag_set1, tag_set2) + end + end + end + + describe '.dual_index.visible' do + context 'when there are single and dual index tag sets, where not all tag groups are visible' do + let!(:tag_group1) { create(:tag_group_with_tags, name: 'TG1') } + let!(:tag_group2) { create(:tag_group_with_tags, name: 'TG2', visible: false) } + let!(:tag_group3) { create(:tag_group_with_tags, name: 'TG3') } + let!(:tag_group4) { create(:tag_group_with_tags, name: 'TG4') } + let!(:tag_group5) { create(:tag_group_with_tags, name: 'TG5') } + + let!(:tag_set1) { create(:tag_set, tag_group: tag_group1, tag2_group: tag_group2) } + let!(:tag_set2) { create(:tag_set, tag_group: tag_group3, tag2_group: tag_group4) } + let!(:tag_set3) { create(:tag_set, tag_group: tag_group5, tag2_group: nil) } + + it 'does not return single or dual index tag sets with non visible tag groups' do + expect(described_class.dual_index.visible).not_to include(tag_set1, tag_set3) + end + + it 'returns dual index tag sets with visible tag groups only' do + expect(described_class.dual_index.visible).to include(tag_set2) + end + end + end + end + describe '#visible' do it 'returns true if it only has one tag_group and it is set to visible' do tag_group = create(:tag_group, visible: true) diff --git a/spec/sample_manifest_excel/upload/row_spec.rb b/spec/sample_manifest_excel/upload/row_spec.rb index 3ab56dcdb6..9dcca8dfda 100644 --- a/spec/sample_manifest_excel/upload/row_spec.rb +++ b/spec/sample_manifest_excel/upload/row_spec.rb @@ -288,6 +288,21 @@ end end + context 'when there are dual index columns to link' do + let(:columns) { configuration.columns.plate_dual_index_tag_library.dup } + + it 'links up specialised fields' do + data[4] = 'Tag Set 1' + data[5] = 'B1' + row = described_class.new(number: 1, data: data, columns: columns) + dual_index_tag_set = + row.specialised_fields.detect { |f| f.is_a?(SequencescapeExcel::SpecialisedField::DualIndexTagSet) } + dual_index_tag_well = + row.specialised_fields.detect { |f| f.is_a?(SequencescapeExcel::SpecialisedField::DualIndexTagWell) } + expect(dual_index_tag_well.sf_dual_index_tag_set).to eq dual_index_tag_set + end + end + context 'when there are bioscan columns to link' do let(:columns) { configuration.columns.plate_bioscan.dup } let(:sample_manifest) { create(:plate_sample_manifest_with_manifest_assets) } diff --git a/spec/sequencescape_excel/specialised_field_spec.rb b/spec/sequencescape_excel/specialised_field_spec.rb index f1bb78ca3c..6079b25665 100644 --- a/spec/sequencescape_excel/specialised_field_spec.rb +++ b/spec/sequencescape_excel/specialised_field_spec.rb @@ -641,6 +641,137 @@ def self.name end end + describe SequencescapeExcel::SpecialisedField::DualIndexTagSet do + let(:tag_group1) { create :tag_group_with_tags } + let(:tag_group2) { create :tag_group_with_tags } + let(:dual_index_tag_set) { create :tag_set, tag_group: tag_group1, tag2_group: tag_group2 } + let(:dual_index_tag_well) { 'A1' } + + describe 'dual index tag set' do + let(:sf_dual_index_tag_set) do + described_class.new(value: dual_index_tag_set.name, sample_manifest_asset: sample_manifest_asset) + end + + it 'will add the value' do + expect(sf_dual_index_tag_set.value).to eq(dual_index_tag_set.name) + end + + it 'will be valid with an existing dual index tag set name' do + expect(sf_dual_index_tag_set).to be_valid + end + + context 'when no tag set name is provided' do + let(:sf_dual_index_tag_set) { described_class.new(value: '', sample_manifest_asset: sample_manifest_asset) } + + it 'will be not be valid' do + expect(sf_dual_index_tag_set).not_to be_valid + expect(sf_dual_index_tag_set.errors.full_messages.join).to include("Dual index tag set can't be blank") + end + end + + context 'when the tag set name is unknown' do + let(:sf_dual_index_tag_set) do + described_class.new(value: 'bananas', sample_manifest_asset: sample_manifest_asset) + end + + it 'will be not be valid' do + expect(sf_dual_index_tag_set).not_to be_valid + expect(sf_dual_index_tag_set.errors.full_messages.join).to include( + "could not find a visible dual index Tag Set with name 'bananas'." + ) + end + end + + context 'when the tag set name is has only one visible tag group' do + let(:tag_group2) { create :tag_group_with_tags, visible: false } + + it 'will be not be valid' do + expect(sf_dual_index_tag_set).not_to be_valid + expect(sf_dual_index_tag_set.errors.full_messages.join).to include( + "could not find a visible dual index Tag Set with name '#{dual_index_tag_set.name}'" + ) + end + end + end + + describe SequencescapeExcel::SpecialisedField::DualIndexTagWell do + let(:sf_dual_index_tag_well) do + described_class.new(value: dual_index_tag_well, sample_manifest_asset: sample_manifest_asset) + end + let(:sf_dual_index_tag_set) do + SequencescapeExcel::SpecialisedField::DualIndexTagSet.new( + value: dual_index_tag_set.name, + sample_manifest_asset: sample_manifest_asset + ) + end + + it 'will add the value' do + expect(sf_dual_index_tag_well.value).to eq(dual_index_tag_well) + end + + describe 'linking' do + context 'when linked to a valid dual tag set' do + before { sf_dual_index_tag_well.sf_dual_index_tag_set = sf_dual_index_tag_set } + + context 'when the well location is valid' do + it 'will be valid when linked to a tag set with two visible tag groups' do + expect(sf_dual_index_tag_well).to be_valid + end + + it 'will apply the two tags associated with the map_id' do + sf_dual_index_tag_well.update(aliquot: aliquot, tag_group: nil) + # well location 'A1' => map_id '1' + expect(asset.aliquots.first.tag.map_id).to eq 1 + expect(asset.aliquots.first.tag.tag_group).to eq tag_group1 + expect(asset.aliquots.first.tag2.map_id).to eq 1 + expect(asset.aliquots.first.tag2.tag_group).to eq tag_group2 + + tag_set = + TagSet.find_by( + tag_group_id: asset.aliquots.first.tag.tag_group.id, + tag2_group_id: asset.aliquots.first.tag2.tag_group.id + ) + expect(tag_set).to eq dual_index_tag_set + end + end + + context 'when applied to a re-upload' do + let(:asset) { create(:tagged_well, map: map, aliquot_count: 1) } + let(:dual_index_tag_well) { 'd1' } + + it 'will apply the 2 tags associated with the updated map_id' do + sf_dual_index_tag_well.update(aliquot: aliquot, tag_group: nil) + # well location 'D1' => map_id '4' + expect(asset.reload.aliquots.first.tag.map_id).to eq 4 + expect(asset.reload.aliquots.first.tag2.map_id).to eq 4 + end + end + + context 'when the well location is empty' do + let(:dual_index_tag_well) { ' ' } + + it 'will not be valid without a well location' do + expect(sf_dual_index_tag_well).not_to be_valid + expect(sf_dual_index_tag_well.errors.full_messages.join).to include("Dual index tag well can't be blank") + end + end + + context 'when the well location is invalid' do + let(:dual_index_tag_well) { 'Z99' } + + it 'will not be valid without a valid well location' do + expect(sf_dual_index_tag_well).not_to be_valid + expect(sf_dual_index_tag_well.errors.full_messages.join).to include('Tag does not have associated i7 tag') + expect(sf_dual_index_tag_well.errors.full_messages.join).to include( + 'Tag2 does not have associated i5 tag' + ) + end + end + end + end + end + end + describe SequencescapeExcel::SpecialisedField::PrimerPanel do let(:primer_panel) { create :primer_panel }