From 0cfecf8a3a410c4bc7c75225f8a73c7b54bb2d43 Mon Sep 17 00:00:00 2001 From: Sanniti Date: Fri, 9 Nov 2018 11:52:09 -0500 Subject: [PATCH 1/5] refactor(api): add touch_tip, mix, air_gap, blow_out, return_tip. Add TransferPlan Class. Closes #2242 --- api/docs/source/new_protocol_api.rst | 2 + .../opentrons/hardware_control/__init__.py | 4 + api/src/opentrons/protocol_api/contexts.py | 400 +++++++++- api/src/opentrons/protocol_api/labware.py | 14 +- api/src/opentrons/protocol_api/transfers.py | 732 ++++++++++++++++++ api/src/opentrons/types.py | 6 + .../opentrons/protocol_api/test_context.py | 94 +++ .../opentrons/protocol_api/test_transfers.py | 411 ++++++++++ 8 files changed, 1641 insertions(+), 22 deletions(-) create mode 100644 api/src/opentrons/protocol_api/transfers.py create mode 100644 api/tests/opentrons/protocol_api/test_transfers.py diff --git a/api/docs/source/new_protocol_api.rst b/api/docs/source/new_protocol_api.rst index f14f21973d3..7924fde15ae 100644 --- a/api/docs/source/new_protocol_api.rst +++ b/api/docs/source/new_protocol_api.rst @@ -160,6 +160,8 @@ Robot and Pipette .. autoclass:: opentrons.protocol_api.contexts.InstrumentContext :members: +.. autoclass:: opentrons.protocol_api.transfers.TransferOptions + :members: .. _protocol-api-labware: diff --git a/api/src/opentrons/hardware_control/__init__.py b/api/src/opentrons/hardware_control/__init__.py index 64c0234630d..7e82785443e 100644 --- a/api/src/opentrons/hardware_control/__init__.py +++ b/api/src/opentrons/hardware_control/__init__.py @@ -46,6 +46,10 @@ class MustHomeError(RuntimeError): pass +class NoTipAttachedError(RuntimeError): + pass + + _Backend = Union[Controller, Simulator] Instruments = Dict[top_types.Mount, Optional[Pipette]] SHAKE_OFF_TIPS_SPEED = 50 diff --git a/api/src/opentrons/protocol_api/contexts.py b/api/src/opentrons/protocol_api/contexts.py index 546b6dfe4ca..44f78f5e1c7 100644 --- a/api/src/opentrons/protocol_api/contexts.py +++ b/api/src/opentrons/protocol_api/contexts.py @@ -2,10 +2,9 @@ import contextlib import logging import time -from typing import Any, Dict, List, Optional, Union, Tuple - from .labware import (Well, Labware, load, load_module, ModuleGeometry, quirks_from_any_parent) +from typing import Any, Dict, List, Optional, Union, Tuple, Sequence from opentrons import types, hardware_control as hc, broker, commands as cmds import opentrons.config.robot_configs as rc from opentrons.config import advanced_settings @@ -13,7 +12,7 @@ from opentrons.hardware_control.types import CriticalPoint from . import geometry - +from . import transfers MODULE_LOG = logging.getLogger(__name__) @@ -596,17 +595,86 @@ def mix(self, volume: float = None, location: Well = None, rate: float = 1.0) -> 'InstrumentContext': - raise NotImplementedError + """ + Mix a volume of liquid (uL) using this pipette. + If no location is specified, the pipette will mix from its current + position. If no Volume is passed, 'mix' will default to its max_volume + + :param repetitions: how many times the pipette should mix (default: 1) + :param volume: number of microlitres to mix (default: self.max_volume) + :param location: a Well or a position relative to well + e.g, `plate.wells('A1').bottom()` (types.Location type) + :param rate: Set plunger speed for this mix, where + speed = rate * (aspirate_speed or dispense_speed) + + :raises NoTipAttachedError: If no tip is attached to the pipette + + :returns: This instance + """ + self._log.debug( + 'mixing {}uL with {} repetitions in {} at rate={}'.format( + volume, repetitions, + location if location else 'current position', rate)) + if not self.hw_pipette['has_tip']: + raise hc.NoTipAttachedError('Pipette has no tip. Aborting mix()') + + self.aspirate(volume, location, rate) + while repetitions - 1 > 0: + self.dispense(volume, rate=rate) + self.aspirate(volume, rate=rate) + repetitions -= 1 + self.dispense(volume, rate=rate) + return self @cmds.publish.both(command=cmds.blow_out) - def blow_out(self, location: Well = None) -> 'InstrumentContext': + def blow_out(self, + location: Union[types.Location, Well] = None + ) -> 'InstrumentContext': """ Blow liquid out of the tip. - If called without arguments, blow out into the - :py:attr:`trash_container`. + If :py:attr:`dispense` is used to completely empty a pipette, + usually a small amount of liquid will remain in the tip. This + method moves the plunger past its usual stops to fully remove + any remaining liquid from the tip. Regardless of how much liquid + was in the tip when this function is called, after it is done + the tip will be empty. + + :param location: The location to blow out into. If not specified, + defaults to the current location of the pipette + :type location: :py:class:`.Well` or :py:class:`.Location` or None + + :raises RuntimeError: If no location is specified and location cache is + None. This should happen if `blow_out` is called + without first calling a method that takes a + location (eg, :py:meth:`.aspirate`, + :py:meth:`dispense`) + :returns: This instance """ - raise NotImplementedError + if location is None: + if not self._ctx.location_cache: + raise RuntimeError('No valid current location cache present') + else: + location = self._ctx.location_cache.labware # type: ignore + # type checked below + + if isinstance(location, Well): + if location.parent.is_tiprack: + self._log.warning('Blow_out being performed on a tiprack. ' + 'Please re-check your code') + target = location.top() + elif isinstance(location, types.Location) and not \ + isinstance(location.labware, Well): + raise TypeError( + 'location should be a Well or None, but it is {}' + .format(location)) + else: + raise TypeError( + 'location should be a Well or None, but it is {}' + .format(location)) + self.move_to(target) + self._hw_manager.hardware.blow_out(self._mount) + return self @cmds.publish.both(command=cmds.touch_tip) def touch_tip(self, @@ -614,13 +682,123 @@ def touch_tip(self, radius: float = 1.0, v_offset: float = -1.0, speed: float = 60.0) -> 'InstrumentContext': - raise NotImplementedError + """ + Touch the pipette tip to the sides of a well, with the intent of + removing left-over droplets + + :param location: If no location is passed, pipette will + touch tip at current well's edges + .. NOTE:: This is behavior change from legacy API + (which accepts any :py:class:`.Placeable` + as location) + :type location: :py:class:`.Well` or None + + :param radius: Describes the proportion of the target well's + radius. When `radius=1.0`, the pipette tip will move to + the edge of the target well; when `radius=0.5`, it will + move to 50% of the well's radius. Default: 1.0 (100%) + :type radius: float + + :param v_offset: The offset in mm from the top of the well to touch tip + A positive offset moves the tip higher above the well, + while a negative offset moves it lower into the well + Default: -1.0 mm + :type v_offset: float + + :param speed: The speed for touch tip motion, in mm/s. + Default: 60.0 mm/s, Max: 80.0 mm/s, Min: 20.0 mm/s + :type speed: float + + :raises NoTipAttachedError: if no tip is attached to the pipette + + :raises RuntimeError: If no location is specified and location cache is + None. This should happen if `touch_tip` is called + without first calling a method that takes a + location (eg, :py:meth:`.aspirate`, + :py:meth:`dispense`) + + :returns: This instance + """ + if not self.hw_pipette['has_tip']: + raise hc.NoTipAttachedError('Pipette has no tip to touch_tip()') + + if speed > 80.0: + self._log.warning('Touch tip speed above limit. Setting to 80mm/s') + speed = 80.0 + elif speed < 20.0: + self._log.warning('Touch tip speed below min. Setting to 20mm/s') + speed = 20.0 + + # If location is a valid well, move to the well first + if location is None: + if not self._ctx.location_cache: + raise RuntimeError('No valid current location cache present') + else: + location = self._ctx.location_cache.labware # type: ignore + # type checked below + + if isinstance(location, Well): + if location.parent.is_tiprack: + self._log.warning('Touch_tip being performed on a tiprack. ' + 'Please re-check your code') + self.move_to(location.top()) + else: + raise TypeError( + 'location should be a Well, but it is {}'.format(location)) + + # Determine the touch_tip edges/points + offset_pt = types.Point(0, 0, v_offset) + well_edges = [ + # right edge + location._from_center_cartesian(x=radius, y=0, z=1) + offset_pt, + # left edge + location._from_center_cartesian(x=-radius, y=0, z=1) + offset_pt, + # back edge + location._from_center_cartesian(x=0, y=radius, z=1) + offset_pt, + # front edge + location._from_center_cartesian(x=0, y=-radius, z=1) + offset_pt + ] + for edge in well_edges: + self._hw_manager.hardware.move_to(self._mount, edge, speed) + return self @cmds.publish.both(command=cmds.air_gap) def air_gap(self, volume: float = None, height: float = None) -> 'InstrumentContext': - raise NotImplementedError + """ + Pull air into the pipette current tip at the current location + + :param volume: The amount in uL to aspirate air into the tube. + (Default will use all remaining volume in tip) + :type volume: float + + :param height: The number of millimiters to move above the current Well + to air-gap aspirate. (Default: 5mm above current Well) + :type height: float + + :raises NoTipAttachedError: If no tip is attached to the pipette + + :raises RuntimeError: If location cache is None. + This should happen if `touch_tip` is called + without first calling a method that takes a + location (eg, :py:meth:`.aspirate`, + :py:meth:`dispense`) + + :returns: This instance + """ + if not self.hw_pipette['has_tip']: + raise hc.NoTipAttachedError('Pipette has no tip. Aborting air_gap') + + if height is None: + height = 5 + loc = self._ctx.location_cache + if not loc or not isinstance(loc.labware, Well): + raise RuntimeError('No previous Well cached to perform air gap') + target = loc.labware.top(height) + self.move_to(target) + self.aspirate(volume) + return self @cmds.publish.both(command=cmds.return_tip) def return_tip(self) -> 'InstrumentContext': @@ -802,6 +980,7 @@ def drop_tip( self.move_to(target) self._hw_manager.hardware.drop_tip(self._mount) + self._last_tip_picked_up_from = None return self def home(self) -> 'InstrumentContext': @@ -830,25 +1009,206 @@ def home_plunger(self) -> 'InstrumentContext': def distribute(self, volume: float, source: Well, - dest: Well, + dest: List[Well], *args, **kwargs) -> 'InstrumentContext': - raise NotImplementedError + """ + Move a volume of liquid from one source to multiple destinations. + + :param volume: The amount of volume to distribute to each destination + well. + :param source: A single well from where liquid will be aspirated. + :param dest: List of Wells where liquid will be dispensed to. + :param kwargs: See :py:meth:`transfer`. + :returns: This instance + """ + self._log.debug("Distributing {} from {} to {}" + .format(volume, source, dest)) + kwargs['mode'] = 'distribute' + kwargs['disposal_vol'] = kwargs.get('disposal_volume', self.min_volume) + return self.transfer(volume, source, dest, **kwargs) @cmds.publish.both(command=cmds.consolidate) def consolidate(self, volume: float, - source: Well, + source: List[Well], dest: Well, *args, **kwargs) -> 'InstrumentContext': - raise NotImplementedError + """ + Move liquid from multiple wells (sources) to a single well(destination) + + :param volume: The amount of volume to consolidate from each source + well. + :param source: List of wells from where liquid will be aspirated. + :param dest: The single well into which liquid will be dispensed. + :param kwargs: See :py:meth:`transfer`. + :returns: This instance + """ + self._log.debug("Consolidate {} from {} to {}" + .format(volume, source, dest)) + kwargs['mode'] = 'consolidate' + kwargs['disposal_vol'] = kwargs.get('disposal_volume', 0) + return self.transfer(volume, source, dest, **kwargs) @cmds.publish.both(command=cmds.transfer) def transfer(self, - volume: float, - source: Well, - dest: Well, + volume: Union[float, Sequence[float]], + source, + dest, **kwargs) -> 'InstrumentContext': - raise NotImplementedError + # source: Union[Well, List[Well], List[List[Well]]], + # dest: Union[Well, List[Well], List[List[Well]]], + # TODO: Reach consensus on kwargs + # TODO: Decide if to use a disposal_volume + # TODO: Accordingly decide if remaining liquid should be blown out to + # TODO: ..trash or the original well. + # TODO: What should happen if the user passes a non-first-row well + # TODO: ..as src/dest *while using multichannel pipette? + """ + Transfer will move a volume of liquid from a source location(s) + to a dest location(s). It is a higher-level command, incorporating + other :py:class:`InstrumentContext` commands, like :py:meth:`aspirate` + and :py:meth:`dispense`, designed to make protocol writing easier at + the cost of specificity. + + :param volume: The amount of volume to aspirate from each source and + dispense to each destination. + If volume is a list, each volume will be used for the + sources/targets at the matching index. If volumes is a + tuple with two elements, like `(20, 100)`, then a list + of volumes will be generated with a linear gradient + between the two volumes in the tuple. + + :param source: A single well or a list of wells from where liquid + will be aspirated. + + :param dest: A single well or a list of wells where liquid + will be dispensed to. + + :param kwargs: + new_tip : :py:class:`string` + 'never': no tips will be picked up or dropped + during the transfer. + 'once': (default) a single tip will be used for + all commands. + 'always': use a new tip for each transfer. + + trash : :py:class:`boolean` + If `False` (default behavior), tips will be + returned to their tip rack. If `True` and a trash + container has been attached to this `Pipette`, + then the tip will be sent to the trash container. + + touch_tip : :py:class:`boolean` + If `True`, a :py:meth:`touch_tip` will occur + following each :py:meth:`aspirate` and + :py:meth:`dispense`. If set to `False` + (default behavior), no :py:meth:`touch_tip` + will occur. + + blow_out : :py:class:`boolean` + If `True`, a :py:meth:`blow_out` will occur + following each :py:meth:`dispense`, but only + if the pipette has no liquid left in it. + If set to `False` (default), no + :py:meth:`blow_out` will occur. + + mix_before : :py:class:`tuple` + The tuple, if specified, gives the amount of + volume to :py:meth:`mix` preceding each + :py:meth:`aspirate` during the transfer. + The tuple is interpreted as + (repetitions, volume). + + mix_after : :py:class:`tuple` + The tuple, if specified, gives the amount of + volume to :py:meth:`mix` after each + :py:meth:`dispense` during the transfer. + The tuple is interpreted as + (repetitions, volume). + + carryover : :py:class:`boolean` + If `True` (default), any `volume` that + exceeds the maximum volume of this Pipette + will be split into multiple smaller volumes. + + gradient : lambda + Function for calculating the curve used for + gradient volumes. When `volume` is a tuple of + length 2, its values are used to create a list + of gradient volumes. The default curve for + this gradient is linear (lambda x: x), however + a method can be passed with the `gradient` + keyword argument to create a custom curve. + + :returns: This instance + """ + self._log.debug("Transfer {} from {} to {}".format( + volume, source, dest)) + + kwargs['mode'] = kwargs.get('mode', 'transfer') + mix_opts = transfers.Mix() + if 'mix_before' in kwargs and 'mix_after' in kwargs: + mix_strategy = transfers.MixStrategy.BOTH + before_opts = kwargs['mix_before'] + after_opts = kwargs['mix_after'] + mix_opts = mix_opts._replace( + mix_after=mix_opts.mix_after._replace( + repetitions=after_opts[0], volume=after_opts[1]), + mix_before=mix_opts.mix_before._replace( + repetitions=before_opts[0], volume=before_opts[1])) + elif 'mix_before' in kwargs: + mix_strategy = transfers.MixStrategy.BEFORE + before_opts = kwargs['mix_before'] + mix_opts = mix_opts._replace( + mix_before=mix_opts.mix_before._replace( + repetitions=before_opts[0], volume=before_opts[1])) + elif 'mix_after' in kwargs: + mix_strategy = transfers.MixStrategy.AFTER + after_opts = kwargs['mix_after'] + mix_opts = mix_opts._replace( + mix_after=mix_opts.mix_after._replace( + repetitions=after_opts[0], volume=after_opts[1])) + else: + mix_strategy = transfers.MixStrategy.NEVER + + if kwargs.get('trash'): + drop_tip = transfers.DropTipStrategy.TRASH + else: + drop_tip = transfers.DropTipStrategy.RETURN + + blow_out = None + if kwargs.get('blow_out'): + blow_out = transfers.BlowOutStrategy.TRASH + + touch_tip = None + if kwargs.get('touch_tip'): + touch_tip = transfers.TouchTipStrategy.ALWAYS + + transfer_args = transfers.Transfer( + new_tip=kwargs.get('new_tip') or transfers.Transfer.new_tip, + air_gap=kwargs.get('air_gap') or transfers.Transfer.air_gap, + carryover=kwargs.get('carryover') or transfers.Transfer.carryover, + gradient_function=(kwargs.get('gradient_function') or + transfers.Transfer.gradient_function), + disposal_volume=(kwargs.get('disposal_volume') or + transfers.Transfer.disposal_volume), + mix_strategy=mix_strategy, + drop_tip_strategy=drop_tip, + blow_out_strategy=blow_out or transfers.Transfer.blow_out_strategy, + touch_tip_strategy=(touch_tip or + transfers.Transfer.touch_tip_strategy) + ) + + transfer_options = transfers.TransferOptions(transfer=transfer_args, + mix=mix_opts) + plan = transfers.TransferPlan(volume, source, dest, self, + kwargs['mode'], transfer_options) + for cmd in plan: + if isinstance(cmd['params'], dict): + getattr(self, cmd['method'])(**cmd['params']) + else: + getattr(self, cmd['method'])(*cmd['params']) + return self def delay(self): return self._ctx.delay() @@ -995,6 +1355,10 @@ def name(self) -> str: """ return self.hw_pipette['name'] + @property + def min_volume(self) ->float: + return self.hw_pipette['min_volume'] + @property def max_volume(self) -> float: """ diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 93f4d48e27d..4f9e912a407 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -28,6 +28,12 @@ class WellShape(Enum): class Well: + """ + The Well class represents a single well in a :py:class:`Labware` + + It provides functions to return positions used in operations on the well + such as :py:meth:`top`, :py:meth:`bottom` + """ def __init__(self, well_props: dict, parent: Location, display_name: str, @@ -85,22 +91,22 @@ def has_tip(self) -> bool: def has_tip(self, value: bool): self._has_tip = value - def top(self) -> Location: + def top(self, z: float = 0.0) -> Location: """ :return: a Point corresponding to the absolute position of the top-center of the well relative to the deck (with the front-left corner of slot 1 as (0,0,0)) """ - return Location(self._position, self) + return Location(self._position + Point(0, 0, z), self) - def bottom(self) -> Location: + def bottom(self, z: float = 0.0) -> Location: """ :return: a Point corresponding to the absolute position of the bottom-center of the well (with the front-left corner of slot 1 as (0,0,0)) """ top = self.top() - bottom_z = top.point.z - self._depth + bottom_z = top.point.z - self._depth + z return Location(Point(x=top.point.x, y=top.point.y, z=bottom_z), self) def center(self) -> Location: diff --git a/api/src/opentrons/protocol_api/transfers.py b/api/src/opentrons/protocol_api/transfers.py new file mode 100644 index 00000000000..b458f22ec1a --- /dev/null +++ b/api/src/opentrons/protocol_api/transfers.py @@ -0,0 +1,732 @@ +import enum +from typing import (Any, List, Optional, Union, NamedTuple, + Callable, TYPE_CHECKING) +from .labware import Well +from opentrons import helpers +from opentrons import types + +if TYPE_CHECKING: + from .contexts import InstrumentContext #noqa (F501) + + +class MixStrategy(enum.Enum): + BOTH = enum.auto() + BEFORE = enum.auto() + AFTER = enum.auto() + NEVER = enum.auto() + + +class DropTipStrategy(enum.Enum): + TRASH = enum.auto() + RETURN = enum.auto() + + +class TouchTipStrategy(enum.Enum): + NEVER = enum.auto() + ALWAYS = enum.auto() + + +class BlowOutStrategy(enum.Enum): + TRASH = enum.auto() + DEST_IF_EMPTY = enum.auto() + CUSTOM_LOCATION = enum.auto() + + +class TransferMode(enum.Enum): + DISTRIBUTE = enum.auto() + CONSOLIDATE = enum.auto() + TRANSFER = enum.auto() + + +class Transfer(NamedTuple): + """ + Options pertaining to behavior of the transfer. + + """ + + new_tip: types.TransferTipPolicy = types.TransferTipPolicy.ONCE + air_gap: float = 0 + carryover: bool = True + gradient_function: Optional[Callable[[float], float]] = None + disposal_volume: Optional[float] = 0 + mix_strategy: MixStrategy = MixStrategy.NEVER + drop_tip_strategy: DropTipStrategy = DropTipStrategy.TRASH + blow_out_strategy: BlowOutStrategy = BlowOutStrategy.TRASH + touch_tip_strategy: TouchTipStrategy = TouchTipStrategy.NEVER + + +Transfer.new_tip.__doc__ = """ + Control when or if to pick up tip during a transfer + + :py:attr:`types.TransferTipPolicy.ALWAYS` + Drop and pick up a new tip after each dispense. + + :py:attr:`types.TransferTipPolicy.ONCE` + Pick up tip at the beginning of the transfer and use it + throughout the transfer. This would speed up the transfer. + + :py:attr:`types.TransferTipPolicy.NEVER` + Do not ever pick up or drop tip. The protocol should explicitly + pick up a tip before transfer and drop it afterwards. + + To customize where to drop tip, see :py:attr:`.drop_tip_strategy`. + To customize the behavior of pickup tip, see + :py:attr:`.TransferOptions.pick_up_tip`. + """ + +Transfer.air_gap.__doc__ = """ + Controls the volume (in uL) of air gap aspirated when moving to + dispense. + + Adding an air gap would slow down a transfer since less liquid will + now fit in the pipette but it prevents the loss of liquid while + moving between wells. + """ + +Transfer.carryover.__doc__ = """ + Controls whether volumes larger than pipette's max volume will be + split into smaller volumes. + """ + +Transfer.gradient_function.__doc__ = """ + Specify a nonlinear gradient for volumes. + + This should be a function that takes a single float between 0 and 1 + and returns a single float between 0 and 1. This function is used + to determine the path the transfer takes between the volume + gradient minimum and maximum if the transfer volume is specified as + a gradient. For instance, specifying the function as + .. code-block:: python + def gradient(a): + if a > 0.5: + return 1.0 + else: + return 0.0 + + would transfer the minimum volume of the gradient to the first half + of the target wells, and the maximum to the other half. + """ + +Transfer.disposal_volume.__doc__ = """ + The amount of liquid (in uL) to aspirate as a buffer. + + The remaining buffer will be blown out into the location specified + by :py:attr:`.blow_out_strategy`. + + This is useful to avoid under-pipetting but can waste reagent and + slow down transfer. + """ + +Transfer.mix_strategy.__doc__ = """ + If and when to mix during a transfer. + + :py:attr:`MixStrategy.NEVER` + Do not ever perform a mix during the transfer. + + :py:attr:`MixStrategy.BEFORE` + Mix before each aspirate. + + :py:attr:`MixStrategy.AFTER` + Mix after each dispense. + + :py:attr:`MixStrategy.BOTH` + Mix before each aspirate and after each dispense. + + To customize the mix behavior, see :py:attr:`.TransferOptions.mix` + """ + +Transfer.drop_tip_strategy.__doc__ = """ + Specifies the location to drop tip into. + + :py:attr:`DropTipStrategy.TRASH` + Drop the tip into the trash container. + + :py:attr:`DropTipStrategy.RETURN` + Return the tip to tiprack. + """ + +Transfer.blow_out_strategy.__doc__ = """ + Specifies the location to blow out the liquid in the pipette to. + + :py:attr:`BlowOutStrategy.TRASH` + Blow out to trash container. + + :py:attr:`BlowOutStrategy.DEST_IF_EMPTY` + If the volume in the current tip is 0 (expected), then blow out + to the destination well in order to dispense any leftover + liquid. + + :py:attr:`BlowOutStrategy.CUSTOM_LOCATION` + If using any other location to blow out to. Specify the location in + :py:attr:`.TransferOptions.blow_out`. + """ + +Transfer.touch_tip_strategy.__doc__ = """ + Controls whether to touch tip during the transfer + + This helps in getting rid of any droplets clinging to the pipette + tip at the cost of slowing down the transfer. + + :py:attr:`TouchTipStrategy.NEVER` + Do not touch tip ever during the transfer. + + :py:attr:`TouchTipStrategy.ALWAYS` + Touch tip after each aspirate. + + To customize the behavior of touch tips, see + :py:attr:`.TransferOptions.touch_tip`. + """ + + +class PickUpTipOpts(NamedTuple): + """ + Options to customize :py:attr:`.TransferOptions.Transfer.new_tip`. + + These options will be passed to + :py:meth:`InstrumentContext.pick_up_tip` when it is called during + the transfer. + """ + location: Optional[types.Location] = None + presses: Optional[int] = None + increment: Optional[int] = None + + +PickUpTipOpts.location.__doc__ = ':py:class:`types.Location`' +PickUpTipOpts.presses.__doc__ = ':py:class:`int`' +PickUpTipOpts.increment.__doc__ = ':py:class:`int`' + + +class MixOpts(NamedTuple): + """ + Options to customize behavior of mix. + + These options will be passed to + :py:meth:`InstrumentContext.mix` when it is called during the + transfer. + """ + + repetitions: Optional[int] = None + volume: Optional[float] = None + rate: Optional[float] = None + + +MixOpts.repetitions.__doc__ = ':py:class:`int`' +MixOpts.volume.__doc__ = ':py:class:`float`' +MixOpts.rate.__doc__ = ':py:class:`float`' + + +class Mix(NamedTuple): + """ + Options to control mix behavior before aspirate and after dispense. + """ + mix_before: MixOpts = MixOpts() + mix_after: MixOpts = MixOpts() + + +Mix.mix_before.__doc__ = """ + Options applied to mix before aspirate. + See :py:class:`.Mix.MixOpts`. + """ + +Mix.mix_after.__doc__ = """ + Options applied to mix after dispense. See :py:class:`.Mix.MixOpts`. + """ + + +class BlowOutOpts(NamedTuple): + """ + Location where to blow out instead of the trash. + + This location will be passed to :py:meth:`InstrumentContext.blow_out` + when called during the transfer + """ + location: Optional[Union[types.Location, Well]] = None + + +BlowOutOpts.location.__doc__ = ':py:class:`types.Location`' + + +class TouchTipOpts(NamedTuple): + """ + Options to customize touch tip. + + These options will be passed to + :py:meth:`InstrumentContext.touch_tip` when called during the + transfer. + """ + radius: Optional[float] = None + v_offset: Optional[float] = None + speed: Optional[float] = None + + +TouchTipOpts.radius.__doc__ = ':py:class:`float`' +TouchTipOpts.v_offset.__doc__ = ':py:class:`float`' +TouchTipOpts.speed.__doc__ = ':py:class:`float`' + + +class AspirateOpts(NamedTuple): + """ + Option to customize aspirate rate. + + This option will be passed to :py:meth:`InstrumentContext.aspirate` + when called during the transfer. + """ + rate: Optional[float] = 1.0 + + +AspirateOpts.rate.__doc__ = ':py:class:`float`' + + +class DispenseOpts(NamedTuple): + """ + Option to customize dispense rate. + + This option will be passed to :py:meth:`InstrumentContext.dispense` + when called during the transfer. + """ + rate: Optional[float] = 1.0 + + +DispenseOpts.rate.__doc__ = ':py:class:`float`' + + +class TransferOptions(NamedTuple): + """ + All available options for a transfer, distribute or consolidate function + """ + transfer: Transfer = Transfer() + pick_up_tip: PickUpTipOpts = PickUpTipOpts() + mix: Mix = Mix() + blow_out: BlowOutOpts = BlowOutOpts() + touch_tip: TouchTipOpts = TouchTipOpts() + aspirate: AspirateOpts = AspirateOpts() + dispense: DispenseOpts = DispenseOpts() + + +TransferOptions.transfer.__doc__ = """ + Options pertaining to behavior of the transfer. + + For instance you can control how frequently to get a new tip using + :py:attr:`.Transfer.new_tip`. For documentation of all transfer options + see :py:class:`.Transfer`. + """ + +TransferOptions.pick_up_tip.__doc__ = """ + Options used when picking up a tip during transfer. + See :py:class:`.TransferOptions.PickUpTipsOpts`. + """ + +TransferOptions.mix.__doc__ = """ + Options to control mix behavior before aspirate and after dispense. + See :py:class:`.TransferOptions.Mix`. + """ + +TransferOptions.blow_out.__doc__ = """ + Option to specify custom location for blow out. See + :py:class:`.TransferOptions.BlowOutOpts`. + """ + +TransferOptions.touch_tip.__doc__ = """ + Options to customize touch tip. See + :py:class:`.TransferOptions.TouchTipOpts`. + """ + +TransferOptions.aspirate.__doc__ = """ + Option to customize aspirate rate. See + :py:class:`.TransferOptions.AspirateOpts`. + """ + +TransferOptions.dispense.__doc__ = """ + Option to customize dispense rate. See + :py:class:`.TransferOptions.DispenseOpts`. + """ + + +class TransferPlan: + """ Calculate and carry state for an arbitrary transfer + + This class encapsulates the logic around planning an M:N transfer. + + It handles calculations based on pipette channels, tip management, and all + the various little commands that can be involved in a transfer. It can be + iterated to resolve methods to call to execute the plan. + """ + def __init__(self, + volume, + sources, + dests, + instr: 'InstrumentContext', + mode: Optional[str] = None, + options: Optional[TransferOptions] = None + ) -> None: + """ Build the transfer plan. + + This method initializes the object and does the work of preparing the + transfer plan. Its arguments are as those of + :py:meth:`.InstrumentContext.transfer`. + """ + self._instr = instr + # Convert sources & dests into proper format + # CASES: + # i. if using multi-channel pipette, + # and the source or target is a row/column of Wells (i.e list of Wells) + # then avoid iterating through its Wells. + # ii. if using single channel pipettes, flatten a multi-dimensional + # list of Wells into a 1 dimensional list of Wells + if self._instr.hw_pipette['channels'] > 1: + sources, dests = self._multichannel_transfer(sources, dests) + else: + if isinstance(sources, List) and isinstance(sources[0], List): + # Source is a List[List[Well]] + sources = [well for well_list in sources for well in well_list] + elif isinstance(sources, Well): + sources = [sources] + if isinstance(dests, List) and isinstance(dests[0], List): + # Dest is a List[List[Well]] + dests = [well for well_list in dests for well in well_list] + elif isinstance(dests, Well): + dests = [dests] + + total_xfers = max(len(sources), len(dests)) + + self._volumes = self._create_volume_list(volume, total_xfers) + self._sources = sources + self._dests = dests + self._options = options or TransferOptions() + self._strategy = self._options.transfer + self._tip_opts = self._options.pick_up_tip + self._blow_opts = self._options.blow_out + self._touch_tip_opts = self._options.touch_tip + self._mix_before_opts = self._options.mix.mix_before + self._mix_after_opts = self._options.mix.mix_after + + if not mode: + if len(sources) < len(dests): + self._mode = TransferMode.DISTRIBUTE + elif len(sources) > len(dests): + self._mode = TransferMode.CONSOLIDATE + else: + self._mode = TransferMode.TRANSFER + else: + self._mode = TransferMode[mode.upper()] + + def __iter__(self): + if self._strategy.new_tip == types.TransferTipPolicy.ONCE: + yield self._format_dict('pick_up_tip', self._tip_opts) + yield from {TransferMode.CONSOLIDATE: self._plan_consolidate, + TransferMode.DISTRIBUTE: self._plan_distribute, + TransferMode.TRANSFER: self._plan_transfer}[self._mode]() + if self._strategy.new_tip == types.TransferTipPolicy.ONCE: + if self._strategy.drop_tip_strategy == DropTipStrategy.RETURN: + yield self._format_dict('return_tip') + else: + yield self._format_dict('drop_tip') + + # def update_option(self, option_name, new_value): + # if option_name in self._options + + def _plan_transfer(self): + """ + Source/ Dest: Multiple sources to multiple destinations. + Src & dest should be equal length + Volume: Single volume or List of volumes is acceptable. This list + should be same length as sources/destinations + # Behavior with transfer options: + New_tip: can be either NEVER or ONCE or ALWAYS + Air_gap: if specified, will be performed after every aspirate + Blow_out: can be performed after each dispense (after mix, + before touch_tip) at the location specified. If there is + liquid present in the tip (as in the case of nonzero + disposal volume), blow_out will be performed at either + user-defined location or (default) trash. + If no liquid is supposed to be present in the tip after + dispense, blow_out will be performed at dispense well loc + (if blow out strategy is DEST_IF_EMPTY) + Touch_tip: can be performed after each aspirate and/or after + each dispense + Mix: can be performed before aspirate and/or after dispense + if there is no disposal volume (i.e. can be performed + only when the tip is supposed to be empty) + Considering all options, the sequence of actions is: + New Tip -> Mix -> Aspirate (with disposal volume) -> Air gap -> + -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> + -> Blow out -> Touch tip -> Drop tip + """ + plan_iter = zip(self._volumes, self._sources, self._dests) + for step_params in plan_iter: + if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: + yield self._format_dict('pick_up_tip', self._tip_opts) + step_vol = step_params[0] + max_vol = self._instr.max_volume - self._strategy.disposal_volume \ + - self._strategy.air_gap + xferred_vol = 0 + while xferred_vol != step_vol: + # TODO: account for unequal length sources, dests + # TODO: ensure last transfer is > min_vol + vol = min(max_vol, step_vol - xferred_vol) + yield from self._aspirate_actions(vol, step_params[1]) + yield from self._dispense_actions(vol, step_params[2]) + xferred_vol += vol + yield from self._new_tip_action() + + def _plan_distribute(self): + """ + Source/ Dest: One source to many destinations + Volume: Single volume or List of volumes is acceptable. This list + should be same length as destinations + # Behavior with transfer options: + New_tip: can be either NEVER or ONCE + (ALWAYS will fallback to ONCE) + Air_gap: if specified, will be performed after every aspirate and + also in-between dispenses (to keep air gap while moving + between wells) + Blow_out: can be performed at the end of distribute (after mix, + before touch_tip) at the location specified. If there is + liquid present in the tip, blow_out will be performed at + either user-defined location or (default) trash. + If no liquid is supposed to be present in the tip at the + end of distribute, blow_out will be performed at the last + well the liquid was dispensed to (if strategy is + DEST_IF_EMPTY) + Touch_tip: can be performed after each aspirate and/or after + every dispense + Mix: can be performed before aspirate and/or after the last + dispense if there is no disposal volume (i.e. can be performed + only when the tip is supposed to be empty) + + Considering all options, the sequence of actions is: + 1. Going from source to dest1: + New Tip -> Mix -> Aspirate (with disposal volume) -> Air gap -> + -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> + -> Blow out -> Touch tip -> Drop tip + 2. Going from destn to destn+1: + .. Dispense air gap -> Dispense -> Touch tip -> Air gap -> + .. Dispense air gap -> ... + + """ + # TODO: decide whether default disposal vol for distribute should be + # pipette min_vol or should we leave it to being 0 by default and + # recommend users to specify a disposal vol when using distribute. + # First method keeps distribute consistent with current behavior while + # the other maintains consistency in default behaviors of all functions + plan_iter = zip(self._volumes, self._dests) + + done = False + current_xfer = next(plan_iter) + if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: + yield self._format_dict('pick_up_tip', self._tip_opts) + while not done: + asp_grouped = [] + try: + while (sum(a[0] for a in asp_grouped) + + self._strategy.disposal_volume + + self._strategy.air_gap + + current_xfer[0]) < self._instr.max_volume: + asp_grouped.append(current_xfer) + current_xfer = next(plan_iter) + except StopIteration: + done = True + yield from self._aspirate_actions(sum(a[0] for a in asp_grouped) + + self._strategy.disposal_volume, + self._sources[0]) + for step in asp_grouped: + yield from self._dispense_actions(step[0], step[1], + step is not asp_grouped[-1]) + yield from self._new_tip_action() + + def _plan_consolidate(self): + """ + Source/ Dest: Many sources to one destination + Volume: Single volume or List of volumes is acceptable. This list + should be same length as sources + # Behavior with transfer options: + New_tip: can be either NEVER or ONCE + (ALWAYS will fallback to ONCE) + Air_gap: if specified, will be performed after every aspirate + so that the aspirated liquids do not mix inside the tip. + The air gap will be dispensed while dispensing the liquid + into the destination well. + Blow_out: can be performed after a dispense (after mix, + before touch_tip) at the location specified. If there is + liquid present in the tip (which shouldn't happen since + consolidate doesn't take a disposal vol, yet), blow_out + will be performed at either user-defined location or + (default) trash. + If no liquid is supposed to be present in the tip after + dispense, blow_out will be performed at dispense well loc + (if blow out strategy is DEST_IF_EMPTY) + Touch_tip: can be performed after each aspirate and/or after + dispense + Mix: can be performed before the first aspirate and/or after + dispense if there is no disposal volume (i.e. can be performed + only when the tip is supposed to be empty) + Considering all options, the sequence of actions is: + 1. Going from source to dest1: + New Tip -> Mix -> Aspirate (with disposal volume?) -> Air gap -> + -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> + -> Blow out -> Touch tip -> Drop tip + 2. Going from source(n) to source(n+1): + .. Aspirate -> Air gap -> Touch tip ->.. + .. Aspirate -> ..... + """ + plan_iter = zip(self._volumes, self._sources) + current_xfer = next(plan_iter) + if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: + yield self._format_dict('pick_up_tip', self._tip_opts) + done = False + while not done: + asp_grouped = [] + try: + while (sum([a[0] for a in asp_grouped]) + + self._strategy.disposal_volume + + self._strategy.air_gap * len(asp_grouped) + + current_xfer[0]) < self._instr.max_volume: + asp_grouped.append(current_xfer) + current_xfer = next(plan_iter) + except StopIteration: + done = True + if not asp_grouped: + break + # Q: What accounts as disposal volume in a consolidate action? + # yield self._format_dict('aspirate', + # self._strategy.disposal_volume, loc) + for step in asp_grouped: + yield from self._aspirate_actions(step[0], step[1]) + yield from self._dispense_actions( + sum([a[0] + self._strategy.air_gap for a in asp_grouped]) + - self._strategy.air_gap, + self._dests[0]) + yield from self._new_tip_action() + + def _aspirate_actions(self, vol, loc): + yield from self._before_aspirate() + yield self._format_dict('aspirate', + [vol, loc, self._options.aspirate.rate]) + yield from self._after_aspirate() + + def _dispense_actions(self, vol, loc, is_disp_next=False): + yield from self._before_dispense() + yield self._format_dict('dispense', + [vol, loc, self._options.dispense.rate]) + yield from self._after_dispense(loc, is_disp_next) + + def _before_aspirate(self): + if self._strategy.mix_strategy == MixStrategy.BEFORE or \ + self._strategy.mix_strategy == MixStrategy.BOTH: + if self._instr.current_volume == 0: + yield self._format_dict('mix', self._mix_before_opts) + + def _after_aspirate(self): + if self._strategy.air_gap: + yield self._format_dict('air_gap', [self._strategy.air_gap]) + if self._strategy.touch_tip_strategy == TouchTipStrategy.ALWAYS: + yield self._format_dict('touch_tip', self._touch_tip_opts) + + def _before_dispense(self): + if self._strategy.air_gap: + yield self._format_dict('dispense', [self._strategy.air_gap]) + + def _after_dispense(self, loc, is_disp_next=False): + # This sequence of actions is subject to change + # TODO: write a proper sequence for blow_out. Current sequence is buggy + if not is_disp_next: + # TODO: check this logic. Esp blow_out + if self._instr.current_volume == 0: + if self._strategy.mix_strategy == MixStrategy.AFTER or \ + self._strategy.mix_strategy == MixStrategy.BOTH: + yield self._format_dict('mix', self._mix_after_opts) + if self._strategy.blow_out_strategy \ + == BlowOutStrategy.DEST_IF_EMPTY: + yield self._format_dict('blow_out', [loc]) + else: + # Custom location. Or trash if not specified + yield self._format_dict('blow_out', self._blow_opts) + else: + # Used by distribute + if self._strategy.air_gap: + yield self._format_dict('air_gap', [self._strategy.air_gap]) + if self._strategy.touch_tip_strategy == TouchTipStrategy.ALWAYS: + yield self._format_dict('touch_tip', self._touch_tip_opts) + + def _new_tip_action(self): + if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: + if self._strategy.drop_tip_strategy == DropTipStrategy.RETURN: + yield self._format_dict('return_tip') + else: + yield self._format_dict('drop_tip') + + def _format_dict(self, method: str, params: Any = []): + # TODO: verify if this is a good way to check for NamedTuple + if getattr(type(params), '_fields', None): + params = {key: val for key, val in params._asdict().items() if val} + return {'method': method, 'params': params} + + def _create_source_target_lists(self, s, t): + # No longer used. Preserved for future use possibility + s = helpers._get_list(s) + t = helpers._get_list(t) + len_s = len(s) + len_t = len(t) + if len_s < len_t: + if (len_t / len_s) % 1 > 0: + raise ValueError( + 'Source and destination lists must be divisible') + s = [source for source in s for i in range(int(len_t / len_s))] + elif len_s > len_t: + if (len_s / len_t) % 1 > 0: + raise ValueError( + 'Source and destination lists must be divisible') + t = [dest for dest in t for i in range(int(len_s / len_t))] + return (s, t) + + def _create_volume_list(self, volume, total_xfers): + if isinstance(volume, (float, int)): + return [volume] * total_xfers + elif isinstance(volume, tuple): + return helpers._create_volume_gradient( + volume[0], volume[-1], total_xfers, + self._strategy.gradient_function) + else: + if not isinstance(volume, List): + raise TypeError("Volume expected as a number or List or" + " tuple but got {}".format(volume)) + elif not len(volume) == total_xfers: + raise RuntimeError("List of volumes should be equal to number " + "of transfers") + return volume + + def _multichannel_transfer(self, s, d): + # TODO: add a check for container being multi-channel compatible? + # Helper function for multi-channel use-case + assert isinstance(s, Well) or \ + (isinstance(s, List) and isinstance(s[0], Well)) or \ + (isinstance(s, List) and isinstance(s[0], List)),\ + 'Source should be a Well or List[Well] but is {}'.format(s) + assert isinstance(d, Well) or \ + (isinstance(d, List) and isinstance(d[0], Well)) or \ + (isinstance(d, List) and isinstance(d[0], List)), \ + 'Target should be a Well or List[Well] but is {}'.format(d) + + # TODO: Account for cases where a src/dest list has a non-first-row + # TODO: ..well (eg, 'B1') and would expect the robot/pipette to + # TODO: ..understand that it is referring to the whole first column + if isinstance(s, List) and isinstance(s[0], List): + # s is a List[List]]; flatten to 1D list + s = [well for list_elem in s for well in list_elem] + for well in s: + if not self._is_first_row(well): + # For now, just remove wells that aren't in first row + s.remove(well) + + if isinstance(d, List) and isinstance(d[0], List): + # s is a List[List]]; flatten to 1D list + d = [well for list_elem in d for well in list_elem] + for well in d: + if not self._is_first_row(well): + # For now, just remove wells that aren't in first row + d.remove(well) + + return s, d + + def _is_first_row(self, well: Well): + return True if 'A' in str(well) else False diff --git a/api/src/opentrons/types.py b/api/src/opentrons/types.py index 69df06be181..7f746f3e719 100644 --- a/api/src/opentrons/types.py +++ b/api/src/opentrons/types.py @@ -91,4 +91,10 @@ def __str__(self): return self.name +class TransferTipPolicy(enum.Enum): + ONCE = enum.auto() + NEVER = enum.auto() + ALWAYS = enum.auto() + + DeckLocation = Union[int, str] diff --git a/api/tests/opentrons/protocol_api/test_context.py b/api/tests/opentrons/protocol_api/test_context.py index a4bad5c56b2..d9b5200110e 100644 --- a/api/tests/opentrons/protocol_api/test_context.py +++ b/api/tests/opentrons/protocol_api/test_context.py @@ -444,3 +444,97 @@ def test_hw_manager(loop): del mgr # but not its new one, even if deleted assert passed.is_alive() + + +def test_mix(loop, monkeypatch): + ctx = papi.ProtocolContext(loop) + ctx.home() + lw = ctx.load_labware_by_name('opentrons_24_tuberack_1.5_mL_eppendorf', 1) + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_300_uL', 3) + instr = ctx.load_instrument('p300_single', Mount.RIGHT, + tip_racks=[tiprack]) + + instr.pick_up_tip() + mix_steps = [] + aspirate_called_with = None + dispense_called_with = None + + def fake_aspirate(vol=None, loc=None, rate=None): + nonlocal aspirate_called_with + nonlocal mix_steps + aspirate_called_with = ('aspirate', vol, loc, rate) + mix_steps.append(aspirate_called_with) + + def fake_dispense(vol=None, loc=None, rate=None): + nonlocal dispense_called_with + nonlocal mix_steps + dispense_called_with = ('dispense', vol, loc, rate) + mix_steps.append(dispense_called_with) + + monkeypatch.setattr(instr, 'aspirate', fake_aspirate) + monkeypatch.setattr(instr, 'dispense', fake_dispense) + + repetitions = 2 + volume = 5 + location = lw.wells()[0] + rate = 2 + instr.mix(repetitions, volume, location, rate) + expected_mix_steps = [('aspirate', volume, location, 2), + ('dispense', volume, None, 2), + ('aspirate', volume, None, 2), + ('dispense', volume, None, 2)] + + assert mix_steps == expected_mix_steps + + +def test_touch_tip_default_args(loop, monkeypatch): + ctx = papi.ProtocolContext(loop) + ctx.home() + lw = ctx.load_labware_by_name('opentrons_24_tuberack_1.5_mL_eppendorf', 1) + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_300_uL', 3) + instr = ctx.load_instrument('p300_single', Mount.RIGHT, + tip_racks=[tiprack]) + + instr.pick_up_tip() + total_hw_moves = [] + + async def fake_hw_move(mount, abs_position, speed=None, + critical_point=None): + nonlocal total_hw_moves + print("new_move_pos:{}".format(abs_position)) + total_hw_moves.append((abs_position, speed)) + + instr.aspirate(10, lw.wells()[0]) + monkeypatch.setattr(ctx._hw_manager.hardware._api, 'move_to', fake_hw_move) + instr.touch_tip() + z_offset = Point(0, 0, 1) # default z offset of 1mm + speed = 60 # default speed + edges = [lw.wells()[0]._from_center_cartesian(1, 0, 1) - z_offset, + lw.wells()[0]._from_center_cartesian(-1, 0, 1) - z_offset, + lw.wells()[0]._from_center_cartesian(0, 1, 1) - z_offset, + lw.wells()[0]._from_center_cartesian(0, -1, 1) - z_offset] + print("Well bottom clearance: {}".format(instr.well_bottom_clearance)) + + for i in range(1, 5): + assert total_hw_moves[i] == (edges[i-1], speed) + + +def test_blow_out(loop, monkeypatch): + ctx = papi.ProtocolContext(loop) + ctx.home() + lw = ctx.load_labware_by_name('opentrons_24_tuberack_1.5_mL_eppendorf', 1) + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_300_uL', 3) + instr = ctx.load_instrument('p300_single', Mount.RIGHT, + tip_racks=[tiprack]) + + move_location = None + instr.pick_up_tip() + instr.aspirate(10, lw.wells()[0]) + + def fake_move(loc): + nonlocal move_location + move_location = loc + + monkeypatch.setattr(instr, 'move_to', fake_move) + instr.blow_out() + assert move_location == lw.wells()[0].top() diff --git a/api/tests/opentrons/protocol_api/test_transfers.py b/api/tests/opentrons/protocol_api/test_transfers.py new file mode 100644 index 00000000000..c1cc4fcb284 --- /dev/null +++ b/api/tests/opentrons/protocol_api/test_transfers.py @@ -0,0 +1,411 @@ +""" Test the Transfer class and its functions """ +import pytest +import opentrons.protocol_api as papi +from opentrons.types import Mount, TransferTipPolicy +from opentrons.protocol_api import transfers as tx + + +@pytest.fixture +def _instr_labware(loop): + ctx = papi.ProtocolContext(loop) + lw1 = ctx.load_labware_by_name('biorad_96_wellPlate_pcr_200_uL', 1) + lw2 = ctx.load_labware_by_name('generic_96_wellPlate_380_uL', 2) + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_300_uL', 3) + instr = ctx.load_instrument('p300_single', Mount.RIGHT, + tip_racks=[tiprack]) + + return {'ctx': ctx, 'instr': instr, 'lw1': lw1, 'lw2': lw2, + 'tiprack': tiprack} + + +def test_default_transfers(_instr_labware): + # Transfer 100ml from row1 of labware1 to row1 of labware2: first with + # new_tip = ONCE, then with new_tip = NEVER + _instr_labware['ctx'].home() + lw1 = _instr_labware['lw1'] + lw2 = _instr_labware['lw2'] + + # ========== Transfer =========== + xfer_plan = tx.TransferPlan(100, lw1.columns()[0], lw2.columns()[0], + _instr_labware['instr']) + xfer_plan_list = [] + for step in xfer_plan: + xfer_plan_list.append(step) + exp1 = [{'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][0], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][0], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][1], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][2], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][3], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][4], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][5], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][5], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][6], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][6], 1.0]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][7], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][7], 1.0]}, + {'method': 'drop_tip', 'params': []}] + assert xfer_plan_list == exp1 + + # ========== Distribute =========== + dist_plan = tx.TransferPlan(50, lw1.columns()[0][0], lw2.columns()[0], + _instr_labware['instr']) + dist_plan_list = [] + for step in dist_plan: + dist_plan_list.append(step) + exp2 = [{'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [250, lw1.columns()[0][0], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][0], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][1], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][2], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][3], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][4], 1.0]}, + {'method': 'aspirate', 'params': [150, lw1.columns()[0][0], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][5], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][6], 1.0]}, + {'method': 'dispense', 'params': [50, lw2.columns()[0][7], 1.0]}, + {'method': 'drop_tip', 'params': []}] + assert dist_plan_list == exp2 + + # ========== Consolidate =========== + consd_plan = tx.TransferPlan(50, lw1.columns()[0], lw2.columns()[0][0], + _instr_labware['instr']) + consd_plan_list = [] + for step in consd_plan: + consd_plan_list.append(step) + exp3 = [{'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][0], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][1], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][2], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][3], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][4], 1.0]}, + {'method': 'dispense', 'params': [250, lw2.columns()[0][0], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][5], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][6], 1.0]}, + {'method': 'aspirate', 'params': [50, lw1.columns()[0][7], 1.0]}, + {'method': 'dispense', 'params': [150, lw2.columns()[0][0], 1.0]}, + {'method': 'drop_tip', 'params': []}] + assert consd_plan_list == exp3 + + +def test_no_new_tip(_instr_labware): + _instr_labware['ctx'].home() + lw1 = _instr_labware['lw1'] + lw2 = _instr_labware['lw2'] + + options = tx.TransferOptions() + options = options._replace( + transfer=options.transfer._replace( + new_tip=TransferTipPolicy.NEVER)) + # ========== Transfer ========== + xfer_plan = tx.TransferPlan(100, lw1.columns()[0], lw2.columns()[0], + _instr_labware['instr'], options=options) + for step in xfer_plan: + assert step['method'] != 'pick_up_tip' + assert step['method'] != 'drop_tip' + + # ========== Distribute =========== + dist_plan = tx.TransferPlan(30, lw1.columns()[0][0], lw2.columns()[0], + _instr_labware['instr'], options=options) + for step in dist_plan: + assert step['method'] != 'pick_up_tip' + assert step['method'] != 'drop_tip' + + # ========== Consolidate =========== + consd_plan = tx.TransferPlan(40, lw1.columns()[0], lw2.rows()[0][1], + _instr_labware['instr'], options=options) + for step in consd_plan: + assert step['method'] != 'pick_up_tip' + assert step['method'] != 'drop_tip' + + +def test_new_tip_always(_instr_labware, monkeypatch): + _instr_labware['ctx'].home() + lw1 = _instr_labware['lw1'] + lw2 = _instr_labware['lw2'] + tiprack = _instr_labware['tiprack'] + i_ctx = _instr_labware['instr'] + + options = tx.TransferOptions() + options = options._replace( + transfer=options.transfer._replace( + new_tip=TransferTipPolicy.ALWAYS, + drop_tip_strategy=tx.DropTipStrategy.RETURN)) + + xfer_plan = tx.TransferPlan(100, + lw1.columns()[0][1:5], lw2.columns()[0][1:5], + _instr_labware['instr'], options=options) + xfer_plan_list = [] + for step in xfer_plan: + xfer_plan_list.append(step) + exp1 = [{'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][1], 1.0]}, + {'method': 'return_tip', 'params': []}, + {'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][2], 1.0]}, + {'method': 'return_tip', 'params': []}, + {'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][3], 1.0]}, + {'method': 'return_tip', 'params': []}, + {'method': 'pick_up_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, + {'method': 'dispense', 'params': [100, lw2.columns()[0][4], 1.0]}, + {'method': 'return_tip', 'params': []}] + assert xfer_plan_list == exp1 + for cmd in xfer_plan_list: + if isinstance(cmd['params'], dict): + getattr(i_ctx, cmd['method'])(**cmd['params']) + else: + getattr(i_ctx, cmd['method'])(*cmd['params']) + assert tiprack.next_tip() == tiprack.columns()[0][4] + + +def test_transfer_w_airgap_blowout(_instr_labware): + _instr_labware['ctx'].home() + lw1 = _instr_labware['lw1'] + lw2 = _instr_labware['lw2'] + + options = tx.TransferOptions() + options = options._replace( + transfer=options.transfer._replace( + air_gap=10, blow_out_strategy=tx.BlowOutStrategy.DEST_IF_EMPTY, + new_tip=TransferTipPolicy.NEVER)) + + # ========== Transfer ========== + xfer_plan = tx.TransferPlan(100, lw1.columns()[0][1:5], lw2.rows()[0][1:5], + _instr_labware['instr'], options=options) + xfer_plan_list = [] + for step in xfer_plan: + xfer_plan_list.append(step) + exp1 = [{'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][1], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[0][1]]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][2], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[0][2]]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][3], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[0][3]]}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][4], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[0][4]]}] + assert xfer_plan_list == exp1 + + # ========== Distribute ========== + dist_plan = tx.TransferPlan(60, lw1.columns()[1][0], lw2.rows()[1][1:6], + _instr_labware['instr'], options=options) + dist_plan_list = [] + for step in dist_plan: + dist_plan_list.append(step) + exp2 = [{'method': 'aspirate', 'params': [240, lw1.columns()[1][0], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][1], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][2], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][3], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][4], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[1][4]]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][5], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[1][5]]}] + assert dist_plan_list == exp2 + + # ========== Consolidate ========== + consd_plan = tx.TransferPlan(60, lw1.columns()[1], lw2.rows()[1][1], + _instr_labware['instr'], options=options) + consd_plan_list = [] + for step in consd_plan: + consd_plan_list.append(step) + exp3 = [{'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][1], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][2], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][3], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [270, lw2.rows()[1][1], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[1][1]]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][4], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][5], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][6], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][7], 1.0]}, + {'method': 'air_gap', 'params': [10]}, + {'method': 'dispense', 'params': [10]}, + {'method': 'dispense', 'params': [270, lw2.rows()[1][1], 1.0]}, + {'method': 'blow_out', 'params': [lw2.rows()[1][1]]}] + assert consd_plan_list == exp3 + + +def test_touchtip_mix(_instr_labware): + _instr_labware['ctx'].home() + lw1 = _instr_labware['lw1'] + lw2 = _instr_labware['lw2'] + + options = tx.TransferOptions() + options = options._replace( + transfer=options.transfer._replace( + new_tip=TransferTipPolicy.NEVER, + touch_tip_strategy=tx.TouchTipStrategy.ALWAYS, + mix_strategy=tx.MixStrategy.AFTER)) + + # ========== Transfer ========== + xfer_plan = tx.TransferPlan(100, lw1.columns()[0][1:5], lw2.rows()[0][1:5], + _instr_labware['instr'], options=options) + xfer_plan_list = [] + for step in xfer_plan: + xfer_plan_list.append(step) + exp1 = [{'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][1], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][2], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][3], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][4], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}] + assert xfer_plan_list == exp1 + + # ========== Distribute ========== + dist_plan = tx.TransferPlan(60, lw1.columns()[1][0], lw2.rows()[1][1:6], + _instr_labware['instr'], options=options) + dist_plan_list = [] + for step in dist_plan: + dist_plan_list.append(step) + exp2 = [{'method': 'aspirate', 'params': [240, lw1.columns()[1][0], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][1], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][2], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][3], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][4], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [60, lw2.rows()[1][5], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}] + + assert dist_plan_list == exp2 + + # ========== Consolidate ========== + consd_plan = tx.TransferPlan(60, lw1.columns()[1], lw2.rows()[1][1], + _instr_labware['instr'], options=options) + consd_plan_list = [] + for step in consd_plan: + consd_plan_list.append(step) + exp3 = [{'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][1], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][2], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][3], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [240, lw2.rows()[1][1], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][4], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][5], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][6], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'aspirate', 'params': [60, lw1.columns()[1][7], 1.0]}, + {'method': 'touch_tip', 'params': {}}, + {'method': 'dispense', 'params': [240, lw2.rows()[1][1], 1.0]}, + {'method': 'mix', 'params': {}}, + {'method': 'touch_tip', 'params': {}}] + assert consd_plan_list == exp3 + + +def test_all_options(_instr_labware): + _instr_labware['ctx'].home() + lw1 = _instr_labware['lw1'] + lw2 = _instr_labware['lw2'] + + options = tx.TransferOptions() + options = options._replace( + transfer=options.transfer._replace( + new_tip=TransferTipPolicy.ONCE, + drop_tip_strategy=tx.DropTipStrategy.RETURN, + touch_tip_strategy=tx.TouchTipStrategy.ALWAYS, + mix_strategy=tx.MixStrategy.AFTER), + pick_up_tip=options.pick_up_tip._replace( + presses=4, + increment=2), + touch_tip=options.touch_tip._replace( + speed=1.6), + mix=options.mix._replace( + mix_after=options.mix.mix_after._replace( + repetitions=2)), + blow_out=options.blow_out._replace( + location=lw2.columns()[10][0]), + aspirate=options.aspirate._replace( + rate=1.5)) + + xfer_plan = tx.TransferPlan(100, lw1.columns()[0][1:4], lw2.rows()[0][1:4], + _instr_labware['instr'], options=options) + xfer_plan_list = [] + for step in xfer_plan: + xfer_plan_list.append(step) + exp1 = [{'method': 'pick_up_tip', 'params': { + 'presses': 4, 'increment': 2}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.5]}, + {'method': 'touch_tip', 'params': {'speed': 1.6}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][1], 1.0]}, + {'method': 'mix', 'params': {'repetitions': 2}}, + {'method': 'touch_tip', 'params': {'speed': 1.6}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.5]}, + {'method': 'touch_tip', 'params': {'speed': 1.6}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][2], 1.0]}, + {'method': 'mix', 'params': {'repetitions': 2}}, + {'method': 'touch_tip', 'params': {'speed': 1.6}}, + {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.5]}, + {'method': 'touch_tip', 'params': {'speed': 1.6}}, + {'method': 'dispense', 'params': [100, lw2.rows()[0][3], 1.0]}, + {'method': 'mix', 'params': {'repetitions': 2}}, + {'method': 'touch_tip', 'params': {'speed': 1.6}}, + {'method': 'return_tip', 'params': []}] + assert xfer_plan_list == exp1 From e636dfd018715c0cd4bf0ed40acb4b6d265f955e Mon Sep 17 00:00:00 2001 From: Sanniti Date: Tue, 15 Jan 2019 17:56:51 -0500 Subject: [PATCH 2/5] review changes- corrected docs, changed Transfer cmd, deleted old code & other minor corrections --- api/docs/source/new_protocol_api.rst | 26 +- api/src/opentrons/protocol_api/contexts.py | 22 +- api/src/opentrons/protocol_api/labware.py | 12 +- api/src/opentrons/protocol_api/transfers.py | 78 ++- .../opentrons/protocol_api/test_context.py | 12 + .../opentrons/protocol_api/test_transfers.py | 506 +++++++++++------- 6 files changed, 392 insertions(+), 264 deletions(-) diff --git a/api/docs/source/new_protocol_api.rst b/api/docs/source/new_protocol_api.rst index 7924fde15ae..2294ba2998d 100644 --- a/api/docs/source/new_protocol_api.rst +++ b/api/docs/source/new_protocol_api.rst @@ -145,7 +145,7 @@ From the example above, the "commands" section looked like: .. code-block:: python pipette.aspirate(100, plate.wells_by_index()['A1']) - pipette.dispense(100, plate.wells_by_index()[’B2’]) + pipette.dispense(100, plate.wells_by_index()['B2']) which does exactly what it says - aspirate 100 uL from A1 and dispense it all in B2. @@ -163,6 +163,30 @@ Robot and Pipette .. autoclass:: opentrons.protocol_api.transfers.TransferOptions :members: +.. autoclass:: opentrons.protocol_api.transfers.Transfer + :members: + +.. autoclass:: opentrons.protocol_api.transfers.PickUpTipOpts + :members: + +.. autoclass:: opentrons.protocol_api.transfers.MixOpts + :members: + +.. autoclass:: opentrons.protocol_api.transfers.Mix + :members: + +.. autoclass:: opentrons.protocol_api.transfers.BlowOutOpts + :members: + +.. autoclass:: opentrons.protocol_api.transfers.TouchTipOpts + :members: + +.. autoclass:: opentrons.protocol_api.transfers.AspirateOpts + :members: + +.. autoclass:: opentrons.protocol_api.transfers.DispenseOpts + :members: + .. _protocol-api-labware: Labware and Wells diff --git a/api/src/opentrons/protocol_api/contexts.py b/api/src/opentrons/protocol_api/contexts.py index 44f78f5e1c7..35b45d1546c 100644 --- a/api/src/opentrons/protocol_api/contexts.py +++ b/api/src/opentrons/protocol_api/contexts.py @@ -593,22 +593,21 @@ def dispense(self, def mix(self, repetitions: int = 1, volume: float = None, - location: Well = None, + location: Union[types.Location, Well] = None, rate: float = 1.0) -> 'InstrumentContext': """ Mix a volume of liquid (uL) using this pipette. If no location is specified, the pipette will mix from its current - position. If no Volume is passed, 'mix' will default to its max_volume + position. If no Volume is passed, 'mix' will default to its max_volume. :param repetitions: how many times the pipette should mix (default: 1) :param volume: number of microlitres to mix (default: self.max_volume) - :param location: a Well or a position relative to well - e.g, `plate.wells('A1').bottom()` (types.Location type) - :param rate: Set plunger speed for this mix, where - speed = rate * (aspirate_speed or dispense_speed) - - :raises NoTipAttachedError: If no tip is attached to the pipette - + :param location: a Well or a position relative to well. + e.g, `plate.rows()[0][0].bottom()` + (types.Location type). + :param rate: Set plunger speed for this mix, where, + speed = rate * (aspirate_speed or dispense_speed) + :raises NoTipAttachedError: If no tip is attached to the pipette. :returns: This instance """ self._log.debug( @@ -1204,10 +1203,7 @@ def transfer(self, plan = transfers.TransferPlan(volume, source, dest, self, kwargs['mode'], transfer_options) for cmd in plan: - if isinstance(cmd['params'], dict): - getattr(self, cmd['method'])(**cmd['params']) - else: - getattr(self, cmd['method'])(*cmd['params']) + getattr(self, cmd['method'])(*cmd['args'], **cmd['kwargs']) return self def delay(self): diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 4f9e912a407..93dd47a7bad 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -93,17 +93,21 @@ def has_tip(self, value: bool): def top(self, z: float = 0.0) -> Location: """ + :param z: the z distance in mm :return: a Point corresponding to the absolute position of the - top-center of the well relative to the deck (with the front-left corner - of slot 1 as (0,0,0)) + top-center of the well relative to the deck (with the + front-left corner of slot 1 as (0,0,0)). If z is specified, + returns a point offset by z mm from top-center """ return Location(self._position + Point(0, 0, z), self) def bottom(self, z: float = 0.0) -> Location: """ + :param z: the z distance in mm :return: a Point corresponding to the absolute position of the - bottom-center of the well (with the front-left corner of slot 1 as - (0,0,0)) + bottom-center of the well (with the front-left corner of + slot 1 as (0,0,0)). If z is specified, returns a point + offset by z mm from bottom-center """ top = self.top() bottom_z = top.point.z - self._depth + z diff --git a/api/src/opentrons/protocol_api/transfers.py b/api/src/opentrons/protocol_api/transfers.py index b458f22ec1a..fc3e25e76b4 100644 --- a/api/src/opentrons/protocol_api/transfers.py +++ b/api/src/opentrons/protocol_api/transfers.py @@ -96,7 +96,9 @@ class Transfer(NamedTuple): to determine the path the transfer takes between the volume gradient minimum and maximum if the transfer volume is specified as a gradient. For instance, specifying the function as + .. code-block:: python + def gradient(a): if a > 0.5: return 1.0 @@ -180,7 +182,7 @@ def gradient(a): class PickUpTipOpts(NamedTuple): """ - Options to customize :py:attr:`.TransferOptions.Transfer.new_tip`. + Options to customize :py:attr:`.Transfer.new_tip`. These options will be passed to :py:meth:`InstrumentContext.pick_up_tip` when it is called during @@ -313,32 +315,32 @@ class TransferOptions(NamedTuple): TransferOptions.pick_up_tip.__doc__ = """ Options used when picking up a tip during transfer. - See :py:class:`.TransferOptions.PickUpTipsOpts`. + See :py:class:`.PickUpTipOpts`. """ TransferOptions.mix.__doc__ = """ Options to control mix behavior before aspirate and after dispense. - See :py:class:`.TransferOptions.Mix`. + See :py:class:`.Mix`. """ TransferOptions.blow_out.__doc__ = """ Option to specify custom location for blow out. See - :py:class:`.TransferOptions.BlowOutOpts`. + :py:class:`.BlowOutOpts`. """ TransferOptions.touch_tip.__doc__ = """ Options to customize touch tip. See - :py:class:`.TransferOptions.TouchTipOpts`. + :py:class:`.TouchTipOpts`. """ TransferOptions.aspirate.__doc__ = """ Option to customize aspirate rate. See - :py:class:`.TransferOptions.AspirateOpts`. + :py:class:`.AspirateOpts`. """ TransferOptions.dispense.__doc__ = """ Option to customize dispense rate. See - :py:class:`.TransferOptions.DispenseOpts`. + :py:class:`.DispenseOpts`. """ @@ -412,7 +414,7 @@ def __init__(self, def __iter__(self): if self._strategy.new_tip == types.TransferTipPolicy.ONCE: - yield self._format_dict('pick_up_tip', self._tip_opts) + yield self._format_dict('pick_up_tip', kwargs=self._tip_opts) yield from {TransferMode.CONSOLIDATE: self._plan_consolidate, TransferMode.DISTRIBUTE: self._plan_distribute, TransferMode.TRANSFER: self._plan_transfer}[self._mode]() @@ -422,9 +424,6 @@ def __iter__(self): else: yield self._format_dict('drop_tip') - # def update_option(self, option_name, new_value): - # if option_name in self._options - def _plan_transfer(self): """ Source/ Dest: Multiple sources to multiple destinations. @@ -453,19 +452,18 @@ def _plan_transfer(self): -> Blow out -> Touch tip -> Drop tip """ plan_iter = zip(self._volumes, self._sources, self._dests) - for step_params in plan_iter: + for step_vol, src, dest in plan_iter: if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: - yield self._format_dict('pick_up_tip', self._tip_opts) - step_vol = step_params[0] + yield self._format_dict('pick_up_tip', kwargs=self._tip_opts) max_vol = self._instr.max_volume - self._strategy.disposal_volume \ - self._strategy.air_gap xferred_vol = 0 - while xferred_vol != step_vol: + while xferred_vol < step_vol: # TODO: account for unequal length sources, dests # TODO: ensure last transfer is > min_vol vol = min(max_vol, step_vol - xferred_vol) - yield from self._aspirate_actions(vol, step_params[1]) - yield from self._dispense_actions(vol, step_params[2]) + yield from self._aspirate_actions(vol, src) + yield from self._dispense_actions(vol, dest) xferred_vol += vol yield from self._new_tip_action() @@ -514,7 +512,7 @@ def _plan_distribute(self): done = False current_xfer = next(plan_iter) if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: - yield self._format_dict('pick_up_tip', self._tip_opts) + yield self._format_dict('pick_up_tip', kwargs=self._tip_opts) while not done: asp_grouped = [] try: @@ -572,7 +570,7 @@ def _plan_consolidate(self): plan_iter = zip(self._volumes, self._sources) current_xfer = next(plan_iter) if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: - yield self._format_dict('pick_up_tip', self._tip_opts) + yield self._format_dict('pick_up_tip', kwargs=self._tip_opts) done = False while not done: asp_grouped = [] @@ -614,13 +612,13 @@ def _before_aspirate(self): if self._strategy.mix_strategy == MixStrategy.BEFORE or \ self._strategy.mix_strategy == MixStrategy.BOTH: if self._instr.current_volume == 0: - yield self._format_dict('mix', self._mix_before_opts) + yield self._format_dict('mix', kwargs=self._mix_before_opts) def _after_aspirate(self): if self._strategy.air_gap: yield self._format_dict('air_gap', [self._strategy.air_gap]) if self._strategy.touch_tip_strategy == TouchTipStrategy.ALWAYS: - yield self._format_dict('touch_tip', self._touch_tip_opts) + yield self._format_dict('touch_tip', kwargs=self._touch_tip_opts) def _before_dispense(self): if self._strategy.air_gap: @@ -634,19 +632,19 @@ def _after_dispense(self, loc, is_disp_next=False): if self._instr.current_volume == 0: if self._strategy.mix_strategy == MixStrategy.AFTER or \ self._strategy.mix_strategy == MixStrategy.BOTH: - yield self._format_dict('mix', self._mix_after_opts) + yield self._format_dict('mix', kwargs=self._mix_after_opts) if self._strategy.blow_out_strategy \ == BlowOutStrategy.DEST_IF_EMPTY: yield self._format_dict('blow_out', [loc]) else: # Custom location. Or trash if not specified - yield self._format_dict('blow_out', self._blow_opts) + yield self._format_dict('blow_out', kwargs=self._blow_opts) else: # Used by distribute if self._strategy.air_gap: yield self._format_dict('air_gap', [self._strategy.air_gap]) if self._strategy.touch_tip_strategy == TouchTipStrategy.ALWAYS: - yield self._format_dict('touch_tip', self._touch_tip_opts) + yield self._format_dict('touch_tip', kwargs=self._touch_tip_opts) def _new_tip_action(self): if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: @@ -655,29 +653,15 @@ def _new_tip_action(self): else: yield self._format_dict('drop_tip') - def _format_dict(self, method: str, params: Any = []): - # TODO: verify if this is a good way to check for NamedTuple - if getattr(type(params), '_fields', None): - params = {key: val for key, val in params._asdict().items() if val} - return {'method': method, 'params': params} - - def _create_source_target_lists(self, s, t): - # No longer used. Preserved for future use possibility - s = helpers._get_list(s) - t = helpers._get_list(t) - len_s = len(s) - len_t = len(t) - if len_s < len_t: - if (len_t / len_s) % 1 > 0: - raise ValueError( - 'Source and destination lists must be divisible') - s = [source for source in s for i in range(int(len_t / len_s))] - elif len_s > len_t: - if (len_s / len_t) % 1 > 0: - raise ValueError( - 'Source and destination lists must be divisible') - t = [dest for dest in t for i in range(int(len_s / len_t))] - return (s, t) + def _format_dict(self, method: str, + args: List = None, kwargs: Any = None): + if kwargs: + params = {key: val for key, val in kwargs._asdict().items() if val} + else: + params = {} + if not args: + args = [] + return {'method': method, 'args': args, 'kwargs': params} def _create_volume_list(self, volume, total_xfers): if isinstance(volume, (float, int)): diff --git a/api/tests/opentrons/protocol_api/test_context.py b/api/tests/opentrons/protocol_api/test_context.py index d9b5200110e..19db4d847db 100644 --- a/api/tests/opentrons/protocol_api/test_context.py +++ b/api/tests/opentrons/protocol_api/test_context.py @@ -538,3 +538,15 @@ def fake_move(loc): monkeypatch.setattr(instr, 'move_to', fake_move) instr.blow_out() assert move_location == lw.wells()[0].top() + +@pytest.mark.xfail +def test_transfer(loop): + ctx = papi.ProtocolContext(loop) + lw1 = ctx.load_labware_by_name('biorad_96_wellPlate_pcr_200_uL', 1) + lw2 = ctx.load_labware_by_name('generic_96_wellPlate_380_uL', 2) + tiprack = ctx.load_labware_by_name('opentrons_96_tiprack_300_uL', 3) + instr = ctx.load_instrument('p300_single', Mount.RIGHT, + tip_racks=[tiprack]) + + ctx.home() + instr.transfer(10, lw1.columns()[0], lw2.columns()[0]) diff --git a/api/tests/opentrons/protocol_api/test_transfers.py b/api/tests/opentrons/protocol_api/test_transfers.py index c1cc4fcb284..310af01f9d3 100644 --- a/api/tests/opentrons/protocol_api/test_transfers.py +++ b/api/tests/opentrons/protocol_api/test_transfers.py @@ -31,24 +31,40 @@ def test_default_transfers(_instr_labware): xfer_plan_list = [] for step in xfer_plan: xfer_plan_list.append(step) - exp1 = [{'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][0], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][0], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][1], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][2], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][3], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][4], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][5], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][5], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][6], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][6], 1.0]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][7], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][7], 1.0]}, - {'method': 'drop_tip', 'params': []}] + exp1 = [{'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][1], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][1], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][2], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][2], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][3], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][3], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][4], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][4], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][5], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][5], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][6], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][6], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][7], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.columns()[0][7], 1.0], 'kwargs': {}}, + {'method': 'drop_tip', 'args': [], 'kwargs': {}}] assert xfer_plan_list == exp1 # ========== Distribute =========== @@ -57,18 +73,28 @@ def test_default_transfers(_instr_labware): dist_plan_list = [] for step in dist_plan: dist_plan_list.append(step) - exp2 = [{'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [250, lw1.columns()[0][0], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][0], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][1], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][2], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][3], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][4], 1.0]}, - {'method': 'aspirate', 'params': [150, lw1.columns()[0][0], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][5], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][6], 1.0]}, - {'method': 'dispense', 'params': [50, lw2.columns()[0][7], 1.0]}, - {'method': 'drop_tip', 'params': []}] + exp2 = [{'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [250, lw1.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][1], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][2], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][3], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][4], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [150, lw1.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][5], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][6], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [50, lw2.columns()[0][7], 1.0], 'kwargs': {}}, + {'method': 'drop_tip', 'args': [], 'kwargs': {}}] assert dist_plan_list == exp2 # ========== Consolidate =========== @@ -77,18 +103,28 @@ def test_default_transfers(_instr_labware): consd_plan_list = [] for step in consd_plan: consd_plan_list.append(step) - exp3 = [{'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][0], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][1], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][2], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][3], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][4], 1.0]}, - {'method': 'dispense', 'params': [250, lw2.columns()[0][0], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][5], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][6], 1.0]}, - {'method': 'aspirate', 'params': [50, lw1.columns()[0][7], 1.0]}, - {'method': 'dispense', 'params': [150, lw2.columns()[0][0], 1.0]}, - {'method': 'drop_tip', 'params': []}] + exp3 = [{'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][1], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][2], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][3], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][4], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [250, lw2.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][5], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][6], 1.0], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [50, lw1.columns()[0][7], 1.0], 'kwargs': {}}, + {'method': 'dispense', + 'args': [150, lw2.columns()[0][0], 1.0], 'kwargs': {}}, + {'method': 'drop_tip', 'args': [], 'kwargs': {}}] assert consd_plan_list == exp3 @@ -142,28 +178,33 @@ def test_new_tip_always(_instr_labware, monkeypatch): xfer_plan_list = [] for step in xfer_plan: xfer_plan_list.append(step) - exp1 = [{'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][1], 1.0]}, - {'method': 'return_tip', 'params': []}, - {'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][2], 1.0]}, - {'method': 'return_tip', 'params': []}, - {'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][3], 1.0]}, - {'method': 'return_tip', 'params': []}, - {'method': 'pick_up_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, - {'method': 'dispense', 'params': [100, lw2.columns()[0][4], 1.0]}, - {'method': 'return_tip', 'params': []}] + exp1 = [{'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', 'args': [100, lw1.columns()[0][1], 1.0], + 'kwargs': {}}, + {'method': 'dispense', 'args': [100, lw2.columns()[0][1], 1.0], + 'kwargs': {}}, + {'method': 'return_tip', 'args': [], 'kwargs': {}}, + {'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', 'args': [100, lw1.columns()[0][2], 1.0], + 'kwargs': {}}, + {'method': 'dispense', 'args': [100, lw2.columns()[0][2], 1.0], + 'kwargs': {}}, + {'method': 'return_tip', 'args': [], 'kwargs': {}}, + {'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', 'args': [100, lw1.columns()[0][3], 1.0], + 'kwargs': {}}, + {'method': 'dispense', 'args': [100, lw2.columns()[0][3], 1.0], + 'kwargs': {}}, + {'method': 'return_tip', 'args': [], 'kwargs': {}}, + {'method': 'pick_up_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', 'args': [100, lw1.columns()[0][4], 1.0], + 'kwargs': {}}, + {'method': 'dispense', 'args': [100, lw2.columns()[0][4], 1.0], + 'kwargs': {}}, + {'method': 'return_tip', 'args': [], 'kwargs': {}}] assert xfer_plan_list == exp1 for cmd in xfer_plan_list: - if isinstance(cmd['params'], dict): - getattr(i_ctx, cmd['method'])(**cmd['params']) - else: - getattr(i_ctx, cmd['method'])(*cmd['params']) + getattr(i_ctx, cmd['method'])(*cmd['args'], **cmd['kwargs']) assert tiprack.next_tip() == tiprack.columns()[0][4] @@ -184,26 +225,45 @@ def test_transfer_w_airgap_blowout(_instr_labware): xfer_plan_list = [] for step in xfer_plan: xfer_plan_list.append(step) - exp1 = [{'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][1], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[0][1]]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][2], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[0][2]]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][3], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[0][3]]}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][4], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[0][4]]}] + exp1 = [{'method': 'aspirate', + 'args': [100, lw1.columns()[0][1], 1.0], 'kwargs': {}}, + {'method': 'air_gap', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][1], 1.0], 'kwargs': {}}, + {'method': 'blow_out', + 'args': [lw2.rows()[0][1]], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][2], 1.0], 'kwargs': {}}, + {'method': 'air_gap', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][2], 1.0], 'kwargs': {}}, + {'method': 'blow_out', + 'args': [lw2.rows()[0][2]], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][3], 1.0], 'kwargs': {}}, + {'method': 'air_gap', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][3], 1.0], 'kwargs': {}}, + {'method': 'blow_out', + 'args': [lw2.rows()[0][3]], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][4], 1.0], 'kwargs': {}}, + {'method': 'air_gap', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][4], 1.0], 'kwargs': {}}, + {'method': 'blow_out', 'args': [lw2.rows()[0][4]], 'kwargs': {}}] assert xfer_plan_list == exp1 # ========== Distribute ========== @@ -212,25 +272,32 @@ def test_transfer_w_airgap_blowout(_instr_labware): dist_plan_list = [] for step in dist_plan: dist_plan_list.append(step) - exp2 = [{'method': 'aspirate', 'params': [240, lw1.columns()[1][0], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][1], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][2], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][3], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][4], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[1][4]]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][5], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[1][5]]}] + exp2 = [{'method': 'aspirate', + 'args': [240, lw1.columns()[1][0], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][1], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][2], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][3], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][4], 1.0], 'kwargs': {}}, + {'method': 'blow_out', 'args': [lw2.rows()[1][4]], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][0], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][5], 1.0], 'kwargs': {}}, + {'method': 'blow_out', 'args': [lw2.rows()[1][5]], 'kwargs': {}}] assert dist_plan_list == exp2 # ========== Consolidate ========== @@ -239,28 +306,38 @@ def test_transfer_w_airgap_blowout(_instr_labware): consd_plan_list = [] for step in consd_plan: consd_plan_list.append(step) - exp3 = [{'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][1], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][2], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][3], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [270, lw2.rows()[1][1], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[1][1]]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][4], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][5], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][6], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][7], 1.0]}, - {'method': 'air_gap', 'params': [10]}, - {'method': 'dispense', 'params': [10]}, - {'method': 'dispense', 'params': [270, lw2.rows()[1][1], 1.0]}, - {'method': 'blow_out', 'params': [lw2.rows()[1][1]]}] + exp3 = [{'method': 'aspirate', + 'args': [60, lw1.columns()[1][0], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][1], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][2], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][3], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [270, lw2.rows()[1][1], 1.0], 'kwargs': {}}, + {'method': 'blow_out', 'args': [lw2.rows()[1][1]], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][4], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][5], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][6], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][7], 1.0], 'kwargs': {}}, + {'method': 'air_gap', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', 'args': [10], 'kwargs': {}}, + {'method': 'dispense', + 'args': [270, lw2.rows()[1][1], 1.0], 'kwargs': {}}, + {'method': 'blow_out', 'args': [lw2.rows()[1][1]], 'kwargs': {}}] assert consd_plan_list == exp3 @@ -282,26 +359,34 @@ def test_touchtip_mix(_instr_labware): xfer_plan_list = [] for step in xfer_plan: xfer_plan_list.append(step) - exp1 = [{'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][1], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][2], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][3], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][4], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][4], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}] + exp1 = [{'method': 'aspirate', + 'args': [100, lw1.columns()[0][1], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][1], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][2], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][2], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][3], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][3], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][4], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][4], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}] assert xfer_plan_list == exp1 # ========== Distribute ========== @@ -310,22 +395,29 @@ def test_touchtip_mix(_instr_labware): dist_plan_list = [] for step in dist_plan: dist_plan_list.append(step) - exp2 = [{'method': 'aspirate', 'params': [240, lw1.columns()[1][0], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][1], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][2], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][3], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][4], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [60, lw2.rows()[1][5], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}] + exp2 = [{'method': 'aspirate', + 'args': [240, lw1.columns()[1][0], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][1], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][2], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][3], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][4], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][0], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [60, lw2.rows()[1][5], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}] assert dist_plan_list == exp2 @@ -335,28 +427,38 @@ def test_touchtip_mix(_instr_labware): consd_plan_list = [] for step in consd_plan: consd_plan_list.append(step) - exp3 = [{'method': 'aspirate', 'params': [60, lw1.columns()[1][0], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][1], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][2], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][3], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [240, lw2.rows()[1][1], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][4], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][5], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][6], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'aspirate', 'params': [60, lw1.columns()[1][7], 1.0]}, - {'method': 'touch_tip', 'params': {}}, - {'method': 'dispense', 'params': [240, lw2.rows()[1][1], 1.0]}, - {'method': 'mix', 'params': {}}, - {'method': 'touch_tip', 'params': {}}] + exp3 = [{'method': 'aspirate', + 'args': [60, lw1.columns()[1][0], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][1], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][2], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][3], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [240, lw2.rows()[1][1], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][4], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][5], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][6], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'aspirate', + 'args': [60, lw1.columns()[1][7], 1.0], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}, + {'method': 'dispense', + 'args': [240, lw2.rows()[1][1], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {}}] assert consd_plan_list == exp3 @@ -390,22 +492,28 @@ def test_all_options(_instr_labware): xfer_plan_list = [] for step in xfer_plan: xfer_plan_list.append(step) - exp1 = [{'method': 'pick_up_tip', 'params': { - 'presses': 4, 'increment': 2}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][1], 1.5]}, - {'method': 'touch_tip', 'params': {'speed': 1.6}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][1], 1.0]}, - {'method': 'mix', 'params': {'repetitions': 2}}, - {'method': 'touch_tip', 'params': {'speed': 1.6}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][2], 1.5]}, - {'method': 'touch_tip', 'params': {'speed': 1.6}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][2], 1.0]}, - {'method': 'mix', 'params': {'repetitions': 2}}, - {'method': 'touch_tip', 'params': {'speed': 1.6}}, - {'method': 'aspirate', 'params': [100, lw1.columns()[0][3], 1.5]}, - {'method': 'touch_tip', 'params': {'speed': 1.6}}, - {'method': 'dispense', 'params': [100, lw2.rows()[0][3], 1.0]}, - {'method': 'mix', 'params': {'repetitions': 2}}, - {'method': 'touch_tip', 'params': {'speed': 1.6}}, - {'method': 'return_tip', 'params': []}] + exp1 = [{'method': 'pick_up_tip', + 'args': [], 'kwargs': {'presses': 4, 'increment': 2}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][1], 1.5], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {'speed': 1.6}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][1], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {'repetitions': 2}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {'speed': 1.6}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][2], 1.5], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {'speed': 1.6}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][2], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {'repetitions': 2}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {'speed': 1.6}}, + {'method': 'aspirate', + 'args': [100, lw1.columns()[0][3], 1.5], 'kwargs': {}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {'speed': 1.6}}, + {'method': 'dispense', + 'args': [100, lw2.rows()[0][3], 1.0], 'kwargs': {}}, + {'method': 'mix', 'args': [], 'kwargs': {'repetitions': 2}}, + {'method': 'touch_tip', 'args': [], 'kwargs': {'speed': 1.6}}, + {'method': 'return_tip', 'args': [], 'kwargs': {}}] assert xfer_plan_list == exp1 From 7136d447cf9eeeabc1680c2d6f18b1a47f859002 Mon Sep 17 00:00:00 2001 From: Sanniti Date: Fri, 18 Jan 2019 19:42:33 -0500 Subject: [PATCH 3/5] refactor(protocol_api): makes changes and corrections requested in PR changed kwargs docs, functools.wraps invocation, fixed multichannel transfer bug, fixed _from_center_cartesian bug, added code to use new location types for transfer functions in commands.py and session.py --- api/src/opentrons/api/session.py | 28 ++- api/src/opentrons/commands/commands.py | 119 +++++++---- api/src/opentrons/protocol_api/contexts.py | 124 +++++------ api/src/opentrons/protocol_api/labware.py | 8 +- api/src/opentrons/protocol_api/transfers.py | 202 ++++++++++-------- .../opentrons/protocol_api/test_context.py | 4 +- .../opentrons/protocol_api/test_labware.py | 8 +- 7 files changed, 278 insertions(+), 215 deletions(-) diff --git a/api/src/opentrons/api/session.py b/api/src/opentrons/api/session.py index 7c3f6eb7691..bdb85ac0e9d 100755 --- a/api/src/opentrons/api/session.py +++ b/api/src/opentrons/api/session.py @@ -10,7 +10,7 @@ from opentrons.legacy_api.containers import get_container, location_to_list from opentrons.legacy_api.containers.placeable import ( Module as ModulePlaceable, Placeable) -from opentrons.commands import tree, types +from opentrons.commands import tree, types as command_types from opentrons.protocols import execute_protocol from opentrons.config import feature_flags as ff from opentrons.protocol_api import (ProtocolContext, @@ -172,7 +172,7 @@ def on_command(message): else: stack.pop() - unsubscribe = subscribe(types.COMMAND, on_command) + unsubscribe = subscribe(command_types.COMMAND, on_command) try: # ensure actual pipettes are cached before driver is disconnected @@ -276,14 +276,14 @@ def run(self): # noqa(C901) def on_command(message): if message['$'] == 'before': self.log_append() - if message['name'] == types.PAUSE: + if message['name'] == command_types.PAUSE: self.set_state('paused') - if message['name'] == types.RESUME: + if message['name'] == command_types.RESUME: self.set_state('running') self._reset() - _unsubscribe = subscribe(types.COMMAND, on_command) + _unsubscribe = subscribe(command_types.COMMAND, on_command) self.startTime = now() self.set_state('running') @@ -467,9 +467,21 @@ def _get_labware(command): containers.append(_get_new_labware(location)) if locations: - list_of_locations = location_to_list(locations) - containers.extend( - [get_container(location) for location in list_of_locations]) + try: + list_of_locations = location_to_list(locations) + except ValueError: + if isinstance(locations, list): + if isinstance(locations[0], list): + # unpack + locations = [loc + for loc_list in locations for loc in loc_list] + if(isinstance(loc, (Location, labware.Well, labware.Labware)) + for loc in locations): + containers.extend( + [_get_new_labware(loc) for loc in locations]) + else: + containers.extend( + [get_container(location) for location in list_of_locations]) containers = [c for c in containers if c is not None] modules = [m for m in modules if m is not None] diff --git a/api/src/opentrons/commands/commands.py b/api/src/opentrons/commands/commands.py index b53c13ccecc..1c2df6031a2 100755 --- a/api/src/opentrons/commands/commands.py +++ b/api/src/opentrons/commands/commands.py @@ -1,8 +1,8 @@ -from . import types +from . import types as command_types from ..broker import broker import functools import inspect -from typing import Union +from typing import Union, Sequence from opentrons.legacy_api.containers import (Well as OldWell, Container as OldContainer, @@ -12,6 +12,28 @@ from opentrons.types import Location +def _get_loc_info(location: Union[Location, Well, Sequence]) -> dict: + loc_info = {'type': type(location), 'is_2D': False, 'is_new_loc': False} + if isinstance(location, list): + if isinstance(location[0], list): + loc_info['is_2D'] = True + location = [loc for loc_list in location for loc in loc_list] + if isinstance(location[0], (Location, Well)): + loc_info['is_new_loc'] = True + elif isinstance(location, (Location, Well)): + loc_info['is_new_loc'] = True + return loc_info + + +def _listify_new_loc(location: Union[Location, Well, Sequence]) -> list: + if isinstance(location, list): + if _get_loc_info(location)['is_2D']: + location = [loc for loc_list in location for loc in loc_list] + return location + else: + return [location] + + def _stringify_new_loc(loc: Union[Location, Well]) -> str: if isinstance(loc, Location): if isinstance(loc.labware, str): @@ -57,9 +79,18 @@ def get_slot(location): def stringify_location(location: Union[Location, None, - OldWell, OldContainer, OldSlot]) -> str: - if isinstance(location, (Location, Well)): - return _stringify_new_loc(location) + OldWell, OldContainer, + OldSlot, Sequence]) -> str: + if _get_loc_info(location)['is_new_loc']: + if isinstance(location, list): + if _get_loc_info(location)['is_2D']: + location = [loc for loc_list in location for loc in loc_list] + loc_str_list = [] + for loc in location: + loc_str_list.append(_stringify_new_loc(loc)) + return ','.join(loc_str_list) + else: + return _stringify_new_loc(location) else: return _stringify_legacy_loc(location) @@ -71,7 +102,7 @@ def make_command(name, payload): def home(mount): text = 'Homing pipette plunger on mount {mount}'.format(mount=mount) return make_command( - name=types.HOME, + name=command_types.HOME, payload={ 'axis': mount, 'text': text @@ -85,7 +116,7 @@ def aspirate(instrument, volume, location, rate): volume=volume, location=location_text, rate=rate ) return make_command( - name=types.ASPIRATE, + name=command_types.ASPIRATE, payload={ 'instrument': instrument, 'volume': volume, @@ -103,7 +134,7 @@ def dispense(instrument, volume, location, rate): ) return make_command( - name=types.DISPENSE, + name=command_types.DISPENSE, payload={ 'instrument': instrument, 'volume': volume, @@ -120,11 +151,15 @@ def consolidate(instrument, volume, source, dest): source=stringify_location(source), dest=stringify_location(dest) ) - # incase either source or dest is list of tuple location - # strip both down to simply lists of Placeables - locations = [] + location_to_list(source) + location_to_list(dest) + if _get_loc_info(source)['is_new_loc']: + # Dest is assumed as new location too + locations = [] + _listify_new_loc(source) + _listify_new_loc(dest) + else: + # incase either source or dest is list of tuple location + # strip both down to simply lists of Placeables + locations = [] + location_to_list(source) + location_to_list(dest) return make_command( - name=types.CONSOLIDATE, + name=command_types.CONSOLIDATE, payload={ 'instrument': instrument, 'locations': locations, @@ -142,11 +177,15 @@ def distribute(instrument, volume, source, dest): source=stringify_location(source), dest=stringify_location(dest) ) - # incase either source or dest is list of tuple location - # strip both down to simply lists of Placeables - locations = [] + location_to_list(source) + location_to_list(dest) + if _get_loc_info(source)['is_new_loc']: + # Dest is assumed as new location too + locations = [] + _listify_new_loc(source) + _listify_new_loc(dest) + else: + # incase either source or dest is list of tuple location + # strip both down to simply lists of Placeables + locations = [] + location_to_list(source) + location_to_list(dest) return make_command( - name=types.DISTRIBUTE, + name=command_types.DISTRIBUTE, payload={ 'instrument': instrument, 'locations': locations, @@ -164,11 +203,15 @@ def transfer(instrument, volume, source, dest): source=stringify_location(source), dest=stringify_location(dest) ) - # incase either source or dest is list of tuple location - # strip both down to simply lists of Placeables - locations = [] + location_to_list(source) + location_to_list(dest) + if _get_loc_info(source)['is_new_loc']: + # Dest is assumed as new location too + locations = [] + _listify_new_loc(source) + _listify_new_loc(dest) + else: + # incase either source or dest is list of tuple location + # strip both down to simply lists of Placeables + locations = [] + location_to_list(source) + location_to_list(dest) return make_command( - name=types.TRANSFER, + name=command_types.TRANSFER, payload={ 'instrument': instrument, 'locations': locations, @@ -183,7 +226,7 @@ def transfer(instrument, volume, source, dest): def comment(msg): text = msg return make_command( - name=types.COMMENT, + name=command_types.COMMENT, payload={ 'text': text } @@ -195,7 +238,7 @@ def mix(instrument, repetitions, volume, location): repetitions=repetitions, volume=volume ) return make_command( - name=types.MIX, + name=command_types.MIX, payload={ 'instrument': instrument, 'location': location, @@ -214,7 +257,7 @@ def blow_out(instrument, location): text += ' at {location}'.format(location=location_text) return make_command( - name=types.BLOW_OUT, + name=command_types.BLOW_OUT, payload={ 'instrument': instrument, 'location': location, @@ -226,7 +269,7 @@ def blow_out(instrument, location): def touch_tip(instrument): text = 'Touching tip' return make_command( - name=types.TOUCH_TIP, + name=command_types.TOUCH_TIP, payload={ 'instrument': instrument, 'text': text @@ -237,7 +280,7 @@ def touch_tip(instrument): def air_gap(): text = 'Air gap' return make_command( - name=types.AIR_GAP, + name=command_types.AIR_GAP, payload={ 'text': text } @@ -247,7 +290,7 @@ def air_gap(): def return_tip(): text = 'Returning tip' return make_command( - name=types.RETURN_TIP, + name=command_types.RETURN_TIP, payload={ 'text': text } @@ -258,7 +301,7 @@ def pick_up_tip(instrument, location): location_text = stringify_location(location) text = 'Picking up tip {location}'.format(location=location_text) return make_command( - name=types.PICK_UP_TIP, + name=command_types.PICK_UP_TIP, payload={ 'instrument': instrument, 'location': location, @@ -271,7 +314,7 @@ def drop_tip(instrument, location): location_text = stringify_location(location) text = 'Dropping tip {location}'.format(location=location_text) return make_command( - name=types.DROP_TIP, + name=command_types.DROP_TIP, payload={ 'instrument': instrument, 'location': location, @@ -283,7 +326,7 @@ def drop_tip(instrument, location): def magdeck_engage(): text = "Engaging magnetic deck module" return make_command( - name=types.MAGDECK_ENGAGE, + name=command_types.MAGDECK_ENGAGE, payload={'text': text} ) @@ -291,7 +334,7 @@ def magdeck_engage(): def magdeck_disengage(): text = "Disengaging magnetic deck module" return make_command( - name=types.MAGDECK_DISENGAGE, + name=command_types.MAGDECK_DISENGAGE, payload={'text': text} ) @@ -299,7 +342,7 @@ def magdeck_disengage(): def magdeck_calibrate(): text = "Calibrating magnetic deck module" return make_command( - name=types.MAGDECK_CALIBRATE, + name=command_types.MAGDECK_CALIBRATE, payload={'text': text} ) @@ -308,7 +351,7 @@ def tempdeck_set_temp(): text = "Setting temperature deck module temperature " \ "(rounded off to nearest integer)" return make_command( - name=types.TEMPDECK_SET_TEMP, + name=command_types.TEMPDECK_SET_TEMP, payload={'text': text} ) @@ -316,7 +359,7 @@ def tempdeck_set_temp(): def tempdeck_deactivate(): text = "Deactivating temperature deck module" return make_command( - name=types.TEMPDECK_DEACTIVATE, + name=command_types.TEMPDECK_DEACTIVATE, payload={'text': text} ) @@ -324,7 +367,7 @@ def tempdeck_deactivate(): def delay(seconds, minutes): text = "Delaying for {minutes}m {seconds}s" return make_command( - name=types.DELAY, + name=command_types.DELAY, payload={ 'minutes': minutes, 'seconds': seconds, @@ -338,7 +381,7 @@ def pause(msg): if msg: text = text + ': {}'.format(msg) return make_command( - name=types.PAUSE, + name=command_types.PAUSE, payload={ 'text': text } @@ -347,7 +390,7 @@ def pause(msg): def resume(): return make_command( - name=types.RESUME, + name=command_types.RESUME, payload={ 'text': 'Resuming robot operation' } @@ -358,7 +401,7 @@ def do_publish(cmd, f, when, res, meta, *args, **kwargs): """ Implement the publish so it can be called outside the decorator """ publish_command = functools.partial( broker.publish, - topic=types.COMMAND) + topic=command_types.COMMAND) call_args = _get_args(f, args, kwargs) command_args = dict( zip( @@ -399,7 +442,7 @@ def do_publish(cmd, f, when, res, meta, *args, **kwargs): def _publish_dec(before, after, command, meta=None): def decorator(f): - @functools.wraps(f) + @functools.wraps(f, updated=functools.WRAPPER_UPDATES+('__globals__',)) def decorated(*args, **kwargs): if before: do_publish(command, f, 'before', None, meta, *args, **kwargs) diff --git a/api/src/opentrons/protocol_api/contexts.py b/api/src/opentrons/protocol_api/contexts.py index 35b45d1546c..1c9b25cfc90 100644 --- a/api/src/opentrons/protocol_api/contexts.py +++ b/api/src/opentrons/protocol_api/contexts.py @@ -1062,7 +1062,7 @@ def transfer(self, # TODO: ..trash or the original well. # TODO: What should happen if the user passes a non-first-row well # TODO: ..as src/dest *while using multichannel pipette? - """ + r""" Transfer will move a volume of liquid from a source location(s) to a dest location(s). It is a higher-level command, incorporating other :py:class:`InstrumentContext` commands, like :py:meth:`aspirate` @@ -1076,68 +1076,57 @@ def transfer(self, tuple with two elements, like `(20, 100)`, then a list of volumes will be generated with a linear gradient between the two volumes in the tuple. - :param source: A single well or a list of wells from where liquid will be aspirated. - :param dest: A single well or a list of wells where liquid will be dispensed to. - - :param kwargs: - new_tip : :py:class:`string` - 'never': no tips will be picked up or dropped - during the transfer. - 'once': (default) a single tip will be used for - all commands. - 'always': use a new tip for each transfer. - - trash : :py:class:`boolean` - If `False` (default behavior), tips will be - returned to their tip rack. If `True` and a trash - container has been attached to this `Pipette`, - then the tip will be sent to the trash container. - - touch_tip : :py:class:`boolean` - If `True`, a :py:meth:`touch_tip` will occur - following each :py:meth:`aspirate` and - :py:meth:`dispense`. If set to `False` - (default behavior), no :py:meth:`touch_tip` - will occur. - - blow_out : :py:class:`boolean` - If `True`, a :py:meth:`blow_out` will occur - following each :py:meth:`dispense`, but only - if the pipette has no liquid left in it. - If set to `False` (default), no - :py:meth:`blow_out` will occur. - - mix_before : :py:class:`tuple` - The tuple, if specified, gives the amount of - volume to :py:meth:`mix` preceding each - :py:meth:`aspirate` during the transfer. - The tuple is interpreted as - (repetitions, volume). - - mix_after : :py:class:`tuple` - The tuple, if specified, gives the amount of - volume to :py:meth:`mix` after each - :py:meth:`dispense` during the transfer. - The tuple is interpreted as - (repetitions, volume). - - carryover : :py:class:`boolean` - If `True` (default), any `volume` that - exceeds the maximum volume of this Pipette - will be split into multiple smaller volumes. - - gradient : lambda - Function for calculating the curve used for - gradient volumes. When `volume` is a tuple of - length 2, its values are used to create a list - of gradient volumes. The default curve for - this gradient is linear (lambda x: x), however - a method can be passed with the `gradient` - keyword argument to create a custom curve. + :param \**kwargs: See below + + :Keyword Arguments: + + * *new_tip* (``string``) -- + + - 'never': no tips will be picked up or dropped during transfer + - 'once': (default) a single tip will be used for all commands. + - 'always': use a new tip for each transfer. + + * *trash* (``boolean``) -- + If `False` (default behavior), tips will be + returned to their tip rack. If `True` and a trash + container has been attached to this `Pipette`, + then the tip will be sent to the trash container. + + * *touch_tip* (``boolean``) -- + If `True`, a :py:meth:`touch_tip` will occur following each + :py:meth:`aspirate` and :py:meth:`dispense`. If set to `False` + (default behavior), no :py:meth:`touch_tip` will occur. + + * *blow_out* (``boolean``) -- + If `True`, a :py:meth:`blow_out` will occur following each + :py:meth:`dispense`, but only if the pipette has no liquid left + in it. If set to `False` (default), no :py:meth:`blow_out` will + occur. + + * *mix_before* (``tuple``) -- + The tuple, if specified, gives the amount of volume to + :py:meth:`mix` preceding each :py:meth:`aspirate` during the + transfer. The tuple is interpreted as (repetitions, volume). + + * *mix_after* (``tuple``) -- + The tuple, if specified, gives the amount of volume to + :py:meth:`mix` after each :py:meth:`dispense` during the + transfer. The tuple is interpreted as (repetitions, volume). + + * *carryover* (``boolean``) -- + If `True` (default), any `volume` that exceeds the maximum volume + of this Pipette will be split into multiple smaller volumes. + + * *gradient* (``lambda``) -- + Function for calculating the curve used for gradient volumes. + When `volume` is a tuple of length 2, its values are used to + create a list of gradient volumes. The default curve for this + gradient is linear (lambda x: x), however a method can be passed + with the `gradient` keyword argument to create a custom curve. :returns: This instance """ @@ -1182,22 +1171,21 @@ def transfer(self, touch_tip = None if kwargs.get('touch_tip'): touch_tip = transfers.TouchTipStrategy.ALWAYS - + default_args = transfers.Transfer() transfer_args = transfers.Transfer( - new_tip=kwargs.get('new_tip') or transfers.Transfer.new_tip, - air_gap=kwargs.get('air_gap') or transfers.Transfer.air_gap, - carryover=kwargs.get('carryover') or transfers.Transfer.carryover, + new_tip=kwargs.get('new_tip') or default_args.new_tip, + air_gap=kwargs.get('air_gap') or default_args.air_gap, + carryover=kwargs.get('carryover') or default_args.carryover, gradient_function=(kwargs.get('gradient_function') or - transfers.Transfer.gradient_function), + default_args.gradient_function), disposal_volume=(kwargs.get('disposal_volume') or - transfers.Transfer.disposal_volume), + default_args.disposal_volume), mix_strategy=mix_strategy, drop_tip_strategy=drop_tip, - blow_out_strategy=blow_out or transfers.Transfer.blow_out_strategy, + blow_out_strategy=blow_out or default_args.blow_out_strategy, touch_tip_strategy=(touch_tip or - transfers.Transfer.touch_tip_strategy) + default_args.touch_tip_strategy) ) - transfer_options = transfers.TransferOptions(transfer=transfer_args, mix=mix_opts) plan = transfers.TransferPlan(volume, source, dest, self, diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 93dd47a7bad..4d2b306584e 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -135,9 +135,9 @@ def _from_center_cartesian( inside of the well. :param x: a float in the range [-1.0, 1.0] for a percentage of half of - the radius/width in the X axis + the radius/length in the X axis :param y: a float in the range [-1.0, 1.0] for a percentage of half of - the radius/length in the Y axis + the radius/width in the Y axis :param z: a float in the range [-1.0, 1.0] for a percentage of half of the height above/below the center @@ -146,8 +146,8 @@ def _from_center_cartesian( """ center = self.center() if self._shape is WellShape.RECTANGULAR: - x_size = self._width - y_size = self._length + x_size = self._length + y_size = self._width else: x_size = self._diameter y_size = self._diameter diff --git a/api/src/opentrons/protocol_api/transfers.py b/api/src/opentrons/protocol_api/transfers.py index fc3e25e76b4..685f9eec855 100644 --- a/api/src/opentrons/protocol_api/transfers.py +++ b/api/src/opentrons/protocol_api/transfers.py @@ -2,7 +2,6 @@ from typing import (Any, List, Optional, Union, NamedTuple, Callable, TYPE_CHECKING) from .labware import Well -from opentrons import helpers from opentrons import types if TYPE_CHECKING: @@ -426,37 +425,42 @@ def __iter__(self): def _plan_transfer(self): """ - Source/ Dest: Multiple sources to multiple destinations. - Src & dest should be equal length - Volume: Single volume or List of volumes is acceptable. This list - should be same length as sources/destinations - # Behavior with transfer options: - New_tip: can be either NEVER or ONCE or ALWAYS - Air_gap: if specified, will be performed after every aspirate - Blow_out: can be performed after each dispense (after mix, - before touch_tip) at the location specified. If there is - liquid present in the tip (as in the case of nonzero - disposal volume), blow_out will be performed at either - user-defined location or (default) trash. - If no liquid is supposed to be present in the tip after - dispense, blow_out will be performed at dispense well loc - (if blow out strategy is DEST_IF_EMPTY) - Touch_tip: can be performed after each aspirate and/or after - each dispense - Mix: can be performed before aspirate and/or after dispense - if there is no disposal volume (i.e. can be performed - only when the tip is supposed to be empty) + * **Source/ Dest:** Multiple sources to multiple destinations. + Src & dest should be equal length + + * **Volume:** Single volume or List of volumes is acceptable. This list + should be same length as sources/destinations + + * **Behavior with transfer options:** + + - New_tip: can be either NEVER or ONCE or ALWAYS + - Air_gap: if specified, will be performed after every aspirate + - Blow_out: can be performed after each dispense (after mix, before + touch_tip) at the location specified. If there is + liquid present in the tip (as in the case of nonzero + disposal volume), blow_out will be performed at either + user-defined location or (default) trash. + If no liquid is supposed to be present in the tip after + dispense, blow_out will be performed at dispense well + location (if blow out strategy is DEST_IF_EMPTY) + - Touch_tip: can be performed after each aspirate and/or after + each dispense + - Mix: can be performed before aspirate and/or after dispense + if there is no disposal volume (i.e. can be performed + only when the tip is supposed to be empty) + Considering all options, the sequence of actions is: - New Tip -> Mix -> Aspirate (with disposal volume) -> Air gap -> + *New Tip -> Mix -> Aspirate (with disposal volume) -> Air gap -> -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> - -> Blow out -> Touch tip -> Drop tip + -> Blow out -> Touch tip -> Drop tip* """ plan_iter = zip(self._volumes, self._sources, self._dests) for step_vol, src, dest in plan_iter: if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: yield self._format_dict('pick_up_tip', kwargs=self._tip_opts) - max_vol = self._instr.max_volume - self._strategy.disposal_volume \ - - self._strategy.air_gap + max_vol = self._instr.max_volume - \ + self._strategy.disposal_volume - \ + self._strategy.air_gap xferred_vol = 0 while xferred_vol < step_vol: # TODO: account for unequal length sources, dests @@ -469,37 +473,39 @@ def _plan_transfer(self): def _plan_distribute(self): """ - Source/ Dest: One source to many destinations - Volume: Single volume or List of volumes is acceptable. This list - should be same length as destinations - # Behavior with transfer options: - New_tip: can be either NEVER or ONCE - (ALWAYS will fallback to ONCE) - Air_gap: if specified, will be performed after every aspirate and - also in-between dispenses (to keep air gap while moving - between wells) - Blow_out: can be performed at the end of distribute (after mix, - before touch_tip) at the location specified. If there is - liquid present in the tip, blow_out will be performed at - either user-defined location or (default) trash. - If no liquid is supposed to be present in the tip at the - end of distribute, blow_out will be performed at the last - well the liquid was dispensed to (if strategy is - DEST_IF_EMPTY) - Touch_tip: can be performed after each aspirate and/or after - every dispense - Mix: can be performed before aspirate and/or after the last - dispense if there is no disposal volume (i.e. can be performed - only when the tip is supposed to be empty) + * **Source/ Dest:** One source to many destinations + * **Volume:** Single volume or List of volumes is acceptable. This list + should be same length as destinations + * **Behavior with transfer options:** + + - New_tip: can be either NEVER or ONCE + (ALWAYS will fallback to ONCE) + - Air_gap: if specified, will be performed after every aspirate and + also in-between dispenses (to keep air gap while moving + between wells) + - Blow_out: can be performed at the end of distribute (after mix, + before touch_tip) at the location specified. If there + is liquid present in the tip, blow_out will be + performed at either user-defined location or (default) + trash. If no liquid is supposed to be present in the + tip at the end of distribute, blow_out will be + performed at the last well the liquid was dispensed to + (if strategy is DEST_IF_EMPTY) + - Touch_tip: can be performed after each aspirate and/or after + every dispense + - Mix: can be performed before aspirate and/or after the last + dispense if there is no disposal volume (i.e. can be + performed only when the tip is supposed to be empty) Considering all options, the sequence of actions is: + 1. Going from source to dest1: - New Tip -> Mix -> Aspirate (with disposal volume) -> Air gap -> + *New Tip -> Mix -> Aspirate (with disposal volume) -> Air gap -> -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> - -> Blow out -> Touch tip -> Drop tip + -> Blow out -> Touch tip -> Drop tip* 2. Going from destn to destn+1: - .. Dispense air gap -> Dispense -> Touch tip -> Air gap -> - .. Dispense air gap -> ... + *.. Dispense air gap -> Dispense -> Touch tip -> Air gap -> + .. Dispense air gap -> ...* """ # TODO: decide whether default disposal vol for distribute should be @@ -534,38 +540,40 @@ def _plan_distribute(self): def _plan_consolidate(self): """ - Source/ Dest: Many sources to one destination - Volume: Single volume or List of volumes is acceptable. This list - should be same length as sources - # Behavior with transfer options: - New_tip: can be either NEVER or ONCE - (ALWAYS will fallback to ONCE) - Air_gap: if specified, will be performed after every aspirate - so that the aspirated liquids do not mix inside the tip. - The air gap will be dispensed while dispensing the liquid - into the destination well. - Blow_out: can be performed after a dispense (after mix, - before touch_tip) at the location specified. If there is - liquid present in the tip (which shouldn't happen since - consolidate doesn't take a disposal vol, yet), blow_out - will be performed at either user-defined location or - (default) trash. - If no liquid is supposed to be present in the tip after - dispense, blow_out will be performed at dispense well loc - (if blow out strategy is DEST_IF_EMPTY) - Touch_tip: can be performed after each aspirate and/or after - dispense - Mix: can be performed before the first aspirate and/or after - dispense if there is no disposal volume (i.e. can be performed - only when the tip is supposed to be empty) + * **Source/ Dest:** Many sources to one destination + * **Volume:** Single volume or List of volumes is acceptable. This list + should be same length as sources + * **Behavior with transfer options:** + + - New_tip: can be either NEVER or ONCE + (ALWAYS will fallback to ONCE) + - Air_gap: if specified, will be performed after every aspirate + so that the aspirated liquids do not mix inside the tip. + The air gap will be dispensed while dispensing the + liquid into the destination well. + - Blow_out: can be performed after a dispense (after mix, + before touch_tip) at the location specified. If there + is liquid present in the tip (which shouldn't happen + since consolidate doesn't take a disposal vol, yet), + blow_out will be performed at either user-defined + location or (default) trash. + If no liquid is supposed to be present in the tip after + dispense, blow_out will be performed at dispense well + loc (if blow out strategy is DEST_IF_EMPTY) + - Touch_tip: can be performed after each aspirate and/or after + dispense + - Mix: can be performed before the first aspirate and/or after + dispense if there is no disposal volume (i.e. can be + performed only when the tip is supposed to be empty) + Considering all options, the sequence of actions is: 1. Going from source to dest1: - New Tip -> Mix -> Aspirate (with disposal volume?) -> Air gap -> + *New Tip -> Mix -> Aspirate (with disposal volume?) -> Air gap -> -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> - -> Blow out -> Touch tip -> Drop tip + -> Blow out -> Touch tip -> Drop tip* 2. Going from source(n) to source(n+1): - .. Aspirate -> Air gap -> Touch tip ->.. - .. Aspirate -> ..... + *.. Aspirate -> Air gap -> Touch tip ->.. + .. Aspirate -> .....* """ plan_iter = zip(self._volumes, self._sources) current_xfer = next(plan_iter) @@ -667,7 +675,7 @@ def _create_volume_list(self, volume, total_xfers): if isinstance(volume, (float, int)): return [volume] * total_xfers elif isinstance(volume, tuple): - return helpers._create_volume_gradient( + return self._create_volume_gradient( volume[0], volume[-1], total_xfers, self._strategy.gradient_function) else: @@ -679,6 +687,18 @@ def _create_volume_list(self, volume, total_xfers): "of transfers") return volume + def _create_volume_gradient(self, min_v, max_v, total, gradient=None): + + diff_vol = max_v - min_v + + def _map_volume(i): + nonlocal diff_vol, total + rel_x = i / (total - 1) + rel_y = gradient(rel_x) if gradient else rel_x + return (rel_y * diff_vol) + min_v + + return [_map_volume(i) for i in range(total)] + def _multichannel_transfer(self, s, d): # TODO: add a check for container being multi-channel compatible? # Helper function for multi-channel use-case @@ -692,25 +712,27 @@ def _multichannel_transfer(self, s, d): 'Target should be a Well or List[Well] but is {}'.format(d) # TODO: Account for cases where a src/dest list has a non-first-row - # TODO: ..well (eg, 'B1') and would expect the robot/pipette to - # TODO: ..understand that it is referring to the whole first column + # well (eg, 'B1') and would expect the robot/pipette to + # understand that it is referring to the whole first column if isinstance(s, List) and isinstance(s[0], List): # s is a List[List]]; flatten to 1D list s = [well for list_elem in s for well in list_elem] + new_src = [] for well in s: - if not self._is_first_row(well): - # For now, just remove wells that aren't in first row - s.remove(well) + if self._is_first_row(well): + # For now, just use wells that are in first row + new_src.append(well) if isinstance(d, List) and isinstance(d[0], List): # s is a List[List]]; flatten to 1D list d = [well for list_elem in d for well in list_elem] + new_dst = [] for well in d: - if not self._is_first_row(well): - # For now, just remove wells that aren't in first row - d.remove(well) + if self._is_first_row(well): + # For now, just use wells that are in first row + new_dst.append(well) - return s, d + return new_src, new_dst def _is_first_row(self, well: Well): - return True if 'A' in str(well) else False + return well in well.parent.rows()[0] diff --git a/api/tests/opentrons/protocol_api/test_context.py b/api/tests/opentrons/protocol_api/test_context.py index 19db4d847db..70fdadde9a0 100644 --- a/api/tests/opentrons/protocol_api/test_context.py +++ b/api/tests/opentrons/protocol_api/test_context.py @@ -501,7 +501,6 @@ def test_touch_tip_default_args(loop, monkeypatch): async def fake_hw_move(mount, abs_position, speed=None, critical_point=None): nonlocal total_hw_moves - print("new_move_pos:{}".format(abs_position)) total_hw_moves.append((abs_position, speed)) instr.aspirate(10, lw.wells()[0]) @@ -513,8 +512,6 @@ async def fake_hw_move(mount, abs_position, speed=None, lw.wells()[0]._from_center_cartesian(-1, 0, 1) - z_offset, lw.wells()[0]._from_center_cartesian(0, 1, 1) - z_offset, lw.wells()[0]._from_center_cartesian(0, -1, 1) - z_offset] - print("Well bottom clearance: {}".format(instr.well_bottom_clearance)) - for i in range(1, 5): assert total_hw_moves[i] == (edges[i-1], speed) @@ -550,3 +547,4 @@ def test_transfer(loop): ctx.home() instr.transfer(10, lw1.columns()[0], lw2.columns()[0]) + diff --git a/api/tests/opentrons/protocol_api/test_labware.py b/api/tests/opentrons/protocol_api/test_labware.py index 7922b88cc44..c2023b88987 100644 --- a/api/tests/opentrons/protocol_api/test_labware.py +++ b/api/tests/opentrons/protocol_api/test_labware.py @@ -20,8 +20,8 @@ 'shape': 'rectangular', 'depth': 20, 'totalLiquidVolume': 200, - 'length': 50, - 'width': 120, + 'length': 120, + 'width': 50, 'x': 45, 'y': 10, 'z': 22 @@ -102,9 +102,9 @@ def test_from_center_cartesian(): percent2_z = 0.9 point2 = well2._from_center_cartesian(percent2_x, percent2_y, percent2_z) - # slot.x + well.x - 0.25 * well.width/2 + # slot.x + well.x - 0.25 * well.length/2 expected_x = 13 + 45 - 15 - # slot.y + well.y + 0.1 * well.length/2 + # slot.y + well.y + 0.1 * well.width/2 expected_y = 14 + 10 + 2.5 # slot.z + well.z + (1 + 0.9) * well.depth/2 expected_z = 15 + 22 + 19 From 92b005e80612a1c22d895949c23679e258cc9ea9 Mon Sep 17 00:00:00 2001 From: Sanniti Date: Wed, 23 Jan 2019 17:20:34 -0500 Subject: [PATCH 4/5] refactor(protocol_api): corrected and refactored parts related to multichannel transfers --- api/src/opentrons/api/session.py | 18 +++---- api/src/opentrons/commands/commands.py | 51 +++++++------------ api/src/opentrons/protocol_api/contexts.py | 1 - api/src/opentrons/protocol_api/transfers.py | 22 +++++--- .../opentrons/protocol_api/test_context.py | 2 +- 5 files changed, 39 insertions(+), 55 deletions(-) diff --git a/api/src/opentrons/api/session.py b/api/src/opentrons/api/session.py index bdb85ac0e9d..682fc7c6d0a 100755 --- a/api/src/opentrons/api/session.py +++ b/api/src/opentrons/api/session.py @@ -11,6 +11,7 @@ from opentrons.legacy_api.containers.placeable import ( Module as ModulePlaceable, Placeable) from opentrons.commands import tree, types as command_types +from opentrons.commands.commands import is_new_loc, listify from opentrons.protocols import execute_protocol from opentrons.config import feature_flags as ff from opentrons.protocol_api import (ProtocolContext, @@ -467,19 +468,12 @@ def _get_labware(command): containers.append(_get_new_labware(location)) if locations: - try: - list_of_locations = location_to_list(locations) - except ValueError: - if isinstance(locations, list): - if isinstance(locations[0], list): - # unpack - locations = [loc - for loc_list in locations for loc in loc_list] - if(isinstance(loc, (Location, labware.Well, labware.Labware)) - for loc in locations): - containers.extend( - [_get_new_labware(loc) for loc in locations]) + if is_new_loc(locations): + list_of_locations = listify(locations) + containers.extend( + [_get_new_labware(loc) for loc in list_of_locations]) else: + list_of_locations = location_to_list(locations) containers.extend( [get_container(location) for location in list_of_locations]) diff --git a/api/src/opentrons/commands/commands.py b/api/src/opentrons/commands/commands.py index 1c2df6031a2..7402165b83d 100755 --- a/api/src/opentrons/commands/commands.py +++ b/api/src/opentrons/commands/commands.py @@ -2,7 +2,7 @@ from ..broker import broker import functools import inspect -from typing import Union, Sequence +from typing import Union, Sequence, List, Any from opentrons.legacy_api.containers import (Well as OldWell, Container as OldContainer, @@ -12,24 +12,15 @@ from opentrons.types import Location -def _get_loc_info(location: Union[Location, Well, Sequence]) -> dict: - loc_info = {'type': type(location), 'is_2D': False, 'is_new_loc': False} - if isinstance(location, list): - if isinstance(location[0], list): - loc_info['is_2D'] = True - location = [loc for loc_list in location for loc in loc_list] - if isinstance(location[0], (Location, Well)): - loc_info['is_new_loc'] = True - elif isinstance(location, (Location, Well)): - loc_info['is_new_loc'] = True - return loc_info +def is_new_loc(location: Union[Location, Well, None, + OldWell, OldContainer, + OldSlot, Sequence]) -> bool: + return isinstance(listify(location)[0], (Location, Well)) -def _listify_new_loc(location: Union[Location, Well, Sequence]) -> list: +def listify(location: Any) -> List: if isinstance(location, list): - if _get_loc_info(location)['is_2D']: - location = [loc for loc_list in location for loc in loc_list] - return location + return sum([listify(loc) for loc in location], []) else: return [location] @@ -81,18 +72,12 @@ def get_slot(location): def stringify_location(location: Union[Location, None, OldWell, OldContainer, OldSlot, Sequence]) -> str: - if _get_loc_info(location)['is_new_loc']: - if isinstance(location, list): - if _get_loc_info(location)['is_2D']: - location = [loc for loc_list in location for loc in loc_list] - loc_str_list = [] - for loc in location: - loc_str_list.append(_stringify_new_loc(loc)) - return ','.join(loc_str_list) - else: - return _stringify_new_loc(location) + if is_new_loc(location): + loc_str_list = [_stringify_new_loc(loc) + for loc in listify(location)] + return ', '.join(loc_str_list) else: - return _stringify_legacy_loc(location) + return _stringify_legacy_loc(location) # type: ignore def make_command(name, payload): @@ -151,9 +136,9 @@ def consolidate(instrument, volume, source, dest): source=stringify_location(source), dest=stringify_location(dest) ) - if _get_loc_info(source)['is_new_loc']: + if is_new_loc(source): # Dest is assumed as new location too - locations = [] + _listify_new_loc(source) + _listify_new_loc(dest) + locations = [] + listify(source) + listify(dest) else: # incase either source or dest is list of tuple location # strip both down to simply lists of Placeables @@ -177,9 +162,9 @@ def distribute(instrument, volume, source, dest): source=stringify_location(source), dest=stringify_location(dest) ) - if _get_loc_info(source)['is_new_loc']: + if is_new_loc(source): # Dest is assumed as new location too - locations = [] + _listify_new_loc(source) + _listify_new_loc(dest) + locations = [] + listify(source) + listify(dest) else: # incase either source or dest is list of tuple location # strip both down to simply lists of Placeables @@ -203,9 +188,9 @@ def transfer(instrument, volume, source, dest): source=stringify_location(source), dest=stringify_location(dest) ) - if _get_loc_info(source)['is_new_loc']: + if is_new_loc(source): # Dest is assumed as new location too - locations = [] + _listify_new_loc(source) + _listify_new_loc(dest) + locations = [] + listify(source) + listify(dest) else: # incase either source or dest is list of tuple location # strip both down to simply lists of Placeables diff --git a/api/src/opentrons/protocol_api/contexts.py b/api/src/opentrons/protocol_api/contexts.py index 1c9b25cfc90..cffdf9cb0ce 100644 --- a/api/src/opentrons/protocol_api/contexts.py +++ b/api/src/opentrons/protocol_api/contexts.py @@ -976,7 +976,6 @@ def drop_tip( "types.Location (e.g. the return value from " "tiprack.wells()[0].top()) or a Well (e.g. tiprack.wells()[0]." " However, it is a {}".format(location)) - self.move_to(target) self._hw_manager.hardware.drop_tip(self._mount) self._last_tip_picked_up_from = None diff --git a/api/src/opentrons/protocol_api/transfers.py b/api/src/opentrons/protocol_api/transfers.py index 685f9eec855..2bb701b5b3f 100644 --- a/api/src/opentrons/protocol_api/transfers.py +++ b/api/src/opentrons/protocol_api/transfers.py @@ -26,6 +26,7 @@ class TouchTipStrategy(enum.Enum): class BlowOutStrategy(enum.Enum): + NONE = enum.auto() TRASH = enum.auto() DEST_IF_EMPTY = enum.auto() CUSTOM_LOCATION = enum.auto() @@ -50,7 +51,7 @@ class Transfer(NamedTuple): disposal_volume: Optional[float] = 0 mix_strategy: MixStrategy = MixStrategy.NEVER drop_tip_strategy: DropTipStrategy = DropTipStrategy.TRASH - blow_out_strategy: BlowOutStrategy = BlowOutStrategy.TRASH + blow_out_strategy: BlowOutStrategy = BlowOutStrategy.NONE touch_tip_strategy: TouchTipStrategy = TouchTipStrategy.NEVER @@ -459,8 +460,7 @@ def _plan_transfer(self): if self._strategy.new_tip == types.TransferTipPolicy.ALWAYS: yield self._format_dict('pick_up_tip', kwargs=self._tip_opts) max_vol = self._instr.max_volume - \ - self._strategy.disposal_volume - \ - self._strategy.air_gap + self._strategy.disposal_volume - self._strategy.air_gap xferred_vol = 0 while xferred_vol < step_vol: # TODO: account for unequal length sources, dests @@ -568,7 +568,7 @@ def _plan_consolidate(self): Considering all options, the sequence of actions is: 1. Going from source to dest1: - *New Tip -> Mix -> Aspirate (with disposal volume?) -> Air gap -> + *New Tip -> Mix -> Aspirate (with disposal volume?) -> Air gap -> Touch tip -> Dispense air gap -> Dispense -> Mix if empty -> -> Blow out -> Touch tip -> Drop tip* 2. Going from source(n) to source(n+1): @@ -634,9 +634,7 @@ def _before_dispense(self): def _after_dispense(self, loc, is_disp_next=False): # This sequence of actions is subject to change - # TODO: write a proper sequence for blow_out. Current sequence is buggy if not is_disp_next: - # TODO: check this logic. Esp blow_out if self._instr.current_volume == 0: if self._strategy.mix_strategy == MixStrategy.AFTER or \ self._strategy.mix_strategy == MixStrategy.BOTH: @@ -644,9 +642,13 @@ def _after_dispense(self, loc, is_disp_next=False): if self._strategy.blow_out_strategy \ == BlowOutStrategy.DEST_IF_EMPTY: yield self._format_dict('blow_out', [loc]) - else: - # Custom location. Or trash if not specified + if self._strategy.blow_out_strategy == BlowOutStrategy.TRASH: + yield self._format_dict('blow_out', [ + self._instr.trash_container.wells()[0]]) + elif self._strategy.blow_out_strategy == \ + BlowOutStrategy.CUSTOM_LOCATION: yield self._format_dict('blow_out', kwargs=self._blow_opts) + else: # Used by distribute if self._strategy.air_gap: @@ -717,6 +719,8 @@ def _multichannel_transfer(self, s, d): if isinstance(s, List) and isinstance(s[0], List): # s is a List[List]]; flatten to 1D list s = [well for list_elem in s for well in list_elem] + elif isinstance(s, Well): + s = [s] new_src = [] for well in s: if self._is_first_row(well): @@ -726,6 +730,8 @@ def _multichannel_transfer(self, s, d): if isinstance(d, List) and isinstance(d[0], List): # s is a List[List]]; flatten to 1D list d = [well for list_elem in d for well in list_elem] + elif isinstance(d, Well): + d = [d] new_dst = [] for well in d: if self._is_first_row(well): diff --git a/api/tests/opentrons/protocol_api/test_context.py b/api/tests/opentrons/protocol_api/test_context.py index 70fdadde9a0..c74e94ab19c 100644 --- a/api/tests/opentrons/protocol_api/test_context.py +++ b/api/tests/opentrons/protocol_api/test_context.py @@ -536,6 +536,7 @@ def fake_move(loc): instr.blow_out() assert move_location == lw.wells()[0].top() + @pytest.mark.xfail def test_transfer(loop): ctx = papi.ProtocolContext(loop) @@ -547,4 +548,3 @@ def test_transfer(loop): ctx.home() instr.transfer(10, lw1.columns()[0], lw2.columns()[0]) - From 8378cc4763ff45629eb2fd34a0c59be53c4f7b24 Mon Sep 17 00:00:00 2001 From: Sanniti Date: Thu, 24 Jan 2019 17:38:39 -0500 Subject: [PATCH 5/5] refactor(protocol_api): corrected new_tip, disposal_vol in transfer(). added test for transfer optio --- api/src/opentrons/protocol_api/contexts.py | 20 ++++-- .../opentrons/protocol_api/test_context.py | 71 +++++++++++++++++-- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/api/src/opentrons/protocol_api/contexts.py b/api/src/opentrons/protocol_api/contexts.py index cffdf9cb0ce..cd5b48a8c16 100644 --- a/api/src/opentrons/protocol_api/contexts.py +++ b/api/src/opentrons/protocol_api/contexts.py @@ -1022,7 +1022,7 @@ def distribute(self, self._log.debug("Distributing {} from {} to {}" .format(volume, source, dest)) kwargs['mode'] = 'distribute' - kwargs['disposal_vol'] = kwargs.get('disposal_volume', self.min_volume) + kwargs['disposal_volume'] = kwargs.get('disposal_vol', self.min_volume) return self.transfer(volume, source, dest, **kwargs) @cmds.publish.both(command=cmds.consolidate) @@ -1044,7 +1044,6 @@ def consolidate(self, self._log.debug("Consolidate {} from {} to {}" .format(volume, source, dest)) kwargs['mode'] = 'consolidate' - kwargs['disposal_vol'] = kwargs.get('disposal_volume', 0) return self.transfer(volume, source, dest, **kwargs) @cmds.publish.both(command=cmds.transfer) @@ -1116,6 +1115,12 @@ def transfer(self, :py:meth:`mix` after each :py:meth:`dispense` during the transfer. The tuple is interpreted as (repetitions, volume). + * *disposal_vol* (``float``) -- + (:py:meth:`distribute` only) Volume of liquid to be disposed off + after distributing. When dispensing multiple times from the same + tip, it is recommended to aspirate an extra amount of liquid to + be disposed off after distributing. + * *carryover* (``boolean``) -- If `True` (default), any `volume` that exceeds the maximum volume of this Pipette will be split into multiple smaller volumes. @@ -1163,6 +1168,10 @@ def transfer(self, else: drop_tip = transfers.DropTipStrategy.RETURN + new_tip = kwargs.get('new_tip') + if isinstance(new_tip, str): + new_tip = types.TransferTipPolicy[new_tip.upper()] + blow_out = None if kwargs.get('blow_out'): blow_out = transfers.BlowOutStrategy.TRASH @@ -1172,7 +1181,7 @@ def transfer(self, touch_tip = transfers.TouchTipStrategy.ALWAYS default_args = transfers.Transfer() transfer_args = transfers.Transfer( - new_tip=kwargs.get('new_tip') or default_args.new_tip, + new_tip=new_tip or default_args.new_tip, air_gap=kwargs.get('air_gap') or default_args.air_gap, carryover=kwargs.get('carryover') or default_args.carryover, gradient_function=(kwargs.get('gradient_function') or @@ -1189,9 +1198,12 @@ def transfer(self, mix=mix_opts) plan = transfers.TransferPlan(volume, source, dest, self, kwargs['mode'], transfer_options) + self._execute_transfer(plan) + return self + + def _execute_transfer(self, plan: transfers.TransferPlan): for cmd in plan: getattr(self, cmd['method'])(*cmd['args'], **cmd['kwargs']) - return self def delay(self): return self._ctx.delay() diff --git a/api/tests/opentrons/protocol_api/test_context.py b/api/tests/opentrons/protocol_api/test_context.py index c74e94ab19c..e323d8552a0 100644 --- a/api/tests/opentrons/protocol_api/test_context.py +++ b/api/tests/opentrons/protocol_api/test_context.py @@ -4,11 +4,12 @@ import pkgutil import opentrons.protocol_api as papi -from opentrons.types import Mount, Point, Location +from opentrons.types import Mount, Point, Location, TransferTipPolicy from opentrons.hardware_control import API, adapters from opentrons.hardware_control.pipette import Pipette from opentrons.hardware_control.types import Axis from opentrons.config.pipette_config import configs +from opentrons.protocol_api import transfers as tf import pytest @@ -537,8 +538,7 @@ def fake_move(loc): assert move_location == lw.wells()[0].top() -@pytest.mark.xfail -def test_transfer(loop): +def test_transfer_options(loop, monkeypatch): ctx = papi.ProtocolContext(loop) lw1 = ctx.load_labware_by_name('biorad_96_wellPlate_pcr_200_uL', 1) lw2 = ctx.load_labware_by_name('generic_96_wellPlate_380_uL', 2) @@ -547,4 +547,67 @@ def test_transfer(loop): tip_racks=[tiprack]) ctx.home() - instr.transfer(10, lw1.columns()[0], lw2.columns()[0]) + transfer_options = None + + def fake_execute_transfer(xfer_plan): + nonlocal transfer_options + transfer_options = xfer_plan._options + + monkeypatch.setattr(instr, '_execute_transfer', fake_execute_transfer) + instr.transfer(10, lw1.columns()[0], lw2.columns()[0], + new_tip='always', mix_before=(2, 10), blow_out=True) + expected_xfer_options1 = tf.TransferOptions( + transfer=tf.Transfer( + new_tip=TransferTipPolicy.ALWAYS, + air_gap=0, + carryover=True, + gradient_function=None, + disposal_volume=0, + mix_strategy=tf.MixStrategy.BEFORE, + drop_tip_strategy=tf.DropTipStrategy.RETURN, + blow_out_strategy=tf.BlowOutStrategy.TRASH, + touch_tip_strategy=tf.TouchTipStrategy.NEVER + ), + pick_up_tip=tf.PickUpTipOpts(), + mix=tf.Mix(mix_before=tf.MixOpts( + repetitions=2, + volume=10, + rate=None), + mix_after=tf.MixOpts() + ), + blow_out=tf.BlowOutOpts(), + touch_tip=tf.TouchTipOpts(), + aspirate=tf.AspirateOpts(), + dispense=tf.DispenseOpts() + ) + assert transfer_options == expected_xfer_options1 + + instr.pick_up_tip() + instr.distribute(50, lw1.rows()[0][0], lw2.columns()[0], + new_tip='never', touch_tip=True, trash=True, + disposal_vol=10, mix_after=(3, 20)) + instr.drop_tip() + expected_xfer_options2 = tf.TransferOptions( + transfer=tf.Transfer( + new_tip=TransferTipPolicy.NEVER, + air_gap=0, + carryover=True, + gradient_function=None, + disposal_volume=10, + mix_strategy=tf.MixStrategy.AFTER, + drop_tip_strategy=tf.DropTipStrategy.TRASH, + blow_out_strategy=tf.BlowOutStrategy.NONE, + touch_tip_strategy=tf.TouchTipStrategy.ALWAYS + ), + pick_up_tip=tf.PickUpTipOpts(), + mix=tf.Mix(mix_before=tf.MixOpts(), + mix_after=tf.MixOpts(repetitions=3, + volume=20, + rate=None) + ), + blow_out=tf.BlowOutOpts(), + touch_tip=tf.TouchTipOpts(), + aspirate=tf.AspirateOpts(), + dispense=tf.DispenseOpts() + ) + assert transfer_options == expected_xfer_options2