Skip to content

Commit

Permalink
feat(api): add tiprack adapter check for 96 ch tip pickup and return (#…
Browse files Browse the repository at this point in the history
…14173)

* added a new flex adapter quirk to the adapter definition
---------

Co-authored-by: Ed Cormany <[email protected]>
  • Loading branch information
sanni-t and ecormany authored Dec 12, 2023
1 parent 4442886 commit ec7bb74
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 6 deletions.
63 changes: 63 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/deck_conflict.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ def __init__(self, message: str) -> None:
)


class UnsuitableTiprackForPipetteMotion(MotionPlanningFailureError):
"""Error raised when trying to perform a pipette movement to a tip rack, based on adapter status."""

def __init__(self, message: str) -> None:
super().__init__(
message=message,
)


_log = logging.getLogger(__name__)

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


def check_safe_for_tip_pickup_and_return(
engine_state: StateView,
pipette_id: str,
labware_id: str,
) -> None:
"""Check if the presence or absence of a tiprack adapter might cause any pipette movement issues.
A 96 channel pipette will pick up tips using cam action when it's configured
to use ALL nozzles. For this, the tiprack needs to be on the Flex 96 channel tiprack adapter
or similar or the tips will not be picked up.
On the other hand, if the pipette is configured with partial nozzle configuration,
it uses the usual pipette presses to pick the tips up, in which case, having the tiprack
on the Flex 96 channel tiprack adapter (or similar) will cause the pipette to
crash against the adapter posts.
In order to check if the 96-channel can move and pickup/drop tips safely, this method
checks for the height attribute of the tiprack adapter rather than checking for the
specific official adapter since users might create custom labware &/or definitions
compatible with the official adapter.
"""
if not engine_state.pipettes.get_channels(pipette_id) == 96:
# Adapters only matter to 96 ch.
return

is_partial_config = engine_state.pipettes.get_is_partially_configured(pipette_id)
tiprack_name = engine_state.labware.get_display_name(labware_id)
tiprack_parent = engine_state.labware.get_location(labware_id)
if isinstance(tiprack_parent, OnLabwareLocation): # tiprack is on an adapter
is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk(
labware_id=labware_id, quirk="tiprackAdapterFor96Channel"
)
tiprack_height = engine_state.labware.get_dimensions(labware_id).z
adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z
if is_partial_config and tiprack_height < adapter_height:
raise PartialTipMovementNotAllowedError(
f"{tiprack_name} cannot be on an adapter taller than the tip rack"
f" when picking up fewer than 96 tips."
)
elif not is_partial_config and not is_96_ch_tiprack_adapter:
raise UnsuitableTiprackForPipetteMotion(
f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter"
f" in order to pick up or return all 96 tips simultaneously."
)

elif (
not is_partial_config
): # tiprack is not on adapter and pipette is in full config
raise UnsuitableTiprackForPipetteMotion(
f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter"
f" in order to pick up or return all 96 tips simultaneously."
)


def _check_deck_conflict_for_96_channel(
engine_state: StateView,
pipette_id: str,
Expand Down
14 changes: 13 additions & 1 deletion api/src/opentrons/protocol_api/core/engine/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ def pick_up_tip(
well_name=well_name,
absolute_point=location.point,
)
deck_conflict.check_safe_for_tip_pickup_and_return(
engine_state=self._engine_client.state,
pipette_id=self._pipette_id,
labware_id=labware_id,
)
deck_conflict.check_safe_for_pipette_movement(
engine_state=self._engine_client.state,
pipette_id=self._pipette_id,
Expand Down Expand Up @@ -434,12 +439,19 @@ def drop_tip(
)
else:
well_location = DropTipWellLocation()

if self._engine_client.state.labware.is_tiprack(labware_id):
deck_conflict.check_safe_for_tip_pickup_and_return(
engine_state=self._engine_client.state,
pipette_id=self._pipette_id,
labware_id=labware_id,
)
deck_conflict.check_safe_for_pipette_movement(
engine_state=self._engine_client.state,
pipette_id=self._pipette_id,
labware_id=labware_id,
well_name=well_name,
well_location=WellLocation(),
well_location=well_location,
)
self._engine_client.drop_tip(
pipette_id=self._pipette_id,
Expand Down
118 changes: 117 additions & 1 deletion api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Unit tests for the deck_conflict module."""

import pytest
from typing import ContextManager, Any
from typing import ContextManager, Any, NamedTuple, List
from decoy import Decoy
from contextlib import nullcontext as does_not_raise
from opentrons_shared_data.labware.dev_types import LabwareUri
Expand All @@ -22,6 +22,9 @@
WellOrigin,
WellOffset,
TipGeometry,
OnDeckLabwareLocation,
OnLabwareLocation,
Dimensions,
)


Expand Down Expand Up @@ -473,3 +476,116 @@ def test_deck_conflict_raises_for_bad_partial_8_channel_move(
well_name="A2",
well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)),
)


class PipetteMovementSpec(NamedTuple):
"""Spec data to test deck_conflict.check_safe_for_tip_pickup_and_return ."""

tiprack_parent: OnDeckLabwareLocation
tiprack_dim: Dimensions
is_on_flex_adapter: bool
is_partial_config: bool
expected_raise: ContextManager[Any]


pipette_movement_specs: List[PipetteMovementSpec] = [
PipetteMovementSpec(
tiprack_parent=DeckSlotLocation(slotName=DeckSlotName.SLOT_5),
tiprack_dim=Dimensions(x=0, y=0, z=50),
is_on_flex_adapter=False,
is_partial_config=False,
expected_raise=pytest.raises(
deck_conflict.UnsuitableTiprackForPipetteMotion,
match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter",
),
),
PipetteMovementSpec(
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
tiprack_dim=Dimensions(x=0, y=0, z=50),
is_on_flex_adapter=True,
is_partial_config=False,
expected_raise=does_not_raise(),
),
PipetteMovementSpec(
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
tiprack_dim=Dimensions(x=0, y=0, z=50),
is_on_flex_adapter=False,
is_partial_config=False,
expected_raise=pytest.raises(
deck_conflict.UnsuitableTiprackForPipetteMotion,
match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter",
),
),
PipetteMovementSpec(
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
tiprack_dim=Dimensions(x=0, y=0, z=50),
is_on_flex_adapter=True,
is_partial_config=True,
expected_raise=pytest.raises(
deck_conflict.PartialTipMovementNotAllowedError,
match="A cool tiprack cannot be on an adapter taller than the tip rack",
),
),
PipetteMovementSpec(
tiprack_parent=OnLabwareLocation(labwareId="adapter-id"),
tiprack_dim=Dimensions(x=0, y=0, z=101),
is_on_flex_adapter=True,
is_partial_config=True,
expected_raise=does_not_raise(),
),
PipetteMovementSpec(
tiprack_parent=DeckSlotLocation(slotName=DeckSlotName.SLOT_5),
tiprack_dim=Dimensions(x=0, y=0, z=50),
is_on_flex_adapter=True, # will be ignored
is_partial_config=True,
expected_raise=does_not_raise(),
),
]


@pytest.mark.parametrize(
("robot_type", "deck_type"),
[("OT-3 Standard", DeckType.OT3_STANDARD)],
)
@pytest.mark.parametrize(
argnames=PipetteMovementSpec._fields,
argvalues=pipette_movement_specs,
)
def test_valid_96_pipette_movement_for_tiprack_and_adapter(
decoy: Decoy,
mock_state_view: StateView,
tiprack_parent: OnDeckLabwareLocation,
tiprack_dim: Dimensions,
is_on_flex_adapter: bool,
is_partial_config: bool,
expected_raise: ContextManager[Any],
) -> None:
"""It should raise appropriate error for unsuitable tiprack parent when moving 96 channel to it."""
decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96)
decoy.when(mock_state_view.labware.get_dimensions("adapter-id")).then_return(
Dimensions(x=0, y=0, z=100)
)
decoy.when(mock_state_view.labware.get_display_name("labware-id")).then_return(
"A cool tiprack"
)
decoy.when(
mock_state_view.pipettes.get_is_partially_configured("pipette-id")
).then_return(is_partial_config)
decoy.when(mock_state_view.labware.get_location("labware-id")).then_return(
tiprack_parent
)
decoy.when(mock_state_view.labware.get_dimensions("labware-id")).then_return(
tiprack_dim
)
decoy.when(
mock_state_view.labware.get_has_quirk(
labware_id="labware-id", quirk="tiprackAdapterFor96Channel"
)
).then_return(is_on_flex_adapter)

with expected_raise:
deck_conflict.check_safe_for_tip_pickup_and_return(
engine_state=mock_state_view,
pipette_id="pipette-id",
labware_id="labware-id",
)
Loading

0 comments on commit ec7bb74

Please sign in to comment.