diff --git a/app/frontend/entrypoints/cherrypick_strategies.js b/app/frontend/entrypoints/cherrypick_strategies.js index c0b227d38a..45ed8cddcf 100644 --- a/app/frontend/entrypoints/cherrypick_strategies.js +++ b/app/frontend/entrypoints/cherrypick_strategies.js @@ -1,3 +1,15 @@ +// Toggle buffer_volume_for_empty_wells input based on automatic_buffer_addition checkbox +document.addEventListener("DOMContentLoaded", function () { + const bufferInput = document.getElementById("buffer_volume_for_empty_wells"); + const autoBufferCheckbox = document.getElementById("automatic_buffer_addition"); + if (bufferInput && autoBufferCheckbox) { + function toggleBufferInput() { + bufferInput.disabled = !autoBufferCheckbox.checked; + } + autoBufferCheckbox.addEventListener("change", toggleBufferInput); + toggleBufferInput(); // Set initial state + } +}); // apply a border highlight to the card, based on the cherrypick strategy selected // this is admittedly a gratuitous addition to the user experience, but it's a nice touch diff --git a/app/models/batch.rb b/app/models/batch.rb index d069a56278..aa156c7abd 100644 --- a/app/models/batch.rb +++ b/app/models/batch.rb @@ -16,6 +16,9 @@ class Batch < ApplicationRecord # rubocop:todo Metrics/ClassLength include ::Batch::PipelineBehaviour include ::Batch::StateMachineBehaviour include UnderRepWellCommentsToBroadcast + # Added for storing buffer_volume_for_empty_wells option on Cherrypick batches. + include HasPolyMetadata + include ::Batch::PolyMetadataBehaviour extend EventfulRecord # The three states of {Batch} Also @see {SequencingQcBatch} diff --git a/app/models/batch/poly_metadata_behaviour.rb b/app/models/batch/poly_metadata_behaviour.rb new file mode 100644 index 0000000000..b23dfe76ce --- /dev/null +++ b/app/models/batch/poly_metadata_behaviour.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module Batch::PolyMetadataBehaviour + # Returns whether the Cherrypick automatic_buffer_addition option is enabled + # @return [Boolean] whether the automatic_buffer_addition option is enabled + def automatic_buffer_addition? + %w[1 on].include?(get_poly_metadata(:automatic_buffer_addition)) + end + + # Returns the Cherrypick buffer_volume_for_empty_wells option value if + # automatic_buffer_addition is enabled, nil otherwise. + # @return [Float, nil] the buffer_volume_for_empty_wells value + def buffer_volume_for_empty_wells + get_poly_metadata(:buffer_volume_for_empty_wells).to_f if automatic_buffer_addition? + end +end diff --git a/app/models/cherrypick/task/buffer_volume_for_empty_wells_option.rb b/app/models/cherrypick/task/buffer_volume_for_empty_wells_option.rb new file mode 100644 index 0000000000..4fdeb4d321 --- /dev/null +++ b/app/models/cherrypick/task/buffer_volume_for_empty_wells_option.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# frozen_sring_literal: true + +module Cherrypick::Task::BufferVolumeForEmptyWellsOption + def create_buffer_volume_for_empty_wells_option(params) + return unless @batch + + key = :automatic_buffer_addition + # The checkbox value is either "1", or nil if not checked. + @batch.set_poly_metadata(key, params[key]) + + return unless %w[1 on].include?(params[key]) + + # If automatic buffer addition for empty wells is required. + key = :buffer_volume_for_empty_wells + unless valid_float_param?(params[key]) + raise Cherrypick::VolumeError, + "Invalid buffer volume for empty wells: #{params[key]}" + end + + @batch.set_poly_metadata(key, params[key]) + end +end diff --git a/app/models/cherrypick/task/pick_helpers.rb b/app/models/cherrypick/task/pick_helpers.rb index a1ff1891f4..45e349ab19 100644 --- a/app/models/cherrypick/task/pick_helpers.rb +++ b/app/models/cherrypick/task/pick_helpers.rb @@ -5,6 +5,7 @@ def self.included(base) include Cherrypick::Task::PickByNanoGramsPerMicroLitre include Cherrypick::Task::PickByNanoGrams include Cherrypick::Task::PickByMicroLitre + include Cherrypick::Task::BufferVolumeForEmptyWellsOption end end diff --git a/app/models/concerns/has_poly_metadata.rb b/app/models/concerns/has_poly_metadata.rb new file mode 100644 index 0000000000..df51d6456a --- /dev/null +++ b/app/models/concerns/has_poly_metadata.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module HasPolyMetadata + extend ActiveSupport::Concern + + included do + has_many :poly_metadata, as: :metadatable, dependent: :destroy, inverse_of: :metadatable + end + + # Sets a PolyMetaDatum for the given key and value. + # If value is present, it will create or update the PolyMetaDatum with the + # given key and value, otherwise it will destroy the PolyMetaDatum with the + # given key if that exists. + # @param key [String] The key of the PolyMetaDatum to set. + # @param value [String] The value of the PolyMetaDatum to set. If nil or empty, the PolyMetaDatum will be destroyed. + # @return [void] + def set_poly_metadata(key, value) + record = poly_metadata.find_by(key:) + if value.present? + if record + record.update!(value:) + else + poly_metadata.create!(key:, value:) + end + else + record&.destroy! + end + end + + # Returns the value of the PolyMetaDatum with the given key. + # @param key [String] The key of the PolyMetaDatum to retrieve. + # @return [String, nil] The value of the PolyMetaDatum, or nil if it does not exist. + def get_poly_metadata(key) + poly_metadata.find_by(key:)&.value + end +end diff --git a/app/models/robot/generator/behaviours/tecan_default.rb b/app/models/robot/generator/behaviours/tecan_default.rb index f7c46f64e6..e716b5b00a 100644 --- a/app/models/robot/generator/behaviours/tecan_default.rb +++ b/app/models/robot/generator/behaviours/tecan_default.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Module with the file generation functionality for Tecan robots -module Robot::Generator::Behaviours::TecanDefault +module Robot::Generator::Behaviours::TecanDefault # rubocop:disable Metrics/ModuleLength def mapping(data_object: picking_data) raise ArgumentError, 'Data object not present for Tecan mapping' if data_object.nil? @@ -70,10 +70,13 @@ def buffer_separator 'C;' end - def buffers(data_object) + def buffers(data_object) # rubocop:disable Metrics/AbcSize + data_object = data_object_for_buffers(data_object) buffer = [] each_mapping(data_object) do |mapping, dest_plate_barcode, plate_details| - next unless total_volume > mapping['volume'] + # src_well is checked to distinguish between buffer for sample wells + # and buffer for empty wells. + next if mapping.key?('src_well') && total_volume <= mapping['volume'] dest_name = data_object['destination'][dest_plate_barcode]['name'] volume = mapping['buffer_volume'] @@ -113,4 +116,88 @@ def sorted_destination_plates def description_to_column_index(well_name, plate_size) Map::Coordinate.description_to_vertical_plate_position(well_name, plate_size) end + + def column_index_to_description(index, plate_size) + Map::Coordinate.vertical_plate_position_to_description(index, plate_size) + end + + # Returns a new data object with buffer entries added for empty destination + # wells, if the option is enabled; otherwise returns the original data object. + # Only the fields used by the buffer steps are added to the new data object. + # @param data_object [Hash] the original data object + # @return [Hash] the new data object with buffer entries for empty wells, + # or the original data object if the option is not enabled + # @example input data_object + # {"destination" => + # {"SQPD-9101" => + # {"name" => "ABgene 0765", + # "plate_size" => 96, + # "control" => false, + # "mapping" => + # [{"src_well" => ["SQPD-9089", "A1"], "dst_well" => "A1", "volume" => 100.0, "buffer_volume" => 0.0}, + # {"src_well" => ["SQPD-9089", "A2"], "dst_well" => "B1", "volume" => 100.0, "buffer_volume" => 0.0}]}, + # "source" => + # {"SQPD-9089" => {"name" => "ABgene 0800", "plate_size" => 96, "control" => false}, + # "SQPD-9090" => {"name" => "ABgene 0800", "plate_size" => 96, "control" => false}}, + # "time" => Thu, 19 Feb 2026 15:20:20.785717000 GMT +00:00, + # "user" => "admin"} + # + # @example output data_object + # {"destination" => + # {"SQPD-9101" => + # {"name" => "ABgene 0765", + # "plate_size" => 96, + # "control" => false, + # "mapping" => + # [{"src_well" => ["SQPD-9089", "A1"], "dst_well" => "A1", "volume" => 100.0, "buffer_volume" => 0.0}, + # {"src_well" => ["SQPD-9089", "A2"], "dst_well" => "B1", "volume" => 100.0, "buffer_volume" => 0.0}, + # {"dst_well" => "C1", "buffer_volume" => 120.0}]}, + # {"dst_well" => "D1", "buffer_volume" => 120.0}]}, + # ... + # ]}, + # } + # } + def data_object_for_buffers(data_object) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/CyclomaticComplexity + buffer_volume_for_empty_wells = @batch&.buffer_volume_for_empty_wells + return data_object unless buffer_volume_for_empty_wells + + obj = { 'destination' => {} } + data_object['destination'].each do |dest_plate_barcode, plate_details| + plate = Plate.find_by_barcode(dest_plate_barcode) + plate_size = plate_details['plate_size'] + # Initialise the destination section + obj['destination'][dest_plate_barcode] = { + 'name' => plate_details['name'], + 'plate_size' => plate_size + } + # Create a hash of column index to the existing mapping entries + index_to_mapping = plate_details['mapping'].index_by do |entry| + description_to_column_index(entry['dst_well'], plate_size) + end + + # Loop through the column order and generate new mapping entries + # Add existing mappings if present and skip non-empty wells in case it is partial plate. + mapping = [] + (1..plate_size).each do |index| + # Add existing mapping if present for this column index. + if index_to_mapping.key?(index) + mapping << index_to_mapping[index] + next + end + + # Check if the destination well empty, in case of partial plate. + dst_well = column_index_to_description(index, plate_size) # A1, B1, etc. + well = plate.find_well_by_name(dst_well) # Well object or nil + next if well.present? && !well.empty? # Skip non-empty wells + + # Add buffer for empty well + mapping << { + 'dst_well' => dst_well, + 'buffer_volume' => buffer_volume_for_empty_wells + } + end + obj['destination'][dest_plate_barcode]['mapping'] = mapping + end + obj + end end diff --git a/app/models/robot/generator/tecan_v3.rb b/app/models/robot/generator/tecan_v3.rb index e6a1bdadde..625ff034b0 100644 --- a/app/models/robot/generator/tecan_v3.rb +++ b/app/models/robot/generator/tecan_v3.rb @@ -14,9 +14,12 @@ class Robot::Generator::TecanV3 < Robot::Generator::TecanV2 # @see Robot::Generator::Behaviours::TecanDefault#buffers # rubocop:disable Metrics/AbcSize,Metrics/MethodLength def buffers(data_object) + data_object = data_object_for_buffers(data_object) groups = Hash.new { |h, k| h[k] = [] } # channel => [steps] each_mapping(data_object) do |mapping, dest_plate_barcode, plate_details| - next unless total_volume > mapping['volume'] + # src_well is checked to distinguish between buffer for sample wells + # and buffer for empty wells. + next if mapping.key?('src_well') && total_volume <= mapping['volume'] dest_name = data_object['destination'][dest_plate_barcode]['name'] volume = mapping['buffer_volume'] diff --git a/app/models/tasks/cherrypick_handler.rb b/app/models/tasks/cherrypick_handler.rb index 9461e8d4b1..926e3952cc 100644 --- a/app/models/tasks/cherrypick_handler.rb +++ b/app/models/tasks/cherrypick_handler.rb @@ -92,6 +92,9 @@ def setup_input_params_for_pass_through # rubocop:todo Metrics/AbcSize else raise StandardError, "Invalid cherrypicking strategy '#{params[:cherrypick][:strategy]}'" end + # Add buffer volume for empty wells option to params for pass through + @automatic_buffer_addition = params[:automatic_buffer_addition] + @buffer_volume_for_empty_wells = params[:buffer_volume_for_empty_wells] @plate_purpose_id = params[:plate_purpose_id] @fluidigm_barcode = params[:fluidigm_plate] end @@ -129,6 +132,9 @@ def do_cherrypick_task(_task, params) # rubocop:todo Metrics/CyclomaticComplexit raise StandardError, "Invalid cherrypicking type #{params[:cherrypick_strategy]}" end + # Store the buffer volume for empty wells option in the batch's poly_metadata + create_buffer_volume_for_empty_wells_option(params) + # We can preload the well locations so that we can do efficient lookup later. well_locations = Map diff --git a/app/views/batches/_cherrypick_single_worksheet.html.erb b/app/views/batches/_cherrypick_single_worksheet.html.erb index 6bb66c2c36..09a5e40a01 100644 --- a/app/views/batches/_cherrypick_single_worksheet.html.erb +++ b/app/views/batches/_cherrypick_single_worksheet.html.erb @@ -14,6 +14,8 @@ <%# see Robot::Verification::Base#pick_number_to_expected_layout for structure of robot_plate_layout %> <% destination_layout, source_layout, control_layout = robot_plate_layout %> <% source_plate_colour = source_layout.transform_values { |sort_order| "colour#{sort_order%12}" } %> +<%# Get buffer_volume_for_empty_wells from batch as float or nil -%> +<% buffer_volume_for_empty_wells = batch.buffer_volume_for_empty_wells %>
<%= render partial: 'cherrypick_worksheet_plate_list', locals: { section_name: 'Source plates', plates: source_layout, bed_prefix: 'SCRC' } %> @@ -48,6 +50,8 @@ <%= rowchar %> <% (num_columns).times do |column| -%> <% well = plate_wells[row*num_columns+column] -%> + <%# Flag to check if well is empty and needs to be represented on the chart with 'e' followed by the buffer volume for empty wells, for example e120.00 -%> + <% empty_well = true %> <% if well.present? -%> <% request = indexed_requests[well.id] %> <% source_well = request&.asset %> @@ -62,6 +66,8 @@ <% else %> <% end -%> + <%# Flag to set if the well is not empty -%> + <% empty_well = false %> <%= source_well.map_description %> <%= source_well.plate.barcode_number %> v<%= "%.#{configatron.tecan_precision}f" % well.get_picked_volume %> b<%= "%.#{configatron.tecan_precision}f" % well.get_buffer_volume %> @@ -74,6 +80,9 @@ <% else %> <% end -%> + <% if empty_well && buffer_volume_for_empty_wells.present? -%> + e<%= "%.#{configatron.tecan_precision}f" % buffer_volume_for_empty_wells %> + <% end %> <% end -%> <%= rowchar %> @@ -92,7 +101,11 @@
diff --git a/app/views/workflows/_cherrypick_batches.html.erb b/app/views/workflows/_cherrypick_batches.html.erb index 6d378979b9..a20af1374e 100644 --- a/app/views/workflows/_cherrypick_batches.html.erb +++ b/app/views/workflows/_cherrypick_batches.html.erb @@ -40,6 +40,9 @@ <%= hidden_field_tag 'robot_id', @robot_id %> <%= hidden_field_tag 'cherrypick_strategy', @cherrypick_strategy %> <%= hidden_field_tag 'plate_type', @plate_type %> + <%# Add hidden fields for the buffer volume for empty wells option to carry over -%> + <%= hidden_field_tag 'automatic_buffer_addition', @automatic_buffer_addition %> + <%= hidden_field_tag 'buffer_volume_for_empty_wells', @buffer_volume_for_empty_wells %> <%= render(partial: 'next_stage_submit', locals: { check_selection: true }) %> <% end %> diff --git a/app/views/workflows/_cherrypick_strategies.html.erb b/app/views/workflows/_cherrypick_strategies.html.erb index c72f3e9d84..07cad95a5c 100644 --- a/app/views/workflows/_cherrypick_strategies.html.erb +++ b/app/views/workflows/_cherrypick_strategies.html.erb @@ -1,4 +1,4 @@ -<%# locals: { form: } -%> +<%# locals: { form: } %>

Choose a strategy below:

@@ -26,5 +26,24 @@ +<%# Add form controls to collect the options for the buffer volume for empty wells -%> +

Additional options:

+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
<%= vite_javascript_tag 'cherrypick_strategies' %> diff --git a/spec/models/robot/generator/tecan_spec.rb b/spec/models/robot/generator/tecan_spec.rb index 3fae3deba7..e94a2702bb 100644 --- a/spec/models/robot/generator/tecan_spec.rb +++ b/spec/models/robot/generator/tecan_spec.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true describe Robot::Generator::Tecan do - before { create(:full_plate) } + before do + create(:full_plate) + allow(batch).to receive(:buffer_volume_for_empty_wells).and_return(nil) + end shared_examples 'a generator' do describe '.as_text' do diff --git a/spec/models/robot/generator/tecan_v3_spec.rb b/spec/models/robot/generator/tecan_v3_spec.rb index 72b1d5fb18..f505550678 100644 --- a/spec/models/robot/generator/tecan_v3_spec.rb +++ b/spec/models/robot/generator/tecan_v3_spec.rb @@ -69,6 +69,11 @@ # Expected output for the test cases. let(:expected_output) { File.read("spec/data/tecan_v3/case_#{case_num}.gwl") } + before do + allow(batch).to receive(:get_poly_metadata).with(:buffer_volume_for_empty_wells).and_return(nil) + allow(batch).to receive(:buffer_volume_for_empty_wells).and_return(nil) + end + shared_examples 'a TecanV3 generator' do it 'generates the expected output' do expect(generator.as_text).to eq expected_output