From 5c49d1a888f01336af663c412c4911d3ab9b92ab Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 25 Oct 2023 13:14:50 -0400 Subject: [PATCH] feat(api): Experimental, barebones support for dropping stuff in the waste chute (#13828) --- api/src/opentrons/protocol_api/__init__.py | 5 +- .../opentrons/protocol_api/_waste_chute.py | 11 +++ .../protocol_api/_waste_chute_dimensions.py | 18 ++++ .../protocol_api/core/engine/instrument.py | 61 +++++++++++++- .../protocol_api/core/engine/protocol.py | 84 ++++++++++++++++--- .../opentrons/protocol_api/core/instrument.py | 7 ++ .../core/legacy/legacy_instrument_core.py | 8 ++ .../core/legacy/legacy_protocol_core.py | 2 + .../legacy_instrument_core.py | 7 ++ .../opentrons/protocol_api/core/protocol.py | 5 +- .../protocol_api/instrument_context.py | 15 +++- .../protocol_api/protocol_context.py | 23 ++++- .../protocol_engine/clients/sync_client.py | 15 ++++ 13 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 api/src/opentrons/protocol_api/_waste_chute.py create mode 100644 api/src/opentrons/protocol_api/_waste_chute_dimensions.py diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 3f5e76b0800..18368b46a92 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -14,7 +14,6 @@ from .deck import Deck from .instrument_context import InstrumentContext from .labware import Labware, Well -from ._types import OFF_DECK from .module_contexts import ( ModuleContext, ThermocyclerContext, @@ -29,6 +28,9 @@ EMPTY, ) +from ._types import OFF_DECK +from ._waste_chute import WasteChute + from .create_protocol_context import ( create_protocol_context, ProtocolEngineCoreRequiredError, @@ -48,6 +50,7 @@ "HeaterShakerContext", "MagneticBlockContext", "Labware", + "WasteChute", "Well", "Liquid", "COLUMN", diff --git a/api/src/opentrons/protocol_api/_waste_chute.py b/api/src/opentrons/protocol_api/_waste_chute.py new file mode 100644 index 00000000000..7472327f941 --- /dev/null +++ b/api/src/opentrons/protocol_api/_waste_chute.py @@ -0,0 +1,11 @@ +class WasteChute: + """Represents a Flex waste chute. + + See :py:obj:`ProtocolContext.load_waste_chute`. + """ + + def __init__( + self, + with_staging_area_slot_d4: bool, + ) -> None: + self._with_staging_area_slot_d4 = with_staging_area_slot_d4 diff --git a/api/src/opentrons/protocol_api/_waste_chute_dimensions.py b/api/src/opentrons/protocol_api/_waste_chute_dimensions.py new file mode 100644 index 00000000000..b0c94f85f0c --- /dev/null +++ b/api/src/opentrons/protocol_api/_waste_chute_dimensions.py @@ -0,0 +1,18 @@ +"""Constants for the dimensions of the Flex waste chute. + +TODO: These should be moved into shared-data and interpreted by Protocol Engine. +""" + + +from opentrons.types import Point + + +SLOT_ORIGIN_TO_1_OR_8_TIP_A1 = Point(64, 21.91, 144) +SLOT_ORIGIN_TO_96_TIP_A1 = Point(14.445, 42.085, 115) + +# TODO: This z-coord is misleading. We need to account for the labware height and the paddle height; +# we can't define this as a single coordinate. +SLOT_ORIGIN_TO_GRIPPER_JAW_CENTER = Point(64, 29, 136.5) + +# This includes the height of the optional lid. +ENVELOPE_HEIGHT = 154 diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index ae64b182b8e..f29b5cd2036 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -27,6 +27,7 @@ from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION +from opentrons.types import Point from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.protocol_api._nozzle_layout import NozzleLayout @@ -34,6 +35,9 @@ from ..instrument import AbstractInstrument from .well import WellCore +from ..._waste_chute import WasteChute +from ... import _waste_chute_dimensions + if TYPE_CHECKING: from .protocol import ProtocolCore @@ -383,6 +387,62 @@ def drop_tip( self._protocol_core.set_last_location(location=location, mount=self.get_mount()) + def _drop_tip_in_place(self, home_after: Optional[bool]) -> None: + self._engine_client.drop_tip_in_place( + pipette_id=self._pipette_id, + home_after=home_after, + ) + + def drop_tip_in_waste_chute( + self, waste_chute: WasteChute, home_after: Optional[bool] + ) -> None: + # TODO: Can we get away with implementing this in two steps like this, + # or does drop_tip() need to take the waste chute location because the z-height + # depends on the intent of dropping tip? How would Protocol Designer want to implement + # this? + self._move_to_waste_chute( + waste_chute, + force_direct=False, + speed=None, + ) + self._drop_tip_in_place(home_after=home_after) + + def _move_to_waste_chute( + self, + waste_chute: WasteChute, + force_direct: bool, + speed: Optional[float], + ) -> None: + if self.get_channels() == 96: + slot_origin_to_tip_a1 = _waste_chute_dimensions.SLOT_ORIGIN_TO_96_TIP_A1 + else: + slot_origin_to_tip_a1 = _waste_chute_dimensions.SLOT_ORIGIN_TO_1_OR_8_TIP_A1 + + # TODO: All of this logic to compute the destination coordinate belongs in Protocol Engine. + slot_d3 = next( + s + for s in self._protocol_core.get_deck_definition()["locations"][ + "orderedSlots" + ] + if s["id"] == "D3" + ) + slot_d3_origin = Point(*slot_d3["position"]) + destination_point = slot_d3_origin + slot_origin_to_tip_a1 + + # Normally, we use a 10 mm margin. (DEFAULT_GENERAL_ARC_Z_MARGIN.) Unfortunately, with + # 1000µL tips, we have slightly not enough room to meet that margin. We can make the margin + # as big as 7.5 mm before the motion planner raises an error. So, use that reduced margin, + # with a little more subtracted in order to leave wiggle room for pipette calibration. + minimum_z = _waste_chute_dimensions.ENVELOPE_HEIGHT + 5.0 + + self.move_to( + Location(destination_point, labware=None), + well_core=None, + force_direct=force_direct, + minimum_z_height=minimum_z, + speed=speed, + ) + def home(self) -> None: z_axis = self._engine_client.state.pipettes.get_z_axis(self._pipette_id) plunger_axis = self._engine_client.state.pipettes.get_plunger_axis( @@ -404,7 +464,6 @@ def move_to( minimum_z_height: Optional[float], speed: Optional[float], ) -> None: - if well_core is not None: labware_id = well_core.labware_id well_name = well_core.get_name() diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index fef1a9bf05a..e7811d52340 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -1,6 +1,8 @@ """ProtocolEngine-based Protocol API core implementation.""" from typing import Dict, Optional, Type, Union, List, Tuple +from opentrons.protocol_api import _waste_chute_dimensions + from opentrons.protocol_engine.commands import LoadModuleResult from opentrons_shared_data.deck.dev_types import DeckDefinitionV3, SlotDefV3 from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -39,8 +41,9 @@ ) from ... import validation -from ..._types import OffDeckType +from ..._types import OffDeckType, OFF_DECK from ..._liquid import Liquid +from ..._waste_chute import WasteChute from ..protocol import AbstractProtocol from ..labware import LabwareLoadParams from .labware import LabwareCore @@ -252,7 +255,12 @@ def move_labware( self, labware_core: LabwareCore, new_location: Union[ - DeckSlotName, LabwareCore, ModuleCore, NonConnectedModuleCore, OffDeckType + DeckSlotName, + LabwareCore, + ModuleCore, + NonConnectedModuleCore, + OffDeckType, + WasteChute, ], use_gripper: bool, pause_for_manual_move: bool, @@ -260,8 +268,6 @@ def move_labware( drop_offset: Optional[Tuple[float, float, float]], ) -> None: """Move the given labware to a new location.""" - to_location = self._convert_labware_location(location=new_location) - if use_gripper: strategy = LabwareMovementStrategy.USING_GRIPPER elif pause_for_manual_move: @@ -282,21 +288,73 @@ def move_labware( else None ) - # TODO(mm, 2023-02-23): Check for conflicts with other items on the deck, - # when move_labware() support is no longer experimental. + if isinstance(new_location, WasteChute): + self._move_labware_to_waste_chute( + labware_core, strategy, _pick_up_offset, _drop_offset + ) + else: + to_location = self._convert_labware_location(location=new_location) + + # TODO(mm, 2023-02-23): Check for conflicts with other items on the deck, + # when move_labware() support is no longer experimental. + + self._engine_client.move_labware( + labware_id=labware_core.labware_id, + new_location=to_location, + strategy=strategy, + pick_up_offset=_pick_up_offset, + drop_offset=_drop_offset, + ) - self._engine_client.move_labware( - labware_id=labware_core.labware_id, - new_location=to_location, - strategy=strategy, - pick_up_offset=_pick_up_offset, - drop_offset=_drop_offset, - ) if strategy == LabwareMovementStrategy.USING_GRIPPER: # Clear out last location since it is not relevant to pipetting # and we only use last location for in-place pipetting commands self.set_last_location(location=None, mount=Mount.EXTENSION) + def _move_labware_to_waste_chute( + self, + labware_core: LabwareCore, + strategy: LabwareMovementStrategy, + pick_up_offset: Optional[LabwareOffsetVector], + drop_offset: Optional[LabwareOffsetVector], + ) -> None: + slot = DeckSlotLocation(slotName=DeckSlotName.SLOT_D3) + slot_width = 128 + slot_height = 86 + drop_offset_from_slot = ( + _waste_chute_dimensions.SLOT_ORIGIN_TO_GRIPPER_JAW_CENTER + - Point(x=slot_width / 2, y=slot_height / 2) + ) + if drop_offset is not None: + drop_offset_from_slot += Point( + x=drop_offset.x, y=drop_offset.y, z=drop_offset.z + ) + + # To get the physical movement to happen, move the labware "into the slot" with a giant + # offset to dunk it in the waste chute. + self._engine_client.move_labware( + labware_id=labware_core.labware_id, + new_location=slot, + strategy=strategy, + pick_up_offset=pick_up_offset, + drop_offset=LabwareOffsetVector( + x=drop_offset_from_slot.x, + y=drop_offset_from_slot.y, + z=drop_offset_from_slot.z, + ), + ) + + # To get the logical movement to be correct, move the labware off-deck. + # Otherwise, leaving the labware "in the slot" would mean you couldn't call this function + # again for other labware. + self._engine_client.move_labware( + labware_id=labware_core.labware_id, + new_location=self._convert_labware_location(OFF_DECK), + strategy=LabwareMovementStrategy.MANUAL_MOVE_WITHOUT_PAUSE, + pick_up_offset=None, + drop_offset=None, + ) + def _resolve_module_hardware( self, serial_number: str, model: ModuleModel ) -> AbstractModule: diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 8fb4d24fb8a..febe300c077 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -10,6 +10,7 @@ from opentrons.protocols.api_support.util import FlowRates from opentrons.protocol_api._nozzle_layout import NozzleLayout +from .._waste_chute import WasteChute from .well import WellCoreType @@ -133,6 +134,12 @@ def drop_tip( """ ... + @abstractmethod + def drop_tip_in_waste_chute( + self, waste_chute: WasteChute, home_after: Optional[bool] + ) -> None: + ... + @abstractmethod def home(self) -> None: ... diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index 92f6741232b..79560c96df3 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -19,6 +19,7 @@ from opentrons.protocols.geometry import planning from opentrons.protocol_api._nozzle_layout import NozzleLayout +from ..._waste_chute import WasteChute from ..instrument import AbstractInstrument from .legacy_well_core import LegacyWellCore from .legacy_module_core import LegacyThermocyclerCore, LegacyHeaterShakerCore @@ -283,6 +284,13 @@ def drop_tip( f"Could not return tip to {labware_core.get_display_name()}" ) + def drop_tip_in_waste_chute( + self, waste_chute: WasteChute, home_after: Optional[bool] + ) -> None: + raise APIVersionError( + "Dropping tips in a waste chute is not supported in this API Version." + ) + def home(self) -> None: """Home the mount""" self._protocol_interface.get_hardware().home_z( diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index 2c99d22bc85..ec05560f35c 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py @@ -18,6 +18,7 @@ from ...labware import Labware from ..._liquid import Liquid from ..._types import OffDeckType +from ..._waste_chute import WasteChute from ..protocol import AbstractProtocol from ..labware import LabwareLoadParams @@ -252,6 +253,7 @@ def move_labware( LegacyLabwareCore, legacy_module_core.LegacyModuleCore, OffDeckType, + WasteChute, ], use_gripper: bool, pause_for_manual_move: bool, diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index 364e4387109..5dda95443fb 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -19,6 +19,8 @@ from opentrons.protocols.geometry import planning from opentrons.protocol_api._nozzle_layout import NozzleLayout +from ..._waste_chute import WasteChute + from ..instrument import AbstractInstrument from ..legacy.legacy_well_core import LegacyWellCore @@ -248,6 +250,11 @@ def drop_tip( f"Could not return tip to {labware_core.get_display_name()}" ) + def drop_tip_in_waste_chute( + self, waste_chute: WasteChute, home_after: Optional[bool] + ) -> None: + raise APIVersionError("Waste chutes are not supported in this PAPI version.") + def home(self) -> None: self._protocol_interface.set_last_location(None) diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index abab603c290..6fab341f4e5 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -19,6 +19,7 @@ from .labware import LabwareCoreType, LabwareLoadParams from .module import ModuleCoreType from .._liquid import Liquid +from .._waste_chute import WasteChute from .._types import OffDeckType @@ -84,7 +85,9 @@ def load_adapter( def move_labware( self, labware_core: LabwareCoreType, - new_location: Union[DeckSlotName, LabwareCoreType, ModuleCoreType, OffDeckType], + new_location: Union[ + DeckSlotName, LabwareCoreType, ModuleCoreType, OffDeckType, WasteChute + ], use_gripper: bool, pause_for_manual_move: bool, pick_up_offset: Optional[Tuple[float, float, float]], diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index dccbdb1c4ac..961cef2be3b 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -31,6 +31,7 @@ from .core.legacy.legacy_instrument_core import LegacyInstrumentCore from .config import Clearances from ._nozzle_layout import NozzleLayout +from ._waste_chute import WasteChute from . import labware, validation @@ -859,7 +860,13 @@ def pick_up_tip( @requires_version(2, 0) def drop_tip( self, - location: Optional[Union[types.Location, labware.Well]] = None, + location: Optional[ + Union[ + types.Location, + labware.Well, + WasteChute, + ] + ] = None, home_after: Optional[bool] = None, ) -> InstrumentContext: """ @@ -957,6 +964,12 @@ def drop_tip( well = maybe_well + elif isinstance(location, WasteChute): + # TODO: Publish to run log. + self._core.drop_tip_in_waste_chute(location, home_after=home_after) + self._last_tip_picked_up_from = None + return self + else: raise TypeError( "If specified, location should be an instance of" diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index a975cb229a5..b2deb0309ad 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -46,6 +46,7 @@ from . import validation from ._liquid import Liquid +from ._waste_chute import WasteChute from .deck import Deck from .instrument_context import InstrumentContext from .labware import Labware @@ -436,6 +437,20 @@ def load_adapter_from_definition( location=location, ) + @requires_version(2, 16) + # TODO: Confirm official naming of "waste chute". + def load_waste_chute( + self, + *, + # TODO: Confirm official naming of "staging area slot". + with_staging_area_slot_d4: bool = False, + ) -> WasteChute: + if with_staging_area_slot_d4: + raise NotImplementedError( + "The waste chute staging area slot is not currently implemented." + ) + return WasteChute(with_staging_area_slot_d4=with_staging_area_slot_d4) + @requires_version(2, 15) def load_adapter( self, @@ -539,7 +554,9 @@ def loaded_labwares(self) -> Dict[int, Labware]: def move_labware( self, labware: Labware, - new_location: Union[DeckLocation, Labware, ModuleTypes, OffDeckType], + new_location: Union[ + DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute + ], use_gripper: bool = False, pick_up_offset: Optional[Mapping[str, float]] = None, drop_offset: Optional[Mapping[str, float]] = None, @@ -581,10 +598,10 @@ def move_labware( f"Expected labware of type 'Labware' but got {type(labware)}." ) - location: Union[ModuleCore, LabwareCore, OffDeckType, DeckSlotName] + location: Union[ModuleCore, LabwareCore, WasteChute, OffDeckType, DeckSlotName] if isinstance(new_location, (Labware, ModuleContext)): location = new_location._core - elif isinstance(new_location, OffDeckType): + elif isinstance(new_location, (OffDeckType, WasteChute)): location = new_location else: location = validation.ensure_and_convert_deck_slot( diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index 623c29d05f5..c7d2dbbfc10 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -258,6 +258,21 @@ def drop_tip( result = self._transport.execute_command(request=request) return cast(commands.DropTipResult, result) + def drop_tip_in_place( + self, + pipette_id: str, + home_after: Optional[bool], + ) -> commands.DropTipInPlaceResult: + """Execute a DropTipInPlace command and return the result.""" + request = commands.DropTipInPlaceCreate( + params=commands.DropTipInPlaceParams( + pipetteId=pipette_id, + homeAfter=home_after, + ) + ) + result = self._transport.execute_command(request=request) + return cast(commands.DropTipInPlaceResult, result) + def configure_for_volume( self, pipette_id: str, volume: float ) -> commands.ConfigureForVolumeResult: