Skip to content

Commit b453489

Browse files
committed
feat(api): return locations from MoveLabware
MoveLabware is a little different than the other implementations. It needs an origin and a destination sequence, since it's a move - that's straightforward. But it also needs to drive a distinction between an immediate destination: where is the labware dropped off? and an eventual destination: where does the labware end up? so that the necessary information to figure out what happens when you move a labware to the trash is there. We need to identify the trash, and which trash; but we also need to indicate that the labware is going to get thrown out. So we have three total locations.
1 parent b8cbdde commit b453489

File tree

3 files changed

+280
-38
lines changed

3 files changed

+280
-38
lines changed

api/src/opentrons/protocol_engine/commands/labware_handling_common.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,3 @@ class LabwarePositionResultMixin(LabwareHandlingResultMixin):
2222
None,
2323
description="An ID referencing the labware offset that will apply to this labware in this location.",
2424
)
25-
26-
27-
class LabwareMotionResultMixin(BaseModel):
28-
"""A result for commands that move a labware entity."""
29-
30-
labwareId: str = Field(..., description="The id of the labware.")
31-
newLocationSequence: LabwareLocationSequence | None = Field(
32-
None,
33-
description="the full location down to the deck of the labware after this command.",
34-
)
35-
originalLocationSequence: LabwareLocationSequence | None = Field(
36-
None,
37-
description="The full location down to the deck of the labware before this command.",
38-
)
39-
offsetId: str | None = Field(
40-
None,
41-
description="An ID referencing the labware offset that will apply to this labware in the position this command leaves it in.",
42-
)

api/src/opentrons/protocol_engine/commands/move_labware.py

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44
from typing import TYPE_CHECKING, Optional, Type, Any, List
5+
from typing_extensions import TypedDict # note: need this instead of typing for py<3.12
56

67
from pydantic.json_schema import SkipJsonSchema
78
from pydantic import BaseModel, Field
@@ -26,6 +27,9 @@
2627
LabwareMovementStrategy,
2728
LabwareOffsetVector,
2829
LabwareMovementOffsetData,
30+
LabwareLocationSequence,
31+
NotOnDeckLocationSequenceComponent,
32+
OFF_DECK_LOCATION,
2933
)
3034
from ..errors import (
3135
LabwareMovementNotAllowedError,
@@ -100,6 +104,31 @@ class MoveLabwareResult(BaseModel):
100104
" so the default of (0, 0, 0) will be used."
101105
),
102106
)
107+
eventualDestinationLocationSequence: LabwareLocationSequence | None = Field(
108+
None,
109+
description=(
110+
"The full location in which this labware will eventually reside. This will typically be the same as its "
111+
"immediate destination, but if this labware is going to the trash then this field will be off deck."
112+
),
113+
)
114+
immediateDestinationLocationSequence: LabwareLocationSequence | None = Field(
115+
None,
116+
description=(
117+
"The full location to which this labware is being moved, right now."
118+
),
119+
)
120+
originLocationSequence: LabwareLocationSequence | None = Field(
121+
None,
122+
description="The full location down to the deck of the labware before this command.",
123+
)
124+
125+
126+
class ErrorDetails(TypedDict):
127+
"""Location details for a failed gripper move."""
128+
129+
originLocationSequence: LabwareLocationSequence
130+
immediateDestinationLocationSequence: LabwareLocationSequence
131+
eventualDestinationLocationSequence: LabwareLocationSequence
103132

104133

105134
class GripperMovementError(ErrorOccurrence):
@@ -112,6 +141,8 @@ class GripperMovementError(ErrorOccurrence):
112141

113142
errorType: Literal["gripperMovement"] = "gripperMovement"
114143

144+
errorInfo: ErrorDetails
145+
115146

116147
_ExecuteReturn = SuccessData[MoveLabwareResult] | DefinedErrorData[GripperMovementError]
117148

@@ -152,6 +183,11 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
152183
f"Cannot move fixed trash labware '{current_labware_definition.parameters.loadName}'."
153184
)
154185

186+
origin_location_sequence = self._state_view.geometry.get_location_sequence(
187+
params.labwareId
188+
)
189+
eventual_destination_location_sequence: LabwareLocationSequence | None = None
190+
155191
if isinstance(params.newLocation, AddressableAreaLocation):
156192
area_name = params.newLocation.addressableAreaName
157193
if (
@@ -181,9 +217,19 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
181217
y=0,
182218
z=0,
183219
)
220+
eventual_destination_location_sequence = [
221+
NotOnDeckLocationSequenceComponent(
222+
logicalLocationName=OFF_DECK_LOCATION
223+
)
224+
]
184225
elif fixture_validation.is_trash(area_name):
185226
# When dropping labware in the trash bins we want to ensure they are lids
186227
# and enforce a y-axis drop offset to ensure they fall within the trash bin
228+
eventual_destination_location_sequence = [
229+
NotOnDeckLocationSequenceComponent(
230+
logicalLocationName=OFF_DECK_LOCATION
231+
)
232+
]
187233
if labware_validation.validate_definition_is_lid(
188234
self._state_view.labware.get_definition(params.labwareId)
189235
):
@@ -298,6 +344,16 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
298344
if trash_lid_drop_offset:
299345
user_offset_data.dropOffset += trash_lid_drop_offset
300346

347+
immediate_destination_location_sequence = (
348+
self._state_view.geometry.get_predicted_location_sequence(
349+
validated_new_loc
350+
)
351+
)
352+
if eventual_destination_location_sequence is None:
353+
eventual_destination_location_sequence = (
354+
immediate_destination_location_sequence
355+
)
356+
301357
try:
302358
# Skips gripper moves when using virtual gripper
303359
await self._labware_movement.move_labware_with_gripper(
@@ -314,20 +370,23 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
314370
# todo(mm, 2024-09-26): Catch LabwareNotPickedUpError when that exists and
315371
# move_labware_with_gripper() raises it.
316372
) as exception:
317-
gripper_movement_error: GripperMovementError | None = (
318-
GripperMovementError(
319-
id=self._model_utils.generate_id(),
320-
createdAt=self._model_utils.get_timestamp(),
321-
errorCode=exception.code.value.code,
322-
detail=exception.code.value.detail,
323-
wrappedErrors=[
324-
ErrorOccurrence.from_failed(
325-
id=self._model_utils.generate_id(),
326-
createdAt=self._model_utils.get_timestamp(),
327-
error=exception,
328-
)
329-
],
330-
)
373+
gripper_movement_error: GripperMovementError | None = GripperMovementError(
374+
id=self._model_utils.generate_id(),
375+
createdAt=self._model_utils.get_timestamp(),
376+
errorCode=exception.code.value.code,
377+
detail=exception.code.value.detail,
378+
errorInfo={
379+
"originLocationSequence": origin_location_sequence,
380+
"immediateDestinationLocationSequence": immediate_destination_location_sequence,
381+
"eventualDestinationLocationSequence": eventual_destination_location_sequence,
382+
},
383+
wrappedErrors=[
384+
ErrorOccurrence.from_failed(
385+
id=self._model_utils.generate_id(),
386+
createdAt=self._model_utils.get_timestamp(),
387+
error=exception,
388+
)
389+
],
331390
)
332391
else:
333392
gripper_movement_error = None
@@ -343,7 +402,27 @@ async def execute(self, params: MoveLabwareParams) -> _ExecuteReturn: # noqa: C
343402

344403
elif params.strategy == LabwareMovementStrategy.MANUAL_MOVE_WITH_PAUSE:
345404
# Pause to allow for manual labware movement
405+
immediate_destination_location_sequence = (
406+
self._state_view.geometry.get_predicted_location_sequence(
407+
params.newLocation
408+
)
409+
)
410+
if eventual_destination_location_sequence is None:
411+
eventual_destination_location_sequence = (
412+
immediate_destination_location_sequence
413+
)
414+
346415
await self._run_control.wait_for_resume()
416+
else:
417+
immediate_destination_location_sequence = (
418+
self._state_view.geometry.get_predicted_location_sequence(
419+
params.newLocation
420+
)
421+
)
422+
if eventual_destination_location_sequence is None:
423+
eventual_destination_location_sequence = (
424+
immediate_destination_location_sequence
425+
)
347426

348427
# We may have just moved the labware that contains the current well out from
349428
# 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
398477
)
399478

400479
return SuccessData(
401-
public=MoveLabwareResult(offsetId=new_offset_id),
480+
public=MoveLabwareResult(
481+
offsetId=new_offset_id,
482+
originLocationSequence=origin_location_sequence,
483+
immediateDestinationLocationSequence=immediate_destination_location_sequence,
484+
eventualDestinationLocationSequence=eventual_destination_location_sequence,
485+
),
402486
state_update=state_update,
403487
)
404488

0 commit comments

Comments
 (0)