UniteLabs
Labware

Tubes and Tube Racks

Define custom tubes and tube racks using the Tube and TubeRack base classes.

Tubes

Tubes can be defined using the Tube base class. A tube has a base dimension equal to the maximum outer diameter in x- and y-direction and the maximum total height.

A fillable tube has a container made up of one or more sections. Sections are defined top-to-bottom — the first section in the list is the uppermost part of the tube, and the last section is the bottom. It is possible to define one or more Hole objects to model pipetting channel access points.

In the example below, a 50 mL conical tube is defined with a cylindrical upper section and a conical frustum at the bottom. A single 9 mm × 9 mm access hole is defined with a depth of 113.65 mm.

import collections.abc
import dataclasses

from unitelabs.labware import (
    ConicalFrustum,
    Container,
    Cylinder,
    Hole,
    Tube,
    Vector,
    place,
)


@dataclasses.dataclass
class Standard50mLTube(Tube):
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=29.36, y=29.36, z=114.65))
    container: Container = dataclasses.field(
        default_factory=lambda: Container(
            max_volume=50_000,
            sections=[
                Cylinder(radius=13.89, height=98.77),           # upper cylindrical section (top)
                ConicalFrustum(radius_lower=3.6, radius_upper=13.89, height=14.88),  # conical bottom
            ],
        )
    )
    children: collections.abc.Sequence[Hole] = dataclasses.field(
        repr=False,
        default_factory=lambda: [
            Hole(dimensions=dimensions).copy(location=location)
            for location, dimensions in place(
                cols=1, rows=1, item=Vector(x=9, y=9, z=113.65), boundary=Vector(x=29.36, y=29.36, z=114.65)
            )
        ],
    )

A simpler tube can be defined by omitting explicit container sections — in this case the container geometry is automatically derived from the tube's dimensions and shape:

import dataclasses

from unitelabs.labware import Tube, Vector
from unitelabs.labware.liquids import Container


@dataclasses.dataclass
class SimpleTube15mL(Tube):
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=17, y=17, z=120))
    container: Container = dataclasses.field(default_factory=Container)

# The above would be identical to:
@dataclasses.dataclass
class SimpleTube15mL(Tube):
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=17, y=17, z=120))

Here the container sections, height, and maximum volume are computed automatically from dimensions during initialization.

Tube Caps

Tubes can have caps which are Cap objects placed on top of the tube:

from unitelabs.labware.tubes import Cap
from unitelabs.labware.math import Decimal

# Add a cap with custom dimensions
@dataclasses.dataclass
class CustomCap(Cap):
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=8, y=8, z=8))
    fitting_depth: Decimal = dataclasses.field(default=Decimal("5"))  # 5mm overlap into tube

One can assign a cap to a tube in the following ways:

# Initialize a lid with a cap directly (always defaults to None)
tube = Tube(lid=CustomCap())

# Or assign a cap to a tube's lid attribute
tube.lid = CustomCap(fitting_depth=Decimal("5"))

# Or use tube.close() to assign a cap to a tube
tube.close(CustomCap(fitting_depth=Decimal("5")))

One can manage and reassign caps on tubes in the following ways:

# Remove the cap from the tube and return it
cap = tube.open()

# Place a cap back onto the tube
tube.close(cap)

# Move a cap from one tube to another
tube.reassign(to=tube2, lid=cap)

Tube Racks

TubeRacks consist of a grid of TubeSpots arranged into rows and columns. These spots can either contain tubes or be empty.

For non-standard grid sizes, create a subclass inheriting from TubeRack. The base class defaults to a 6x4 grid (24 tubes), but you can specify any cols and rows configuration:

import dataclasses
import decimal
import collections.abc

from unitelabs.labware import TubeRack, Tube, TubeSpot, Vector
from unitelabs.labware.liquids import Container
from unitelabs.labware.math import place


@dataclasses.dataclass
class CustomTubeRack(TubeRack):
    """A 6x4 tube rack for 15 mL conical tubes."""

    cols: int = 6
    rows: int = 4
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=120, y=80, z=30))
    fitting_depth: decimal.Decimal = dataclasses.field(default_factory=lambda: decimal.Decimal("25"))

    # Define tube spot positions using `place`
    children: collections.abc.Sequence[TubeSpot] = dataclasses.field(
        repr=False,
        default_factory=lambda: [
            TubeSpot().copy(location=loc)
            for loc in place(
                cols=6,
                rows=4,
                item=Vector(x=10, y=10),      # tube footprint
                boundary=Vector(x=120, y=80),  # rack footprint
            )
        ],
    )


@dataclasses.dataclass
class Tube15mL(Tube):
    """15 mL conical tube."""
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=17, y=17, z=120))
    container: Container = dataclasses.field(default_factory=Container)


# Create and fill the rack
rack = CustomTubeRack(filled_with=Tube15mL)

A Standard96TubeRack (of standard ANSI-SLAS footprint) is implemented and easily customizable with the spot_offset and fitting_depth parameters:

  • spot_offset: the offset of the A1 tube spot center from the A1 corner of the rack
  • fitting_depth: how far into the rack (from its top plane) that tubes sit in the rack
import dataclasses
import decimal

from unitelabs.labware import Tube, Standard96TubeRack, Vector
from unitelabs.labware.liquids import Container


@dataclasses.dataclass
class CustomTube(Tube):
    """A 2 mL Eppendorf tube."""
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(7.8, 7.8, 42.5))
    container: Container = dataclasses.field(default_factory=Container)


@dataclasses.dataclass
class Custom96TubeRack(Standard96TubeRack):
    """96-position tube rack for 2 mL tubes."""
    dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(z=30.4))
    spot_offset: Vector = dataclasses.field(default_factory=lambda: Vector(x=14.7, y=11.2))
    fitting_depth: decimal.Decimal = dataclasses.field(default_factory=lambda: decimal.Decimal("28.1"))

# Create a filled rack
tube_rack = Custom96TubeRack(filled_with=CustomTube)

Accessing Tubes

Any TubeRack provides a convenient way to access tubes in the rack using .tubes:

tubes = tube_rack.tubes
for tube in tubes:
    print(tube.container.volume)  # Volume of the tube
    print(tube.lid)  # Lid of the tube, if any

One can access tubes by their well position or numerical (column-major) index:

# Access tubes by well position
tube = tube_rack.tubes["A1"]
tube = tube_rack.tubes["A2":"H2"]
tube = tube_rack.tubes["A7":]

# Access tubes by numerical index
tube = tube_rack.tubes[0]  # First tube (A1)
tube = tube_rack.tubes[0:8]  # First column (A1:H1)

It is essential to note that since TubeSpots can be empty, calling .tubes over a slice of TubeSpots may return fewer tubes than the number of spots searched! When necessary, one can enforce that tubes are present in all spots by using .tubes.strict, following the same pattern as .tubes:

tubes = tube_rack.tubes.strict  # Raises IndexError if any spot is empty on the rack

# Access tubes by well position as with .tubes
tube = tube_rack.tubes.strict["A2":"H2"]  # Raises IndexError if any spot is empty
tube = tube_rack.tubes.strict[9:16]  # Raises IndexError if any spot is empty

Convenience Methods

.filled_spots, .empty_spots: quickly find filled or empty spots in a tube rack:

filled_spots = tube_rack.filled_spots
empty_spots = tube_rack.empty_spots

# Print filled spot labels
for spot in filled_spots:
    print(spot.well_label)

# Print empty spot labels
for spot in empty_spots:
    print(spot.well_label)

.any_caps: quickly find if a tube rack has any caps:

if tube_rack.any_caps:
    print("The tube rack has at least one cap.")

.height: get the current effective height of the rack, accounting for:

  • Presence of tubes in the rack (adjusted by the rack's fitting depth)
  • Presence of caps on tubes (adjusted by the cap's fitting depth)
empty_rack = Standard96TubeRack(dimensions=Vector(z=50.0))
print(empty_rack.height)  # empty_rack.dimensions.z

filled_rack = Standard96TubeRack(
  filled_with=Tube(dimensions=Vector(x=9.0, y=9.0, z=30.0)),
  fitting_depth=25.0
)
print(filled_rack.height)  #  filled_rack.dimensions.z + Tube.dimensions.z - filled_rack.fitting_depth

cap = Cap(
  dimensions=Vector(x=10.0, y=10.0, z=5.0),
  fitting_depth=3.0
)
filled_rack.tubes["A1"].close(cap)
print(filled_rack.height) # filled_rack.dimensions.z + Tube.dimensions.z - filled_rack.fitting_depth + cap.dimensions.z - cap.fitting_depth

Liquid Handling

One can flexibly provide TubeRack objects to liquid handling operations:

lhs.pipettes.aspirate(tube_rack) # Targets the first present tubes in the rack with column-major searching

lhs.pipettes.dispense(tube_rack.tubes["A6":"H6"]) # Target tubes present from A6-H6 on the rack

lhs.pipettes.aspirate(tube_rack.tubes.strict["A6":"H6"]) # Target tubes present from A6-H6 on the rack, raising an error if any spot is empty

lhs.core96.aspirate(tube_rack) # Target all tubes in the rack. Note that for a 96-channel head, tubes must be arranged in a compatible layout on the rack!

See Basic Pipetting for more information on liquid handling operations.