Skip to content

Commit

Permalink
fix(api): Center instrument over moveable trash regardless of configu…
Browse files Browse the repository at this point in the history
…ration (#14413)

Introduce tip configuration ignore flag to move_to_addressable_area_to_drop_tip to ensure instrument center used
  • Loading branch information
CaseyBatten authored Feb 6, 2024
1 parent 3a00e51 commit b022b4b
Show file tree
Hide file tree
Showing 13 changed files with 151 additions and 6 deletions.
47 changes: 45 additions & 2 deletions api/src/opentrons/hardware_control/nozzle_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ class NozzleMap:
configuration: NozzleConfigurationType
#: The kind of configuration this is

full_instrument_map_store: Dict[str, Point]
#: A map of all of the nozzles of an instrument
full_instrument_rows: Dict[str, List[str]]
#: A map of all the rows of an instrument

def __str__(self) -> str:
return f"back_left_nozzle: {self.back_left} front_right_nozzle: {self.front_right} configuration: {self.configuration}"

Expand All @@ -124,6 +129,23 @@ def front_right(self) -> str:
"""
return next(reversed(list(self.rows.values())))[-1]

@property
def full_instrument_back_left(self) -> str:
"""The backest, leftest (i.e. back if it's a column, left if it's a row) nozzle of the full instrument.
Note: This value represents the back left nozzle of the underlying physical pipette. For instance,
the back-left nozzle of a 96-Channel pipette is A1.
"""
return next(iter(self.full_instrument_rows.values()))[0]

@property
def full_instrument_front_right(self) -> str:
"""The frontest, rightest (i.e. front if it's a column, right if it's a row) nozzle of the full instrument.
Note: This value represents the front right nozzle of the physical pipette. See the note on full_instrument_back_left.
"""
return next(reversed(list(self.full_instrument_rows.values())))[-1]

@property
def starting_nozzle_offset(self) -> Point:
"""The position of the starting nozzle."""
Expand All @@ -133,13 +155,28 @@ def starting_nozzle_offset(self) -> Point:
def xy_center_offset(self) -> Point:
"""The position of the geometrical center of all nozzles in the configuration.
Note: This is the value relevant fro this configuration, not the physical pipette. See the note on back_left.
Note: This is the value relevant for this configuration, not the physical pipette. See the note on back_left.
"""
difference = self.map_store[self.front_right] - self.map_store[self.back_left]
return self.map_store[self.back_left] + Point(
difference[0] / 2, difference[1] / 2, 0
)

@property
def instrument_xy_center_offset(self) -> Point:
"""The position of the geometrical center of all nozzles for the entire instrument.
Note: This the value reflects the center of the maximum number of nozzles of the physical pipette.
This would be the same as a full configuration.
"""
difference = (
self.full_instrument_map_store[self.full_instrument_front_right]
- self.full_instrument_map_store[self.full_instrument_back_left]
)
return self.full_instrument_map_store[self.full_instrument_back_left] + Point(
difference[0] / 2, difference[1] / 2, 0
)

@property
def y_center_offset(self) -> Point:
"""The position in the center of the primary column of the map."""
Expand Down Expand Up @@ -220,6 +257,8 @@ def build(
starting_nozzle=starting_nozzle,
map_store=map_store,
rows=rows,
full_instrument_map_store=physical_nozzles,
full_instrument_rows=physical_rows,
columns=columns,
configuration=NozzleConfigurationType.determine_nozzle_configuration(
physical_rows, rows, physical_columns, columns
Expand Down Expand Up @@ -324,7 +363,11 @@ def critical_point_with_tip_length(
cp_override: Optional[CriticalPoint],
tip_length: float = 0.0,
) -> Point:
if cp_override == CriticalPoint.XY_CENTER:
if cp_override == CriticalPoint.INSTRUMENT_XY_CENTER:
current_nozzle = (
self._current_nozzle_configuration.instrument_xy_center_offset
)
elif cp_override == CriticalPoint.XY_CENTER:
current_nozzle = self._current_nozzle_configuration.xy_center_offset
elif cp_override == CriticalPoint.Y_CENTER:
current_nozzle = self._current_nozzle_configuration.y_center_offset
Expand Down
7 changes: 7 additions & 0 deletions api/src/opentrons/hardware_control/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,13 @@ class CriticalPoint(enum.Enum):
point. This is the same as the GRIPPER_JAW_CENTER for grippers.
"""

INSTRUMENT_XY_CENTER = enum.auto()
"""
The INSTRUMENT_XY_CENTER means the critical point under consideration is
the XY center of the entire pipette, regardless of configuration.
No pipettes, single or multi, will change their instrument center point.
"""

FRONT_NOZZLE = enum.auto()
"""
The end of the front-most nozzle of a multipipette with a tip attached.
Expand Down
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_api/core/engine/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ def _move_to_disposal_location(
speed=speed,
minimum_z_height=None,
alternate_drop_location=alternate_tip_drop,
ignore_tip_configuration=True,
)

if isinstance(disposal_location, WasteChute):
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/clients/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def move_to_addressable_area_for_drop_tip(
force_direct: bool,
speed: Optional[float],
alternate_drop_location: Optional[bool],
ignore_tip_configuration: Optional[bool] = True,
) -> commands.MoveToAddressableAreaForDropTipResult:
"""Execute a MoveToAddressableArea command and return the result."""
request = commands.MoveToAddressableAreaForDropTipCreate(
Expand All @@ -231,6 +232,7 @@ def move_to_addressable_area_for_drop_tip(
minimumZHeight=minimum_z_height,
speed=speed,
alternateDropLocation=alternate_drop_location,
ignoreTipConfiguration=ignore_tip_configuration,
)
)
result = self._transport.execute_command(request=request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ class MoveToAddressableAreaForDropTipParams(PipetteIdMixin, MovementMixin):
" If False, the tip will be dropped at the top center of the area."
),
)
ignoreTipConfiguration: Optional[bool] = Field(
True,
description=(
"Whether to utilize the critical point of the tip configuraiton when moving to an addressable area."
" If True, this command will ignore the tip configuration and use the center of the entire instrument"
" as the critical point for movement."
" If False, this command will use the critical point provided by the current tip configuration."
),
)


class MoveToAddressableAreaForDropTipResult(DestinationPositionResult):
Expand Down Expand Up @@ -113,6 +122,7 @@ async def execute(
force_direct=params.forceDirect,
minimum_z_height=params.minimumZHeight,
speed=params.speed,
ignore_tip_configuration=params.ignoreTipConfiguration,
)

return MoveToAddressableAreaForDropTipResult(position=DeckPoint(x=x, y=y, z=z))
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/execution/movement.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ async def move_to_addressable_area(
minimum_z_height: Optional[float] = None,
speed: Optional[float] = None,
stay_at_highest_possible_z: bool = False,
ignore_tip_configuration: Optional[bool] = True,
) -> Point:
"""Move to a specific addressable area."""
# Check for presence of heater shakers on deck, and if planned
Expand Down Expand Up @@ -193,6 +194,7 @@ async def move_to_addressable_area(
force_direct=force_direct,
minimum_z_height=minimum_z_height,
stay_at_max_travel_z=stay_at_highest_possible_z,
ignore_tip_configuration=ignore_tip_configuration,
)

speed = self._state_store.pipettes.get_movement_speed(
Expand Down
6 changes: 5 additions & 1 deletion api/src/opentrons/protocol_engine/state/motion.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def get_movement_waypoints_to_addressable_area(
force_direct: bool = False,
minimum_z_height: Optional[float] = None,
stay_at_max_travel_z: bool = False,
ignore_tip_configuration: Optional[bool] = True,
) -> List[motion_planning.Waypoint]:
"""Calculate waypoints to a destination that's specified as an addressable area."""
location = self._pipettes.get_current_location()
Expand Down Expand Up @@ -177,7 +178,10 @@ def get_movement_waypoints_to_addressable_area(
destination = base_destination + Point(offset.x, offset.y, offset.z)

# TODO(jbl 11-28-2023) This may need to change for partial tip configurations on a 96
destination_cp = CriticalPoint.XY_CENTER
if ignore_tip_configuration:
destination_cp = CriticalPoint.INSTRUMENT_XY_CENTER
else:
destination_cp = CriticalPoint.XY_CENTER

all_labware_highest_z = self._geometry.get_all_obstacle_highest_z()
if minimum_z_height is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
from opentrons.protocol_api._waste_chute import WasteChute
from opentrons.protocol_api.labware import Labware
from opentrons.protocol_api.core.engine import deck_conflict
from opentrons.protocol_engine import Config, DeckSlotLocation, ModuleModel, StateView
from opentrons.protocol_engine import (
Config,
DeckSlotLocation,
ModuleModel,
StateView,
)
from opentrons.protocol_engine.errors import LabwareNotLoadedOnModuleError
from opentrons.types import DeckSlotName, Point

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ async def test_move_to_addressable_area_for_drop_tip_implementation(
minimumZHeight=4.56,
speed=7.89,
alternateDropLocation=True,
ignoreTipConfiguration=False,
)

decoy.when(
Expand All @@ -47,6 +48,7 @@ async def test_move_to_addressable_area_for_drop_tip_implementation(
force_direct=True,
minimum_z_height=4.56,
speed=7.89,
ignore_tip_configuration=False,
)
).then_return(Point(x=9, y=8, z=7))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ async def test_move_to_addressable_area(
force_direct=True,
minimum_z_height=12.3,
stay_at_max_travel_z=True,
ignore_tip_configuration=False,
)
).then_return(
[Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER), Waypoint(Point(4, 5, 6))]
Expand All @@ -378,6 +379,7 @@ async def test_move_to_addressable_area(
minimum_z_height=12.3,
speed=45.6,
stay_at_highest_possible_z=True,
ignore_tip_configuration=False,
)

assert result == Point(x=4, y=5, z=6)
Expand Down
61 changes: 61 additions & 0 deletions api/tests/opentrons/protocol_engine/state/test_motion_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,66 @@ def test_get_movement_waypoints_to_addressable_area(
max_travel_z=1337,
force_direct=True,
minimum_z_height=123,
ignore_tip_configuration=False,
)

assert result == waypoints


def test_move_to_moveable_trash_addressable_area(
decoy: Decoy,
pipette_view: PipetteView,
addressable_area_view: AddressableAreaView,
geometry_view: GeometryView,
subject: MotionView,
) -> None:
"""Ensure that a move request to a moveableTrash addressable utilizes the Instrument Center critical point."""
location = CurrentAddressableArea(
pipette_id="123", addressable_area_name="moveableTrashA1"
)

decoy.when(pipette_view.get_current_location()).then_return(location)
decoy.when(
addressable_area_view.get_addressable_area_move_to_location("moveableTrashA1")
).then_return(Point(x=3, y=3, z=3))
decoy.when(geometry_view.get_all_obstacle_highest_z()).then_return(42)

decoy.when(
addressable_area_view.get_addressable_area_base_slot("moveableTrashA1")
).then_return(DeckSlotName.SLOT_1)

decoy.when(
geometry_view.get_extra_waypoints(location, DeckSlotName.SLOT_1)
).then_return([])

waypoints = [
motion_planning.Waypoint(
position=Point(1, 2, 3), critical_point=CriticalPoint.INSTRUMENT_XY_CENTER
)
]

decoy.when(
motion_planning.get_waypoints(
move_type=motion_planning.MoveType.DIRECT,
origin=Point(x=1, y=2, z=3),
origin_cp=CriticalPoint.MOUNT,
max_travel_z=1337,
min_travel_z=123,
dest=Point(x=4, y=5, z=6),
dest_cp=CriticalPoint.INSTRUMENT_XY_CENTER,
xy_waypoints=[],
)
).then_return(waypoints)

result = subject.get_movement_waypoints_to_addressable_area(
addressable_area_name="moveableTrashA1",
offset=AddressableOffsetVector(x=1, y=2, z=3),
origin=Point(x=1, y=2, z=3),
origin_cp=CriticalPoint.MOUNT,
max_travel_z=1337,
force_direct=True,
minimum_z_height=123,
ignore_tip_configuration=True,
)

assert result == waypoints
Expand Down Expand Up @@ -624,6 +684,7 @@ def test_get_movement_waypoints_to_addressable_area_stay_at_max_travel_z(
force_direct=True,
minimum_z_height=123,
stay_at_max_travel_z=True,
ignore_tip_configuration=False,
)

assert result == waypoints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ index 2d36460ca6..8578768930 100644
def ok_to_push_out(self, push_out_dist_mm: float) -> bool:
return push_out_dist_mm <= (
diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py
index 7ef2cfcbea..a89548afea 100644
index 1a756f751f..a739ec553c 100644
--- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py
+++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py
@@ -223,18 +223,12 @@ def check_safe_for_tip_pickup_and_return(
@@ -267,18 +267,12 @@ def check_safe_for_tip_pickup_and_return(
f" when picking up fewer than 96 tips."
)
elif not is_partial_config and not is_96_ch_tiprack_adapter:
Expand Down
6 changes: 6 additions & 0 deletions shared-data/command/schemas/8.json
Original file line number Diff line number Diff line change
Expand Up @@ -2120,6 +2120,12 @@
"description": "Whether to alternate location where tip is dropped within the addressable area. If True, this command will ignore the offset provided and alternate between dropping tips at two predetermined locations inside the specified labware well. If False, the tip will be dropped at the top center of the area.",
"default": false,
"type": "boolean"
},
"ignoreTipConfiguration": {
"title": "Ignoretipconfiguration",
"description": "Whether to utilize the critical point of the tip configuraiton when moving to an addressable area. If True, this command will ignore the tip configuration and use the center of the entire instrument as the critical point for movement. If False, this command will use the critical point provided by the current tip configuration.",
"default": true,
"type": "boolean"
}
},
"required": ["pipetteId", "addressableAreaName"]
Expand Down

0 comments on commit b022b4b

Please sign in to comment.