diff --git a/hardware-testing/hardware_testing/protocols/flex_diluent_for_96ch.py b/hardware-testing/hardware_testing/protocols/flex_diluent_for_96ch.py new file mode 100644 index 00000000000..6ea6c6149f4 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/flex_diluent_for_96ch.py @@ -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")