Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(api, shared-data): Allow labware lids to be disposed in the trash bin #16638

Merged
merged 7 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
}
}
Loading