Skip to content

Commit

Permalink
feat(hardware-testing): Flex protocol to fill diluent for 96ch photom…
Browse files Browse the repository at this point in the history
…etric test (#14320)
  • Loading branch information
andySigler authored Jan 17, 2024
1 parent 088f560 commit 2a64068
Showing 1 changed file with 238 additions and 0 deletions.
238 changes: 238 additions & 0 deletions hardware-testing/hardware_testing/protocols/flex_diluent_for_96ch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""Flex: Diluent for 96ch.."""
from math import pi
from typing import List, Optional, Dict, Tuple

from opentrons.protocol_api import ProtocolContext, InstrumentContext, Labware

##############################################
# EDIT - START #
##############################################

metadata = {"protocolName": "Flex: Diluent for 96ch"}
requirements = {"robotType": "Flex", "apiLevel": "2.15"}

RETURN_TIP = False

DILUENT_VOLUME = 195
DILUENT_PUSH_OUT = 15
DILUENT_SOURCES = [
{
"source": "A1",
"destinations": ["A1", "A2", "A3", "A4", "A5", "A6"],
},
{
"source": "A2",
"destinations": ["A7", "A8", "A9", "A10", "A11", "A12"],
},
]

##############################################
# EDIT - END #
##############################################

SUBMERGE_MM = {
"aspirate": 3.0,
"dispense": 1.0,
}
RETRACT_MM = 5.0
MIN_MM_FROM_BOTTOM = 1.0

TOUCH_TIP_SPEED = 30
TOUCH_TIP_DEPTH = -1

DELAY_ASPIRATE = 1.0
DELAY_DISPENSE = 0.5

MIN_VOL_SRC = {
"nest_96_wellplate_2ml_deep": 500,
"nest_12_reservoir_15ml": 3000,
"nest_1_reservoir_195ml": 30000,
}


class _LiquidHeightInFlatBottomWell:
def __init__(
self,
bottom_diameter: float,
top_diameter: float,
height: float,
resolution_mm: float = 0.1,
) -> None:
self._bottom_radius = bottom_diameter / 2
self._top_radius = top_diameter / 2
self._height = height
self._resolution_mm = resolution_mm

def _volume_of_frustum(self, surface_height: float, surface_radius: float) -> float:
"""Calculate the volume of a frustum given its height and radii."""
a = pi * self._bottom_radius * surface_radius
b = pi * surface_radius**2
c = pi * self._bottom_radius**2
return (a + b + c) * (surface_height / 3)

def height_from_volume(self, volume: float) -> float:
"""Given the volume, compute the height of the liquid in the well."""
_rad_diff = self._top_radius - self._bottom_radius
low, high = 0.0, self._height
while high - low > self._resolution_mm:
mid = (low + high) / 2
r_mid = self._bottom_radius + (mid / self._height) * _rad_diff
if self._volume_of_frustum(mid, r_mid) < volume:
low = mid
else:
high = mid
return (low + high) / 2

def volume_from_height(self, height: float) -> float:
"""Given the height, compute the volume of the liquid in the well."""
_rel_height = height / self._height
_rad_diff = self._top_radius - self._bottom_radius
surface_radius = self._bottom_radius + _rad_diff * _rel_height
return self._volume_of_frustum(height, surface_radius)


LIQUID_HEIGHT_LOOKUP: Dict[str, List[Tuple[float, float]]] = {
"nest_12_reservoir_15ml": [
(0, 0),
(3000, 6.0),
(3500, 7.0),
(4000, 8.0),
(5500, 10.5),
(8000, 14.7),
(10000, 18.0),
(12600, 22.5),
(15000, 26.85), # full depth of well
],
}


def _convert_ul_in_well_to_height_in_well(load_name: str, ul: float) -> float:
if load_name in LIQUID_HEIGHT_LOOKUP:
lookup = LIQUID_HEIGHT_LOOKUP[load_name]
for i in range(len(lookup) - 1):
low = lookup[i]
high = lookup[i + 1]
if low[0] <= ul <= high[0]:
ul_scale = (ul - low[0]) / (high[0] - low[0])
return (ul_scale * (high[1] - low[1])) + low[1]
elif load_name == "corning_96_wellplate_360ul_flat":
well = _LiquidHeightInFlatBottomWell(
bottom_diameter=6.35, top_diameter=6.858, height=10.668
)
return well.height_from_volume(ul)
raise ValueError(f"unable to find height of {ul} ul in {load_name}")


def _start_volumes_per_trial(
volume: float, load_name: str, channels: int, trials: int
) -> List[float]:
ul_per_aspirate = volume * channels
ul_per_run = ul_per_aspirate * trials
ul_at_start = ul_per_run + MIN_VOL_SRC[load_name]
return [ul_at_start - (ul_per_aspirate * i) for i in range(trials)]


def _end_volumes_per_trial(
volume: float, load_name: str, channels: int, trials: int
) -> List[float]:
return [
ul - (volume * channels)
for ul in _start_volumes_per_trial(volume, load_name, channels, trials)
]


def _assign_starting_volumes(
ctx: ProtocolContext,
pipette: InstrumentContext,
reservoir: Labware,
) -> None:
diluent = ctx.define_liquid(
name="Diluent",
description="Artel MVS Diluent",
display_color="#0000FF",
)
for test in DILUENT_SOURCES:
src_ul_per_trial = _start_volumes_per_trial(
DILUENT_VOLUME,
reservoir.load_name,
pipette.channels,
len(test["destinations"]),
)
first_trial_ul = src_ul_per_trial[0]
reservoir[str(test["source"])].load_liquid(diluent, first_trial_ul)


def _transfer(
ctx: ProtocolContext,
volume: float,
pipette: InstrumentContext,
reservoir: Labware,
plate: Labware,
source: str,
destinations: List[str],
push_out: Optional[float] = None,
touch_tip: bool = False,
volume_already_in_plate: float = 0,
) -> None:
end_volumes = _end_volumes_per_trial(
volume, reservoir.load_name, pipette.channels, len(destinations)
)
src_heights = [
_convert_ul_in_well_to_height_in_well(reservoir.load_name, ul)
for ul in end_volumes
]
volume_in_plate = volume + volume_already_in_plate
dst_height = _convert_ul_in_well_to_height_in_well(plate.load_name, volume_in_plate)
for dst_name, height_src in zip(destinations, src_heights):
# calculate pipetting positions
aspirate_pos = reservoir[source].bottom(
max(height_src - SUBMERGE_MM["aspirate"], MIN_MM_FROM_BOTTOM)
)
dispense_pos = plate[dst_name].bottom(
max(dst_height - SUBMERGE_MM["dispense"], MIN_MM_FROM_BOTTOM)
)
blow_out_pos = plate[dst_name].bottom(
max(dst_height + RETRACT_MM, MIN_MM_FROM_BOTTOM)
)
# transfer
if pipette.current_volume > 0:
pipette.dispense(pipette.current_volume, reservoir[source].top())
pipette.aspirate(volume, aspirate_pos)
ctx.delay(seconds=DELAY_ASPIRATE)
pipette.dispense(volume, dispense_pos, push_out=push_out)
ctx.delay(seconds=DELAY_DISPENSE)
pipette.blow_out(blow_out_pos)
if touch_tip:
pipette.touch_tip(speed=TOUCH_TIP_SPEED, v_offset=TOUCH_TIP_DEPTH)
pipette.aspirate(1, blow_out_pos) # trailing air-gap to avoid droplets


def run(ctx: ProtocolContext) -> None:
"""Run."""
tips = ctx.load_labware("opentrons_flex_96_tiprack_200uL", "B2")
reservoir = ctx.load_labware("nest_12_reservoir_15ml", "C2")
plate = ctx.load_labware("corning_96_wellplate_360ul_flat", "D2")
pipette = ctx.load_instrument("flex_8channel_1000", "left", tip_racks=[tips])
_assign_starting_volumes(ctx, pipette, reservoir)
for i in range(12):
pipette.configure_for_volume(DILUENT_VOLUME)
pipette.pick_up_tip()
for test in DILUENT_SOURCES:
_transfer(
ctx,
DILUENT_VOLUME,
pipette,
reservoir,
plate,
test["source"], # type: ignore[arg-type]
test["destinations"], # type: ignore[arg-type]
push_out=DILUENT_PUSH_OUT,
touch_tip=True,
volume_already_in_plate=0,
)
if RETURN_TIP:
pipette.return_tip()
else:
pipette.drop_tip()
if i < 11:
ctx.pause("refresh Diluent to start volumes, and add new Plate")

0 comments on commit 2a64068

Please sign in to comment.