Skip to content

Commit 7393c92

Browse files
authored
feat(api, shared-data, app): Move lid PAPI and Core implementation (#17259)
Covers EXEC-1005 Introduce `move_lid()` command to PAPI, allows the movement of lids from stacks and source labware to destination locations.
1 parent 2b75afa commit 7393c92

File tree

60 files changed

+3550
-178
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+3550
-178
lines changed

analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[7d16d5dbf0][Flex_X_v2_21_tc_lids_wrong_target].json

+844-22
Large diffs are not rendered by default.

analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[99c15c6c62][Flex_S_v2_21_tc_lids_happy_path].json

+1,977-62
Large diffs are not rendered by default.

api/src/opentrons/legacy_commands/helpers.py

+15
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,18 @@ def stringify_labware_movement_command(
7878
destination_text = _stringify_labware_movement_location(destination)
7979
gripper_text = " with gripper" if use_gripper else ""
8080
return f"Moving {source_labware_text} to {destination_text}{gripper_text}"
81+
82+
83+
def stringify_lid_movement_command(
84+
source: Union[
85+
DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin
86+
],
87+
destination: Union[
88+
DeckLocation, OffDeckType, Labware, ModuleContext, WasteChute, TrashBin
89+
],
90+
use_gripper: bool,
91+
) -> str:
92+
source_labware_text = _stringify_labware_movement_location(source)
93+
destination_text = _stringify_labware_movement_location(destination)
94+
gripper_text = " with gripper" if use_gripper else ""
95+
return f"Moving lid from {source_labware_text} to {destination_text}{gripper_text}"

api/src/opentrons/protocol_api/core/engine/deck_conflict.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
OnLabwareLocation,
2626
AddressableAreaLocation,
2727
OFF_DECK_LOCATION,
28+
SYSTEM_LOCATION,
2829
)
2930
from opentrons.protocol_engine.errors.exceptions import LabwareNotLoadedOnModuleError
3031
from opentrons.types import DeckSlotName, StagingSlotName, Point
@@ -245,7 +246,10 @@ def _map_labware(
245246
# TODO(jbl 2023-06-08) check if we need to do any logic here or if this is correct
246247
return None
247248

248-
elif location_from_engine == OFF_DECK_LOCATION:
249+
elif (
250+
location_from_engine == OFF_DECK_LOCATION
251+
or location_from_engine == SYSTEM_LOCATION
252+
):
249253
# This labware is off-deck. Exclude it from conflict checking.
250254
# todo(mm, 2023-02-23): Move this logic into wrapped_deck_conflict.
251255
return None

api/src/opentrons/protocol_api/core/engine/protocol.py

+200
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from opentrons.protocol_engine.types import (
4848
ModuleModel as ProtocolEngineModuleModel,
4949
OFF_DECK_LOCATION,
50+
SYSTEM_LOCATION,
5051
LabwareLocation,
5152
NonStackedLocation,
5253
)
@@ -77,6 +78,7 @@
7778
)
7879
from .exceptions import InvalidModuleLocationError
7980
from . import load_labware_params, deck_conflict, overlap_versions
81+
from opentrons.protocol_engine.resources import labware_validation
8082

8183
if TYPE_CHECKING:
8284
from ...labware import Labware
@@ -442,6 +444,203 @@ def move_labware(
442444
existing_module_ids=list(self._module_cores_by_id.keys()),
443445
)
444446

447+
def move_lid( # noqa: C901
448+
self,
449+
source_location: Union[DeckSlotName, StagingSlotName, LabwareCore],
450+
new_location: Union[
451+
DeckSlotName,
452+
StagingSlotName,
453+
LabwareCore,
454+
OffDeckType,
455+
WasteChute,
456+
TrashBin,
457+
],
458+
use_gripper: bool,
459+
pause_for_manual_move: bool,
460+
pick_up_offset: Optional[Tuple[float, float, float]],
461+
drop_offset: Optional[Tuple[float, float, float]],
462+
) -> LabwareCore | None:
463+
"""Move the given lid to a new location."""
464+
if use_gripper:
465+
strategy = LabwareMovementStrategy.USING_GRIPPER
466+
elif pause_for_manual_move:
467+
strategy = LabwareMovementStrategy.MANUAL_MOVE_WITH_PAUSE
468+
else:
469+
strategy = LabwareMovementStrategy.MANUAL_MOVE_WITHOUT_PAUSE
470+
471+
if isinstance(source_location, DeckSlotName) or isinstance(
472+
source_location, StagingSlotName
473+
):
474+
# Find the source labware at the provided deck slot
475+
labware_in_slot = self._engine_client.state.labware.get_by_slot(
476+
source_location
477+
)
478+
if labware_in_slot is None:
479+
raise LabwareNotLoadedOnLabwareError(
480+
"Lid cannot be loaded on non-labware position."
481+
)
482+
else:
483+
labware = LabwareCore(labware_in_slot.id, self._engine_client)
484+
else:
485+
labware = source_location
486+
487+
# if this is a labware stack, we need to find the labware at the top of the stack
488+
if labware_validation.is_lid_stack(labware.load_name):
489+
lid_id = self._engine_client.state.labware.get_highest_child_labware(
490+
labware.labware_id
491+
)
492+
# if this is a labware with a lid, we just need to find its lid_id
493+
else:
494+
lid = self._engine_client.state.labware.get_lid_by_labware_id(
495+
labware.labware_id
496+
)
497+
if lid is not None:
498+
lid_id = lid.id
499+
else:
500+
raise ValueError("Cannot move a lid off of a labware with no lid.")
501+
502+
_pick_up_offset = (
503+
LabwareOffsetVector(
504+
x=pick_up_offset[0], y=pick_up_offset[1], z=pick_up_offset[2]
505+
)
506+
if pick_up_offset
507+
else None
508+
)
509+
_drop_offset = (
510+
LabwareOffsetVector(x=drop_offset[0], y=drop_offset[1], z=drop_offset[2])
511+
if drop_offset
512+
else None
513+
)
514+
515+
create_new_lid_stack = False
516+
517+
if isinstance(new_location, DeckSlotName) or isinstance(
518+
new_location, StagingSlotName
519+
):
520+
# Find the destination labware at the provided deck slot
521+
destination_labware_in_slot = self._engine_client.state.labware.get_by_slot(
522+
new_location
523+
)
524+
if destination_labware_in_slot is None:
525+
to_location = self._convert_labware_location(location=new_location)
526+
# absolutely must make a new lid stack
527+
create_new_lid_stack = True
528+
else:
529+
highest_child_location = (
530+
self._engine_client.state.labware.get_highest_child_labware(
531+
destination_labware_in_slot.id
532+
)
533+
)
534+
if labware_validation.validate_definition_is_adapter(
535+
self._engine_client.state.labware.get_definition(
536+
highest_child_location
537+
)
538+
):
539+
# absolutely must make a new lid stack
540+
create_new_lid_stack = True
541+
542+
to_location = self._convert_labware_location(
543+
location=LabwareCore(highest_child_location, self._engine_client)
544+
)
545+
elif isinstance(new_location, LabwareCore):
546+
highest_child_location = (
547+
self._engine_client.state.labware.get_highest_child_labware(
548+
new_location.labware_id
549+
)
550+
)
551+
if labware_validation.validate_definition_is_adapter(
552+
self._engine_client.state.labware.get_definition(highest_child_location)
553+
):
554+
# absolutely must make a new lid stack
555+
create_new_lid_stack = True
556+
to_location = self._convert_labware_location(
557+
location=LabwareCore(highest_child_location, self._engine_client)
558+
)
559+
else:
560+
to_location = self._convert_labware_location(location=new_location)
561+
562+
output_result = None
563+
if create_new_lid_stack:
564+
# Make a new lid stack object that is empty
565+
result = self._engine_client.execute_command_without_recovery(
566+
cmd.LoadLidStackParams(
567+
location=SYSTEM_LOCATION,
568+
loadName="empty",
569+
version=1,
570+
namespace="empty",
571+
quantity=0,
572+
)
573+
)
574+
575+
# Move the lid stack object from the SYSTEM_LOCATION space to the desired deck location
576+
self._engine_client.execute_command(
577+
cmd.MoveLabwareParams(
578+
labwareId=result.stackLabwareId,
579+
newLocation=to_location,
580+
strategy=LabwareMovementStrategy.MANUAL_MOVE_WITHOUT_PAUSE,
581+
pickUpOffset=None,
582+
dropOffset=None,
583+
)
584+
)
585+
586+
output_result = LabwareCore(
587+
labware_id=result.stackLabwareId, engine_client=self._engine_client
588+
)
589+
destination = self._convert_labware_location(location=output_result)
590+
else:
591+
destination = to_location
592+
593+
self._engine_client.execute_command(
594+
cmd.MoveLabwareParams(
595+
labwareId=lid_id,
596+
newLocation=destination,
597+
strategy=strategy,
598+
pickUpOffset=_pick_up_offset,
599+
dropOffset=_drop_offset,
600+
)
601+
)
602+
603+
# Handle leftover empty lid stack if there is one
604+
if (
605+
labware_validation.is_lid_stack(labware.load_name)
606+
and self._engine_client.state.labware.get_highest_child_labware(
607+
labware_id=labware.labware_id
608+
)
609+
== labware.labware_id
610+
):
611+
# The originating lid stack is now empty, so we need to move it to the SYSTEM_LOCATION
612+
self._engine_client.execute_command(
613+
cmd.MoveLabwareParams(
614+
labwareId=labware.labware_id,
615+
newLocation=SYSTEM_LOCATION,
616+
strategy=LabwareMovementStrategy.MANUAL_MOVE_WITHOUT_PAUSE,
617+
pickUpOffset=None,
618+
dropOffset=None,
619+
)
620+
)
621+
622+
if strategy == LabwareMovementStrategy.USING_GRIPPER:
623+
# Clear out last location since it is not relevant to pipetting
624+
# and we only use last location for in-place pipetting commands
625+
self.set_last_location(location=None, mount=Mount.EXTENSION)
626+
627+
# FIXME(jbl, 2024-01-04) deck conflict after execution logic issue, read notes in load_labware for more info:
628+
deck_conflict.check(
629+
engine_state=self._engine_client.state,
630+
new_labware_id=lid_id,
631+
existing_disposal_locations=self._disposal_locations,
632+
# TODO: We can now fetch these IDs from engine too.
633+
# See comment in self.load_labware().
634+
existing_labware_ids=[
635+
labware_id
636+
for labware_id in self._labware_cores_by_id
637+
if labware_id != labware_id
638+
],
639+
existing_module_ids=list(self._module_cores_by_id.keys()),
640+
)
641+
642+
return output_result
643+
445644
def _resolve_module_hardware(
446645
self, serial_number: str, model: ModuleModel
447646
) -> AbstractModule:
@@ -734,6 +933,7 @@ def load_lid_stack(
734933
)
735934

736935
# FIXME(CHB, 2024-12-04) just like load labware and load adapter we have a validating after loading the object issue
936+
assert load_result.definition is not None
737937
validation.ensure_definition_is_lid(load_result.definition)
738938

739939
deck_conflict.check(

api/src/opentrons/protocol_api/core/engine/stringify.py

+3
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ def _labware_location_string(
5050
elif location == "offDeck":
5151
return "[off-deck]"
5252

53+
elif location == "systemLocation":
54+
return "[systemLocation]"
55+
5356

5457
def _labware_name(engine_client: SyncClient, labware_id: str) -> str:
5558
"""Return the user-specified labware label, or fall back to the display name from the def."""

api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py

+19
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,25 @@ def move_labware(
307307
"""Move labware to new location."""
308308
raise APIVersionError(api_element="Labware movement")
309309

310+
def move_lid(
311+
self,
312+
source_location: Union[DeckSlotName, StagingSlotName, LegacyLabwareCore],
313+
new_location: Union[
314+
DeckSlotName,
315+
StagingSlotName,
316+
LegacyLabwareCore,
317+
OffDeckType,
318+
WasteChute,
319+
TrashBin,
320+
],
321+
use_gripper: bool,
322+
pause_for_manual_move: bool,
323+
pick_up_offset: Optional[Tuple[float, float, float]],
324+
drop_offset: Optional[Tuple[float, float, float]],
325+
) -> LegacyLabwareCore | None:
326+
"""Move lid to new location."""
327+
raise APIVersionError(api_element="Lid movement")
328+
310329
def load_module(
311330
self,
312331
model: ModuleModel,

api/src/opentrons/protocol_api/core/protocol.py

+19
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,25 @@ def move_labware(
131131
) -> None:
132132
...
133133

134+
@abstractmethod
135+
def move_lid(
136+
self,
137+
source_location: Union[DeckSlotName, StagingSlotName, LabwareCoreType],
138+
new_location: Union[
139+
DeckSlotName,
140+
StagingSlotName,
141+
LabwareCoreType,
142+
OffDeckType,
143+
WasteChute,
144+
TrashBin,
145+
],
146+
use_gripper: bool,
147+
pause_for_manual_move: bool,
148+
pick_up_offset: Optional[Tuple[float, float, float]],
149+
drop_offset: Optional[Tuple[float, float, float]],
150+
) -> LabwareCoreType | None:
151+
...
152+
134153
@abstractmethod
135154
def load_module(
136155
self,

0 commit comments

Comments
 (0)