diff --git a/api/src/opentrons/protocol_engine/commands/labware_handling_common.py b/api/src/opentrons/protocol_engine/commands/labware_handling_common.py index 9ceaf0bb700..cafa4583539 100644 --- a/api/src/opentrons/protocol_engine/commands/labware_handling_common.py +++ b/api/src/opentrons/protocol_engine/commands/labware_handling_common.py @@ -22,21 +22,3 @@ class LabwarePositionResultMixin(LabwareHandlingResultMixin): None, description="An ID referencing the labware offset that will apply to this labware in this location.", ) - - -class LabwareMotionResultMixin(BaseModel): - """A result for commands that move a labware entity.""" - - labwareId: str = Field(..., description="The id of the labware.") - newLocationSequence: LabwareLocationSequence | None = Field( - None, - description="the full location down to the deck of the labware after this command.", - ) - originalLocationSequence: LabwareLocationSequence | None = Field( - None, - description="The full location down to the deck of the labware before this command.", - ) - offsetId: str | None = Field( - None, - description="An ID referencing the labware offset that will apply to this labware in the position this command leaves it in.", - ) diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index 6b10fc1ec8d..9bf71018e78 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional, Type, Any, List +from typing_extensions import TypedDict # note: need this instead of typing for py<3.12 from pydantic.json_schema import SkipJsonSchema from pydantic import BaseModel, Field @@ -26,6 +27,9 @@ LabwareMovementStrategy, LabwareOffsetVector, LabwareMovementOffsetData, + LabwareLocationSequence, + NotOnDeckLocationSequenceComponent, + OFF_DECK_LOCATION, ) from ..errors import ( LabwareMovementNotAllowedError, @@ -100,6 +104,31 @@ class MoveLabwareResult(BaseModel): " so the default of (0, 0, 0) will be used." ), ) + eventualDestinationLocationSequence: LabwareLocationSequence | None = Field( + None, + description=( + "The full location in which this labware will eventually reside. This will typically be the same as its " + "immediate destination, but if this labware is going to the trash then this field will be off deck." + ), + ) + immediateDestinationLocationSequence: LabwareLocationSequence | None = Field( + None, + description=( + "The full location to which this labware is being moved, right now." + ), + ) + originLocationSequence: LabwareLocationSequence | None = Field( + None, + description="The full location down to the deck of the labware before this command.", + ) + + +class ErrorDetails(TypedDict): + """Location details for a failed gripper move.""" + + originLocationSequence: LabwareLocationSequence + immediateDestinationLocationSequence: LabwareLocationSequence + eventualDestinationLocationSequence: LabwareLocationSequence class GripperMovementError(ErrorOccurrence): @@ -112,6 +141,8 @@ class GripperMovementError(ErrorOccurrence): errorType: Literal["gripperMovement"] = "gripperMovement" + errorInfo: ErrorDetails + _ExecuteReturn = SuccessData[MoveLabwareResult] | DefinedErrorData[GripperMovementError] @@ -152,6 +183,11 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C f"Cannot move fixed trash labware '{current_labware_definition.parameters.loadName}'." ) + origin_location_sequence = self._state_view.geometry.get_location_sequence( + params.labwareId + ) + eventual_destination_location_sequence: LabwareLocationSequence | None = None + if isinstance(params.newLocation, AddressableAreaLocation): area_name = params.newLocation.addressableAreaName if ( @@ -181,9 +217,19 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C y=0, z=0, ) + eventual_destination_location_sequence = [ + NotOnDeckLocationSequenceComponent( + logicalLocationName=OFF_DECK_LOCATION + ) + ] 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 + eventual_destination_location_sequence = [ + NotOnDeckLocationSequenceComponent( + logicalLocationName=OFF_DECK_LOCATION + ) + ] if labware_validation.validate_definition_is_lid( self._state_view.labware.get_definition(params.labwareId) ): @@ -298,6 +344,16 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C if trash_lid_drop_offset: user_offset_data.dropOffset += trash_lid_drop_offset + immediate_destination_location_sequence = ( + self._state_view.geometry.get_predicted_location_sequence( + validated_new_loc + ) + ) + if eventual_destination_location_sequence is None: + eventual_destination_location_sequence = ( + immediate_destination_location_sequence + ) + try: # Skips gripper moves when using virtual gripper await self._labware_movement.move_labware_with_gripper( @@ -314,20 +370,23 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C # todo(mm, 2024-09-26): Catch LabwareNotPickedUpError when that exists and # move_labware_with_gripper() raises it. ) as exception: - gripper_movement_error: GripperMovementError | None = ( - GripperMovementError( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - errorCode=exception.code.value.code, - detail=exception.code.value.detail, - wrappedErrors=[ - ErrorOccurrence.from_failed( - id=self._model_utils.generate_id(), - createdAt=self._model_utils.get_timestamp(), - error=exception, - ) - ], - ) + gripper_movement_error: GripperMovementError | None = GripperMovementError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + errorCode=exception.code.value.code, + detail=exception.code.value.detail, + errorInfo={ + "originLocationSequence": origin_location_sequence, + "immediateDestinationLocationSequence": immediate_destination_location_sequence, + "eventualDestinationLocationSequence": eventual_destination_location_sequence, + }, + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=exception, + ) + ], ) else: gripper_movement_error = None @@ -343,7 +402,27 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C elif params.strategy == LabwareMovementStrategy.MANUAL_MOVE_WITH_PAUSE: # Pause to allow for manual labware movement + immediate_destination_location_sequence = ( + self._state_view.geometry.get_predicted_location_sequence( + params.newLocation + ) + ) + if eventual_destination_location_sequence is None: + eventual_destination_location_sequence = ( + immediate_destination_location_sequence + ) + await self._run_control.wait_for_resume() + else: + immediate_destination_location_sequence = ( + self._state_view.geometry.get_predicted_location_sequence( + params.newLocation + ) + ) + if eventual_destination_location_sequence is None: + eventual_destination_location_sequence = ( + immediate_destination_location_sequence + ) # We may have just moved the labware that contains the current well out from # under the pipette. Clear the current location to reflect the fact that the @@ -398,7 +477,12 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C ) return SuccessData( - public=MoveLabwareResult(offsetId=new_offset_id), + public=MoveLabwareResult( + offsetId=new_offset_id, + originLocationSequence=origin_location_sequence, + immediateDestinationLocationSequence=immediate_destination_location_sequence, + eventualDestinationLocationSequence=eventual_destination_location_sequence, + ), state_update=state_update, ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py index 3a1e8c47b02..46b45a66fb4 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py @@ -36,6 +36,11 @@ LabwareMovementOffsetData, DeckType, AddressableAreaLocation, + OnAddressableAreaLocationSequenceComponent, + OnLabwareLocationSequenceComponent, + NotOnDeckLocationSequenceComponent, + OFF_DECK_LOCATION, + LabwareLocationSequence, ) from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.commands.command import DefinedErrorData, SuccessData @@ -123,6 +128,24 @@ async def test_manual_move_labware_implementation( labware_location=DeckSlotLocation(slotName=DeckSlotName.SLOT_5), ) ).then_return("wowzers-a-new-offset-id") + decoy.when( + state_view.geometry.get_location_sequence("my-cool-labware-id") + ).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="5", slotName=DeckSlotName.SLOT_5 + ) + ] + ) + decoy.when( + state_view.geometry.get_predicted_location_sequence(new_location) + ).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="4", slotName=DeckSlotName.SLOT_4 + ) + ] + ) result = await subject.execute(data) decoy.verify(await run_control.wait_for_resume(), times=times_pause_called) @@ -132,6 +155,21 @@ async def test_manual_move_labware_implementation( assert result == SuccessData( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", + originLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="5", slotName=DeckSlotName.SLOT_5 + ) + ], + immediateDestinationLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="4", slotName=DeckSlotName.SLOT_4 + ) + ], + eventualDestinationLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="4", slotName=DeckSlotName.SLOT_4 + ) + ], ), state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( @@ -185,6 +223,27 @@ async def test_move_labware_implementation_on_labware( labware_location=OnLabwareLocation(labwareId="my-even-cooler-labware-id"), ) ).then_return("wowzers-a-new-offset-id") + decoy.when( + state_view.geometry.get_location_sequence("my-cool-labware-id") + ).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="1", slotName=DeckSlotName.SLOT_1 + ) + ] + ) + decoy.when( + state_view.geometry.get_predicted_location_sequence( + OnLabwareLocation(labwareId="new-labware-id") + ) + ).then_return( + [ + OnLabwareLocationSequenceComponent(labwareId="new-labware-id", lidId=None), + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="2", slotName=DeckSlotName.SLOT_2 + ), + ] + ) result = await subject.execute(data) decoy.verify( @@ -200,6 +259,27 @@ async def test_move_labware_implementation_on_labware( assert result == SuccessData( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", + originLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="1", slotName=DeckSlotName.SLOT_1 + ) + ], + immediateDestinationLocationSequence=[ + OnLabwareLocationSequenceComponent( + labwareId="new-labware-id", lidId=None + ), + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="2", slotName=DeckSlotName.SLOT_2 + ), + ], + eventualDestinationLocationSequence=[ + OnLabwareLocationSequenceComponent( + labwareId="new-labware-id", lidId=None + ), + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="2", slotName=DeckSlotName.SLOT_2 + ), + ], ), state_update=update_types.StateUpdate( labware_location=update_types.LabwareLocationUpdate( @@ -264,6 +344,26 @@ async def test_gripper_move_labware_implementation( decoy.when( labware_validation.validate_gripper_compatible(sentinel.labware_definition) ).then_return(True) + decoy.when( + state_view.geometry.get_location_sequence("my-cool-labware-id") + ).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="1", slotName=DeckSlotName.SLOT_1 + ) + ] + ) + decoy.when( + state_view.geometry.get_predicted_location_sequence( + sentinel.new_location_validated_for_gripper + ) + ).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="5", slotName=DeckSlotName.SLOT_5 + ) + ] + ) result = await subject.execute(data) decoy.verify( @@ -282,6 +382,21 @@ async def test_gripper_move_labware_implementation( assert result == SuccessData( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", + originLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="1", slotName=DeckSlotName.SLOT_1 + ) + ], + immediateDestinationLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="5", slotName=DeckSlotName.SLOT_5 + ) + ], + eventualDestinationLocationSequence=[ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="5", slotName=DeckSlotName.SLOT_5 + ) + ], ), state_update=update_types.StateUpdate( pipette_location=update_types.CLEAR, @@ -321,7 +436,7 @@ async def test_gripper_error( labware_def = LabwareDefinition.model_construct( # type: ignore[call-arg] namespace=labware_namespace, ) - original_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_A1) + origin_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_A1) new_location = DeckSlotLocation(slotName=DeckSlotName.SLOT_A2) error_id = "error-id" error_created_at = datetime.now() @@ -335,13 +450,13 @@ async def test_gripper_error( id=labware_id, loadName=labware_load_name, definitionUri=labware_definition_uri, - location=original_location, + location=origin_location, offsetId=None, ) ) decoy.when( - state_view.geometry.ensure_valid_gripper_location(original_location) - ).then_return(original_location) + state_view.geometry.ensure_valid_gripper_location(origin_location) + ).then_return(origin_location) decoy.when( state_view.geometry.ensure_valid_gripper_location(new_location) ).then_return(new_location) @@ -363,7 +478,7 @@ async def test_gripper_error( decoy.when( await labware_movement.move_labware_with_gripper( labware_id=labware_id, - current_location=original_location, + current_location=origin_location, new_location=new_location, user_offset_data=LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=0, y=0, z=0), @@ -374,6 +489,22 @@ async def test_gripper_error( ).then_raise(underlying_exception) decoy.when(model_utils.get_timestamp()).then_return(error_created_at) decoy.when(model_utils.generate_id()).then_return(error_id) + decoy.when(state_view.geometry.get_location_sequence("labware-id")).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="A1", slotName=DeckSlotName.SLOT_A1 + ) + ] + ) + decoy.when( + state_view.geometry.get_predicted_location_sequence(new_location) + ).then_return( + [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="A2", slotName=DeckSlotName.SLOT_A2 + ) + ] + ) result = await subject.execute(params) @@ -383,6 +514,23 @@ async def test_gripper_error( createdAt=error_created_at, errorCode=underlying_exception.code.value.code, detail=underlying_exception.code.value.detail, + errorInfo={ + "originLocationSequence": [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="A1", slotName=DeckSlotName.SLOT_A1 + ) + ], + "immediateDestinationLocationSequence": [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="A2", slotName=DeckSlotName.SLOT_A2 + ) + ], + "eventualDestinationLocationSequence": [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="A2", slotName=DeckSlotName.SLOT_A2 + ) + ], + }, wrappedErrors=[matchers.Anything()], ), state_update=update_types.StateUpdate( @@ -429,6 +577,12 @@ async def test_clears_location_if_current_labware_moved_from_under_pipette( pipette_id="pipette-id", labware_id=current_labware_id, well_name="A1" ) ) + decoy.when(state_view.geometry.get_location_sequence(moved_labware_id)).then_return( + [] + ) + decoy.when( + state_view.geometry.get_predicted_location_sequence(to_location) + ).then_return([]) result = await subject.execute( params=MoveLabwareParams( @@ -458,6 +612,19 @@ async def test_gripper_move_to_waste_chute_implementation( expected_slide_offset = Point( x=labware_width / 2 + GRIPPER_PADDLE_WIDTH / 2 + 8, y=0, z=0 ) + from_loc_sequence: LabwareLocationSequence = [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="1", slotName=DeckSlotName.SLOT_1 + ) + ] + immediate_dest_loc_sequence: LabwareLocationSequence = [ + OnAddressableAreaLocationSequenceComponent( + addressableAreaName="gripperWasteChute", slotName=None + ) + ] + eventual_dest_loc_sequence: LabwareLocationSequence = [ + NotOnDeckLocationSequenceComponent(logicalLocationName=OFF_DECK_LOCATION) + ] data = MoveLabwareParams( labwareId="my-cool-labware-id", @@ -475,6 +642,12 @@ async def test_gripper_move_to_waste_chute_implementation( decoy.when( state_view.labware.get_definition(labware_id="my-cool-labware-id") ).then_return(labware_def) + decoy.when( + state_view.geometry.get_location_sequence("my-cool-labware-id") + ).then_return(from_loc_sequence) + decoy.when( + state_view.geometry.get_predicted_location_sequence(new_location) + ).then_return(immediate_dest_loc_sequence) decoy.when(state_view.labware.get(labware_id="my-cool-labware-id")).then_return( LoadedLabware( id="my-cool-labware-id", @@ -523,6 +696,9 @@ async def test_gripper_move_to_waste_chute_implementation( assert result == SuccessData( public=MoveLabwareResult( offsetId="wowzers-a-new-offset-id", + originLocationSequence=from_loc_sequence, + immediateDestinationLocationSequence=immediate_dest_loc_sequence, + eventualDestinationLocationSequence=eventual_dest_loc_sequence, ), state_update=update_types.StateUpdate( pipette_location=update_types.CLEAR,