Skip to content

Commit ec7bb74

Browse files
sanni-tecormany
andauthored
feat(api): add tiprack adapter check for 96 ch tip pickup and return (#14173)
* added a new flex adapter quirk to the adapter definition --------- Co-authored-by: Ed Cormany <[email protected]>
1 parent 4442886 commit ec7bb74

File tree

6 files changed

+287
-6
lines changed

6 files changed

+287
-6
lines changed

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ def __init__(self, message: str) -> None:
3636
)
3737

3838

39+
class UnsuitableTiprackForPipetteMotion(MotionPlanningFailureError):
40+
"""Error raised when trying to perform a pipette movement to a tip rack, based on adapter status."""
41+
42+
def __init__(self, message: str) -> None:
43+
super().__init__(
44+
message=message,
45+
)
46+
47+
3948
_log = logging.getLogger(__name__)
4049

4150
# TODO (spp, 2023-12-06): move this to a location like motion planning where we can
@@ -174,6 +183,60 @@ def check_safe_for_pipette_movement(
174183
)
175184

176185

186+
def check_safe_for_tip_pickup_and_return(
187+
engine_state: StateView,
188+
pipette_id: str,
189+
labware_id: str,
190+
) -> None:
191+
"""Check if the presence or absence of a tiprack adapter might cause any pipette movement issues.
192+
193+
A 96 channel pipette will pick up tips using cam action when it's configured
194+
to use ALL nozzles. For this, the tiprack needs to be on the Flex 96 channel tiprack adapter
195+
or similar or the tips will not be picked up.
196+
197+
On the other hand, if the pipette is configured with partial nozzle configuration,
198+
it uses the usual pipette presses to pick the tips up, in which case, having the tiprack
199+
on the Flex 96 channel tiprack adapter (or similar) will cause the pipette to
200+
crash against the adapter posts.
201+
202+
In order to check if the 96-channel can move and pickup/drop tips safely, this method
203+
checks for the height attribute of the tiprack adapter rather than checking for the
204+
specific official adapter since users might create custom labware &/or definitions
205+
compatible with the official adapter.
206+
"""
207+
if not engine_state.pipettes.get_channels(pipette_id) == 96:
208+
# Adapters only matter to 96 ch.
209+
return
210+
211+
is_partial_config = engine_state.pipettes.get_is_partially_configured(pipette_id)
212+
tiprack_name = engine_state.labware.get_display_name(labware_id)
213+
tiprack_parent = engine_state.labware.get_location(labware_id)
214+
if isinstance(tiprack_parent, OnLabwareLocation): # tiprack is on an adapter
215+
is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk(
216+
labware_id=labware_id, quirk="tiprackAdapterFor96Channel"
217+
)
218+
tiprack_height = engine_state.labware.get_dimensions(labware_id).z
219+
adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z
220+
if is_partial_config and tiprack_height < adapter_height:
221+
raise PartialTipMovementNotAllowedError(
222+
f"{tiprack_name} cannot be on an adapter taller than the tip rack"
223+
f" when picking up fewer than 96 tips."
224+
)
225+
elif not is_partial_config and not is_96_ch_tiprack_adapter:
226+
raise UnsuitableTiprackForPipetteMotion(
227+
f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter"
228+
f" in order to pick up or return all 96 tips simultaneously."
229+
)
230+
231+
elif (
232+
not is_partial_config
233+
): # tiprack is not on adapter and pipette is in full config
234+
raise UnsuitableTiprackForPipetteMotion(
235+
f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter"
236+
f" in order to pick up or return all 96 tips simultaneously."
237+
)
238+
239+
177240
def _check_deck_conflict_for_96_channel(
178241
engine_state: StateView,
179242
pipette_id: str,

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,11 @@ def pick_up_tip(
383383
well_name=well_name,
384384
absolute_point=location.point,
385385
)
386+
deck_conflict.check_safe_for_tip_pickup_and_return(
387+
engine_state=self._engine_client.state,
388+
pipette_id=self._pipette_id,
389+
labware_id=labware_id,
390+
)
386391
deck_conflict.check_safe_for_pipette_movement(
387392
engine_state=self._engine_client.state,
388393
pipette_id=self._pipette_id,
@@ -434,12 +439,19 @@ def drop_tip(
434439
)
435440
else:
436441
well_location = DropTipWellLocation()
442+
443+
if self._engine_client.state.labware.is_tiprack(labware_id):
444+
deck_conflict.check_safe_for_tip_pickup_and_return(
445+
engine_state=self._engine_client.state,
446+
pipette_id=self._pipette_id,
447+
labware_id=labware_id,
448+
)
437449
deck_conflict.check_safe_for_pipette_movement(
438450
engine_state=self._engine_client.state,
439451
pipette_id=self._pipette_id,
440452
labware_id=labware_id,
441453
well_name=well_name,
442-
well_location=WellLocation(),
454+
well_location=well_location,
443455
)
444456
self._engine_client.drop_tip(
445457
pipette_id=self._pipette_id,

api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Unit tests for the deck_conflict module."""
22

33
import pytest
4-
from typing import ContextManager, Any
4+
from typing import ContextManager, Any, NamedTuple, List
55
from decoy import Decoy
66
from contextlib import nullcontext as does_not_raise
77
from opentrons_shared_data.labware.dev_types import LabwareUri
@@ -22,6 +22,9 @@
2222
WellOrigin,
2323
WellOffset,
2424
TipGeometry,
25+
OnDeckLabwareLocation,
26+
OnLabwareLocation,
27+
Dimensions,
2528
)
2629

2730

@@ -473,3 +476,116 @@ def test_deck_conflict_raises_for_bad_partial_8_channel_move(
473476
well_name="A2",
474477
well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)),
475478
)
479+
480+
481+
class PipetteMovementSpec(NamedTuple):
482+
"""Spec data to test deck_conflict.check_safe_for_tip_pickup_and_return ."""
483+
484+
tiprack_parent: OnDeckLabwareLocation
485+
tiprack_dim: Dimensions
486+
is_on_flex_adapter: bool
487+
is_partial_config: bool
488+
expected_raise: ContextManager[Any]
489+
490+
491+
pipette_movement_specs: List[PipetteMovementSpec] = [
492+
PipetteMovementSpec(
493+
tiprack_parent=DeckSlotLocation(slotName=DeckSlotName.SLOT_5),
494+
tiprack_dim=Dimensions(x=0, y=0, z=50),
495+
is_on_flex_adapter=False,
496+
is_partial_config=False,
497+
expected_raise=pytest.raises(
498+
deck_conflict.UnsuitableTiprackForPipetteMotion,
499+
match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter",
500+
),
501+
),
502+
PipetteMovementSpec(
503+
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
504+
tiprack_dim=Dimensions(x=0, y=0, z=50),
505+
is_on_flex_adapter=True,
506+
is_partial_config=False,
507+
expected_raise=does_not_raise(),
508+
),
509+
PipetteMovementSpec(
510+
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
511+
tiprack_dim=Dimensions(x=0, y=0, z=50),
512+
is_on_flex_adapter=False,
513+
is_partial_config=False,
514+
expected_raise=pytest.raises(
515+
deck_conflict.UnsuitableTiprackForPipetteMotion,
516+
match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter",
517+
),
518+
),
519+
PipetteMovementSpec(
520+
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
521+
tiprack_dim=Dimensions(x=0, y=0, z=50),
522+
is_on_flex_adapter=True,
523+
is_partial_config=True,
524+
expected_raise=pytest.raises(
525+
deck_conflict.PartialTipMovementNotAllowedError,
526+
match="A cool tiprack cannot be on an adapter taller than the tip rack",
527+
),
528+
),
529+
PipetteMovementSpec(
530+
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
531+
tiprack_dim=Dimensions(x=0, y=0, z=101),
532+
is_on_flex_adapter=True,
533+
is_partial_config=True,
534+
expected_raise=does_not_raise(),
535+
),
536+
PipetteMovementSpec(
537+
tiprack_parent=DeckSlotLocation(slotName=DeckSlotName.SLOT_5),
538+
tiprack_dim=Dimensions(x=0, y=0, z=50),
539+
is_on_flex_adapter=True, # will be ignored
540+
is_partial_config=True,
541+
expected_raise=does_not_raise(),
542+
),
543+
]
544+
545+
546+
@pytest.mark.parametrize(
547+
("robot_type", "deck_type"),
548+
[("OT-3 Standard", DeckType.OT3_STANDARD)],
549+
)
550+
@pytest.mark.parametrize(
551+
argnames=PipetteMovementSpec._fields,
552+
argvalues=pipette_movement_specs,
553+
)
554+
def test_valid_96_pipette_movement_for_tiprack_and_adapter(
555+
decoy: Decoy,
556+
mock_state_view: StateView,
557+
tiprack_parent: OnDeckLabwareLocation,
558+
tiprack_dim: Dimensions,
559+
is_on_flex_adapter: bool,
560+
is_partial_config: bool,
561+
expected_raise: ContextManager[Any],
562+
) -> None:
563+
"""It should raise appropriate error for unsuitable tiprack parent when moving 96 channel to it."""
564+
decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96)
565+
decoy.when(mock_state_view.labware.get_dimensions("adapter-id")).then_return(
566+
Dimensions(x=0, y=0, z=100)
567+
)
568+
decoy.when(mock_state_view.labware.get_display_name("labware-id")).then_return(
569+
"A cool tiprack"
570+
)
571+
decoy.when(
572+
mock_state_view.pipettes.get_is_partially_configured("pipette-id")
573+
).then_return(is_partial_config)
574+
decoy.when(mock_state_view.labware.get_location("labware-id")).then_return(
575+
tiprack_parent
576+
)
577+
decoy.when(mock_state_view.labware.get_dimensions("labware-id")).then_return(
578+
tiprack_dim
579+
)
580+
decoy.when(
581+
mock_state_view.labware.get_has_quirk(
582+
labware_id="labware-id", quirk="tiprackAdapterFor96Channel"
583+
)
584+
).then_return(is_on_flex_adapter)
585+
586+
with expected_raise:
587+
deck_conflict.check_safe_for_tip_pickup_and_return(
588+
engine_state=mock_state_view,
589+
pipette_id="pipette-id",
590+
labware_id="labware-id",
591+
)

0 commit comments

Comments
 (0)