Skip to content

Commit

Permalink
fix(api, shared-data): Allow labware lids to be disposed in the trash…
Browse files Browse the repository at this point in the history
… bin (#16638)

Covers PLAT-539
Allows the TC lid to be dropped in the trash bin at a slight
offset.
  • Loading branch information
CaseyBatten authored Oct 31, 2024
1 parent a74408d commit ec7641c
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 12 deletions.
10 changes: 8 additions & 2 deletions api/src/opentrons/legacy_commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ def stringify_disposal_location(location: Union[TrashBin, WasteChute]) -> str:


def _stringify_labware_movement_location(
location: Union[DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute]
location: Union[
DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin
]
) -> str:
if isinstance(location, (int, str)):
return f"slot {location}"
Expand All @@ -61,11 +63,15 @@ def _stringify_labware_movement_location(
return str(location)
elif isinstance(location, WasteChute):
return "Waste Chute"
elif isinstance(location, TrashBin):
return "Trash Bin " + location.location.name


def stringify_labware_movement_command(
source_labware: Labware,
destination: Union[DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute],
destination: Union[
DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin
],
use_gripper: bool,
) -> str:
source_labware_text = _stringify_labware_movement_location(source_labware)
Expand Down
4 changes: 4 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ def is_adapter(self) -> bool:
"""Whether the labware is an adapter."""
return LabwareRole.adapter in self._definition.allowedRoles

def is_lid(self) -> bool:
"""Whether the labware is a lid."""
return LabwareRole.lid in self._definition.allowedRoles

def is_fixed_trash(self) -> bool:
"""Whether the labware is a fixed trash."""
return self._engine_client.state.labware.is_fixed_trash(
Expand Down
5 changes: 5 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def move_labware(
NonConnectedModuleCore,
OffDeckType,
WasteChute,
TrashBin,
],
use_gripper: bool,
pause_for_manual_move: bool,
Expand Down Expand Up @@ -807,6 +808,7 @@ def _convert_labware_location(
NonConnectedModuleCore,
OffDeckType,
WasteChute,
TrashBin,
],
) -> LabwareLocation:
if isinstance(location, LabwareCore):
Expand All @@ -823,6 +825,7 @@ def _get_non_stacked_location(
NonConnectedModuleCore,
OffDeckType,
WasteChute,
TrashBin,
]
) -> NonStackedLocation:
if isinstance(location, (ModuleCore, NonConnectedModuleCore)):
Expand All @@ -836,3 +839,5 @@ def _get_non_stacked_location(
elif isinstance(location, WasteChute):
# TODO(mm, 2023-12-06) This will need to determine the appropriate Waste Chute to return, but only move_labware uses this for now
return AddressableAreaLocation(addressableAreaName="gripperWasteChute")
elif isinstance(location, TrashBin):
return AddressableAreaLocation(addressableAreaName=location.area_name)
4 changes: 4 additions & 0 deletions api/src/opentrons/protocol_api/core/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ def is_tip_rack(self) -> bool:
def is_adapter(self) -> bool:
"""Whether the labware is an adapter."""

@abstractmethod
def is_lid(self) -> bool:
"""Whether the labware is a lid."""

@abstractmethod
def is_fixed_trash(self) -> bool:
"""Whether the labware is a fixed trash."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ def is_tip_rack(self) -> bool:
def is_adapter(self) -> bool:
return False # Adapters were introduced in v2.15 and not supported in legacy protocols

def is_lid(self) -> bool:
return (
False # Lids were introduced in v2.21 and not supported in legacy protocols
)

def is_fixed_trash(self) -> bool:
"""Whether the labware is fixed trash."""
return "fixedTrash" in self.get_quirks()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def move_labware(
legacy_module_core.LegacyModuleCore,
OffDeckType,
WasteChute,
TrashBin,
],
use_gripper: bool,
pause_for_manual_move: bool,
Expand Down
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_api/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def move_labware(
ModuleCoreType,
OffDeckType,
WasteChute,
TrashBin,
],
use_gripper: bool,
pause_for_manual_move: bool,
Expand Down
11 changes: 10 additions & 1 deletion api/src/opentrons/protocol_api/protocol_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
UnsupportedAPIError,
)
from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated
from opentrons.protocol_engine.errors import LabwareMovementNotAllowedError

from ._types import OffDeckType
from .core.common import ModuleCore, LabwareCore, ProtocolCore
Expand Down Expand Up @@ -668,7 +669,7 @@ def move_labware(
self,
labware: Labware,
new_location: Union[
DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute
DeckLocation, Labware, ModuleTypes, OffDeckType, WasteChute, TrashBin
],
use_gripper: bool = False,
pick_up_offset: Optional[Mapping[str, float]] = None,
Expand Down Expand Up @@ -727,11 +728,19 @@ def move_labware(
OffDeckType,
DeckSlotName,
StagingSlotName,
TrashBin,
]
if isinstance(new_location, (Labware, ModuleContext)):
location = new_location._core
elif isinstance(new_location, (OffDeckType, WasteChute)):
location = new_location
elif isinstance(new_location, TrashBin):
if labware._core.is_lid():
location = new_location
else:
raise LabwareMovementNotAllowedError(
"Can only dispose of tips and Lid-type labware in a Trash Bin. Did you mean to use a Waste Chute?"
)
else:
location = validation.ensure_and_convert_deck_slot(
new_location, self._api_version, self._core.robot_type
Expand Down
44 changes: 40 additions & 4 deletions api/src/opentrons/protocol_engine/commands/move_labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
LabwareOffsetVector,
LabwareMovementOffsetData,
)
from ..errors import LabwareMovementNotAllowedError, NotSupportedOnRobotType
from ..errors import (
LabwareMovementNotAllowedError,
NotSupportedOnRobotType,
LabwareOffsetDoesNotExistError,
)
from ..resources import labware_validation, fixture_validation
from .command import (
AbstractCommandImpl,
Expand Down Expand Up @@ -130,6 +134,7 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
)
definition_uri = current_labware.definitionUri
post_drop_slide_offset: Optional[Point] = None
trash_lid_drop_offset: Optional[LabwareOffsetVector] = None

if self._state_view.labware.is_fixed_trash(params.labwareId):
raise LabwareMovementNotAllowedError(
Expand All @@ -138,9 +143,11 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C

if isinstance(params.newLocation, AddressableAreaLocation):
area_name = params.newLocation.addressableAreaName
if not fixture_validation.is_gripper_waste_chute(
area_name
) and not fixture_validation.is_deck_slot(area_name):
if (
not fixture_validation.is_gripper_waste_chute(area_name)
and not fixture_validation.is_deck_slot(area_name)
and not fixture_validation.is_trash(area_name)
):
raise LabwareMovementNotAllowedError(
f"Cannot move {current_labware.loadName} to addressable area {area_name}"
)
Expand All @@ -162,6 +169,32 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
y=0,
z=0,
)
elif fixture_validation.is_trash(area_name):
# When dropping labware in the trash bins we want to ensure they are lids
# and enforce a y-axis drop offset to ensure they fall within the trash bin
if labware_validation.validate_definition_is_lid(
self._state_view.labware.get_definition(params.labwareId)
):
lid_disposable_offfets = (
current_labware_definition.gripperOffsets.get(
"lidDisposalOffsets"
)
)
if lid_disposable_offfets is not None:
trash_lid_drop_offset = LabwareOffsetVector(
x=lid_disposable_offfets.dropOffset.x,
y=lid_disposable_offfets.dropOffset.y,
z=lid_disposable_offfets.dropOffset.z,
)
else:
raise LabwareOffsetDoesNotExistError(
f"Labware Definition {current_labware.loadName} does not contain required field 'lidDisposalOffsets' of 'gripperOffsets'."
)
else:
raise LabwareMovementNotAllowedError(
"Can only move labware with allowed role 'Lid' to a Trash Bin."
)

elif isinstance(params.newLocation, DeckSlotLocation):
self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration(
params.newLocation.slotName.id
Expand Down Expand Up @@ -232,6 +265,9 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
dropOffset=params.dropOffset or LabwareOffsetVector(x=0, y=0, z=0),
)

if trash_lid_drop_offset:
user_offset_data.dropOffset += trash_lid_drop_offset

try:
# Skips gripper moves when using virtual gripper
await self._labware_movement.move_labware_with_gripper(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ def is_drop_tip_waste_chute(addressable_area_name: str) -> bool:

def is_trash(addressable_area_name: str) -> bool:
"""Check if an addressable area is a trash bin."""
return addressable_area_name in {"movableTrash", "fixedTrash", "shortFixedTrash"}
return any(
[
s in addressable_area_name
for s in {"movableTrash", "fixedTrash", "shortFixedTrash"}
]
)


def is_staging_slot(addressable_area_name: str) -> bool:
Expand Down
9 changes: 5 additions & 4 deletions api/src/opentrons/protocol_engine/state/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,11 @@ def _set_labware_location(self, state_update: update_types.StateUpdate) -> None:
if labware_location_update.new_location:
new_location = labware_location_update.new_location

if isinstance(
new_location, AddressableAreaLocation
) and fixture_validation.is_gripper_waste_chute(
new_location.addressableAreaName
if isinstance(new_location, AddressableAreaLocation) and (
fixture_validation.is_gripper_waste_chute(
new_location.addressableAreaName
)
or fixture_validation.is_trash(new_location.addressableAreaName)
):
# If a labware has been moved into a waste chute it's been chuted away and is now technically off deck
new_location = OFF_DECK_LOCATION
Expand Down
76 changes: 76 additions & 0 deletions api/tests/opentrons/protocol_api/test_protocol_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
from opentrons.protocols.api_support.deck_type import (
NoTrashDefinedError,
)
from opentrons.protocol_engine.errors import LabwareMovementNotAllowedError
from opentrons.protocol_engine.clients import SyncClient as EngineClient


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -101,6 +103,12 @@ def api_version() -> APIVersion:
return MAX_SUPPORTED_VERSION


@pytest.fixture
def mock_engine_client(decoy: Decoy) -> EngineClient:
"""Get a mock ProtocolEngine synchronous client."""
return decoy.mock(cls=EngineClient)


@pytest.fixture
def subject(
mock_core: ProtocolCore,
Expand Down Expand Up @@ -944,6 +952,74 @@ def test_move_labware_off_deck_raises(
subject.move_labware(labware=movable_labware, new_location=OFF_DECK)


def test_move_labware_to_trash_raises(
subject: ProtocolContext,
decoy: Decoy,
mock_core: ProtocolCore,
mock_core_map: LoadedCoreMap,
mock_engine_client: EngineClient,
) -> None:
"""It should raise an LabwareMovementNotAllowedError if using move_labware to move something that is not a lid to a TrashBin."""
mock_labware_core = decoy.mock(cls=LabwareCore)
trash_location = TrashBin(
location=DeckSlotName.SLOT_D3,
addressable_area_name="moveableTrashD3",
api_version=MAX_SUPPORTED_VERSION,
engine_client=mock_engine_client,
)

decoy.when(mock_labware_core.get_well_columns()).then_return([])

movable_labware = Labware(
core=mock_labware_core,
api_version=MAX_SUPPORTED_VERSION,
protocol_core=mock_core,
core_map=mock_core_map,
)

with pytest.raises(LabwareMovementNotAllowedError):
subject.move_labware(labware=movable_labware, new_location=trash_location)


def test_move_lid_to_trash_passes(
decoy: Decoy,
mock_core: ProtocolCore,
mock_core_map: LoadedCoreMap,
subject: ProtocolContext,
mock_engine_client: EngineClient,
) -> None:
"""It should move a lid labware into a trashbin successfully."""
mock_labware_core = decoy.mock(cls=LabwareCore)
trash_location = TrashBin(
location=DeckSlotName.SLOT_D3,
addressable_area_name="moveableTrashD3",
api_version=MAX_SUPPORTED_VERSION,
engine_client=mock_engine_client,
)

decoy.when(mock_labware_core.get_well_columns()).then_return([])
decoy.when(mock_labware_core.is_lid()).then_return(True)

movable_labware = Labware(
core=mock_labware_core,
api_version=MAX_SUPPORTED_VERSION,
protocol_core=mock_core,
core_map=mock_core_map,
)

subject.move_labware(labware=movable_labware, new_location=trash_location)
decoy.verify(
mock_core.move_labware(
labware_core=mock_labware_core,
new_location=trash_location,
use_gripper=False,
pause_for_manual_move=True,
pick_up_offset=None,
drop_offset=None,
)
)


def test_load_trash_bin(
decoy: Decoy,
mock_core: ProtocolCore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@
"y": 0,
"z": -1
}
},
"lidDisposalOffsets": {
"pickUpOffset": {
"x": 0,
"y": 0,
"z": 0
},
"dropOffset": {
"x": 0,
"y": 5.0,
"z": 50.0
}
}
}
}

0 comments on commit ec7641c

Please sign in to comment.