Skip to content

Commit

Permalink
feat(api, shared-data): add location-based gripper offsets to final l…
Browse files Browse the repository at this point in the history
…abware movement offsets (#13186)

* added gripper offsets to deck, module and labware definitions schema
* updated definitions and dev types to include gripper offsets
* updated gripper movement final offset calculation to include location based offsets
---------

Co-authored-by: Max Marrone <[email protected]>
  • Loading branch information
sanni-t and SyntaxColoring authored Aug 1, 2023
1 parent fcab19c commit 534f0f9
Show file tree
Hide file tree
Showing 30 changed files with 784 additions and 97 deletions.
1 change: 1 addition & 0 deletions api/release-notes-internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For more details about this release, please see the full [technical change log][
- Support for PVT (v1.1) grippers
- Update progress should get displayed after restart for firmware updates
- Removed `use_pick_up_location_lpc_offset` and `use_drop_location_lpc_offset` from `protocol_context.move_labware` arguments. So they should be removed from any protocols that used them. This change also requires resetting the protocol run database on the robot.
- Added 'contextual' gripper offsets to deck, labware and module definitions. So, any labware movement offsets that were previously being specified in the protocol should now be removed or adjusted or they will get added twice.


## Big Things That Don't Work Yet So Don't Report Bugs About Them
Expand Down
4 changes: 2 additions & 2 deletions api/src/opentrons/motion_planning/waypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ def get_gripper_labware_movement_waypoints(
offset_data: LabwareMovementOffsetData,
) -> List[GripperMovementWaypointsWithJawStatus]:
"""Get waypoints for moving labware using a gripper."""
pick_up_offset = offset_data.pick_up_offset
drop_offset = offset_data.drop_offset
pick_up_offset = offset_data.pickUpOffset
drop_offset = offset_data.dropOffset

pick_up_location = from_labware_center + Point(
pick_up_offset.x, pick_up_offset.y, pick_up_offset.z
Expand Down
5 changes: 2 additions & 3 deletions api/src/opentrons/protocol_engine/commands/move_labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,8 @@ async def execute(self, params: MoveLabwareParams) -> MoveLabwareResult:
available_new_location,
)
user_offset_data = LabwareMovementOffsetData(
pick_up_offset=params.pickUpOffset
or LabwareOffsetVector(x=0, y=0, z=0),
drop_offset=params.dropOffset or LabwareOffsetVector(x=0, y=0, z=0),
pickUpOffset=params.pickUpOffset or LabwareOffsetVector(x=0, y=0, z=0),
dropOffset=params.dropOffset or LabwareOffsetVector(x=0, y=0, z=0),
)
# Skips gripper moves when using virtual gripper
await self._labware_movement.move_labware_with_gripper(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Labware movement command handling."""
from __future__ import annotations

from typing import Optional, Union, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING
from opentrons_shared_data.gripper.constants import (
LABWARE_GRIP_FORCE,
IDLE_STATE_GRIP_FORCE,
Expand All @@ -26,11 +26,10 @@
)

from ..types import (
DeckSlotLocation,
ModuleLocation,
OnLabwareLocation,
LabwareLocation,
LabwareMovementOffsetData,
OnDeckLabwareLocation,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -84,8 +83,8 @@ def __init__(
async def move_labware_with_gripper(
self,
labware_id: str,
current_location: Union[DeckSlotLocation, ModuleLocation, OnLabwareLocation],
new_location: Union[DeckSlotLocation, ModuleLocation, OnLabwareLocation],
current_location: OnDeckLabwareLocation,
new_location: OnDeckLabwareLocation,
user_offset_data: LabwareMovementOffsetData,
) -> None:
"""Move a loaded labware from one location to another using gripper."""
Expand Down
135 changes: 115 additions & 20 deletions api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN

from .. import errors
from ..errors import LabwareMovementNotAllowedError
from ..types import (
OFF_DECK_LOCATION,
LoadedLabware,
Expand All @@ -26,7 +25,7 @@
CurrentWell,
TipGeometry,
LabwareMovementOffsetData,
ModuleModel,
OnDeckLabwareLocation,
)
from .config import Config
from .labware import LabwareView
Expand All @@ -38,7 +37,6 @@


SLOT_WIDTH = 128
_ADDITIONAL_TC2_PICKUP_OFFSET = 3.5


class _TipDropSection(enum.Enum):
Expand All @@ -48,6 +46,13 @@ class _TipDropSection(enum.Enum):
RIGHT = "right"


class _GripperMoveType(enum.Enum):
"""Types of gripper movement."""

PICK_UP_LABWARE = enum.auto()
DROP_LABWARE = enum.auto()


# TODO(mc, 2021-06-03): continue evaluation of which selectors should go here
# vs which selectors should be in LabwareView
class GeometryView:
Expand Down Expand Up @@ -623,27 +628,26 @@ def _get_drop_tip_well_x_offset(

def get_final_labware_movement_offset_vectors(
self,
from_location: LabwareLocation,
to_location: LabwareLocation,
from_location: OnDeckLabwareLocation,
to_location: OnDeckLabwareLocation,
additional_offset_vector: LabwareMovementOffsetData,
) -> LabwareMovementOffsetData:
"""Calculate the final labware offset vector to use in labware movement."""
# TODO (fps, 2022-05-30): Update this once RLAB-295 is merged
# Get location-based offsets from deck/module/adapter definitions,
# then add additional offsets
pick_up_offset = additional_offset_vector.pick_up_offset
drop_offset = additional_offset_vector.drop_offset

if isinstance(from_location, ModuleLocation):
module_id = from_location.moduleId
if (
self._modules.get_connected_model(module_id)
== ModuleModel.THERMOCYCLER_MODULE_V2
):
pick_up_offset.z += _ADDITIONAL_TC2_PICKUP_OFFSET
pick_up_offset = (
self.get_total_nominal_gripper_offset_for_move_type(
location=from_location, move_type=_GripperMoveType.PICK_UP_LABWARE
)
+ additional_offset_vector.pickUpOffset
)
drop_offset = (
self.get_total_nominal_gripper_offset_for_move_type(
location=to_location, move_type=_GripperMoveType.DROP_LABWARE
)
+ additional_offset_vector.dropOffset
)

return LabwareMovementOffsetData(
pick_up_offset=pick_up_offset, drop_offset=drop_offset
pickUpOffset=pick_up_offset, dropOffset=drop_offset
)

@staticmethod
Expand All @@ -654,7 +658,98 @@ def ensure_valid_gripper_location(
if not isinstance(
location, (DeckSlotLocation, ModuleLocation, OnLabwareLocation)
):
raise LabwareMovementNotAllowedError(
raise errors.LabwareMovementNotAllowedError(
"Off-deck labware movements are not supported using the gripper."
)
return location

def get_total_nominal_gripper_offset_for_move_type(
self, location: OnDeckLabwareLocation, move_type: _GripperMoveType
) -> LabwareOffsetVector:
"""Get the total of the offsets to be used to pick up labware in its current location."""
if move_type == _GripperMoveType.PICK_UP_LABWARE:
if isinstance(location, (ModuleLocation, DeckSlotLocation)):
return self._nominal_gripper_offsets_for_location(location).pickUpOffset
else:
# If it's a labware on a labware (most likely an adapter),
# we calculate the offset as sum of offsets for the direct parent labware
# and the underlying non-labware parent location.
direct_parent_offset = self._nominal_gripper_offsets_for_location(
location
)
ancestor = self._labware.get_parent_location(location.labwareId)
assert isinstance(
ancestor, (DeckSlotLocation, ModuleLocation)
), "No gripper offsets for off-deck labware"
return (
direct_parent_offset.pickUpOffset
+ self._nominal_gripper_offsets_for_location(
location=ancestor
).pickUpOffset
)
else:
if isinstance(location, (ModuleLocation, DeckSlotLocation)):
return self._nominal_gripper_offsets_for_location(location).dropOffset
else:
# If it's a labware on a labware (most likely an adapter),
# we calculate the offset as sum of offsets for the direct parent labware
# and the underlying non-labware parent location.
direct_parent_offset = self._nominal_gripper_offsets_for_location(
location
)
ancestor = self._labware.get_parent_location(location.labwareId)
assert isinstance(
ancestor, (DeckSlotLocation, ModuleLocation)
), "No gripper offsets for off-deck labware"
return (
direct_parent_offset.dropOffset
+ self._nominal_gripper_offsets_for_location(
location=ancestor
).dropOffset
)

def _nominal_gripper_offsets_for_location(
self, location: OnDeckLabwareLocation
) -> LabwareMovementOffsetData:
"""Provide the default gripper offset data for the given location type."""
if isinstance(location, DeckSlotLocation):
offsets = self._labware.get_deck_default_gripper_offsets()
elif isinstance(location, ModuleLocation):
offsets = self._modules.get_default_gripper_offsets(location.moduleId)
else:
# Labware is on a labware/adapter
offsets = self._labware_gripper_offsets(location.labwareId)
return offsets or LabwareMovementOffsetData(
pickUpOffset=LabwareOffsetVector(x=0, y=0, z=0),
dropOffset=LabwareOffsetVector(x=0, y=0, z=0),
)

def _labware_gripper_offsets(
self, labware_id: str
) -> Optional[LabwareMovementOffsetData]:
"""Provide the most appropriate gripper offset data for the specified labware.
We check the types of gripper offsets available for the labware ("default" or slot-based)
and return the most appropriate one for the overall location of the labware.
Currently, only module adapters (specifically, the H/S universal flat adapter)
have non-default offsets that are specific to location of the module on deck,
so, this code only checks for the presence of those known offsets.
"""
parent_location = self._labware.get_parent_location(labware_id)
assert isinstance(
parent_location, (DeckSlotLocation, ModuleLocation)
), "No gripper offsets for off-deck labware"

if isinstance(parent_location, DeckSlotLocation):
slot_name = parent_location.slotName
else:
module_loc = self._modules.get_location(parent_location.moduleId)
slot_name = module_loc.slotName

slot_based_offset = self._labware.get_labware_gripper_offsets(
labware_id=labware_id, slot_name=slot_name.to_ot3_equivalent()
)

return slot_based_offset or self._labware.get_labware_gripper_offsets(
labware_id=labware_id, slot_name=None
)
44 changes: 44 additions & 0 deletions api/src/opentrons/protocol_engine/state/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
ModuleLocation,
ModuleModel,
OverlapOffset,
LabwareMovementOffsetData,
)
from ..actions import (
Action,
Expand Down Expand Up @@ -719,3 +720,46 @@ def _is_magnetic_module_uri_in_half_millimeter(self, labware_id: str) -> bool:
"""Check whether the labware uri needs to be calculated in half a millimeter."""
uri = self.get_uri_from_definition(self.get_definition(labware_id))
return uri in _MAGDECK_HALF_MM_LABWARE

def get_deck_default_gripper_offsets(self) -> Optional[LabwareMovementOffsetData]:
"""Get the deck's default gripper offsets."""
parsed_offsets = (
self.get_deck_definition().get("gripperOffsets", {}).get("default")
)
return (
LabwareMovementOffsetData(
pickUpOffset=LabwareOffsetVector(
x=parsed_offsets["pickUpOffset"]["x"],
y=parsed_offsets["pickUpOffset"]["y"],
z=parsed_offsets["pickUpOffset"]["z"],
),
dropOffset=LabwareOffsetVector(
x=parsed_offsets["dropOffset"]["x"],
y=parsed_offsets["dropOffset"]["y"],
z=parsed_offsets["dropOffset"]["z"],
),
)
if parsed_offsets
else None
)

def get_labware_gripper_offsets(
self,
labware_id: str,
slot_name: Optional[DeckSlotName],
) -> Optional[LabwareMovementOffsetData]:
"""Get the labware's gripper offsets of the specified type."""
parsed_offsets = self.get_definition(labware_id).gripperOffsets
offset_key = slot_name.name if slot_name else "default"
return (
LabwareMovementOffsetData(
pickUpOffset=cast(
LabwareOffsetVector, parsed_offsets[offset_key].pickUpOffset
),
dropOffset=cast(
LabwareOffsetVector, parsed_offsets[offset_key].dropOffset
),
)
if parsed_offsets
else None
)
8 changes: 8 additions & 0 deletions api/src/opentrons/protocol_engine/state/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
HeaterShakerMovementRestrictors,
ModuleLocation,
DeckType,
LabwareMovementOffsetData,
)
from .. import errors
from ..commands import (
Expand Down Expand Up @@ -945,3 +946,10 @@ def raise_if_module_in_location(
raise errors.LocationIsOccupiedError(
f"Module {module.model} is already present at {location}."
)

def get_default_gripper_offsets(
self, module_id: str
) -> Optional[LabwareMovementOffsetData]:
"""Get the deck's default gripper offsets."""
offsets = self.get_definition(module_id).gripperOffsets
return offsets.get("default") if offsets else None
20 changes: 13 additions & 7 deletions api/src/opentrons/protocol_engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class OnLabwareLocation(BaseModel):
]
"""Union of all locations where it's legal to keep a labware."""

OnDeckLabwareLocation = Union[DeckSlotLocation, ModuleLocation, OnLabwareLocation]

NonStackedLocation = Union[DeckSlotLocation, ModuleLocation, _OffDeckLocationType]
"""Union of all locations where it's legal to keep a labware that can't be stacked on another labware"""

Expand Down Expand Up @@ -382,6 +384,13 @@ class OverlapOffset(Vec3f):
"""Offset representing overlap space of one labware on top of another labware or module."""


class LabwareMovementOffsetData(BaseModel):
"""Offsets to be used during labware movement."""

pickUpOffset: LabwareOffsetVector
dropOffset: LabwareOffsetVector


# TODO(mm, 2023-04-13): Move to shared-data, so this binding can be maintained alongside the JSON
# schema that it's sourced from. We already do that for labware definitions and JSON protocols.
class ModuleDefinition(BaseModel):
Expand Down Expand Up @@ -440,6 +449,10 @@ class ModuleDefinition(BaseModel):
...,
description="List of module models this model is compatible with.",
)
gripperOffsets: Optional[Dict[str, LabwareMovementOffsetData]] = Field(
default_factory=dict,
description="Offsets to use for labware movement using gripper",
)


class LoadedModule(BaseModel):
Expand Down Expand Up @@ -616,10 +629,3 @@ class LabwareMovementStrategy(str, Enum):
USING_GRIPPER = "usingGripper"
MANUAL_MOVE_WITH_PAUSE = "manualMoveWithPause"
MANUAL_MOVE_WITHOUT_PAUSE = "manualMoveWithoutPause"


class LabwareMovementOffsetData(BaseModel):
"""Offsets to be used during labware movement."""

pick_up_offset: LabwareOffsetVector
drop_offset: LabwareOffsetVector
4 changes: 2 additions & 2 deletions api/tests/opentrons/motion_planning/test_waypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,8 @@ def test_get_gripper_labware_movement_waypoints() -> None:
to_labware_center=Point(201, 202, 219.5),
gripper_home_z=999,
offset_data=LabwareMovementOffsetData(
pick_up_offset=LabwareOffsetVector(x=-1, y=-2, z=-3),
drop_offset=LabwareOffsetVector(x=1, y=2, z=3),
pickUpOffset=LabwareOffsetVector(x=-1, y=-2, z=-3),
dropOffset=LabwareOffsetVector(x=1, y=2, z=3),
),
)
assert result == [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def test_get_definition(subject: LabwareCore) -> None:
"allowedRoles": [],
"stackingOffsetWithLabware": {},
"stackingOffsetWithModule": {},
"gripperOffsets": {},
},
)
assert subject.get_parameters() == cast(LabwareParamsDict, {"loadName": "world"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ async def test_gripper_move_labware_implementation(
current_location=validated_from_location,
new_location=validated_new_location,
user_offset_data=LabwareMovementOffsetData(
pick_up_offset=LabwareOffsetVector(x=1, y=2, z=3),
drop_offset=LabwareOffsetVector(x=0, y=0, z=0),
pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3),
dropOffset=LabwareOffsetVector(x=0, y=0, z=0),
),
),
)
Expand Down
Loading

0 comments on commit 534f0f9

Please sign in to comment.