Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
aa0b7ce
added Ultima P3 and P3 tag groups and adjusted sample sheet generator
andrewsparkes Mar 20, 2026
6bcbb5a
Merge branch 'develop' into Y26-084-ultima-support-new-tags-for-ultim…
andrewsparkes Mar 20, 2026
8cbf4cf
Merge branch 'develop' into Y26-084-ultima-support-new-tags-for-ultim…
andrewsparkes Mar 23, 2026
8ff92b6
Merge branch 'develop' into Y26-084-ultima-support-new-tags-for-ultim…
andrewsparkes Mar 23, 2026
af8d255
added wafer size to ultima requests and pipeline
andrewsparkes Mar 24, 2026
ede2885
adding ug200 request type
andrewsparkes Mar 25, 2026
5ab4f98
change to schema
andrewsparkes Mar 25, 2026
9b57642
Added inflection for UG200
andrewsparkes Mar 25, 2026
2d737b3
added UG200 task descriptors (draft may change)
andrewsparkes Mar 25, 2026
4a746a7
fixed tests
andrewsparkes Mar 25, 2026
16e939c
fixed tests
andrewsparkes Mar 25, 2026
a988ce7
modified ug200 sequencing request to remove read length and add ot re…
andrewsparkes Mar 27, 2026
72a640d
wip the submission template yml
andrewsparkes Mar 27, 2026
7a1a074
Merge branch 'develop' into Y26-085-submission_template-for-ultima-ug…
andrewsparkes Mar 27, 2026
ac9574d
for ug200 changed readlength for ot recipe
andrewsparkes Mar 27, 2026
573135f
fixed incorrect it text
andrewsparkes Mar 27, 2026
6160381
refactor(ultima_sample_sheet): convert Generator constants to config …
yoldas Apr 2, 2026
65ac4e0
fix(ultima_sample_sheet): sort ultima tag groups by config index order
yoldas Apr 2, 2026
ea30f62
fix(ultima_sample_sheet): correct typo in num_columns_config
yoldas Apr 2, 2026
4daca71
feat(ultima_sample_sheets): add Ultima UG200 sample sheet generator
yoldas Apr 2, 2026
defa05d
refactor(ultima_sample_sheets): update tag group config with explicit…
yoldas Apr 2, 2026
bc5f3d1
test(ultima_sample_sheets): add tests for UG200 sample sheet generator
yoldas Apr 2, 2026
4c58943
feat(batches): direct UG200 sample sheet requests to dedicated generator
yoldas Apr 2, 2026
2734596
test: add ultima_ug200_sequencing factory and request type placeholde…
yoldas Apr 2, 2026
751c52f
Merge branch 'develop' into y26-106-ug200-sample-sheet-generator
yoldas Apr 2, 2026
cb87c76
Merge branch 'develop' into y26-106-ug200-sample-sheet-generator
yoldas Apr 2, 2026
12c6dd6
Revert sample_sheet_generator to develop version
yoldas Apr 2, 2026
7f86463
Revert sample_sheet_generator spec to develop version
yoldas Apr 2, 2026
9f5c088
chore(ultima): rename for consistent file naming and easy unwip
yoldas Apr 2, 2026
334bb0c
feat(ultima_ug200): rename Ultima P4 to UG-RD-1916 (Solaris 2.0 V1 PC…
yoldas Apr 2, 2026
260e04d
chore(ultima_ug200): change the record loader file name number prefix…
yoldas Apr 2, 2026
c12e3a2
doc(ultima_ug200): add title to ug200 record loader files
yoldas Apr 2, 2026
cf2e7b7
Merge branch 'Y26-085-submission_template-for-ultima-ug200-sequencing…
yoldas Apr 7, 2026
e8227b3
Merge branch 'Y26-084-ultima-support-new-tags-for-ultima-ug200-sequen…
yoldas Apr 7, 2026
233cfc8
Merge branch 'y26-106-ug200-sample-sheet-generator' into ultima_ug200…
yoldas Apr 7, 2026
5dbd4a9
test(validators): split UG200 checks into dedicated ultima_ug200 vali…
yoldas Apr 7, 2026
e8b64f2
fix(submissions): apply selection defaults for blank values
yoldas Apr 7, 2026
fcbcd9c
docs(remove todo commit about selection defaults)
yoldas Apr 7, 2026
bd33b3a
test(submissions): apply selection defaults for blank values
yoldas Apr 7, 2026
ac0602b
fix(records): correct OTRecipeInformationTypeForUltimaUG200 association
yoldas Apr 7, 2026
7a2d52d
chore(records): rename ultima_ug200 files to wip
yoldas Apr 7, 2026
9e6b11e
fix(records): make task section names unique insider record loader fo…
yoldas Apr 7, 2026
db7c961
fix(records): make descriptor section names unique within the record …
yoldas Apr 7, 2026
42f363d
feat(config): add UG200 specific request types because of purpose dep…
yoldas Apr 7, 2026
9e1df90
feat(config): update request type keys of the Limber-Htp - Ultima PCR…
yoldas Apr 7, 2026
82926a0
style(config): prettier format spacing and indentation
yoldas Apr 7, 2026
83e3f30
feat(config): add UPF2 Cherrypicked purpose required by Limber Ultima…
yoldas Apr 7, 2026
7c27119
feat(config): add ultima ug200 tube purposes
yoldas Apr 7, 2026
3a8b09a
test(ci): fix flaky tests and add missing assertions
yoldas Apr 7, 2026
54ca292
Merge branch 'develop' into Y26-085-submission_template-for-ultima-ug…
yoldas Apr 7, 2026
9927b61
Merge branch 'Y26-085-submission_template-for-ultima-ug200-sequencing…
yoldas Apr 7, 2026
4ebff56
docs(sample_sheets): clarify checking most specific pipeline class first
yoldas Apr 9, 2026
f8397ad
Update config/default_records/request_types/026_ultima_ug200_request_…
yoldas Apr 9, 2026
8cb2478
refactor(sample_sheets): rename variable tg to tag_group for readabil…
yoldas Apr 9, 2026
e429b98
refactor: remove redundant method and constant from UltimaUG200Validator
yoldas Apr 9, 2026
389d835
Merge branch 'y26-106-ug200-sample-sheet-generator' into ultima_ug200…
yoldas Apr 9, 2026
3518439
Merge branch 'Y26-085-submission_template-for-ultima-ug200-sequencing…
yoldas Apr 9, 2026
8fa13e9
Merge branch 'develop' into ultima_ug200_staging
yoldas Apr 9, 2026
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
44 changes: 34 additions & 10 deletions app/controllers/batches_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -387,24 +387,34 @@ def find_batch_by_barcode
# the batch. Ultima sample sheets are allowed to be downloaded without
# authentication. For all other pipelines, the user must be logged in.
#
# @note When checking the pipeline class, always check the most specific
# subclass first in the inheritance hierarchy, before considering parent
# classes.
#
# @return [Boolean] true if download is allowed, false otherwise
def allow_sample_sheet_download?
@batch.pipeline.is_a?(UltimaSequencingPipeline) || logged_in?
return true if @batch.pipeline.is_a?(UltimaUG200SequencingPipeline)
return true if @batch.pipeline.is_a?(UltimaSequencingPipeline)

logged_in?
end

# Generates and sends the appropriate sample sheet(s) for the batch.
#
# @note When checking the pipeline class, always check the most specific
# subclass first in the inheritance hierarchy, before considering parent
# classes.
#
# @return [void]
def generate_sample_sheet
return redirect_to(login_path) unless allow_sample_sheet_download?

if @batch.pipeline.is_a?(ElementAvitiSequencingPipeline)
generate_element_aviti_sample_sheet
elsif @batch.pipeline.is_a?(UltimaSequencingPipeline)
generate_ultima_sample_sheet
else
flash[:error] = 'Sample sheet generation is not supported for this pipeline.'
redirect_to controller: 'batches', action: 'show', id: @batch.id
end
return generate_element_aviti_sample_sheet if @batch.pipeline.is_a?(ElementAvitiSequencingPipeline)
return generate_ultima_ug200_sample_sheet if @batch.pipeline.is_a?(UltimaUG200SequencingPipeline)
return generate_ultima_sample_sheet if @batch.pipeline.is_a?(UltimaSequencingPipeline)

flash[:error] = 'Sample sheet generation is not supported for this pipeline.'
redirect_to controller: 'batches', action: 'show', id: @batch.id
end

private
Expand All @@ -422,7 +432,21 @@ def generate_element_aviti_sample_sheet
# Generates and sends the Ultima sample sheet ZIP archive for the batch.
# @return [void]
def generate_ultima_sample_sheet
zip_string = UltimaSampleSheet::SampleSheetGenerator.generate(@batch)
send_run_manifest_zip(UltimaSampleSheet::SampleSheetGenerator)
end

# Generates and sends the Ultima UG200 sample sheet ZIP archive for the batch.
# @return [void]
def generate_ultima_ug200_sample_sheet
send_run_manifest_zip(UltimaSampleSheet::UG200SampleSheetGenerator)
end

# Helper method to generate and send the Ultima sample sheet ZIP archive for
# the batch using the provided generator class.
# @param generator [Class] the sample sheet generator class to use
# @return [void]
def send_run_manifest_zip(generator)
zip_string = generator.generate(@batch)
send_data zip_string,
type: 'application/zip',
filename: "batch_#{@batch.id}_run_manifest.zip",
Expand Down
131 changes: 77 additions & 54 deletions app/controllers/ultima_sample_sheet/sample_sheet_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,48 @@ def self.generate(batch)
# for each request in the given Ultima sequencing batch.
class Generator # rubocop:disable Metrics/ClassLength
PLATE_LENGTH = 8 # Assumes 96-well tag plates with 8 rows (A-H).
HEADER_TITLE = ['[Header]'].freeze
GLOBAL_TITLE = ['[Global]'].freeze
GLOBAL_HEADERS = %w[
Application
sequencing_recipe
analysis_recipe
].freeze
SAMPLES_TITLE = ['[Samples]'].freeze
SAMPLES_HEADERS = %w[
Sample_ID
Library_name
Index_Barcode_Num
Index_Barcode_Sequence
Barcode_Plate_Num
Barcode_Plate_Well
application_type
study_id
].freeze
NUM_COLUMS = SAMPLES_HEADERS.size
# The names of the Ultima tag groups are mapped to the index numbers for
# the Barcode_Plate_Num column, i.e. 1 or 2. The number is also used for
# determining the consistent starting index number for the
# Index_Barcode_Num column, i.e. Z0001 or Z097.
ULTIMA_TAG_GROUPS = {
'Ultima P1' => 1,
'Ultima P2' => 2
}.freeze

# Using config methods instead of constants to allow easier overriding.
def header_title_config
['[Header]'].freeze
end

def global_title_config
['[Global]'].freeze
end

def global_headers_config
%w[Application sequencing_recipe analysis_recipe].freeze
end

def samples_title_config
['[Samples]'].freeze
end

def samples_headers_config
%w[
Sample_ID Library_name Index_Barcode_Num Index_Barcode_Sequence
Barcode_Plate_Num Barcode_Plate_Well application_type study_id
].freeze
end

def num_columns_config
samples_headers_config.size
end

# Tag group config used to derive CSV plate numbers and Z-index starts.
# Values are explicit to avoid arithmetic assumptions in index mapping.
def ultima_tag_groups_config
{
'Ultima P1' => { plate_num: 1, z_start: 1 },
'Ultima P2' => { plate_num: 2, z_start: 97 },
'Ultima P3' => { plate_num: 3, z_start: 193 },
'UG-RD-1916 (Solaris 2.0 V1 PCR-Free Adapters for Ultima Genomics P4)' => {
plate_num: 4,
z_start: 289
}
}.freeze
end

# Initializes the generator with the given batch.
# @param batch [UltimaSequencingBatch] the batch to generate sample sheets for
Expand Down Expand Up @@ -96,7 +111,7 @@ def folder_name
# @param csv [CSV] the CSV object to append rows to
# @param request [UltimaSequencingRequest] the request whose header data is to be added
def add_header_section(csv, request)
csv << pad(HEADER_TITLE)
csv << pad(header_title_config)
free_form_text = "Batch #{@batch.id} #{request.asset.human_barcode}"
csv << pad([free_form_text])
end
Expand All @@ -106,8 +121,8 @@ def add_header_section(csv, request)
# @param csv [CSV] the CSV object to append rows to
# @param _request [UltimaSequencingRequest] the request whose global data is to be added
def add_global_section(csv, _request)
csv << pad(GLOBAL_TITLE)
csv << pad(GLOBAL_HEADERS)
csv << pad(global_title_config)
csv << pad(global_headers_config)
# Currently there is only one UltimaGlobal record; get the last one.
# Future enhancements may allow selecting different records based on
# sequencing request or batch properties.
Expand All @@ -121,22 +136,29 @@ def add_global_section(csv, _request)
# @param csv [CSV] the CSV object to append rows to
# @param request [UltimaSequencingRequest] the request whose samples are to be added
def add_samples_section(csv, request)
csv << pad(SAMPLES_TITLE)
csv << pad(SAMPLES_HEADERS)
csv << pad(samples_title_config)
csv << pad(samples_headers_config)
request.asset.aliquots.sort_by(&:id).each do |aliquot|
csv << [
sample_id_for(aliquot),
library_name_for(aliquot),
index_barcode_num_for(aliquot),
index_barcode_sequence_for(aliquot),
barcode_plate_num_for(aliquot),
barcode_plate_well_for(aliquot),
'native', # application_type
study_id_for(aliquot)
]
csv << sample_row_for(aliquot)
end
end

# Builds a sample section row for a single aliquot.
# @param aliquot [Aliquot] the aliquot whose sample row is needed
# @return [Array<String>] the sample row values
def sample_row_for(aliquot)
[
sample_id_for(aliquot),
library_name_for(aliquot),
index_barcode_num_for(aliquot),
index_barcode_sequence_for(aliquot),
barcode_plate_num_for(aliquot),
barcode_plate_well_for(aliquot),
'native', # application_type
study_id_for(aliquot)
]
end

# Returns a unique sample_ID for the given aliquot. This prefixes numbers
# with 's' (lowercase s character) for Sample_ID column, as in s1 … sN,
# where N is num of samples on wafer. It starts them with s1 for each wafer,
Expand Down Expand Up @@ -196,29 +218,30 @@ def study_id_for(aliquot)
end

# Returns a mapping of all Ultima tags to their respective 1-based index
# numbers. This sorts the tags by their tag group ID and map ID to ensure
# consistent ordering. The index numbers run across all Ultima tag groups,
# i.e. the index is 1 for the first tag in the first tag group and 97 for
# the first tag in the second tag group.
# numbers. Each group's starting index is taken directly from
# ultima_tag_groups_config[:z_start], so numbering is independent of which
# other tag groups exist in the database.
# @return [Hash{Tag => Integer}] mapping of tags to index numbers
def tag_index_map
@tag_index_map ||= begin
tags = ultima_tag_groups.flat_map { |tg| tg.tags.sort_by(&:map_id) }
tags.each_with_index.to_h { |tag, i| [tag, i + 1] }
@tag_index_map ||= ultima_tag_groups.each_with_object({}) do |tag_group, map|
start_index = ultima_tag_groups_config[tag_group.name][:z_start]
tag_group.tags.sort_by(&:map_id).each_with_index { |tag, i| map[tag] = start_index + i }
end
end

# Returns a mapping of all Ultima tag groups to 1-based index numbers.
# This indexes the tag groups as given in the ULTIMA_TAG_GROUPS hash.
# This indexes the tag groups as given in the ultima_tag_groups_config hash.
# @return [Hash{TagGroup => Integer}] mapping of tag groups to index numbers
def tag_group_index_map
@tag_group_index_map ||= ultima_tag_groups.index_with { |tg| ULTIMA_TAG_GROUPS[tg.name] }
@tag_group_index_map ||= ultima_tag_groups.index_with { |tg| ultima_tag_groups_config[tg.name][:plate_num] }
end

# Returns all unique tag groups used for Ultima sequencing from database.
# Returns all unique tag groups used for Ultima sequencing from database,
# sorted by their index number as defined in ultima_tag_groups_config.
# @return [Array<TagGroup>] the tag groups used for Ultima sequencing
def ultima_tag_groups
@ultima_tag_groups ||= TagGroup.where(name: ULTIMA_TAG_GROUPS.keys)
@ultima_tag_groups ||= TagGroup.where(name: ultima_tag_groups_config.keys)
.sort_by { |tg| ultima_tag_groups_config[tg.name][:plate_num] }
end

# Returns the requests associated with the batch.
Expand Down Expand Up @@ -250,7 +273,7 @@ def sample_id_index_map
# @param row [Array<String>] the row to pad (defaults to an empty array)
# @return [Array<String>] the padded row
def pad(row = [])
row + Array.new(NUM_COLUMS - row.size, '')
row + Array.new(num_columns_config - row.size, '')
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module UltimaSampleSheet::UG200SampleSheetGenerator
# Initiates the sample sheet generation for the given batch.
# @param batch [Batch] the Ultima UG200 sequencing batch to generate sample sheets for
# @return [String] the ZIP archive as a binary string
def self.generate(batch)
Generator.new(batch).generate
end

# Ultima UG200 sample sheet generator class.
# Uses the shared Ultima base implementation with UG200-specific globals and tags.
class Generator < UltimaSampleSheet::SampleSheetGenerator::Generator
def global_headers_config
['Application'].freeze
end

# Added ultima_tag_groups_config items to the parent class to make them
# available to all Ultima sample sheet generators.

private

# Adds the global section to the CSV for UG200.
# The request parameter is currently unused.
# @param csv [CSV] the CSV object to append rows to
# @param _request [UltimaSequencingRequest] the request whose global data is to be added
def add_global_section(csv, _request)
csv << pad(global_title_config)
csv << pad(global_headers_config)
data = ['WGS Native'] # Application
csv << pad(data)
end
end
end
3 changes: 2 additions & 1 deletion app/helpers/submissions_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ def submission_link(submission, options)
def field_selection_tag(request_options, field_info, name_format, enforce_required)
select_tag(
name_format % field_info.key,
options_for_select(field_info.selection.map(&:to_s), request_options[field_info.key]),
options_for_select(field_info.selection.map(&:to_s),
request_options[field_info.key].presence || field_info.default_value),
class: 'custom-select',
required: enforce_required && field_info.required,
read_only: field_info.selection.size == 1
Expand Down
6 changes: 4 additions & 2 deletions app/models/bulk_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ def process # rubocop:todo Metrics/CyclomaticComplexity
'scrna core cells per chip well',
'% phix requested',
'low diversity',
'ot recipe'
'ot recipe',
'wafer size'
].freeze

ALIAS_FIELDS = { 'plate barcode' => 'barcode', 'tube barcode' => 'barcode' }.freeze
Expand Down Expand Up @@ -360,7 +361,8 @@ def extract_request_options(details)
['scrna core cells per chip well', 'cells_per_chip_well'],
['% phix requested', 'percent_phix_requested'],
['low diversity', 'low_diversity'],
['ot recipe', 'ot_recipe']
['ot recipe', 'ot_recipe'],
['wafer size', 'wafer_size']
].each do |source_key, target_key|
assign_value_if_source_present(details, source_key, request_options, target_key)
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/ultima_sequencing_pipeline.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

# Specialized sequencing pipeline for Ultima
# Specialized sequencing pipeline for Ultima UG100
class UltimaSequencingPipeline < SequencingPipeline
def ot_recipe_consistent_for_batch?(batch)
ot_recipe_list = batch.requests.filter_map { |request| request.request_metadata.ot_recipe }
Expand Down
13 changes: 13 additions & 0 deletions app/models/ultima_ug200_sequencing_pipeline.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

# Specialized sequencing pipeline for Ultima UG200
class UltimaUG200SequencingPipeline < UltimaSequencingPipeline
def wafer_size_consistent_for_batch?(batch)
wafer_size_list = batch.requests.filter_map { |request| request.request_metadata.wafer_size }

# There are some requests that don't have the wafer_size attribute
return false if wafer_size_list.size != batch.requests.size

(wafer_size_list.uniq.size == 1)
end
end
43 changes: 43 additions & 0 deletions app/models/ultima_ug200_sequencing_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

# Request class specific to the Ultima UG200 sequencing platform.
# Includes wafer size and OT recipe.
class UltimaUG200SequencingRequest < SequencingRequest
include Api::Messages::UseqWaferIo::LaneExtensions

FREE = 'Free'
FLEX = 'Flex'
OT_RECIPE_OPTIONS = [FREE, FLEX].freeze

has_metadata as: Request do
# Defining the sequencing request metadata here again, as 'has_metadata'
# does not automatically append these custom attributes to the request.
#
# The has_metadata call dynamically defines an inner Metadata class and
# takes the attributes from the block and adds them to the Metadata class.
# There is an assumption that the inner Metadata class is available in a
# sequencing request class defintion. Calling has_metadata again does not
# inherit the attributes given in the block supplied in the superclass.
# They need to be supplied again for this class for a proper inner Metadata
# class definition. In a future refactoring these attributes can be moved a
# class attribute and subclasses can merge its own attibutes to that. A
# common method can set up the inner Metadata class in the subclasses.
custom_attribute(:fragment_size_required_from, integer: true, minimum: 1)
custom_attribute(:fragment_size_required_to, integer: true, minimum: 1)

custom_attribute(:ot_recipe, default: FREE, in: OT_RECIPE_OPTIONS, required: true)
enum :ot_recipe, { Free: 0, Flex: 1 }
custom_attribute(:wafer_size, default: '10TB', validator: true, required: true, selection: true)
end

# Delegate to request_metadata so the attributes are visible to the validator in the RSpec tests.
# This delegation has no real effect outside of the tests.
delegate :wafer_size, :ot_recipe, to: :request_metadata

# Generates unique wafer ID, concatenation of batch_for_opentrons,
# id_pool_lims, and request_order.
# @return [String] unique wafer ID for LIMS
def id_wafer_lims
"#{batch.id}_#{source_labware.human_barcode}_#{position}"
end
end
Loading
Loading