diff --git a/api/src/opentrons/hardware_control/nozzle_manager.py b/api/src/opentrons/hardware_control/nozzle_manager.py index c3b8c63fc3a..0f55dfb1151 100644 --- a/api/src/opentrons/hardware_control/nozzle_manager.py +++ b/api/src/opentrons/hardware_control/nozzle_manager.py @@ -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}" @@ -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.""" @@ -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.""" @@ -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 @@ -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 diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 6724e2dc93c..769abb8d85c 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -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. diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 9064894f5b2..17626b1a777 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -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): diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index 86a39d7ace0..53703c16dee 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -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( @@ -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) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py index dccdf1d6313..dc79714c829 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py @@ -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): @@ -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)) diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index 8e65986fd07..9c77d7dde0a 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -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 @@ -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( diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index edd4cca2cca..e8eff73447b 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -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() @@ -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: 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 68fb3d87f02..781b219df27 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 @@ -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 diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py index 2565756ab1a..73478ccafd5 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_addressable_area_for_drop_tip.py @@ -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( @@ -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)) diff --git a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py index cd4345f7f67..75205b6e45d 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py @@ -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))] @@ -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) diff --git a/api/tests/opentrons/protocol_engine/state/test_motion_view.py b/api/tests/opentrons/protocol_engine/state/test_motion_view.py index 0b76a55f7af..61ec01262f3 100644 --- a/api/tests/opentrons/protocol_engine/state/test_motion_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_motion_view.py @@ -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 @@ -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 diff --git a/hardware-testing/hardware_testing/gravimetric/overrides/api.patch b/hardware-testing/hardware_testing/gravimetric/overrides/api.patch index 8ac99bb553f..c14653a8be8 100644 --- a/hardware-testing/hardware_testing/gravimetric/overrides/api.patch +++ b/hardware-testing/hardware_testing/gravimetric/overrides/api.patch @@ -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: diff --git a/shared-data/command/schemas/8.json b/shared-data/command/schemas/8.json index 1d3b6e91405..3895b046765 100644 --- a/shared-data/command/schemas/8.json +++ b/shared-data/command/schemas/8.json @@ -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"]