Skip to content

Commit

Permalink
feat(api): Update robot deck extents to check the full pipette bounds (
Browse files Browse the repository at this point in the history
…#15532)

# Overview

Previously, we were only checking for a very specific set of bounds for
the robot. Now, we should be able to check the exact "absolute" value
that a pipette type can move to and throw and error if we exceed those
bounds.

# Test Plan

- Put a bunch of protocols through an app and see whether the software
correctly determines if something is out of bounds

# Changelog

- Added two new components to the deck definition for 'extents' and
'mount offsets'
- Added accessors for that in the protocol engine
- Modified `_is_within_pipette_extents` function to check the absolute
bound per pipette type per mount without any offsets

# Review requests

Pls check my math, I'm almost sure I did one or two things wrong

# Risk assessment

Medium. If we don't get this right it could throw erroneous deck bound
errors.
  • Loading branch information
Laura-Danielle authored Jul 18, 2024
1 parent dd53508 commit 5b09f72
Show file tree
Hide file tree
Showing 22 changed files with 377 additions and 77 deletions.
81 changes: 28 additions & 53 deletions api/src/opentrons/protocol_api/core/engine/deck_conflict.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError
from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE

from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType
from opentrons.hardware_control.modules.types import ModuleType
from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict
from opentrons.motion_planning import adjacent_slots_getters
Expand Down Expand Up @@ -63,21 +62,6 @@ def __init__(self, message: str) -> None:

_log = logging.getLogger(__name__)

# TODO (spp, 2023-12-06): move this to a location like motion planning where we can
# derive these values from geometry definitions
# Also, verify y-axis extents values for the nozzle columns.
# Bounding box measurements
A12_column_front_left_bound = Point(x=-11.03, y=2)
A12_column_back_right_bound = Point(x=526.77, y=506.2)

_NOZZLE_PITCH = 9
A1_column_front_left_bound = Point(
x=A12_column_front_left_bound.x - _NOZZLE_PITCH * 11, y=2
)
A1_column_back_right_bound = Point(
x=A12_column_back_right_bound.x - _NOZZLE_PITCH * 11, y=506.2
)

_FLEX_TC_LID_BACK_LEFT_PT = Point(
x=FLEX_TC_LID_COLLISION_ZONE["back_left"]["x"],
y=FLEX_TC_LID_COLLISION_ZONE["back_left"]["y"],
Expand Down Expand Up @@ -244,20 +228,23 @@ def check_safe_for_pipette_movement(
)
primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id)

pipette_bounds_at_well_location = (
engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position(
pipette_id=pipette_id, destination_position=well_location_point
)
)
if not _is_within_pipette_extents(
engine_state=engine_state, pipette_id=pipette_id, location=well_location_point
engine_state=engine_state,
pipette_id=pipette_id,
pipette_bounding_box_at_loc=pipette_bounds_at_well_location,
):
raise PartialTipMovementNotAllowedError(
f"Requested motion with the {primary_nozzle} nozzle partial configuration"
f" is outside of robot bounds for the pipette."
)

labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id)
pipette_bounds_at_well_location = (
engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position(
pipette_id=pipette_id, destination_position=well_location_point
)
)

surrounding_slots = adjacent_slots_getters.get_surrounding_slots(
slot=labware_slot.as_int(), robot_type=engine_state.config.robot_type
)
Expand Down Expand Up @@ -423,42 +410,30 @@ def check_safe_for_tip_pickup_and_return(
)


# TODO (spp, 2023-02-06): update the extents check to use all nozzle bounds instead of
# just position of primary nozzle when checking if the pipette is out-of-bounds
def _is_within_pipette_extents(
engine_state: StateView,
pipette_id: str,
location: Point,
pipette_bounding_box_at_loc: Tuple[Point, Point, Point, Point],
) -> bool:
"""Whether a given point is within the extents of a configured pipette on the specified robot."""
robot_type = engine_state.config.robot_type
pipette_channels = engine_state.pipettes.get_channels(pipette_id)
nozzle_config = engine_state.pipettes.get_nozzle_layout_type(pipette_id)
primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id)
if robot_type == "OT-3 Standard":
if pipette_channels == 96 and nozzle_config == NozzleConfigurationType.COLUMN:
# TODO (spp, 2023-12-18): change this eventually to use column mappings in
# the pipette geometry definitions.
if primary_nozzle == "A12":
return (
A12_column_front_left_bound.x
<= location.x
<= A12_column_back_right_bound.x
and A12_column_front_left_bound.y
<= location.y
<= A12_column_back_right_bound.y
)
elif primary_nozzle == "A1":
return (
A1_column_front_left_bound.x
<= location.x
<= A1_column_back_right_bound.x
and A1_column_front_left_bound.y
<= location.y
<= A1_column_back_right_bound.y
)
# TODO (spp, 2023-11-07): check for 8-channel nozzle A1 & H1 extents on Flex & OT2
return True
mount = engine_state.pipettes.get_mount(pipette_id)
robot_extent_per_mount = engine_state.geometry.absolute_deck_extents
pip_back_left_bound, pip_front_right_bound, _, _ = pipette_bounding_box_at_loc
pipette_bounds_offsets = engine_state.pipettes.get_pipette_bounding_box(pipette_id)
from_back_right = (
robot_extent_per_mount.back_right[mount]
+ pipette_bounds_offsets.back_right_corner
)
from_front_left = (
robot_extent_per_mount.front_left[mount]
+ pipette_bounds_offsets.front_left_corner
)
return (
from_back_right.x >= pip_back_left_bound.x >= from_front_left.x
and from_back_right.y >= pip_back_left_bound.y >= from_front_left.y
and from_back_right.x >= pip_front_right_bound.x >= from_front_left.x
and from_back_right.y >= pip_front_right_bound.y >= from_front_left.y
)


def _map_labware(
Expand Down
4 changes: 3 additions & 1 deletion api/src/opentrons/protocol_engine/create_protocol_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from opentrons.hardware_control.types import DoorState
from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy
from opentrons.util.async_helpers import async_context_manager_in_thread
from opentrons_shared_data.robot import load as load_robot

from .protocol_engine import ProtocolEngine
from .resources import DeckDataProvider, ModuleDataProvider
Expand Down Expand Up @@ -45,11 +46,12 @@ async def create_protocol_engine(
else []
)
module_calibration_offsets = ModuleDataProvider.load_module_calibrations()

robot_definition = load_robot(config.robot_type)
state_store = StateStore(
config=config,
deck_definition=deck_definition,
deck_fixed_labware=deck_fixed_labware,
robot_definition=robot_definition,
is_door_open=hardware_api.door_state is DoorState.OPEN,
module_calibration_offsets=module_calibration_offsets,
deck_configuration=deck_configuration,
Expand Down
24 changes: 23 additions & 1 deletion api/src/opentrons/protocol_engine/state/addressable_areas.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Basic addressable area data state and store."""
from dataclasses import dataclass
from functools import cached_property
from typing import Dict, List, Optional, Set, Union

from opentrons_shared_data.robot.dev_types import RobotType
from opentrons_shared_data.robot.dev_types import RobotType, RobotDefinition
from opentrons_shared_data.deck.dev_types import (
DeckDefinitionV5,
SlotDefV3,
Expand Down Expand Up @@ -77,6 +78,9 @@ class AddressableAreaState:
use_simulated_deck_config: bool
"""See `Config.use_simulated_deck_config`."""

"""Information about the current robot model."""
robot_definition: RobotDefinition


_OT2_ORDERED_SLOTS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]
_FLEX_ORDERED_SLOTS = [
Expand Down Expand Up @@ -164,6 +168,7 @@ def __init__(
deck_configuration: DeckConfigurationType,
config: Config,
deck_definition: DeckDefinitionV5,
robot_definition: RobotDefinition,
) -> None:
"""Initialize an addressable area store and its state."""
if config.use_simulated_deck_config:
Expand All @@ -183,6 +188,7 @@ def __init__(
deck_definition=deck_definition,
robot_type=config.robot_type,
use_simulated_deck_config=config.use_simulated_deck_config,
robot_definition=robot_definition,
)

def handle_action(self, action: Action) -> None:
Expand Down Expand Up @@ -330,6 +336,22 @@ def __init__(self, state: AddressableAreaState) -> None:
"""
self._state = state

@cached_property
def deck_extents(self) -> Point:
"""The maximum space on the deck."""
extents = self._state.robot_definition["extents"]
return Point(x=extents[0], y=extents[1], z=extents[2])

@cached_property
def mount_offsets(self) -> Dict[str, Point]:
"""The left and right mount offsets of the robot."""
left_offset = self.state.robot_definition["mountOffsets"]["left"]
right_offset = self.state.robot_definition["mountOffsets"]["right"]
return {
"left": Point(x=left_offset[0], y=left_offset[1], z=left_offset[2]),
"right": Point(x=right_offset[0], y=right_offset[1], z=right_offset[2]),
}

def get_addressable_area(self, addressable_area_name: str) -> AddressableArea:
"""Get addressable area."""
if not self._state.use_simulated_deck_config:
Expand Down
26 changes: 26 additions & 0 deletions api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from numpy import array, dot, double as npdouble
from numpy.typing import NDArray
from typing import Optional, List, Tuple, Union, cast, TypeVar, Dict
from dataclasses import dataclass
from functools import cached_property

from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType

Expand Down Expand Up @@ -71,6 +73,12 @@ class _GripperMoveType(enum.Enum):
DROP_LABWARE = enum.auto()


@dataclass
class _AbsoluteRobotExtents:
front_left: Dict[MountType, Point]
back_right: Dict[MountType, Point]


_LabwareLocation = TypeVar("_LabwareLocation", bound=LabwareLocation)


Expand All @@ -95,6 +103,24 @@ def __init__(
self._addressable_areas = addressable_area_view
self._last_drop_tip_location_spot: Dict[str, _TipDropSection] = {}

@cached_property
def absolute_deck_extents(self) -> _AbsoluteRobotExtents:
"""The absolute deck extents for a given robot deck."""
left_offset = self._addressable_areas.mount_offsets["left"]
right_offset = self._addressable_areas.mount_offsets["right"]

front_left_abs = {
MountType.LEFT: Point(left_offset.x, -1 * left_offset.y, left_offset.z),
MountType.RIGHT: Point(right_offset.x, -1 * right_offset.y, right_offset.z),
}
back_right_abs = {
MountType.LEFT: self._addressable_areas.deck_extents + left_offset,
MountType.RIGHT: self._addressable_areas.deck_extents + right_offset,
}
return _AbsoluteRobotExtents(
front_left=front_left_abs, back_right=back_right_abs
)

def get_labware_highest_z(self, labware_id: str) -> float:
"""Get the highest Z-point of a labware."""
labware_data = self._labware.get(labware_id)
Expand Down
17 changes: 17 additions & 0 deletions api/src/opentrons/protocol_engine/state/pipettes.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class PipetteBoundingBoxOffsets:

back_left_corner: Point
front_right_corner: Point
back_right_corner: Point
front_left_corner: Point


@dataclass(frozen=True)
Expand Down Expand Up @@ -194,6 +196,16 @@ def _handle_command( # noqa: C901
pipette_bounding_box_offsets=PipetteBoundingBoxOffsets(
back_left_corner=config.back_left_corner_offset,
front_right_corner=config.front_right_corner_offset,
back_right_corner=Point(
config.front_right_corner_offset.x,
config.back_left_corner_offset.y,
config.back_left_corner_offset.z,
),
front_left_corner=Point(
config.back_left_corner_offset.x,
config.front_right_corner_offset.y,
config.back_left_corner_offset.z,
),
),
bounding_nozzle_offsets=BoundingNozzlesOffsets(
back_left_offset=config.nozzle_map.back_left_nozzle_offset,
Expand Down Expand Up @@ -788,6 +800,10 @@ def get_pipette_bounding_nozzle_offsets(
"""Get the nozzle offsets of the pipette's bounding nozzles."""
return self.get_config(pipette_id).bounding_nozzle_offsets

def get_pipette_bounding_box(self, pipette_id: str) -> PipetteBoundingBoxOffsets:
"""Get the bounding box of the pipette."""
return self.get_config(pipette_id).pipette_bounding_box_offsets

def get_pipette_bounds_at_specified_move_to_position(
self,
pipette_id: str,
Expand All @@ -796,6 +812,7 @@ def get_pipette_bounds_at_specified_move_to_position(
"""Get the pipette's bounding offsets when primary nozzle is at the given position."""
primary_nozzle_offset = self.get_primary_nozzle_offset(pipette_id)
tip = self.get_attached_tip(pipette_id)
# TODO update this for pipette robot stackup
# Primary nozzle position at destination, in deck coordinates
primary_nozzle_position = destination_position + Point(
x=0, y=0, z=tip.length if tip else 0
Expand Down
4 changes: 4 additions & 0 deletions api/src/opentrons/protocol_engine/state/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing_extensions import ParamSpec

from opentrons_shared_data.deck.dev_types import DeckDefinitionV5
from opentrons_shared_data.robot.dev_types import RobotDefinition

from opentrons.protocol_engine.types import ModuleOffsetData
from opentrons.util.change_notifier import ChangeNotifier
Expand Down Expand Up @@ -144,6 +145,7 @@ def __init__(
config: Config,
deck_definition: DeckDefinitionV5,
deck_fixed_labware: Sequence[DeckFixedLabware],
robot_definition: RobotDefinition,
is_door_open: bool,
change_notifier: Optional[ChangeNotifier] = None,
module_calibration_offsets: Optional[Dict[str, ModuleOffsetData]] = None,
Expand All @@ -162,6 +164,7 @@ def __init__(
change_notifier: Internal state change notifier.
module_calibration_offsets: Module offsets to preload.
deck_configuration: The initial deck configuration the addressable area store will be instantiated with.
robot_definition: Static information about the robot type being used.
notify_publishers: Notifies robot server publishers of internal state change.
"""
self._command_store = CommandStore(config=config, is_door_open=is_door_open)
Expand All @@ -172,6 +175,7 @@ def __init__(
deck_configuration=deck_configuration,
config=config,
deck_definition=deck_definition,
robot_definition=robot_definition,
)
self._labware_store = LabwareStore(
deck_fixed_labware=deck_fixed_labware,
Expand Down
Loading

0 comments on commit 5b09f72

Please sign in to comment.