From d7cbd75d1095f287bbf5d4f2fb790fd1a4429641 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 10 Jan 2024 16:15:41 -0500 Subject: [PATCH] feat(api): add pipette movement restrictions for 96ch col A1 and 8ch single A1 (#14230) --------- Co-authored-by: Max Marrone --- .../protocol_api/core/engine/deck_conflict.py | 167 +++++++++------- .../core/engine/test_deck_conflict.py | 2 +- .../test_pipette_movement_deck_conflicts.py | 179 ++++++++++++++++++ 3 files changed, 282 insertions(+), 66 deletions(-) create mode 100644 api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py 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 a0e26cd37b5..2571a71d90d 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -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, @@ -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 @@ -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): @@ -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( @@ -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): @@ -332,6 +352,7 @@ 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 @@ -339,34 +360,42 @@ def _check_deck_conflict_for_8_channel( # 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( @@ -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 diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 55cf1afc9c4..43e9bd8dab9 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -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", diff --git a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py new file mode 100644 index 00000000000..cf01608d2fe --- /dev/null +++ b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py @@ -0,0 +1,179 @@ +"""Tests for the APIs around deck conflicts during pipette movement.""" + +import pytest + +from opentrons import simulate +from opentrons.protocol_api import COLUMN, ALL +from opentrons.protocol_api.core.engine.deck_conflict import ( + PartialTipMovementNotAllowedError, +) + + +@pytest.mark.ot3_only +def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: + """It should raise errors for the expected deck conflicts.""" + protocol_context = simulate.get_protocol_api(version="2.16", robot_type="Flex") + trash_labware = protocol_context.load_labware( + "opentrons_1_trash_3200ml_fixed", "A3" + ) + + badly_placed_tiprack = protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "C2" + ) + well_placed_tiprack = protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "C1" + ) + tiprack_on_adapter = protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", + "C3", + adapter="opentrons_flex_96_tiprack_adapter", + ) + + thermocycler = protocol_context.load_module("thermocyclerModuleV2") + partially_accessible_plate = thermocycler.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt" + ) + + instrument = protocol_context.load_instrument("flex_96channel_1000", mount="left") + instrument.trash_container = trash_labware + + # ############ SHORT LABWARE ################ + # These labware should be to the west of tall labware to avoid any partial tip deck conflicts + badly_placed_labware = protocol_context.load_labware( + "nest_96_wellplate_200ul_flat", "D2" + ) + well_placed_labware = protocol_context.load_labware( + "nest_96_wellplate_200ul_flat", "D3" + ) + + # ############ TALL LABWARE ############## + protocol_context.load_labware( + "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", "D1" + ) + + # ########### Use Partial Nozzles ############# + instrument.configure_nozzle_layout(style=COLUMN, start="A12") + + with pytest.raises( + PartialTipMovementNotAllowedError, match="collision with items in deck slot" + ): + instrument.pick_up_tip(badly_placed_tiprack.wells_by_name()["A1"]) + + # No error since no tall item in west slot of destination slot + instrument.pick_up_tip(well_placed_tiprack.wells_by_name()["A1"]) + instrument.aspirate(50, well_placed_labware.wells_by_name()["A4"]) + + with pytest.raises( + PartialTipMovementNotAllowedError, match="collision with items in deck slot D1" + ): + instrument.dispense(50, badly_placed_labware.wells()[0]) + + # No error cuz dispensing from high above plate, so it clears tuberack in west slot + instrument.dispense(25, badly_placed_labware.wells_by_name()["A1"].top(150)) + + thermocycler.open_lid() # type: ignore[union-attr] + + # Will NOT raise error since first column of TC labware is accessible + # (it is just a few mm away from the left bound) + instrument.dispense(25, partially_accessible_plate.wells_by_name()["A1"]) + + instrument.drop_tip() + + # ######## CHANGE CONFIG TO ALL ######### + instrument.configure_nozzle_layout(style=ALL, tip_racks=[tiprack_on_adapter]) + + # No error because of full config + instrument.pick_up_tip() + + # No error NOW because of full config + instrument.aspirate(50, badly_placed_labware.wells_by_name()["A1"]) + + # No error NOW because of full config + instrument.dispense(50, partially_accessible_plate.wells_by_name()["A1"]) + + +@pytest.mark.ot3_only +def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: + """It should raise errors for expected deck conflicts.""" + protocol = simulate.get_protocol_api(version="2.16", robot_type="Flex") + instrument = protocol.load_instrument("flex_96channel_1000", mount="left") + trash_labware = protocol.load_labware("opentrons_1_trash_3200ml_fixed", "A3") + instrument.trash_container = trash_labware + + badly_placed_tiprack = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "C2") + well_placed_tiprack = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "A1") + tiprack_on_adapter = protocol.load_labware( + "opentrons_flex_96_tiprack_50ul", + "C3", + adapter="opentrons_flex_96_tiprack_adapter", + ) + + # ############ SHORT LABWARE ################ + # These labware should be to the east of tall labware to avoid any partial tip deck conflicts + badly_placed_plate = protocol.load_labware("nest_96_wellplate_200ul_flat", "B1") + well_placed_plate = protocol.load_labware("nest_96_wellplate_200ul_flat", "B3") + + # ############ TALL LABWARE ############### + my_tuberack = protocol.load_labware( + "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", "B2" + ) + + # ########### Use Partial Nozzles ############# + instrument.configure_nozzle_layout(style=COLUMN, start="A1") + + with pytest.raises( + PartialTipMovementNotAllowedError, match="collision with items in deck slot" + ): + instrument.pick_up_tip(badly_placed_tiprack.wells_by_name()["H12"]) + + # No error cuz within pipette extent bounds and no taller labware to right of tiprack + instrument.pick_up_tip(well_placed_tiprack.wells_by_name()["A12"]) + + # No error cuz no labware on right of plate, and also well A10 is juusst inside the right bound + instrument.aspirate(25, well_placed_plate.wells_by_name()["A10"]) + + # No error cuz dispensing from high above plate, so it clears tuberack on the right + instrument.dispense(25, badly_placed_plate.wells_by_name()["A1"].top(150)) + + with pytest.raises( + PartialTipMovementNotAllowedError, match="collision with items in deck slot" + ): + instrument.aspirate(25, badly_placed_plate.wells_by_name()["A1"]) + + with pytest.raises( + PartialTipMovementNotAllowedError, match="outside of robot bounds" + ): + instrument.aspirate(25, well_placed_plate.wells_by_name()["A11"]) + + # No error cuz no taller labware on the right + instrument.aspirate(10, my_tuberack.wells_by_name()["A1"]) + + with pytest.raises( + PartialTipMovementNotAllowedError, match="outside of robot bounds" + ): + # Raises error because drop tip alternation makes the pipette drop the tips + # near the trash bin labware's right edge, which is out of bounds for column1 nozzles + # We should probably move this tip drop location within the nozzles' accessible area, + # but since we do not recommend loading the trash as labware (there are other things + # wrong with that approach), it is not a critical issue. + instrument.drop_tip() + + instrument.trash_container = None # type: ignore + protocol.load_trash_bin("C1") + + # This doesn't raise an error because it now treats the trash bin as an addressable area + # and the bounds check doesn't yet check moves to addressable areas. + # The aim is to do checks for ALL moves, but also, fix the offset used for tip drop alternation. + instrument.drop_tip() + + # ######## CHANGE CONFIG TO ALL ######### + instrument.configure_nozzle_layout(style=ALL, tip_racks=[tiprack_on_adapter]) + + # No error because of full config + instrument.pick_up_tip() + + # No error NOW because of full config + instrument.aspirate(50, badly_placed_plate.wells_by_name()["A1"]) + + # No error NOW because of full config + instrument.dispense(50, badly_placed_plate.wells_by_name()["A1"].bottom())