Skip to content

Commit

Permalink
feat(api): add pipette movement restrictions for 96ch col A1 and 8ch …
Browse files Browse the repository at this point in the history
…single A1 (#14230)

---------

Co-authored-by: Max Marrone <[email protected]>
  • Loading branch information
sanni-t and SyntaxColoring authored Jan 10, 2024
1 parent 6a0e3fe commit d7cbd75
Show file tree
Hide file tree
Showing 3 changed files with 282 additions and 66 deletions.
167 changes: 102 additions & 65 deletions api/src/opentrons/protocol_api/core/engine/deck_conflict.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from opentrons.motion_planning.adjacent_slots_getters import (
get_north_slot,
get_west_slot,
get_east_slot,
get_south_slot,
)
from opentrons.protocol_engine import (
StateView,
Expand Down Expand Up @@ -49,9 +51,18 @@ def __init__(self, message: str) -> None:

# 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=-1.8, y=2)
A12_column_back_right_bound = Point(x=592, y=506.2)
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
)

# Arbitrary safety margin in z-direction
Z_SAFETY_MARGIN = 10
Expand Down Expand Up @@ -250,9 +261,8 @@ def _check_deck_conflict_for_96_channel(
if not (
engine_state.pipettes.get_nozzle_layout_type(pipette_id)
== NozzleConfigurationType.COLUMN
and engine_state.pipettes.get_primary_nozzle(pipette_id) == "A12"
):
# Checking deck conflicts only for 12th column config
# Checking deck conflicts only for column config
return

if isinstance(well_location, DropTipWellLocation):
Expand All @@ -267,41 +277,52 @@ def _check_deck_conflict_for_96_channel(
well_location_point = engine_state.geometry.get_well_position(
labware_id=labware_id, well_name=well_name, well_location=well_location
)
primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id)

if not _is_within_pipette_extents(
engine_state=engine_state, pipette_id=pipette_id, location=well_location_point
):
raise PartialTipMovementNotAllowedError(
"Requested motion with A12 nozzle column configuration"
" is outside of robot bounds for the 96-channel."
f"Requested motion with the {primary_nozzle} nozzle column configuration"
f" is outside of robot bounds for the 96-channel."
)

labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id)
west_slot_number = get_west_slot(
_deck_slot_to_int(DeckSlotLocation(slotName=labware_slot))
)
if west_slot_number is None:
return

west_slot = DeckSlotName.from_primitive(
west_slot_number
).to_equivalent_for_robot_type(engine_state.config.robot_type)
destination_slot_num = labware_slot.as_int()
adjacent_slot_num = None
# TODO (spp, 2023-12-18): change this eventually to "column 1"/"column 12"
# via the column mappings in the pipette geometry definitions.
if primary_nozzle == "A12":
adjacent_slot_num = get_west_slot(destination_slot_num)
elif primary_nozzle == "A1":
adjacent_slot_num = get_east_slot(destination_slot_num)

def _check_conflict_with_slot_item(
adjacent_slot: DeckSlotName,
) -> None:
"""Raises error if the pipette is expected to collide with adjacent slot items."""
slot_highest_z = engine_state.geometry.get_highest_z_in_slot(
DeckSlotLocation(slotName=adjacent_slot)
)

west_slot_highest_z = engine_state.geometry.get_highest_z_in_slot(
DeckSlotLocation(slotName=west_slot)
)
pipette_tip = engine_state.pipettes.get_attached_tip(pipette_id)
tip_length = pipette_tip.length if pipette_tip else 0.0

pipette_tip = engine_state.pipettes.get_attached_tip(pipette_id)
tip_length = pipette_tip.length if pipette_tip else 0.0
if slot_highest_z + Z_SAFETY_MARGIN > well_location_point.z + tip_length:
raise PartialTipMovementNotAllowedError(
f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot"
f" {labware_slot} with pipette column {primary_nozzle} nozzle configuration"
f" will result in collision with items in deck slot {adjacent_slot}."
)

if (
west_slot_highest_z + Z_SAFETY_MARGIN > well_location_point.z + tip_length
): # a safe margin magic number
raise PartialTipMovementNotAllowedError(
f"Moving to {engine_state.labware.get_load_name(labware_id)} in slot {labware_slot}"
f" with a Column nozzle configuration will result in collision with"
f" items in deck slot {west_slot}."
)
if adjacent_slot_num is None:
return
_check_conflict_with_slot_item(
adjacent_slot=DeckSlotName.from_primitive(
adjacent_slot_num
).to_equivalent_for_robot_type(engine_state.config.robot_type)
)


def _check_deck_conflict_for_8_channel(
Expand All @@ -315,9 +336,8 @@ def _check_deck_conflict_for_8_channel(
if not (
engine_state.pipettes.get_nozzle_layout_type(pipette_id)
== NozzleConfigurationType.SINGLE
and engine_state.pipettes.get_primary_nozzle(pipette_id) == "H1"
):
# Checking deck conflicts only for H1 single tip config
# Checking deck conflicts only for single tip config
return

if isinstance(well_location, DropTipWellLocation):
Expand All @@ -332,41 +352,50 @@ def _check_deck_conflict_for_8_channel(
well_location_point = engine_state.geometry.get_well_position(
labware_id=labware_id, well_name=well_name, well_location=well_location
)
primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id)

if not _is_within_pipette_extents(
engine_state=engine_state, pipette_id=pipette_id, location=well_location_point
):
# WARNING: (spp, 2023-11-30: this needs to be wired up to check for
# 8-channel pipette extents on both OT2 & Flex!!)
raise PartialTipMovementNotAllowedError(
"Requested motion with single H1 nozzle configuration"
" is outside of robot bounds for the 8-channel."
f"Requested motion with single {primary_nozzle} nozzle configuration"
f" is outside of robot bounds for the 8-channel."
)

labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id)
north_slot_number = get_north_slot(
_deck_slot_to_int(DeckSlotLocation(slotName=labware_slot))
)
if north_slot_number is None:
return

north_slot = DeckSlotName.from_primitive(
north_slot_number
).to_equivalent_for_robot_type(engine_state.config.robot_type)
destination_slot = labware_slot.as_int()
adjacent_slot_num = None
# TODO (spp, 2023-12-18): change this eventually to use nozzles from mappings in
# the pipette geometry definitions.
if primary_nozzle == "H1":
adjacent_slot_num = get_north_slot(destination_slot)
elif primary_nozzle == "A1":
adjacent_slot_num = get_south_slot(destination_slot)

def _check_conflict_with_slot_item(adjacent_slot: DeckSlotName) -> None:
slot_highest_z = engine_state.geometry.get_highest_z_in_slot(
DeckSlotLocation(slotName=adjacent_slot)
)

north_slot_highest_z = engine_state.geometry.get_highest_z_in_slot(
DeckSlotLocation(slotName=north_slot)
)
pipette_tip = engine_state.pipettes.get_attached_tip(pipette_id)
tip_length = pipette_tip.length if pipette_tip else 0.0

pipette_tip = engine_state.pipettes.get_attached_tip(pipette_id)
tip_length = pipette_tip.length if pipette_tip else 0.0
if slot_highest_z + Z_SAFETY_MARGIN > well_location_point.z + tip_length:
raise PartialTipMovementNotAllowedError(
f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot"
f" {labware_slot} with pipette nozzle {primary_nozzle} configuration"
f" will result in collision with items in deck slot {adjacent_slot}."
)

if north_slot_highest_z + Z_SAFETY_MARGIN > well_location_point.z + tip_length:
raise PartialTipMovementNotAllowedError(
f"Moving to {engine_state.labware.get_load_name(labware_id)} in slot {labware_slot}"
f" with a Single nozzle configuration will result in collision with"
f" items in deck slot {north_slot}."
)
if adjacent_slot_num is None:
return
_check_conflict_with_slot_item(
adjacent_slot=DeckSlotName.from_primitive(
adjacent_slot_num
).to_equivalent_for_robot_type(engine_state.config.robot_type)
)


def _is_within_pipette_extents(
Expand All @@ -380,20 +409,28 @@ def _is_within_pipette_extents(
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
and 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
)
# TODO (spp, 2023-11-07): check for 8-channel nozzle H1 extents on Flex & OT2
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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ def get_expected_mapping_result() -> wrapped_deck_conflict.DeckItem:
),
# Out-of-bounds error
(
Point(x=-10, y=100, z=60),
Point(x=-12, y=100, z=60),
pytest.raises(
deck_conflict.PartialTipMovementNotAllowedError,
match="outside of robot bounds",
Expand Down
Loading

0 comments on commit d7cbd75

Please sign in to comment.