Skip to content

Commit

Permalink
Merge branch '3936-dpl-969-support-for-chromium-chip-labware' into ua…
Browse files Browse the repository at this point in the history
…t-26th-jan-24
  • Loading branch information
yoldas committed Jan 26, 2024
2 parents 6f09bd1 + 112be8a commit ab07318
Show file tree
Hide file tree
Showing 20 changed files with 949 additions and 12 deletions.
12 changes: 12 additions & 0 deletions app/controllers/api/v2/plate_purposes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module Api
module V2
# Provides a JSON API controller for receptacle
# See: http://jsonapi-resources.com/ for JSONAPI::Resource documentation
class PlatePurposesController < JSONAPI::ResourceController
# By default JSONAPI::ResourceController provides most the standard
# behaviour, and in many cases this file may be left empty.
end
end
end
15 changes: 14 additions & 1 deletion app/models/map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ module Coordinate
# TODO: These methods are only valid for standard plates. Moved them here to make that more explicit
# (even if its not strictly appropriate) They could do with refactoring/removing.

PLATE_DIMENSIONS = Hash.new { |_h, _k| [] }.merge(96 => [12, 8], 384 => [24, 16])
# A hash representing the dimensions of different types of plates.
# The keys are the total number of wells in the plate, and the values are
# arrays, where the first element is the number of columns and the second
# element is the number of rows.
#
# @note
# - 96 represents a 96-well plate, arranged in 12 columns and 8 rows.
# - 384 represents a 384-well plate, arranged in 24 columns and 16 rows.
# - 16 represents a 16-well Chromium Chip, which has 8 columns and 2 rows.
# Although a 16-well Chromium Chip does not have 3:2 ratio to be a
# standard plate, i.e. it has 4:1 ratio, the methods here still apply.
# @return [Hash{Integer => Array<Integer>}] the dimensions of the plates
PLATE_DIMENSIONS = Hash.new { |_h, _k| [] }.merge(96 => [12, 8], 384 => [24, 16], 16 => [8, 2])

# Seems to expect row to be zero-indexed but column to be 1 indexed
def self.location_from_row_and_column(row, column, _ = nil, __ = nil)
Expand Down Expand Up @@ -269,5 +281,6 @@ def walk_plate_in_row_major_order(size, asset_shape = nil)
.order(:row_order)
.each { |position| yield(position, position.row_order) }
end
alias walk_plate_horizontally walk_plate_in_row_major_order
end
end
85 changes: 85 additions & 0 deletions app/resources/api/v2/plate_purpose_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

module Api
module V2
# Provides a JSON API representation of PlatePurpose
# See: http://jsonapi-resources.com/ for JSONAPI::Resource documentation
class PlatePurposeResource < BaseResource
model_name 'PlatePurpose'

# This resource is similar to PurposeResource but it was created to
# migrate the registration of plate purposes done by the Limber rake
# task config:generate from API version 1 to API version 2.

# The following attributes are sent by Limber for a new plate purpose.

# @!attribute name
# @return [String] the name of the plate purpose
attribute :name

# @!attribute stock_plate
# @return [Boolean] whether the plates of this purpose are stock plates
attribute :stock_plate

# @!attribute cherrypickable_target
# @return [Boolean] whether the plates of this purpose are cherrypickable
attribute :cherrypickable_target

# @!attribute input_plate
# @return [Boolean] whether the plates of this purpose are input plates
attribute :input_plate

# @!attribute size
# @return [Integer] the size of the plates of this purpose
attribute :size

# @!attribute asset_shape
# @return [String] the name of the shape of the plates of this purpose
attribute :asset_shape

# The following attribute is required by Limber to store purposes.

# @!attribute [r] uuid
# @return [String] the UUID of the plate purpose
attribute :uuid, readonly: true

# Sets the asset shape of the plate purpose by name if given.
# 'asset_shape' can be given via the Limber purpose configuration and
# defaults to 'Standard' if not provided. If the name is given and not
# found, an error is raised. Note that the name is case-sensitive.
#
# @param name [String] the name of the asset shape
# @return [void]
def asset_shape=(name)
@model.asset_shape = (AssetShape.find_by!(name: name) if name.present?) || AssetShape.default
end

# Returns the name of the asset shape of the plate purpose.
# The asset_shape association is not utilized in Limber. This method
# returns the name of the asset shape associated with the plate purpose.
#
# @return [String] the name of the asset shape
def asset_shape
@model.asset_shape.name
end

# Returns the input_plate attribute from the type of the plate purpose.
# This method is the counterpart to the model's attribute writer for
# input_plate. It performs the inverse operation, determining the value
# of input_plate attribute based on the model's type.
#
# @return [Boolean] whether the plate purpose is an input plate
def input_plate
@model.type == 'PlatePurpose::Input'
end

# Prevents updating existing plate purposes.
#
# @param _context [JSONAPI::Resource::Context] not used
# @return [Array<Symbol>] empty array
def updatable_fields(_context)
[]
end
end
end
end
65 changes: 65 additions & 0 deletions config/default_records/asset_shapes/default_records.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# This file contains the AssetShape and associated Map records to be created in
# deployment environments by RecordLoader. The loading is triggered by the
# "post_deploy" task. The records are checked against the database by their
# names. If the records already exist, they are not created. AssetShape records
# are created in the "asset_shapes" table, and Map records are created in the
# "maps" table. Each section in this file starts with the name of the AssetShape
# record. The "horizontal_ratio" and "vertical_ratio" options are used to define
# the plate's shape. These options represent the simplest form of the ratio
# between the number of columns and rows (i.e. width / height). Specifically,
# "horizontal_ratio" corresponds to the number of columns (numerator), and
# "vertical_ratio" corresponds to the number of rows (denominator). For
# instance, a "Standard" 96-well plate has a horizontal ratio of 3 and a
# vertical ratio of 2 because it has 12 columns (1 to 12) and 8 rows (A to H).
# The "sizes" option defines different plate sizes with that shape, for example,
# 96 and 384. Each size determines the number of Map records to be created for
# that shape and size. The "description_strategy" option defines the nested
# module in the Map model that is used for handling positions, rows, columns,
# and wells in plate geometry. AssetShapeLoader uses PlateMapGeneration to
# create records in the database. Each "Well" on a "Plate" is associated with a
# "Map".
#
# When configuring a "Purpose", both "asset_shape" and "size" need to be
# specified in that configuration in order to use the correct labware. If not
# specified, they will default to Standard and 96 respectively. For example,
# LRC HT 5p Chip:
# :asset_shape: ChromiumChip
# :size: 16
#
# The information in this file is duplicated in a couple of places in the
# codebase. When the local development environment is "setup" or "reset", a
# database "seed" is executed. This results in using the maps hash in
# PlateMapGeneration. The same hash is used when the RSpec before suite hook
# calls PlateMapGeneration to create records in the local test environment.
# Plate sizes and number of rows and columns are also defined separately in
# a hash in the Map model, which is used by the nested Coordinate module.
---
Standard:
horizontal_ratio: 3
vertical_ratio: 2
description_strategy: Coordinate
sizes: [96, 384]

Fluidigm96:
horizontal_ratio: 3
vertical_ratio: 8
description_strategy: Sequential
sizes: [96]

Fluidigm192:
horizontal_ratio: 3
vertical_ratio: 4
description_strategy: Sequential
sizes: [192]

StripTubeColumn:
horizontal_ratio: 1,
vertical_ratio: 8,
description_strategy: Sequential
sizes: [8]

ChromiumChip:
horizontal_ratio: 4
vertical_ratio: 1
description_strategy: Coordinate
sizes: [16]
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
jsonapi_resources :lot_types
jsonapi_resources :lots
jsonapi_resources :orders
jsonapi_resources :plate_purposes
jsonapi_resources :plate_templates
jsonapi_resources :plates
jsonapi_resources :poly_metadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
Given(/^I have a plate "([^"]*)" with the following wells:$/) do |plate_barcode, well_details|
plate = FactoryBot.create :plate, barcode: plate_barcode
well_details.hashes.each do |well_detail|
well = Well.create!(map: Map.find_by(description: well_detail[:well_location], asset_size: 96), plate: plate)
well =
Well.create!(map: Map.find_by(description: well_detail[:well_location], asset_size: plate.size), plate: plate)
well.well_attribute.update!(
concentration: well_detail[:measured_concentration],
measured_volume: well_detail[:measured_volume]
Expand Down
5 changes: 3 additions & 2 deletions features/support/step_definitions/plate_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
Given /^well "([^"]*)" is holded by plate "([^"]*)"$/ do |well_uuid, plate_uuid|
well = Uuid.find_by(external_id: well_uuid).resource
plate = Uuid.find_by(external_id: plate_uuid).resource
well.update!(plate: plate, map: Map.find_by(description: 'A1'))
well.update!(plate: plate, map: Map.find_by(description: 'A1', asset_size: plate.size))
Plate.find(plate_id).primary_barcode.update!(barcode: 'DN1S')
end

Expand Down Expand Up @@ -213,7 +213,8 @@
# plate = FactoryBot.create :plate, :barcode => plate_barcode
plate = Uuid.find_by(external_id: uuid).resource
well_details.hashes.each do |well_detail|
well = Well.create!(map: Map.find_by(description: well_detail[:well_location], asset_size: 96), plate: plate)
well =
Well.create!(map: Map.find_by(description: well_detail[:well_location], asset_size: plate.size), plate: plate)
well.well_attribute.update!(
concentration: well_detail[:measured_concentration],
measured_volume: well_detail[:measured_volume]
Expand Down
3 changes: 2 additions & 1 deletion lib/plate_map_generation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def self.maps
vertical_ratio: 8,
description_strategy: 'Sequential',
sizes: [8]
}
},
{ name: 'ChromiumChip', horizontal_ratio: 4, vertical_ratio: 1, description_strategy: 'Coordinate', sizes: [16] }
]
end

Expand Down
33 changes: 33 additions & 0 deletions lib/record_loader/asset_shape_loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

# RecordLoader handles automatic population and updating of database records
# across different environments
# @see https://rubydoc.info/github/sanger/record_loader/
module RecordLoader
# Creates the specified asset shapes if they are not present
class AssetShapeLoader < ApplicationRecordLoader
config_folder 'asset_shapes'

# Creates an AssetShape record with the given name and options. If a record
# with the same name already exists, it is skipped. If not, a new one is
# created. For each size specified in the options, Map records are generated
# up to that size, unless they already exist.
#
# @param name [String] the name of the AssetShape record
# @param options [Hash] the options to be used for creating the record
# @option options [Integer] :horizontal_ratio the numerator in the simplest form of the plate width/height ratio
# @option options [Integer] :vertical_ratio the denominator in the simplest form of the plate width/height ratio
# @option options [String] :description_strategy The strategy for describing the plate
# @option options [Array<Integer>] :sizes The sizes of the plates to generate Maps for
def create_or_update!(name, options)
config = { name: name }.merge(options.symbolize_keys)

# PlateMapGeneration expects a non-namespaced constant for
# description_strategy. It adds "Map::" prefix to refer to a nested
# module in the Map class. We remove this prefix in case it is given
# in the records file.
config[:description_strategy] = config[:description_strategy].delete_prefix('Map::')
PlateMapGeneration.new(**config).save!
end
end
end
1 change: 1 addition & 0 deletions lib/record_loader/plate_purpose_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def create_purpose(name, config)
creator = config.delete('plate_creator')
config['barcode_printer_type'] = barcode_printer_type(config.delete('barcode_printer_type'))
config['name'] = name
config['asset_shape'] = AssetShape.find_by(name: config.delete('asset_shape')) || AssetShape.default
purpose = PlatePurpose.create!(config)
build_creator(purpose, creator) if creator
end
Expand Down
12 changes: 12 additions & 0 deletions lib/tasks/record_loader/asset_shape.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

namespace :record_loader do
desc 'Automatically generate AssetShapes and Maps through AssetShapesLoader'
task asset_shape: :environment do
RecordLoader::AssetShapeLoader.new.create!
end
end

# Automatically run this record loader as part of record_loader:all
# Remove this line if the task should only run when invoked explicitly
task 'record_loader:all' => 'record_loader:asset_shape'
26 changes: 22 additions & 4 deletions spec/controllers/submissions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,25 @@

session[:user] = @user

# We need to specify the details of the map while using Map.find_by
# to avoid picking up another map with the same description. As of
# the current records, the 'description' and 'asset_size' attributes
# can uniquely identify a map; asset_shape is added for completeness.
@asset_shape = AssetShape.default
@asset_size = 96

@plate = build :plate, barcode: 'SQPD-123456'
%w[A1 A2 A3 B1 B2 B3 C1 C2 C3].each do |location|
well = build :well_with_sample_and_without_plate, map: Map.find_by(description: location)
well =
build :well_with_sample_and_without_plate,
map: Map.find_by(description: location, asset_shape: @asset_shape, asset_size: @asset_size)
@plate.wells << well
end
build(:well, map: Map.find_by(description: 'C5'), plate: @plate)
build(
:well,
map: Map.find_by(description: 'C5', asset_shape: @asset_shape, asset_size: @asset_size),
plate: @plate
)
@plate.save
@study = create :study, name: 'A study'
@project = create :project, name: 'A project'
Expand Down Expand Up @@ -142,7 +155,10 @@
context 'with a more recent plate' do
before do
@new_plate = create :plate, plate_purpose: @plate.purpose
@well = create :well, map: Map.find_by(description: 'A1'), plate: @new_plate
@well =
create :well,
map: Map.find_by(description: 'A1', asset_shape: @asset_shape, asset_size: @asset_size),
plate: @new_plate
create(:aliquot, sample: Sample.find_by(name: @samples.first), receptacle: @well)
post(
:create,
Expand Down Expand Up @@ -184,7 +200,9 @@
@order_count = Order.count
@wd_plate = create :working_dilution_plate
%w[A1 A2 A3 B1 B2 B3 C1 C2 C3].each do |location|
well = create :empty_well, map: Map.find_by(description: location)
well =
create :empty_well,
map: Map.find_by(description: location, asset_shape: @asset_shape, asset_size: @asset_size)
well.aliquots.create(sample: @plate.wells.located_at(location).first.aliquots.first.sample)
@wd_plate.wells << well
end
Expand Down
6 changes: 6 additions & 0 deletions spec/data/record_loader/plate_purposes/003_chromium_chip.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
ChromiumChip Plate:
asset_shape: ChromiumChip
cherrypickable_target: false
target_type: plate
size: 16
5 changes: 5 additions & 0 deletions spec/factories/purpose_factories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
size { 192 }
asset_shape factory: %i[fluidigm_192_shape]
end

factory :chromium_chip_purpose do
size { 16 }
asset_shape { AssetShape.find_by(name: 'ChromiumChip') }
end
end

factory :dilution_plate_purpose do
Expand Down
14 changes: 14 additions & 0 deletions spec/lib/record_loader/plate_purpose_loader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,18 @@ def a_new_record_loader
expect(the_creator.parent_plate_purposes).to eq(PlatePurpose.where(name: 'Stock Plate'))
end
end

context 'with ChromimumChip Plate purpose' do
let(:selected_files) { '003_chromium_chip' }
let(:purpose_name) { 'ChromiumChip Plate' }

before { record_loader.create! }

it 'creates a plate purpose' do
expect(Purpose.where(name: purpose_name).count).to eq(1)
purpose = Purpose.where(name: purpose_name).first
expect(purpose.asset_shape).to eq(AssetShape.find_by(name: 'ChromiumChip'))
expect(purpose.size).to eq(16)
end
end
end
2 changes: 1 addition & 1 deletion spec/models/api/messages/well_stock_resource_io_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
let(:plate_barcode) { build(:plate_barcode) }
let(:well) do
create :well,
map: Map.find_by!(description: 'A1'),
map: Map.find_by!(description: 'A1', asset_shape: AssetShape.default, asset_size: 96),
plate: create(:plate, barcode: plate_barcode.barcode),
well_attribute: create(:complete_well_attribute)
end
Expand Down
Loading

0 comments on commit ab07318

Please sign in to comment.