diff --git a/api/docs/v2/new_protocol_api.rst b/api/docs/v2/new_protocol_api.rst index acba9e8c2ce..0fd8deb4afb 100644 --- a/api/docs/v2/new_protocol_api.rst +++ b/api/docs/v2/new_protocol_api.rst @@ -35,9 +35,10 @@ Labware signatures, since users should never construct these directly. .. autoclass:: opentrons.protocol_api.TrashBin() + :members: .. autoclass:: opentrons.protocol_api.WasteChute() - + :members: Wells and Liquids ================= diff --git a/api/src/opentrons/commands/commands.py b/api/src/opentrons/commands/commands.py index b2c635d75d2..68b6f1a0595 100755 --- a/api/src/opentrons/commands/commands.py +++ b/api/src/opentrons/commands/commands.py @@ -6,8 +6,7 @@ from . import types as command_types from opentrons.types import Location -from opentrons.protocol_api._trash_bin import TrashBin -from opentrons.protocol_api._waste_chute import WasteChute +from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute if TYPE_CHECKING: from opentrons.protocol_api import InstrumentContext diff --git a/api/src/opentrons/commands/helpers.py b/api/src/opentrons/commands/helpers.py index b7ff02fa12b..b3de03de4bc 100644 --- a/api/src/opentrons/commands/helpers.py +++ b/api/src/opentrons/commands/helpers.py @@ -2,8 +2,7 @@ from opentrons.protocol_api.labware import Well, Labware from opentrons.protocol_api.module_contexts import ModuleContext -from opentrons.protocol_api._trash_bin import TrashBin -from opentrons.protocol_api._waste_chute import WasteChute +from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute from opentrons.protocol_api._types import OffDeckType from opentrons.types import Location, DeckLocation diff --git a/api/src/opentrons/commands/types.py b/api/src/opentrons/commands/types.py index e4438401282..5aaa72b8e09 100755 --- a/api/src/opentrons/commands/types.py +++ b/api/src/opentrons/commands/types.py @@ -7,8 +7,7 @@ if TYPE_CHECKING: from opentrons.protocol_api import InstrumentContext from opentrons.protocol_api.labware import Well - from opentrons.protocol_api._trash_bin import TrashBin - from opentrons.protocol_api._waste_chute import WasteChute + from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute from opentrons.types import Location diff --git a/api/src/opentrons/motion_planning/deck_conflict.py b/api/src/opentrons/motion_planning/deck_conflict.py index 53c6a53930e..8b26897dc1b 100644 --- a/api/src/opentrons/motion_planning/deck_conflict.py +++ b/api/src/opentrons/motion_planning/deck_conflict.py @@ -65,6 +65,7 @@ class TrashBin: """A non-labware trash bin (loaded via api level 2.16 and above).""" name_for_errors: str + highest_z: float @dataclass @@ -138,9 +139,7 @@ def is_allowed(self, item: DeckItem) -> bool: elif isinstance(item, _Module): return item.highest_z_including_labware < self.max_height elif isinstance(item, TrashBin): - # Since this is a restriction for OT-2 only and OT-2 trashes exceeded the height limit, always return False - # TODO(jbl 2024-01-16) Include trash height and use that for check for more robustness - return False + return item.highest_z < self.max_height class _NoModule(NamedTuple): diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 6fedc494c82..e9bc4356aaf 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -22,10 +22,9 @@ HeaterShakerContext, MagneticBlockContext, ) +from .disposal_locations import TrashBin, WasteChute from ._liquid import Liquid from ._types import OFF_DECK -from ._trash_bin import TrashBin -from ._waste_chute import WasteChute from ._nozzle_layout import ( COLUMN, ALL, diff --git a/api/src/opentrons/protocol_api/_trash_bin.py b/api/src/opentrons/protocol_api/_trash_bin.py deleted file mode 100644 index 60b5f7ac89a..00000000000 --- a/api/src/opentrons/protocol_api/_trash_bin.py +++ /dev/null @@ -1,32 +0,0 @@ -from opentrons.types import DeckSlotName - - -class TrashBin: - """Represents a Flex or OT-2 trash bin. - - See :py:meth:`.ProtocolContext.load_trash_bin`. - """ - - def __init__(self, location: DeckSlotName, addressable_area_name: str) -> None: - self._location = location - self._addressable_area_name = addressable_area_name - - @property - def location(self) -> DeckSlotName: - """Location of the trash bin. - - :meta private: - - This is intended for Opentrons internal use only and is not a guaranteed API. - """ - return self._location - - @property - def area_name(self) -> str: - """Addressable area name of the trash bin. - - :meta private: - - This is intended for Opentrons internal use only and is not a guaranteed API. - """ - return self._addressable_area_name diff --git a/api/src/opentrons/protocol_api/_waste_chute.py b/api/src/opentrons/protocol_api/_waste_chute.py deleted file mode 100644 index 8e7ab765151..00000000000 --- a/api/src/opentrons/protocol_api/_waste_chute.py +++ /dev/null @@ -1,5 +0,0 @@ -class WasteChute: - """Represents a Flex waste chute. - - See :py:meth:`.ProtocolContext.load_waste_chute`. - """ diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index 0ba7e17621d..d778c167200 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -32,8 +32,7 @@ from opentrons.protocol_engine.errors.exceptions import LabwareNotLoadedOnModuleError from opentrons.protocol_engine.types import StagingSlotLocation, Dimensions from opentrons.types import DeckSlotName, StagingSlotName, Point -from ..._trash_bin import TrashBin -from ..._waste_chute import WasteChute +from ...disposal_locations import TrashBin, WasteChute if TYPE_CHECKING: from ...labware import Labware @@ -521,7 +520,9 @@ def _map_disposal_location( if isinstance(disposal_location, TrashBin): return ( disposal_location.location, - wrapped_deck_conflict.TrashBin(name_for_errors="trash bin"), + wrapped_deck_conflict.TrashBin( + name_for_errors="trash bin", highest_z=disposal_location.height + ), ) else: return None diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 17626b1a777..1bbe70712ce 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -38,8 +38,7 @@ from ..instrument import AbstractInstrument from .well import WellCore -from ..._trash_bin import TrashBin -from ..._waste_chute import WasteChute +from ...disposal_locations import TrashBin, WasteChute if TYPE_CHECKING: from .protocol import ProtocolCore @@ -478,13 +477,16 @@ def drop_tip( self._protocol_core.set_last_location(location=location, mount=self.get_mount()) def drop_tip_in_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute], home_after: Optional[bool] + self, + disposal_location: Union[TrashBin, WasteChute], + home_after: Optional[bool], + alternate_tip_drop: bool = False, ) -> None: self._move_to_disposal_location( disposal_location, force_direct=False, speed=None, - alternate_tip_drop=True, + alternate_tip_drop=alternate_tip_drop, ) self._drop_tip_in_place(home_after=home_after) self._protocol_core.set_last_location(location=None, mount=self.get_mount()) @@ -498,10 +500,14 @@ def _move_to_disposal_location( ) -> None: # TODO (nd, 2023-11-30): give appropriate offset when finalized # https://opentrons.atlassian.net/browse/RSS-391 - offset = AddressableOffsetVector(x=0, y=0, z=0) + + disposal_offset = disposal_location.offset + offset = AddressableOffsetVector( + x=disposal_offset.x, y=disposal_offset.y, z=disposal_offset.z + ) if isinstance(disposal_location, TrashBin): - addressable_area_name = disposal_location._addressable_area_name + addressable_area_name = disposal_location.area_name self._engine_client.move_to_addressable_area_for_drop_tip( pipette_id=self._pipette_id, addressable_area_name=addressable_area_name, diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 17c9c9bcec9..e3146a98a08 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -51,8 +51,7 @@ from ... import validation from ..._types import OffDeckType from ..._liquid import Liquid -from ..._trash_bin import TrashBin -from ..._waste_chute import WasteChute +from ...disposal_locations import TrashBin, WasteChute from ..protocol import AbstractProtocol from ..labware import LabwareLoadParams from .labware import LabwareCore @@ -138,14 +137,14 @@ def append_disposal_location( """Append a disposal location object to the core.""" self._disposal_locations.append(disposal_location) - def add_disposal_location_to_engine( + def _add_disposal_location_to_engine( self, disposal_location: Union[TrashBin, WasteChute] ) -> None: """Verify and add disposal location to engine store and append it to the core.""" + self._engine_client.state.addressable_areas.raise_if_area_not_in_deck_configuration( + disposal_location.area_name + ) if isinstance(disposal_location, TrashBin): - self._engine_client.state.addressable_areas.raise_if_area_not_in_deck_configuration( - disposal_location.area_name - ) deck_conflict.check( engine_state=self._engine_client.state, new_trash_bin=disposal_location, @@ -157,20 +156,7 @@ def add_disposal_location_to_engine( existing_labware_ids=list(self._labware_cores_by_id.keys()), existing_module_ids=list(self._module_cores_by_id.keys()), ) - self._engine_client.add_addressable_area(disposal_location.area_name) - elif isinstance(disposal_location, WasteChute): - # TODO(jbl 2024-01-25) hardcoding this specific addressable area should be refactored - # when analysis is fixed up - # - # We want to tell Protocol Engine that there's a waste chute in the waste chute location when it's loaded, - # so analysis can prevent the user from doing anything that would collide with it. At the same time, we - # do not want to create a false negative when it comes to addressable area conflict. We therefore use the - # addressable area `1ChannelWasteChute` because every waste chute cutout fixture provides it and it will - # provide the engine with the information it needs. - self._engine_client.state.addressable_areas.raise_if_area_not_in_deck_configuration( - "1ChannelWasteChute" - ) - self._engine_client.add_addressable_area("1ChannelWasteChute") + self._engine_client.add_addressable_area(disposal_location.area_name) self.append_disposal_location(disposal_location) def get_disposal_locations(self) -> List[Union[Labware, TrashBin, WasteChute]]: @@ -524,6 +510,51 @@ def load_instrument( default_movement_speed=400, ) + def load_trash_bin(self, slot_name: DeckSlotName, area_name: str) -> TrashBin: + """Load a deck configuration based trash bin. + + Args: + slot_name: the slot the trash is being loaded into. + area_name: the addressable area name of the trash. + + Returns: + A trash bin object. + """ + trash_bin = TrashBin( + location=slot_name, + addressable_area_name=area_name, + api_version=self._api_version, + engine_client=self._engine_client, + ) + self._add_disposal_location_to_engine(trash_bin) + return trash_bin + + def load_ot2_fixed_trash_bin(self) -> None: + """Load a deck configured OT-2 fixed trash in Slot 12.""" + _fixed_trash_trash_bin = TrashBin( + location=DeckSlotName.FIXED_TRASH, + addressable_area_name="fixedTrash", + api_version=self._api_version, + engine_client=self._engine_client, + ) + # We are just appending the fixed trash to the core's internal list here, not adding it to the engine via + # the core, since that method works through the SyncClient and if called from here, will cause protocols + # to deadlock. Instead, that method is called in protocol engine directly in create_protocol_context after + # ProtocolContext is initialized. + self.append_disposal_location(_fixed_trash_trash_bin) + + def load_waste_chute(self) -> WasteChute: + """Load a deck configured waste chute into Slot D3. + + Returns: + A waste chute object. + """ + waste_chute = WasteChute( + engine_client=self._engine_client, api_version=self._api_version + ) + self._add_disposal_location_to_engine(waste_chute) + return waste_chute + def pause(self, msg: Optional[str]) -> None: """Pause the protocol.""" self._engine_client.wait_for_resume(message=msg) diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index e6bf63347b2..1864d308c4f 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -10,8 +10,7 @@ from opentrons.protocols.api_support.util import FlowRates from opentrons.protocol_api._nozzle_layout import NozzleLayout -from .._trash_bin import TrashBin -from .._waste_chute import WasteChute +from ..disposal_locations import TrashBin, WasteChute from .well import WellCoreType @@ -137,13 +136,17 @@ def drop_tip( @abstractmethod def drop_tip_in_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute], home_after: Optional[bool] + self, + disposal_location: Union[TrashBin, WasteChute], + home_after: Optional[bool], + alternate_tip_drop: bool = False, ) -> None: """Move to and drop tip into a TrashBin or WasteChute. Args: disposal_location: The disposal location object we're dropping to. home_after: Whether to home the pipette after the tip is dropped. + alternate_tip_drop: Whether to alternate tip drop location in a trash bin. """ ... 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 f70540534af..db3ad39e6d9 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,8 +19,7 @@ from opentrons.protocols.geometry import planning from opentrons.protocol_api._nozzle_layout import NozzleLayout -from ..._trash_bin import TrashBin -from ..._waste_chute import WasteChute +from ...disposal_locations import TrashBin, WasteChute from ..instrument import AbstractInstrument from .legacy_well_core import LegacyWellCore from .legacy_module_core import LegacyThermocyclerCore, LegacyHeaterShakerCore @@ -295,7 +294,10 @@ def drop_tip( ) def drop_tip_in_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute], home_after: Optional[bool] + self, + disposal_location: Union[TrashBin, WasteChute], + home_after: Optional[bool], + alternate_tip_drop: bool = False, ) -> None: raise APIVersionError( "Dropping tips in a trash bin or waste chute is not supported in this API Version." 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 36518f68494..d99c3032a71 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 @@ -16,10 +16,9 @@ from opentrons.protocols import labware as labware_definition from ...labware import Labware +from ...disposal_locations import TrashBin, WasteChute from ..._liquid import Liquid from ..._types import OffDeckType -from ..._trash_bin import TrashBin -from ..._waste_chute import WasteChute from ..protocol import AbstractProtocol from ..labware import LabwareLoadParams @@ -143,11 +142,6 @@ def append_disposal_location( ) self._disposal_locations.append(disposal_location) - def add_disposal_location_to_engine( - self, disposal_location: Union[TrashBin, WasteChute] - ) -> None: - assert False, "add_disposal_location_to_engine only supported on engine core" - def add_labware_definition( self, definition: LabwareDefinition, @@ -384,6 +378,21 @@ def load_instrument( return new_instr + def load_trash_bin(self, slot_name: DeckSlotName, area_name: str) -> TrashBin: + raise APIVersionError( + "Loading deck configured trash bin is not supported in this API version." + ) + + def load_ot2_fixed_trash_bin(self) -> None: + raise APIVersionError( + "Loading deck configured OT-2 fixed trash bin is not supported in this API version." + ) + + def load_waste_chute(self) -> WasteChute: + raise APIVersionError( + "Loading waste chute is not supported in this API version." + ) + def get_loaded_instruments( self, ) -> Dict[Mount, Optional[LegacyInstrumentCore]]: 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 cd1c3b84a5d..fb47da62c50 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 @@ -21,8 +21,7 @@ UnexpectedTipAttachError, ) -from ..._trash_bin import TrashBin -from ..._waste_chute import WasteChute +from ...disposal_locations import TrashBin, WasteChute from opentrons.protocol_api._nozzle_layout import NozzleLayout from ..instrument import AbstractInstrument @@ -263,7 +262,10 @@ def drop_tip( ) def drop_tip_in_disposal_location( - self, disposal_location: Union[TrashBin, WasteChute], home_after: Optional[bool] + self, + disposal_location: Union[TrashBin, WasteChute], + home_after: Optional[bool], + alternate_tip_drop: bool = False, ) -> None: raise APIVersionError( "Dropping tips in a trash bin or waste chute is not supported in this API Version." diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index 7c198646905..8ed83388c07 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -19,9 +19,8 @@ from .labware import LabwareCoreType, LabwareLoadParams from .module import ModuleCoreType from .._liquid import Liquid -from .._trash_bin import TrashBin -from .._waste_chute import WasteChute from .._types import OffDeckType +from ..disposal_locations import TrashBin, WasteChute if TYPE_CHECKING: from ..labware import Labware @@ -69,13 +68,6 @@ def append_disposal_location( """Append a disposal location object to the core""" ... - @abstractmethod - def add_disposal_location_to_engine( - self, disposal_location: Union[TrashBin, WasteChute] - ) -> None: - """Verify and add disposal location to engine store and append it to the core.""" - ... - @abstractmethod def load_labware( self, @@ -136,6 +128,18 @@ def load_instrument( ) -> InstrumentCoreType: ... + @abstractmethod + def load_trash_bin(self, slot_name: DeckSlotName, area_name: str) -> TrashBin: + ... + + @abstractmethod + def load_ot2_fixed_trash_bin(self) -> None: + ... + + @abstractmethod + def load_waste_chute(self) -> WasteChute: + ... + @abstractmethod def pause(self, msg: Optional[str]) -> None: ... diff --git a/api/src/opentrons/protocol_api/create_protocol_context.py b/api/src/opentrons/protocol_api/create_protocol_context.py index 22832911c90..f48510049fa 100644 --- a/api/src/opentrons/protocol_api/create_protocol_context.py +++ b/api/src/opentrons/protocol_api/create_protocol_context.py @@ -22,7 +22,7 @@ from .protocol_context import ProtocolContext from .deck import Deck -from ._trash_bin import TrashBin +from .disposal_locations import TrashBin from .core.common import ProtocolCore as AbstractProtocolCore from .core.legacy.deck import Deck as LegacyDeck diff --git a/api/src/opentrons/protocol_api/disposal_locations.py b/api/src/opentrons/protocol_api/disposal_locations.py new file mode 100644 index 00000000000..9c80be9720d --- /dev/null +++ b/api/src/opentrons/protocol_api/disposal_locations.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing_extensions import Protocol as TypingProtocol + +from opentrons.types import DeckSlotName +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocol_engine.clients import SyncClient + + +# TODO(jbl 2024-02-26) these are hardcoded here since there is a 1 to many relationship going from +# addressable area names to cutout fixture ids. Currently for trash and waste chute this would not be +# an issue (trash has only one fixture that provides it, all waste chute fixtures are the same height). +# The ultimate fix for this is a multiple pass analysis, so for now these are being hardcoded to avoid +# writing cumbersome guessing logic for area name -> fixture name while still providing a direct link to +# the numbers in shared data. +_TRASH_BIN_CUTOUT_FIXTURE = "trashBinAdapter" +_TRASH_BIN_OT2_CUTOUT_FIXTURE = "fixedTrashSlot" +_WASTE_CHUTE_CUTOUT_FIXTURE = "wasteChuteRightAdapterCovered" + + +@dataclass(frozen=True) +class DisposalOffset: + x: float + y: float + z: float + + +class _DisposalLocation(TypingProtocol): + """Abstract class for disposal location.""" + + def top(self, x: float = 0, y: float = 0, z: float = 0) -> _DisposalLocation: + """Returns a disposal location with a user set offset.""" + ... + + @property + def offset(self) -> DisposalOffset: + """Offset of the disposal location. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + ... + + @property + def location(self) -> DeckSlotName: + """Location of the disposal location. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + ... + + @property + def area_name(self) -> str: + """Addressable area name of the disposal location. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + ... + + @property + def height(self) -> float: + """Height of the disposal location. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + ... + + +class TrashBin(_DisposalLocation): + """Represents a Flex or OT-2 trash bin. + + See :py:meth:`.ProtocolContext.load_trash_bin`. + """ + + def __init__( + self, + location: DeckSlotName, + addressable_area_name: str, + engine_client: SyncClient, + api_version: APIVersion, + offset: DisposalOffset = DisposalOffset(x=0, y=0, z=0), + ) -> None: + self._location = location + self._addressable_area_name = addressable_area_name + self._offset = offset + self._api_version = api_version + self._engine_client = engine_client + if self._engine_client.state.config.robot_type == "OT-2 Standard": + self._cutout_fixture_name = _TRASH_BIN_OT2_CUTOUT_FIXTURE + else: + self._cutout_fixture_name = _TRASH_BIN_CUTOUT_FIXTURE + + def top(self, x: float = 0, y: float = 0, z: float = 0) -> TrashBin: + """Add a location offset to a trash bin. + + The default location (``x``, ``y``, and ``z`` all set to ``0``) is the center of + the bin on the x- and y-axes, and slightly below its physical top on the z-axis. + + Offsets can be positive or negative and are measured in mm. + See :ref:`protocol-api-deck-coords`. + """ + return TrashBin( + location=self._location, + addressable_area_name=self._addressable_area_name, + engine_client=self._engine_client, + api_version=self._api_version, + offset=DisposalOffset(x=x, y=y, z=z), + ) + + @property + def offset(self) -> DisposalOffset: + """Current offset of the trash bin. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return self._offset + + @property + def location(self) -> DeckSlotName: + """Location of the trash bin. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return self._location + + @property + def area_name(self) -> str: + """Addressable area name of the trash bin. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return self._addressable_area_name + + @property + def height(self) -> float: + """Height of the trash bin. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return self._engine_client.state.addressable_areas.get_fixture_height( + self._cutout_fixture_name + ) + + +class WasteChute(_DisposalLocation): + """Represents a Flex waste chute. + + See :py:meth:`.ProtocolContext.load_waste_chute`. + """ + + def __init__( + self, + engine_client: SyncClient, + api_version: APIVersion, + offset: DisposalOffset = DisposalOffset(x=0, y=0, z=0), + ) -> None: + self._engine_client = engine_client + self._api_version = api_version + self._offset = offset + + def top(self, x: float = 0, y: float = 0, z: float = 0) -> WasteChute: + """Add a location offset to a waste chute. + + The default location (``x``, ``y``, and ``z`` all set to ``0``) is the center of + the chute's opening on the x- and y-axes, and slightly below its physical top + on the z-axis. See :ref:`configure-waste-chute` for more information on possible + configurations of the chute. + + Offsets can be positive or negative and are measured in mm. + See :ref:`protocol-api-deck-coords`. + """ + return WasteChute( + engine_client=self._engine_client, + api_version=self._api_version, + offset=DisposalOffset(x=x, y=y, z=z), + ) + + @property + def offset(self) -> DisposalOffset: + """Current offset of the waste chute. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return self._offset + + @property + def location(self) -> DeckSlotName: + """Location of the waste chute. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return DeckSlotName.SLOT_D3 + + @property + def area_name(self) -> str: + """Addressable area name of the waste chute. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + # TODO(jbl 2024-02-06) this is hardcoded here and should be removed when a multiple pass analysis exists + # + # We want to tell Protocol Engine that there's a waste chute in the waste chute location when it's loaded, + # so analysis can prevent the user from doing anything that would collide with it. At the same time, we + # do not want to create a false negative when it comes to addressable area conflict. We therefore use the + # addressable area `1ChannelWasteChute` because every waste chute cutout fixture provides it and it will + # provide the engine with the information it needs. + return "1ChannelWasteChute" + + @property + def height(self) -> float: + """Height of the waste chute. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + return self._engine_client.state.addressable_areas.get_fixture_height( + _WASTE_CHUTE_CUTOUT_FIXTURE + ) diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 45b7d385684..e92c1bb6bab 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -32,8 +32,7 @@ from .core.engine import ENGINE_CORE_API_VERSION from .core.legacy.legacy_instrument_core import LegacyInstrumentCore from .config import Clearances -from ._trash_bin import TrashBin -from ._waste_chute import WasteChute +from .disposal_locations import TrashBin, WasteChute from ._nozzle_layout import NozzleLayout from . import labware, validation @@ -1018,7 +1017,9 @@ def drop_tip( well = trash_container.wells()[0] else: # implicit drop tip in disposal location, not well self._core.drop_tip_in_disposal_location( - trash_container, home_after=home_after + trash_container, + home_after=home_after, + alternate_tip_drop=True, ) self._last_tip_picked_up_from = None return self @@ -1048,6 +1049,8 @@ def drop_tip( instrument=self, location=location ), ): + # TODO(jbl 2024-02-28) when adding 2.18 api version checks, set alternate_tip_drop + # if below that version for compatability self._core.drop_tip_in_disposal_location( location, home_after=home_after ) diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 33b2a55e490..bcd3ea7424b 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -52,8 +52,7 @@ from . import validation from ._liquid import Liquid -from ._trash_bin import TrashBin -from ._waste_chute import WasteChute +from .disposal_locations import TrashBin, WasteChute from .deck import Deck from .instrument_context import InstrumentContext from .labware import Labware @@ -165,14 +164,7 @@ def __init__( elif should_load_fixed_trash_area_for_python_protocol( self._api_version, self._core.robot_type ): - _fixed_trash_trashbin = TrashBin( - location=DeckSlotName.FIXED_TRASH, addressable_area_name="fixedTrash" - ) - # We are just appending the fixed trash to the core's internal list here, not adding it to the engine via - # the core, since that method works through the SyncClient and if called from here, will cause protocols - # to deadlock. Instead, that method is called in protocol engine directly in create_protocol_context after - # ProtocolContext is initialized. - self._core.append_disposal_location(_fixed_trash_trashbin) + self._core.load_ot2_fixed_trash_bin() self._commands: List[str] = [] self._unsubscribe_commands: Optional[Callable[[], None]] = None @@ -512,10 +504,7 @@ def load_trash_bin(self, location: DeckLocation) -> TrashBin: api_version=self._api_version, robot_type=self._core.robot_type, ) - trash_bin = TrashBin( - location=slot_name, addressable_area_name=addressable_area_name - ) - self._core.add_disposal_location_to_engine(trash_bin) + trash_bin = self._core.load_trash_bin(slot_name, addressable_area_name) return trash_bin @requires_version(2, 16) @@ -531,9 +520,7 @@ def load_waste_chute( load another item in slot D3 after loading the waste chute, or vice versa, the API will raise an error. """ - waste_chute = WasteChute() - self._core.add_disposal_location_to_engine(waste_chute) - return waste_chute + return self._core.load_waste_chute() @requires_version(2, 15) def load_adapter( diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 372913ad20e..f714f35cecd 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -32,8 +32,7 @@ ThermocyclerStep, ) -from ._trash_bin import TrashBin -from ._waste_chute import WasteChute +from .disposal_locations import TrashBin, WasteChute if TYPE_CHECKING: from .labware import Well diff --git a/api/tests/opentrons/motion_planning/test_deck_conflict.py b/api/tests/opentrons/motion_planning/test_deck_conflict.py index 72fc9f247c7..553821289fc 100644 --- a/api/tests/opentrons/motion_planning/test_deck_conflict.py +++ b/api/tests/opentrons/motion_planning/test_deck_conflict.py @@ -214,7 +214,7 @@ def test_flex_trash_bin_blocks_thermocycler() -> None: highest_z_including_labware=123, is_semi_configuration=False, ) - trash = deck_conflict.TrashBin(name_for_errors="some_trash_bin") + trash = deck_conflict.TrashBin(name_for_errors="some_trash_bin", highest_z=1.23) with pytest.raises( deck_conflict.DeckConflictError, @@ -539,7 +539,7 @@ def test_heater_shaker_restrictions_trash_bin_addressable_area() -> None: heater_shaker = deck_conflict.HeaterShakerModule( highest_z_including_labware=123, name_for_errors="some_heater_shaker" ) - trash = deck_conflict.TrashBin(name_for_errors="some_trash_bin") + trash = deck_conflict.TrashBin(name_for_errors="some_trash_bin", highest_z=456) with pytest.raises( deck_conflict.DeckConflictError, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 7d4d7c6f10e..21ed3577c99 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -10,8 +10,13 @@ from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict from opentrons.motion_planning import adjacent_slots_getters from opentrons.motion_planning.adjacent_slots_getters import _MixedTypeSlots -from opentrons.protocol_api._trash_bin import TrashBin -from opentrons.protocol_api._waste_chute import WasteChute +from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocol_api import MAX_SUPPORTED_VERSION +from opentrons.protocol_api.disposal_locations import ( + TrashBin, + WasteChute, + _TRASH_BIN_CUTOUT_FIXTURE, +) from opentrons.protocol_api.labware import Labware from opentrons.protocol_api.core.engine import deck_conflict from opentrons.protocol_engine import ( @@ -20,6 +25,7 @@ ModuleModel, StateView, ) +from opentrons.protocol_engine.clients import SyncClient from opentrons.protocol_engine.errors import LabwareNotLoadedOnModuleError from opentrons.types import DeckSlotName, Point, StagingSlotName @@ -65,6 +71,18 @@ def use_mock_wrapped_deck_conflict( monkeypatch.setattr(wrapped_deck_conflict, "check", mock_check) +@pytest.fixture +def api_version() -> APIVersion: + """Get mocked api_version.""" + return MAX_SUPPORTED_VERSION + + +@pytest.fixture +def mock_sync_client(decoy: Decoy) -> SyncClient: + """Return a mock in the shape of a SyncClient.""" + return decoy.mock(cls=SyncClient) + + @pytest.fixture def mock_state_view( decoy: Decoy, @@ -324,32 +342,51 @@ def get_expected_mapping_result() -> wrapped_deck_conflict.DeckItem: ("OT-3 Standard", DeckType.OT3_STANDARD), ], ) -def test_maps_trash_bins(decoy: Decoy, mock_state_view: StateView) -> None: +def test_maps_trash_bins( + decoy: Decoy, + mock_state_view: StateView, + api_version: APIVersion, + mock_sync_client: SyncClient, +) -> None: """It should correctly map disposal locations.""" mock_trash_lw = decoy.mock(cls=Labware) + decoy.when( + mock_sync_client.state.addressable_areas.get_fixture_height( + _TRASH_BIN_CUTOUT_FIXTURE + ) + ).then_return(1.23) + deck_conflict.check( engine_state=mock_state_view, existing_labware_ids=[], existing_module_ids=[], existing_disposal_locations=[ - TrashBin(location=DeckSlotName.SLOT_B1, addressable_area_name="blah"), - WasteChute(), + TrashBin( + location=DeckSlotName.SLOT_B1, + addressable_area_name="blah", + engine_client=mock_sync_client, + api_version=api_version, + ), + WasteChute(engine_client=mock_sync_client, api_version=api_version), mock_trash_lw, ], new_trash_bin=TrashBin( - location=DeckSlotName.SLOT_A1, addressable_area_name="blah" + location=DeckSlotName.SLOT_A1, + addressable_area_name="blah", + engine_client=mock_sync_client, + api_version=api_version, ), ) decoy.verify( wrapped_deck_conflict.check( existing_items={ DeckSlotName.SLOT_B1: wrapped_deck_conflict.TrashBin( - name_for_errors="trash bin", + name_for_errors="trash bin", highest_z=1.23 ) }, new_item=wrapped_deck_conflict.TrashBin( - name_for_errors="trash bin", + name_for_errors="trash bin", highest_z=1.23 ), new_location=DeckSlotName.SLOT_A1, robot_type=mock_state_view.config.robot_type, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index eaed859b17d..0b5a0f26a47 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -29,6 +29,12 @@ RowNozzleLayoutConfiguration, SingleNozzleLayoutConfiguration, ColumnNozzleLayoutConfiguration, + AddressableOffsetVector, +) +from opentrons.protocol_api.disposal_locations import ( + TrashBin, + WasteChute, + DisposalOffset, ) from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.protocol_api.core.engine import ( @@ -384,6 +390,68 @@ def test_drop_tip_with_location( ) +def test_drop_tip_in_trash_bin( + decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore +) -> None: + """It should move to the trash bin and drop the tip in place.""" + trash_bin = decoy.mock(cls=TrashBin) + + decoy.when(trash_bin.offset).then_return(DisposalOffset(x=1, y=2, z=3)) + decoy.when(trash_bin.area_name).then_return("my tubular area") + + subject.drop_tip_in_disposal_location( + trash_bin, home_after=True, alternate_tip_drop=True + ) + + decoy.verify( + mock_engine_client.move_to_addressable_area_for_drop_tip( + pipette_id="abc123", + addressable_area_name="my tubular area", + offset=AddressableOffsetVector(x=1, y=2, z=3), + force_direct=False, + speed=None, + minimum_z_height=None, + alternate_drop_location=True, + ignore_tip_configuration=True, + ), + mock_engine_client.drop_tip_in_place( + pipette_id="abc123", + home_after=True, + ), + ) + + +def test_drop_tip_in_waste_chute( + decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore +) -> None: + """It should move to the trash bin and drop the tip in place.""" + waste_chute = decoy.mock(cls=WasteChute) + + decoy.when(waste_chute.offset).then_return(DisposalOffset(x=4, y=5, z=6)) + decoy.when( + mock_engine_client.state.tips.get_pipette_channels("abc123") + ).then_return(96) + + subject.drop_tip_in_disposal_location( + waste_chute, home_after=True, alternate_tip_drop=True + ) + + decoy.verify( + mock_engine_client.move_to_addressable_area( + pipette_id="abc123", + addressable_area_name="96ChannelWasteChute", + offset=AddressableOffsetVector(x=4, y=5, z=6), + force_direct=False, + speed=None, + minimum_z_height=None, + ), + mock_engine_client.drop_tip_in_place( + pipette_id="abc123", + home_after=True, + ), + ) + + def test_aspirate_from_well( decoy: Decoy, mock_engine_client: EngineClient, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py index a8f83d63a7e..fdf12f1e51b 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_protocol_core.py @@ -66,6 +66,7 @@ load_labware_params, ) from opentrons.protocol_api._liquid import Liquid +from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute from opentrons.protocol_api.core.engine.exceptions import InvalidModuleLocationError from opentrons.protocol_api.core.engine.module_core import ( TemperatureModuleCore, @@ -679,6 +680,80 @@ def test_load_adapter_on_staging_slot( assert subject.get_slot_item(StagingSlotName.SLOT_B4) is result +def test_load_trash_bin( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: ProtocolCore, +) -> None: + """It should load a trash bin.""" + prior_disposal_locations = subject.get_disposal_locations() + trash = subject.load_trash_bin( + slot_name=DeckSlotName.SLOT_D2, area_name="my trendy area" + ) + assert isinstance(trash, TrashBin) + decoy.verify( + mock_engine_client.state.addressable_areas.raise_if_area_not_in_deck_configuration( + "my trendy area" + ), + deck_conflict.check( + engine_state=mock_engine_client.state, + new_trash_bin=trash, + existing_disposal_locations=prior_disposal_locations, + existing_labware_ids=[], + existing_module_ids=[], + ), + mock_engine_client.add_addressable_area("my trendy area"), + ) + + assert trash in subject.get_disposal_locations() + + +def test_load_ot2_fixed_trash_bin( + decoy: Decoy, mock_engine_client: EngineClient, subject: ProtocolCore +) -> None: + """It should load a fixed trash bin for the OT-2.""" + prior_disposal_locations = subject.get_disposal_locations() + subject.load_ot2_fixed_trash_bin() + fixed_trash = subject.get_disposal_locations()[-1] + assert isinstance(fixed_trash, TrashBin) + assert fixed_trash.area_name == "fixedTrash" + decoy.verify( + mock_engine_client.state.addressable_areas.raise_if_area_not_in_deck_configuration( + "fixedTrash" + ), + times=0, + ) + decoy.verify( + deck_conflict.check( + engine_state=mock_engine_client.state, + new_trash_bin=fixed_trash, + existing_disposal_locations=prior_disposal_locations, + existing_labware_ids=[], + existing_module_ids=[], + ), + times=0, + ) + decoy.verify(mock_engine_client.add_addressable_area("fixedTrash"), times=0) + + +def test_load_waste_chute( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: ProtocolCore, +) -> None: + """It should load a waste chute.""" + waste_chute = subject.load_waste_chute() + assert isinstance(waste_chute, WasteChute) + decoy.verify( + mock_engine_client.state.addressable_areas.raise_if_area_not_in_deck_configuration( + "1ChannelWasteChute" + ), + mock_engine_client.add_addressable_area("1ChannelWasteChute"), + ) + + assert waste_chute in subject.get_disposal_locations() + + @pytest.mark.parametrize( argnames=["use_gripper", "pause_for_manual_move", "expected_strategy"], argvalues=[ diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index be45907ab31..239d61c9d95 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -29,6 +29,7 @@ from opentrons.protocol_api.core.legacy.legacy_instrument_core import ( LegacyInstrumentCore, ) +from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.types import Location, Mount, Point @@ -712,6 +713,65 @@ def test_drop_tip_to_randomized_trash_location( ) +def test_drop_tip_in_trash_bin( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, +) -> None: + """It should drop a tip in a deck configured trash bin.""" + trash_bin = decoy.mock(cls=TrashBin) + + subject.drop_tip(trash_bin) + + decoy.verify( + mock_instrument_core.drop_tip_in_disposal_location( + trash_bin, + home_after=None, + ), + times=1, + ) + + +def test_drop_tip_in_waste_chute( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, +) -> None: + """It should drop a tip in a deck configured trash bin or waste chute.""" + waste_chute = decoy.mock(cls=WasteChute) + + subject.drop_tip(waste_chute) + + decoy.verify( + mock_instrument_core.drop_tip_in_disposal_location( + waste_chute, + home_after=None, + ), + times=1, + ) + + +def test_drop_tip_in_disposal_location_implicitly( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, +) -> None: + """It should drop a tip in a deck configured trash bin when no arguments have been provided.""" + trash_bin = decoy.mock(cls=TrashBin) + subject.trash_container = trash_bin + + subject.drop_tip() + + decoy.verify( + mock_instrument_core.drop_tip_in_disposal_location( + trash_bin, + home_after=None, + alternate_tip_drop=True, + ), + times=1, + ) + + def test_return_tip( decoy: Decoy, mock_instrument_core: InstrumentCore, subject: InstrumentContext ) -> None: diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index f70299209cb..c792fc4574c 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -38,6 +38,7 @@ MagneticModuleCore, MagneticBlockCore, ) +from opentrons.protocol_api.disposal_locations import TrashBin, WasteChute from opentrons.protocols.api_support.deck_type import ( NoTrashDefinedError, ) @@ -112,6 +113,42 @@ def subject( ) +def test_legacy_trash_loading( + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + mock_fixed_trash: Labware, + mock_deck: Deck, +) -> None: + """It should load a trash labware on init on API level 2.15 and below.""" + decoy.when(mock_core_map.get(mock_core.fixed_trash)).then_return(mock_fixed_trash) + context = ProtocolContext( + api_version=APIVersion(2, 15), + core=mock_core, + core_map=mock_core_map, + deck=mock_deck, + ) + assert mock_fixed_trash == context.fixed_trash + decoy.verify(mock_core.append_disposal_location(mock_fixed_trash)) + + +def test_automatic_ot2_trash_loading( + decoy: Decoy, + mock_core: ProtocolCore, + mock_core_map: LoadedCoreMap, + mock_deck: Deck, +) -> None: + """It should load a trash labware on init on API level 2.15 and below.""" + decoy.when(mock_core.robot_type).then_return("OT-2 Standard") + ProtocolContext( + api_version=APIVersion(2, 16), + core=mock_core, + core_map=mock_core_map, + deck=mock_deck, + ) + decoy.verify(mock_core.load_ot2_fixed_trash_bin()) + + def test_fixed_trash( decoy: Decoy, mock_core: ProtocolCore, @@ -839,6 +876,66 @@ def test_move_labware_off_deck_raises( subject.move_labware(labware=movable_labware, new_location=OFF_DECK) +def test_load_trash_bin( + decoy: Decoy, + mock_core: ProtocolCore, + api_version: APIVersion, + subject: ProtocolContext, +) -> None: + """It should load a trash bin.""" + mock_trash = decoy.mock(cls=TrashBin) + + decoy.when(mock_core.robot_type).then_return("OT-3 Standard") + decoy.when( + mock_validation.ensure_and_convert_deck_slot( + "blah", api_version, "OT-3 Standard" + ) + ).then_return(DeckSlotName.SLOT_A1) + decoy.when( + mock_validation.ensure_and_convert_trash_bin_location( + "blah", api_version, "OT-3 Standard" + ) + ).then_return("my swanky trash bin") + decoy.when( + mock_core.load_trash_bin(DeckSlotName.SLOT_A1, "my swanky trash bin") + ).then_return(mock_trash) + + result = subject.load_trash_bin("blah") + + assert result == mock_trash + + +def test_load_trash_bin_raises_for_staging_slot( + decoy: Decoy, + mock_core: ProtocolCore, + api_version: APIVersion, + subject: ProtocolContext, +) -> None: + """It should raise when a trash bin load is attempted in a staging slot.""" + decoy.when(mock_core.robot_type).then_return("OT-3 Standard") + decoy.when( + mock_validation.ensure_and_convert_deck_slot( + "bleh", api_version, "OT-3 Standard" + ) + ).then_return(StagingSlotName.SLOT_A4) + + with pytest.raises(ValueError, match="Staging areas not permitted"): + subject.load_trash_bin("bleh") + + +def test_load_wast_chute( + decoy: Decoy, + mock_core: ProtocolCore, + api_version: APIVersion, + subject: ProtocolContext, +) -> None: + """It should load a waste chute.""" + mock_chute = decoy.mock(cls=WasteChute) + decoy.when(mock_core.load_waste_chute()).then_return(mock_chute) + result = subject.load_waste_chute() + assert result == mock_chute + + def test_load_module( decoy: Decoy, mock_core: ProtocolCore,