Skip to content

Commit 9d4cf06

Browse files
authored
fix(engine): deck configuration pipette movement related errors (#14067)
1 parent 9dd2d1f commit 9d4cf06

File tree

8 files changed

+207
-4
lines changed

8 files changed

+207
-4
lines changed

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from typing import TYPE_CHECKING, Optional, Type
55
from typing_extensions import Literal
66

7+
from ..errors import LocationNotAccessibleByPipetteError
78
from ..types import DeckPoint, AddressableOffsetVector
9+
from ..resources import fixture_validation
810
from .pipetting_common import (
911
PipetteIdMixin,
1012
MovementMixin,
@@ -71,6 +73,11 @@ async def execute(
7173
self, params: MoveToAddressableAreaParams
7274
) -> MoveToAddressableAreaResult:
7375
"""Move the requested pipette to the requested addressable area."""
76+
if fixture_validation.is_staging_slot(params.addressableAreaName):
77+
raise LocationNotAccessibleByPipetteError(
78+
f"Cannot move pipette to staging slot {params.addressableAreaName}"
79+
)
80+
7481
x, y, z = await self._movement.move_to_addressable_area(
7582
pipette_id=params.pipetteId,
7683
addressable_area_name=params.addressableAreaName,

api/src/opentrons/protocol_engine/errors/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
LabwareMovementNotAllowedError,
6060
LabwareIsNotAllowedInLocationError,
6161
LocationIsOccupiedError,
62+
LocationNotAccessibleByPipetteError,
63+
LocationIsStagingSlotError,
6264
InvalidAxisForRobotType,
6365
NotSupportedOnRobotType,
6466
)
@@ -126,6 +128,8 @@
126128
"LabwareMovementNotAllowedError",
127129
"LabwareIsNotAllowedInLocationError",
128130
"LocationIsOccupiedError",
131+
"LocationNotAccessibleByPipetteError",
132+
"LocationIsStagingSlotError",
129133
"InvalidAxisForRobotType",
130134
"NotSupportedOnRobotType",
131135
# error occurrence models

api/src/opentrons/protocol_engine/errors/exceptions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,32 @@ def __init__(
806806
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
807807

808808

809+
class LocationNotAccessibleByPipetteError(ProtocolEngineError):
810+
"""Raised when attempting to move pipette to an inaccessible location."""
811+
812+
def __init__(
813+
self,
814+
message: Optional[str] = None,
815+
details: Optional[Dict[str, Any]] = None,
816+
wrapping: Optional[Sequence[EnumeratedError]] = None,
817+
) -> None:
818+
"""Build a LocationNotAccessibleByPipetteError."""
819+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
820+
821+
822+
class LocationIsStagingSlotError(ProtocolEngineError):
823+
"""Raised when referencing a labware on a staging slot when trying to get standard deck slot."""
824+
825+
def __init__(
826+
self,
827+
message: Optional[str] = None,
828+
details: Optional[Dict[str, Any]] = None,
829+
wrapping: Optional[Sequence[EnumeratedError]] = None,
830+
) -> None:
831+
"""Build a LocationIsStagingSlotError."""
832+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
833+
834+
809835
class FirmwareUpdateRequired(ProtocolEngineError):
810836
"""Raised when the firmware needs to be updated."""
811837

api/src/opentrons/protocol_engine/execution/movement.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ async def move_to_well(
7373
speed: Optional[float] = None,
7474
) -> Point:
7575
"""Move to a specific well."""
76+
self._state_store.labware.raise_if_labware_inaccessible_by_pipette(
77+
labware_id=labware_id
78+
)
79+
7680
self._state_store.labware.raise_if_labware_has_labware_on_top(
7781
labware_id=labware_id
7882
)

api/src/opentrons/protocol_engine/state/geometry.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,11 @@ def get_min_travel_z(
141141

142142
def get_labware_parent_nominal_position(self, labware_id: str) -> Point:
143143
"""Get the position of the labware's uncalibrated parent slot (deck, module, or another labware)."""
144-
slot_name = self.get_ancestor_slot_name(labware_id)
145-
slot_pos = self._addressable_areas.get_addressable_area_position(slot_name.id)
144+
try:
145+
slot_name = self.get_ancestor_slot_name(labware_id).id
146+
except errors.LocationIsStagingSlotError:
147+
slot_name = self._get_staging_slot_name(labware_id)
148+
slot_pos = self._addressable_areas.get_addressable_area_position(slot_name)
146149
labware_data = self._labware.get(labware_id)
147150
offset = self._get_labware_position_offset(labware_id, labware_data.location)
148151

@@ -459,6 +462,22 @@ def get_checked_tip_drop_location(
459462
),
460463
)
461464

465+
# TODO(jbl 11-30-2023) fold this function into get_ancestor_slot_name see RSS-411
466+
def _get_staging_slot_name(self, labware_id: str) -> str:
467+
"""Get the staging slot name that the labware is on."""
468+
labware_location = self._labware.get(labware_id).location
469+
if isinstance(labware_location, OnLabwareLocation):
470+
below_labware_id = labware_location.labwareId
471+
return self._get_staging_slot_name(below_labware_id)
472+
elif isinstance(
473+
labware_location, AddressableAreaLocation
474+
) and fixture_validation.is_staging_slot(labware_location.addressableAreaName):
475+
return labware_location.addressableAreaName
476+
else:
477+
raise ValueError(
478+
"Cannot get staging slot name for labware not on staging slot."
479+
)
480+
462481
def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName:
463482
"""Get the slot name of the labware or the module that the labware is on."""
464483
labware = self._labware.get(labware_id)
@@ -477,7 +496,7 @@ def get_ancestor_slot_name(self, labware_id: str) -> DeckSlotName:
477496
# TODO we might want to eventually return some sort of staging slot name when we're ready to work through
478497
# the linting nightmare it will create
479498
if fixture_validation.is_staging_slot(area_name):
480-
raise ValueError(
499+
raise errors.LocationIsStagingSlotError(
481500
"Cannot get ancestor slot name for labware on staging slot."
482501
)
483502
slot_name = DeckSlotName.from_primitive(area_name)

api/src/opentrons/protocol_engine/state/labware.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,26 @@ def get_fixed_trash_id(self) -> Optional[str]:
656656

657657
def is_fixed_trash(self, labware_id: str) -> bool:
658658
"""Check if labware is fixed trash."""
659-
return self.get_fixed_trash_id() == labware_id
659+
return self.get_has_quirk(labware_id, "fixedTrash")
660+
661+
def raise_if_labware_inaccessible_by_pipette(self, labware_id: str) -> None:
662+
"""Raise an error if the specified location cannot be reached via a pipette."""
663+
labware = self.get(labware_id)
664+
labware_location = labware.location
665+
if isinstance(labware_location, OnLabwareLocation):
666+
return self.raise_if_labware_inaccessible_by_pipette(
667+
labware_location.labwareId
668+
)
669+
elif isinstance(labware_location, AddressableAreaLocation):
670+
if fixture_validation.is_staging_slot(labware_location.addressableAreaName):
671+
raise errors.LocationNotAccessibleByPipetteError(
672+
f"Cannot move pipette to {labware.loadName},"
673+
f" labware is on staging slot {labware_location.addressableAreaName}"
674+
)
675+
elif labware_location == OFF_DECK_LOCATION:
676+
raise errors.LocationNotAccessibleByPipetteError(
677+
f"Cannot move pipette to {labware.loadName}, labware is off-deck."
678+
)
660679

661680
def raise_if_labware_in_location(
662681
self,

api/tests/opentrons/protocol_engine/state/test_geometry_view.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DeckSlotLocation,
2020
ModuleLocation,
2121
OnLabwareLocation,
22+
AddressableAreaLocation,
2223
ModuleOffsetVector,
2324
ModuleOffsetData,
2425
LoadedLabware,
@@ -529,6 +530,67 @@ def test_get_all_labware_highest_z(
529530
assert all_z == max(plate_z, reservoir_z)
530531

531532

533+
def test_get_all_labware_highest_z_with_staging_area(
534+
decoy: Decoy,
535+
well_plate_def: LabwareDefinition,
536+
falcon_tuberack_def: LabwareDefinition,
537+
labware_view: LabwareView,
538+
module_view: ModuleView,
539+
addressable_area_view: AddressableAreaView,
540+
subject: GeometryView,
541+
) -> None:
542+
"""It should get the highest Z amongst all labware including staging area."""
543+
plate = LoadedLabware(
544+
id="plate-id",
545+
loadName="plate-load-name",
546+
definitionUri="plate-definition-uri",
547+
location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3),
548+
offsetId="plate-offset-id",
549+
)
550+
staging_lw = LoadedLabware(
551+
id="staging-id",
552+
loadName="staging-load-name",
553+
definitionUri="staging-definition-uri",
554+
location=AddressableAreaLocation(addressableAreaName="D4"),
555+
offsetId="plate-offset-id",
556+
)
557+
558+
plate_offset = LabwareOffsetVector(x=1, y=-2, z=3)
559+
staging_lw_offset = LabwareOffsetVector(x=1, y=-2, z=3)
560+
561+
decoy.when(module_view.get_all()).then_return([])
562+
decoy.when(addressable_area_view.get_all()).then_return([])
563+
564+
decoy.when(labware_view.get_all()).then_return([plate, staging_lw])
565+
decoy.when(labware_view.get("plate-id")).then_return(plate)
566+
decoy.when(labware_view.get("staging-id")).then_return(staging_lw)
567+
568+
decoy.when(labware_view.get_definition("plate-id")).then_return(well_plate_def)
569+
decoy.when(labware_view.get_definition("staging-id")).then_return(
570+
falcon_tuberack_def # Something tall.
571+
)
572+
573+
decoy.when(labware_view.get_labware_offset_vector("plate-id")).then_return(
574+
plate_offset
575+
)
576+
decoy.when(labware_view.get_labware_offset_vector("staging-id")).then_return(
577+
staging_lw_offset
578+
)
579+
580+
decoy.when(
581+
addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id)
582+
).then_return(Point(1, 2, 3))
583+
decoy.when(addressable_area_view.get_addressable_area_position("D4")).then_return(
584+
Point(4, 5, 6)
585+
)
586+
587+
staging_z = subject.get_labware_highest_z("staging-id")
588+
all_z = subject.get_all_labware_highest_z()
589+
590+
# Should exclude the off-deck plate.
591+
assert all_z == staging_z
592+
593+
532594
def test_get_all_labware_highest_z_with_modules(
533595
decoy: Decoy,
534596
labware_view: LabwareView,

api/tests/opentrons/protocol_engine/state/test_labware_view.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
ModuleLocation,
3535
OnLabwareLocation,
3636
LabwareLocation,
37+
AddressableAreaLocation,
3738
OFF_DECK_LOCATION,
3839
OverlapOffset,
3940
LabwareMovementOffsetData,
@@ -1196,6 +1197,67 @@ def test_get_all_labware_definition_empty() -> None:
11961197
assert result == []
11971198

11981199

1200+
def test_raise_if_labware_inaccessible_by_pipette_staging_area() -> None:
1201+
"""It should raise if the labware is on a staging slot."""
1202+
subject = get_labware_view(
1203+
labware_by_id={
1204+
"labware-id": LoadedLabware(
1205+
id="labware-id",
1206+
loadName="test",
1207+
definitionUri="def-uri",
1208+
location=AddressableAreaLocation(addressableAreaName="B4"),
1209+
)
1210+
},
1211+
)
1212+
1213+
with pytest.raises(
1214+
errors.LocationNotAccessibleByPipetteError, match="on staging slot"
1215+
):
1216+
subject.raise_if_labware_inaccessible_by_pipette("labware-id")
1217+
1218+
1219+
def test_raise_if_labware_inaccessible_by_pipette_off_deck() -> None:
1220+
"""It should raise if the labware is off-deck."""
1221+
subject = get_labware_view(
1222+
labware_by_id={
1223+
"labware-id": LoadedLabware(
1224+
id="labware-id",
1225+
loadName="test",
1226+
definitionUri="def-uri",
1227+
location=OFF_DECK_LOCATION,
1228+
)
1229+
},
1230+
)
1231+
1232+
with pytest.raises(errors.LocationNotAccessibleByPipetteError, match="off-deck"):
1233+
subject.raise_if_labware_inaccessible_by_pipette("labware-id")
1234+
1235+
1236+
def test_raise_if_labware_inaccessible_by_pipette_stacked_labware_on_staging_area() -> None:
1237+
"""It should raise if the labware is stacked on a staging slot."""
1238+
subject = get_labware_view(
1239+
labware_by_id={
1240+
"labware-id": LoadedLabware(
1241+
id="labware-id",
1242+
loadName="test",
1243+
definitionUri="def-uri",
1244+
location=OnLabwareLocation(labwareId="lower-labware-id"),
1245+
),
1246+
"lower-labware-id": LoadedLabware(
1247+
id="lower-labware-id",
1248+
loadName="test",
1249+
definitionUri="def-uri",
1250+
location=AddressableAreaLocation(addressableAreaName="B4"),
1251+
),
1252+
},
1253+
)
1254+
1255+
with pytest.raises(
1256+
errors.LocationNotAccessibleByPipetteError, match="on staging slot"
1257+
):
1258+
subject.raise_if_labware_inaccessible_by_pipette("labware-id")
1259+
1260+
11991261
def test_raise_if_labware_cannot_be_stacked_is_adapter() -> None:
12001262
"""It should raise if the labware trying to be stacked is an adapter."""
12011263
subject = get_labware_view()

0 commit comments

Comments
 (0)