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 cd1c892c953..8922e2a6c43 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -1,10 +1,18 @@ """A Protocol-Engine-friendly wrapper for opentrons.motion_planning.deck_conflict.""" import itertools -from typing import Collection, Dict, Optional, Tuple, overload +import logging +from typing import Collection, Dict, Optional, Tuple, overload, Union +from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError + +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.adjacent_slots_getters import ( + get_north_slot, + get_west_slot, +) from opentrons.protocol_engine import ( StateView, DeckSlotLocation, @@ -12,9 +20,32 @@ OnLabwareLocation, AddressableAreaLocation, OFF_DECK_LOCATION, + WellLocation, + DropTipWellLocation, ) from opentrons.protocol_engine.errors.exceptions import LabwareNotLoadedOnModuleError -from opentrons.types import DeckSlotName +from opentrons.types import DeckSlotName, Point + + +class PartialTipMovementNotAllowedError(MotionPlanningFailureError): + """Error raised when trying to perform a partial tip movement to an illegal location.""" + + def __init__(self, message: str) -> None: + super().__init__( + message=message, + ) + + +_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 +# 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) + +# Arbitrary safety margin in z-direction +Z_SAFETY_MARGIN = 10 @overload @@ -106,6 +137,201 @@ def check( ) +def check_safe_for_pipette_movement( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if the labware is safe to move to with a pipette in partial tip configuration. + Args: + engine_state: engine state view + pipette_id: ID of the pipette to be moved + labware_id: ID of the labware we are moving to + well_name: Name of the well to move to + well_location: exact location within the well to move to + """ + # TODO: either hide unsupported configurations behind an advance setting + # or log a warning that deck conflicts cannot be checked for tip config other than + # column config with A12 primary nozzle for the 96 channel + # or single tip config for 8-channel. + if engine_state.pipettes.get_channels(pipette_id) == 96: + _check_deck_conflict_for_96_channel( + engine_state=engine_state, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) + elif engine_state.pipettes.get_channels(pipette_id) == 8: + _check_deck_conflict_for_8_channel( + engine_state=engine_state, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) + + +def _check_deck_conflict_for_96_channel( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if there are any conflicts moving to the given labware with the configuration of 96-ch pipette.""" + 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 + return + + if isinstance(well_location, DropTipWellLocation): + # convert to WellLocation + well_location = engine_state.geometry.get_checked_tip_drop_location( + pipette_id=pipette_id, + labware_id=labware_id, + well_location=well_location, + partially_configured=True, + ) + + well_location_point = engine_state.geometry.get_well_position( + labware_id=labware_id, well_name=well_name, well_location=well_location + ) + + 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." + ) + + 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) + + 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 + + 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}." + ) + + +def _check_deck_conflict_for_8_channel( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if there are any conflicts moving to the given labware with the configuration of 8-ch pipette.""" + 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 + return + + if isinstance(well_location, DropTipWellLocation): + # convert to WellLocation + well_location = engine_state.geometry.get_checked_tip_drop_location( + pipette_id=pipette_id, + labware_id=labware_id, + well_location=well_location, + partially_configured=True, + ) + + well_location_point = engine_state.geometry.get_well_position( + labware_id=labware_id, well_name=well_name, well_location=well_location + ) + + 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." + ) + + 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) + + 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 + + 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}." + ) + + +def _is_within_pipette_extents( + engine_state: StateView, + pipette_id: str, + location: 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 + 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 + return True + + def _map_labware( engine_state: StateView, labware_id: str, diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index b3d46c30be9..7bce8d9808e 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -32,6 +32,7 @@ from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType +from . import deck_conflict from ..instrument import AbstractInstrument from .well import WellCore @@ -141,7 +142,13 @@ def aspirate( absolute_point=location.point, ) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.aspirate( pipette_id=self._pipette_id, labware_id=labware_id, @@ -155,7 +162,7 @@ def aspirate( def dispense( self, - location: Location, + location: Union[Location, TrashBin, WasteChute], well_core: Optional[WellCore], volume: float, rate: float, @@ -175,15 +182,20 @@ def dispense( """ if well_core is None: if not in_place: - self._engine_client.move_to_coordinates( - pipette_id=self._pipette_id, - coordinates=DeckPoint( - x=location.point.x, y=location.point.y, z=location.point.z - ), - minimum_z_height=None, - force_direct=False, - speed=None, - ) + if isinstance(location, (TrashBin, WasteChute)): + self._move_to_disposal_location( + disposal_location=location, force_direct=False, speed=None + ) + else: + self._engine_client.move_to_coordinates( + pipette_id=self._pipette_id, + coordinates=DeckPoint( + x=location.point.x, y=location.point.y, z=location.point.z + ), + minimum_z_height=None, + force_direct=False, + speed=None, + ) self._engine_client.dispense_in_place( pipette_id=self._pipette_id, @@ -192,6 +204,8 @@ def dispense( push_out=push_out, ) else: + if isinstance(location, (TrashBin, WasteChute)): + raise ValueError("Trash Bin and Waste Chute have no Wells.") well_name = well_core.get_name() labware_id = well_core.labware_id @@ -202,7 +216,13 @@ def dispense( absolute_point=location.point, ) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.dispense( pipette_id=self._pipette_id, labware_id=labware_id, @@ -213,10 +233,18 @@ def dispense( push_out=push_out, ) - self._protocol_core.set_last_location(location=location, mount=self.get_mount()) + if isinstance(location, (TrashBin, WasteChute)): + self._protocol_core.set_last_location(location=None, mount=self.get_mount()) + else: + self._protocol_core.set_last_location( + location=location, mount=self.get_mount() + ) def blow_out( - self, location: Location, well_core: Optional[WellCore], in_place: bool + self, + location: Union[Location, TrashBin, WasteChute], + well_core: Optional[WellCore], + in_place: bool, ) -> None: """Blow liquid out of the tip. @@ -228,20 +256,27 @@ def blow_out( flow_rate = self.get_blow_out_flow_rate(1.0) if well_core is None: if not in_place: - self._engine_client.move_to_coordinates( - pipette_id=self._pipette_id, - coordinates=DeckPoint( - x=location.point.x, y=location.point.y, z=location.point.z - ), - force_direct=False, - minimum_z_height=None, - speed=None, - ) + if isinstance(location, (TrashBin, WasteChute)): + self._move_to_disposal_location( + disposal_location=location, force_direct=False, speed=None + ) + else: + self._engine_client.move_to_coordinates( + pipette_id=self._pipette_id, + coordinates=DeckPoint( + x=location.point.x, y=location.point.y, z=location.point.z + ), + force_direct=False, + minimum_z_height=None, + speed=None, + ) self._engine_client.blow_out_in_place( pipette_id=self._pipette_id, flow_rate=flow_rate ) else: + if isinstance(location, (TrashBin, WasteChute)): + raise ValueError("Trash Bin and Waste Chute have no Wells.") well_name = well_core.get_name() labware_id = well_core.labware_id @@ -252,7 +287,13 @@ def blow_out( absolute_point=location.point, ) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.blow_out( pipette_id=self._pipette_id, labware_id=labware_id, @@ -263,7 +304,12 @@ def blow_out( flow_rate=flow_rate, ) - self._protocol_core.set_last_location(location=location, mount=self.get_mount()) + if isinstance(location, (TrashBin, WasteChute)): + self._protocol_core.set_last_location(location=None, mount=self.get_mount()) + else: + self._protocol_core.set_last_location( + location=location, mount=self.get_mount() + ) def touch_tip( self, @@ -289,7 +335,13 @@ def touch_tip( well_location = WellLocation( origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=z_offset) ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.touch_tip( pipette_id=self._pipette_id, labware_id=labware_id, @@ -331,7 +383,13 @@ def pick_up_tip( well_name=well_name, absolute_point=location.point, ) - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + ) self._engine_client.pick_up_tip( pipette_id=self._pipette_id, labware_id=labware_id, @@ -376,7 +434,13 @@ def drop_tip( ) else: well_location = DropTipWellLocation() - + deck_conflict.check_safe_for_pipette_movement( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=WellLocation(), + ) self._engine_client.drop_tip( pipette_id=self._pipette_id, labware_id=labware_id, @@ -397,6 +461,7 @@ def drop_tip_in_disposal_location( speed=None, ) self._drop_tip_in_place(home_after=home_after) + self._protocol_core.set_last_location(location=None, mount=self.get_mount()) def _move_to_disposal_location( self, @@ -448,13 +513,15 @@ def home_plunger(self) -> None: def move_to( self, - location: Location, + location: Union[Location, TrashBin, WasteChute], well_core: Optional[WellCore], force_direct: bool, minimum_z_height: Optional[float], speed: Optional[float], ) -> None: if well_core is not None: + if isinstance(location, (TrashBin, WasteChute)): + raise ValueError("Trash Bin and Waste Chute have no Wells.") labware_id = well_core.labware_id well_name = well_core.get_name() well_location = ( @@ -475,16 +542,26 @@ def move_to( speed=speed, ) else: - self._engine_client.move_to_coordinates( - pipette_id=self._pipette_id, - coordinates=DeckPoint( - x=location.point.x, y=location.point.y, z=location.point.z - ), - minimum_z_height=minimum_z_height, - force_direct=force_direct, - speed=speed, + if isinstance(location, (TrashBin, WasteChute)): + self._move_to_disposal_location( + disposal_location=location, force_direct=force_direct, speed=speed + ) + else: + self._engine_client.move_to_coordinates( + pipette_id=self._pipette_id, + coordinates=DeckPoint( + x=location.point.x, y=location.point.y, z=location.point.z + ), + minimum_z_height=minimum_z_height, + force_direct=force_direct, + speed=speed, + ) + if isinstance(location, (TrashBin, WasteChute)): + self._protocol_core.set_last_location(location=None, mount=self.get_mount()) + else: + self._protocol_core.set_last_location( + location=location, mount=self.get_mount() ) - self._protocol_core.set_last_location(location=location, mount=self.get_mount()) def get_mount(self) -> Mount: """Get the mount the pipette is attached to.""" diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 67691af0646..503b41ee97e 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -325,23 +325,18 @@ def move_labware( else None ) - if isinstance(new_location, WasteChute): - self._move_labware_to_waste_chute( - labware_core, strategy, _pick_up_offset, _drop_offset - ) - else: - to_location = self._convert_labware_location(location=new_location) + to_location = self._convert_labware_location(location=new_location) - # TODO(mm, 2023-02-23): Check for conflicts with other items on the deck, - # when move_labware() support is no longer experimental. + # TODO(mm, 2023-02-23): Check for conflicts with other items on the deck, + # when move_labware() support is no longer experimental. - self._engine_client.move_labware( - labware_id=labware_core.labware_id, - new_location=to_location, - strategy=strategy, - pick_up_offset=_pick_up_offset, - drop_offset=_drop_offset, - ) + self._engine_client.move_labware( + labware_id=labware_core.labware_id, + new_location=to_location, + strategy=strategy, + pick_up_offset=_pick_up_offset, + drop_offset=_drop_offset, + ) if strategy == LabwareMovementStrategy.USING_GRIPPER: # Clear out last location since it is not relevant to pipetting @@ -709,6 +704,7 @@ def _convert_labware_location( ModuleCore, NonConnectedModuleCore, OffDeckType, + WasteChute, ], ) -> LabwareLocation: if isinstance(location, LabwareCore): @@ -724,6 +720,7 @@ def _get_non_stacked_location( ModuleCore, NonConnectedModuleCore, OffDeckType, + WasteChute, ] ) -> NonStackedLocation: if isinstance(location, (ModuleCore, NonConnectedModuleCore)): @@ -734,3 +731,6 @@ def _get_non_stacked_location( return DeckSlotLocation(slotName=location) elif isinstance(location, StagingSlotName): return AddressableAreaLocation(addressableAreaName=location.id) + elif isinstance(location, WasteChute): + # TODO(mm, 2023-12-06) This will need to determine the appropriate Waste Chute to return, but only move_labware uses this for now + return AddressableAreaLocation(addressableAreaName="gripperWasteChute") diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 155c630e0b8..96fe6024952 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -48,7 +48,7 @@ def aspirate( @abstractmethod def dispense( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[WellCoreType], volume: float, rate: float, @@ -71,7 +71,7 @@ def dispense( @abstractmethod def blow_out( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[WellCoreType], in_place: bool, ) -> None: @@ -158,7 +158,7 @@ def home_plunger(self) -> None: @abstractmethod def move_to( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[WellCoreType], force_direct: bool, minimum_z_height: Optional[float], diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index 63fb06b6104..b91a8821c97 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -113,7 +113,7 @@ def aspirate( def dispense( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[LegacyWellCore], volume: float, rate: float, @@ -131,6 +131,10 @@ def dispense( in_place: Whether we should move_to location. push_out: The amount to push the plunger below bottom position. """ + if isinstance(location, (TrashBin, WasteChute)): + raise APIVersionError( + "Dispense in Moveable Trash or Waste Chute are not supported in this API Version." + ) if push_out: raise APIVersionError("push_out is not supported in this API version.") if not in_place: @@ -140,7 +144,7 @@ def dispense( def blow_out( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[LegacyWellCore], in_place: bool, ) -> None: @@ -151,6 +155,11 @@ def blow_out( well_core: Unused by legacy core. in_place: Whether we should move_to location. """ + if isinstance(location, (TrashBin, WasteChute)): + raise APIVersionError( + "Blow Out in Moveable Trash or Waste Chute are not supported in this API Version." + ) + if not in_place: self.move_to(location=location) self._protocol_interface.get_hardware().blow_out(self._mount) @@ -308,7 +317,7 @@ def home_plunger(self) -> None: def move_to( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[LegacyWellCore] = None, force_direct: bool = False, minimum_z_height: Optional[float] = None, @@ -327,6 +336,10 @@ def move_to( LabwareHeightError: An item on the deck is taller than the computed safe travel height. """ + if isinstance(location, (TrashBin, WasteChute)): + raise APIVersionError( + "Move To Trash Bin and Waste Chute are not supported in this API Version." + ) self.flag_unsafe_move(location) # prevent direct movement bugs in PAPI version >= 2.10 diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index f63bb419036..549275c3983 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -123,7 +123,7 @@ def aspirate( def dispense( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[LegacyWellCore], volume: float, rate: float, @@ -131,6 +131,10 @@ def dispense( in_place: bool, push_out: Optional[float], ) -> None: + if isinstance(location, (TrashBin, WasteChute)): + raise APIVersionError( + "Dispense in Moveable Trash or Waste Chute are not supported in this API Version." + ) if not in_place: self.move_to(location=location, well_core=well_core) self._raise_if_no_tip(HardwareAction.DISPENSE.name) @@ -138,10 +142,14 @@ def dispense( def blow_out( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[LegacyWellCore], in_place: bool, ) -> None: + if isinstance(location, (TrashBin, WasteChute)): + raise APIVersionError( + "Blow Out in Moveable Trash or Waste Chute are not supported in this API Version." + ) if not in_place: self.move_to(location=location, well_core=well_core) self._raise_if_no_tip(HardwareAction.BLOWOUT.name) @@ -269,13 +277,18 @@ def home_plunger(self) -> None: def move_to( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], well_core: Optional[LegacyWellCore] = None, force_direct: bool = False, minimum_z_height: Optional[float] = None, speed: Optional[float] = None, ) -> None: """Simulation of only the motion planning portion of move_to.""" + if isinstance(location, (TrashBin, WasteChute)): + raise APIVersionError( + "Move To Trash Bin and Waste Chute are not supported in this API Version." + ) + self.flag_unsafe_move(location) last_location = self._protocol_interface.get_last_location() diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 3bcf226f054..6f766406ed3 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -237,7 +237,10 @@ def aspirate( well = target.well if isinstance(target, validation.PointTarget): move_to_location = target.location - + if isinstance(target, (TrashBin, WasteChute)): + raise ValueError( + "Trash Bin and Waste Chute are not acceptable location parameters for Aspirate commands." + ) if self.api_version >= APIVersion(2, 11): instrument.validate_takes_liquid( location=move_to_location, @@ -276,7 +279,9 @@ def aspirate( def dispense( self, volume: Optional[float] = None, - location: Optional[Union[types.Location, labware.Well]] = None, + location: Optional[ + Union[types.Location, labware.Well, TrashBin, WasteChute] + ] = None, rate: float = 1.0, push_out: Optional[float] = None, ) -> InstrumentContext: @@ -374,7 +379,9 @@ def dispense( if isinstance(target, validation.PointTarget): move_to_location = target.location - if self.api_version >= APIVersion(2, 11): + if self.api_version >= APIVersion(2, 11) and not isinstance( + target, (TrashBin, WasteChute) + ): instrument.validate_takes_liquid( location=move_to_location, reject_module=self.api_version >= APIVersion(2, 13), @@ -388,6 +395,20 @@ def dispense( flow_rate = self._core.get_dispense_flow_rate(rate) + if isinstance(target, (TrashBin, WasteChute)): + # HANDLE THE MOVETOADDDRESSABLEAREA + self._core.dispense( + volume=c_vol, + rate=rate, + location=target, + well_core=None, + flow_rate=flow_rate, + in_place=False, + push_out=push_out, + ) + # TODO publish this info + return self + with publisher.publish_context( broker=self.broker, command=cmds.dispense( @@ -489,7 +510,10 @@ def mix( @requires_version(2, 0) def blow_out( - self, location: Optional[Union[types.Location, labware.Well]] = None + self, + location: Optional[ + Union[types.Location, labware.Well, TrashBin, WasteChute] + ] = None, ) -> InstrumentContext: """ Blow an extra amount of air through a pipette's tip to clear it. @@ -535,6 +559,14 @@ def blow_out( well = target.well elif isinstance(target, validation.PointTarget): move_to_location = target.location + elif isinstance(target, (TrashBin, WasteChute)): + # TODO handle publish info + self._core.blow_out( + location=target, + well_core=None, + in_place=False, + ) + return self with publisher.publish_context( broker=self.broker, @@ -1342,7 +1374,7 @@ def delay(self, *args: Any, **kwargs: Any) -> None: @requires_version(2, 0) def move_to( self, - location: types.Location, + location: Union[types.Location, TrashBin, WasteChute], force_direct: bool = False, minimum_z_height: Optional[float] = None, speed: Optional[float] = None, @@ -1373,6 +1405,17 @@ def move_to( """ publish_ctx = nullcontext() + if isinstance(location, (TrashBin, WasteChute)): + self._core.move_to( + location=location, + well_core=None, + force_direct=force_direct, + minimum_z_height=minimum_z_height, + speed=speed, + ) + # TODO handle publish + return self + if publish: publish_ctx = publisher.publish_context( broker=self.broker, diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index f4fe59185f1..c4849950c50 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -32,6 +32,9 @@ ThermocyclerStep, ) +from ._trash_bin import TrashBin +from ._waste_chute import WasteChute + if TYPE_CHECKING: from .labware import Well @@ -407,8 +410,9 @@ class LocationTypeError(TypeError): def validate_location( - location: Union[Location, Well, None], last_location: Optional[Location] -) -> Union[WellTarget, PointTarget]: + location: Union[Location, Well, TrashBin, WasteChute, None], + last_location: Optional[Location], +) -> Union[WellTarget, PointTarget, TrashBin, WasteChute]: """Validate a given location for a liquid handling command. Args: @@ -430,11 +434,14 @@ def validate_location( if target_location is None: raise NoLocationError() - if not isinstance(target_location, (Location, Well)): + if not isinstance(target_location, (Location, Well, TrashBin, WasteChute)): raise LocationTypeError( - f"location should be a Well or Location, but it is {location}" + f"location should be a Well, Location, TrashBin or WasteChute, but it is {location}" ) + if isinstance(target_location, (TrashBin, WasteChute)): + return target_location + in_place = target_location == last_location if isinstance(target_location, Well): diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index bf787e2d640..f4178768a14 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -7,6 +7,7 @@ from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN from .. import errors +from ..errors import LabwareNotLoadedOnLabwareError, LabwareNotLoadedOnModuleError from ..resources import fixture_validation from ..types import ( OFF_DECK_LOCATION, @@ -100,6 +101,8 @@ def get_all_obstacle_highest_z(self) -> float: default=0.0, ) + # Fixme (spp, 2023-12-04): the overall height is not the true highest z of modules + # on a Flex. highest_module_z = max( ( self._modules.get_overall_height(module.id) @@ -136,6 +139,38 @@ def get_all_obstacle_highest_z(self) -> float: highest_fixture_z, ) + def get_highest_z_in_slot(self, slot: DeckSlotLocation) -> float: + """Get the highest Z-point of all items stacked in the given deck slot.""" + slot_item = self.get_slot_item(slot.slotName) + if isinstance(slot_item, LoadedModule): + # get height of module + all labware on it + module_id = slot_item.id + try: + labware_id = self._labware.get_id_by_module(module_id=module_id) + except LabwareNotLoadedOnModuleError: + deck_type = DeckType(self._labware.get_deck_definition()["otId"]) + return self._modules.get_module_highest_z( + module_id=module_id, deck_type=deck_type + ) + else: + return self.get_highest_z_of_labware_stack(labware_id) + elif isinstance(slot_item, LoadedLabware): + # get stacked heights of all labware in the slot + return self.get_highest_z_of_labware_stack(slot_item.id) + else: + return 0 + + def get_highest_z_of_labware_stack(self, labware_id: str) -> float: + """Get the highest Z-point of the topmost labware in the stack of labware on the given labware. + + If there is no labware on the given labware, returns highest z of the given labware. + """ + try: + stacked_labware_id = self._labware.get_id_by_labware(labware_id) + except LabwareNotLoadedOnLabwareError: + return self.get_labware_highest_z(labware_id) + return self.get_highest_z_of_labware_stack(stacked_labware_id) + def get_min_travel_z( self, pipette_id: str, @@ -378,6 +413,13 @@ def _get_highest_z_from_labware_data(self, lw_data: LoadedLabware) -> float: z_dim = definition.dimensions.zDimension height_over_labware: float = 0 if isinstance(lw_data.location, ModuleLocation): + # Note: when calculating highest z of stacked labware, height-over-labware + # gets accounted for only if the top labware is directly on the module. + # So if there's a labware on an adapter on a module, then this + # over-module-height gets ignored. We currently do not have any modules + # that use an adapter and has height over labware so this doesn't cause + # any issues yet. But if we add one in the future then this calculation + # should be updated. module_id = lw_data.location.moduleId height_over_labware = self._modules.get_height_over_labware(module_id) return labware_pos.z + z_dim + height_over_labware diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 1239feab138..f63cd631204 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -702,6 +702,41 @@ def get_height_over_labware(self, module_id: str) -> float: """Get the height of module parts above module labware base.""" return self.get_dimensions(module_id).overLabwareHeight + def get_module_highest_z(self, module_id: str, deck_type: DeckType) -> float: + """Get the highest z point of the module, as placed on the robot. + + The highest Z of a module, unlike the bare overall height, depends on + the robot it is on. We will calculate this value using the info we already have + about the transformation of the module's placement, based on the deck it is on. + + This value is calculated as: + highest_z = ( nominal_robot_transformed_labware_offset_z + + z_difference_between_default_labware_offset_point_and_overall_height + + module_calibration_offset_z + ) + + For OT2, the default_labware_offset point is the same as nominal_robot_transformed_labware_offset_z + and hence the highest z will equal to the overall height of the module. + + For Flex, since those two offsets are not the same, the final highest z will be + transformed the same amount as the labware offset point is. + + Note: For thermocycler, the lid height is not taken into account. + """ + module_height = self.get_overall_height(module_id) + default_lw_offset_point = self.get_definition(module_id).labwareOffset.z + z_difference = module_height - default_lw_offset_point + + nominal_transformed_lw_offset_z = self.get_nominal_module_offset( + module_id=module_id, deck_type=deck_type + ).z + calibration_offset = self.get_module_calibration_offset(module_id) + return ( + nominal_transformed_lw_offset_z + + z_difference + + (calibration_offset.moduleOffsetVector.z if calibration_offset else 0) + ) + # TODO(mc, 2022-01-19): this method is missing unit test coverage and # is also unused. Remove or add tests. def get_lid_height(self, module_id: str) -> float: diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 73d88df37be..384a0189f0b 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -637,3 +637,8 @@ def get_nozzle_layout_type(self, pipette_id: str) -> NozzleConfigurationType: def get_is_partially_configured(self, pipette_id: str) -> bool: """Determine if the provided pipette is partially configured.""" return self.get_nozzle_layout_type(pipette_id) != NozzleConfigurationType.FULL + + def get_primary_nozzle(self, pipette_id: str) -> Optional[str]: + """Get the primary nozzle, if any, related to the given pipette's nozzle configuration.""" + nozzle_map = self._state.nozzle_configuration_by_id.get(pipette_id) + return nozzle_map.starting_nozzle if nozzle_map else None diff --git a/api/src/opentrons/protocols/advanced_control/transfers.py b/api/src/opentrons/protocols/advanced_control/transfers.py index a43a323daea..df1c6961be6 100644 --- a/api/src/opentrons/protocols/advanced_control/transfers.py +++ b/api/src/opentrons/protocols/advanced_control/transfers.py @@ -17,6 +17,7 @@ from opentrons import types from opentrons.protocols.api_support.types import APIVersion + if TYPE_CHECKING: from opentrons.protocol_api import InstrumentContext from opentrons.protocols.execution.dev_types import Dictable @@ -805,8 +806,8 @@ def _after_dispense(self, dest, src, is_disp_next=False): # noqa: C901 "blow_out", [self._instr.trash_container.wells()[0]] ) else: - # TODO (nd 2023/12/04) handle blowout at TrashBin or WasteChute - raise NotImplementedError("Cannot blow out at this location.") + yield self._format_dict("blow_out", [self._instr.trash_container]) + else: # Used by distribute if self._strategy.air_gap: 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 6d682b3e9a5..a47f5ad04c9 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 @@ -1,18 +1,28 @@ """Unit tests for the deck_conflict module.""" -from decoy import Decoy import pytest - +from typing import ContextManager, Any +from decoy import Decoy +from contextlib import nullcontext as does_not_raise from opentrons_shared_data.labware.dev_types import LabwareUri from opentrons_shared_data.robot.dev_types import RobotType +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict from opentrons.protocol_api.core.engine import deck_conflict from opentrons.protocol_engine import Config, DeckSlotLocation, ModuleModel, StateView from opentrons.protocol_engine.errors import LabwareNotLoadedOnModuleError -from opentrons.types import DeckSlotName +from opentrons.types import DeckSlotName, Point -from opentrons.protocol_engine.types import DeckType +from opentrons.protocol_engine.types import ( + DeckType, + LoadedLabware, + LoadedModule, + WellLocation, + WellOrigin, + WellOffset, + TipGeometry, +) @pytest.fixture(autouse=True) @@ -265,3 +275,201 @@ def get_expected_mapping_result() -> wrapped_deck_conflict.DeckItem: robot_type=mock_state_view.config.robot_type, ) ) + + +plate = LoadedLabware( + id="plate-id", + loadName="plate-load-name", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_C1), + definitionUri="some-plate-uri", + offsetId=None, + displayName="Fancy Plate Name", +) + +module = LoadedModule( + id="module-id", + model=ModuleModel.TEMPERATURE_MODULE_V1, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_C1), + serialNumber="serial-number", +) + + +@pytest.mark.parametrize( + ("robot_type", "deck_type"), + [("OT-3 Standard", DeckType.OT3_STANDARD)], +) +@pytest.mark.parametrize( + ["destination_well_point", "expected_raise"], + [ + (Point(x=100, y=100, z=60), does_not_raise()), + # Z-collisions + ( + Point(x=100, y=100, z=10), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + ( + Point(x=100, y=100, z=20), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + # Out-of-bounds error + ( + Point(x=-10, y=100, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ( + Point(x=593, y=100, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ( + Point(x=100, y=1, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ( + Point(x=100, y=507, z=60), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="outside of robot bounds", + ), + ), + ], +) +def test_deck_conflict_raises_for_bad_partial_96_channel_move( + decoy: Decoy, + mock_state_view: StateView, + destination_well_point: Point, + expected_raise: ContextManager[Any], +) -> None: + """It should raise errors when moving to locations with restrictions for partial tip 96-channel movement. + + Test premise: + - we are using a pipette configured for COLUMN nozzle layout with primary nozzle A12 + - there's a labware of height 50mm in C1 + - we are checking for conflicts when moving to a labware in C2. + For each test case, we are moving to a different point in the destination labware, + with the same pipette and tip (tip length is 10mm) + """ + decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96) + decoy.when( + mock_state_view.pipettes.get_nozzle_layout_type("pipette-id") + ).then_return(NozzleConfigurationType.COLUMN) + decoy.when(mock_state_view.pipettes.get_primary_nozzle("pipette-id")).then_return( + "A12" + ) + decoy.when( + mock_state_view.geometry.get_ancestor_slot_name("destination-labware-id") + ).then_return(DeckSlotName.SLOT_C2) + decoy.when( + mock_state_view.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=DeckSlotName.SLOT_C1) + ) + ).then_return(50) + decoy.when( + mock_state_view.geometry.get_well_position( + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + ).then_return(destination_well_point) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=10, diameter=100, volume=0) + ) + + with expected_raise: + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_state_view, + pipette_id="pipette-id", + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + + +@pytest.mark.parametrize( + ("robot_type", "deck_type"), + [("OT-3 Standard", DeckType.OT3_STANDARD)], +) +@pytest.mark.parametrize( + ["destination_well_point", "expected_raise"], + [ + (Point(x=100, y=100, z=60), does_not_raise()), + # Z-collisions + ( + Point(x=100, y=100, z=10), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + ( + Point(x=100, y=100, z=20), + pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="collision with items in deck slot", + ), + ), + ], +) +def test_deck_conflict_raises_for_bad_partial_8_channel_move( + decoy: Decoy, + mock_state_view: StateView, + destination_well_point: Point, + expected_raise: ContextManager[Any], +) -> None: + """It should raise errors when moving to locations with restrictions for partial tip 8-channel movement. + + Test premise: + - we are using a pipette configured for SINGLE nozzle layout with primary nozzle H1 + - there's a labware of height 50mm in B2 + - we are checking for conflicts when moving to a labware in C2. + For each test case, we are moving to a different point in the destination labware, + with the same pipette and tip (tip length is 10mm) + """ + decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(8) + decoy.when( + mock_state_view.pipettes.get_nozzle_layout_type("pipette-id") + ).then_return(NozzleConfigurationType.SINGLE) + decoy.when(mock_state_view.pipettes.get_primary_nozzle("pipette-id")).then_return( + "H1" + ) + decoy.when( + mock_state_view.geometry.get_ancestor_slot_name("destination-labware-id") + ).then_return(DeckSlotName.SLOT_C2) + decoy.when( + mock_state_view.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=DeckSlotName.SLOT_B2) + ) + ).then_return(50) + decoy.when( + mock_state_view.geometry.get_well_position( + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) + ).then_return(destination_well_point) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=10, diameter=100, volume=0) + ) + + with expected_raise: + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_state_view, + pipette_id="pipette-id", + labware_id="destination-labware-id", + well_name="A2", + well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py index b42b25fbbb9..f6b45376f7e 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py @@ -133,5 +133,6 @@ async def test_configure_nozzle_layout_implementation( assert result == ConfigureNozzleLayoutResult() assert private_result == ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", nozzle_map=expected_nozzlemap + pipette_id="pipette-id", + nozzle_map=expected_nozzlemap, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index a92b9aa9f05..39211c5bb24 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -635,6 +635,275 @@ def test_get_all_obstacle_highest_z_with_fixtures( assert result == 1337.0 +def test_get_highest_z_in_slot_with_single_labware( + decoy: Decoy, + labware_view: LabwareView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + well_plate_def: LabwareDefinition, +) -> None: + """It should get the highest Z in slot with just a single labware.""" + # Case: Slot has a labware that doesn't have any other labware on it. Highest z is equal to labware height. + labware_in_slot = LoadedLabware( + id="just-labware-id", + loadName="just-labware-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + offsetId="offset-id", + ) + slot_pos = Point(1, 2, 3) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + + decoy.when(labware_view.get_by_slot(DeckSlotName.SLOT_3)).then_return( + labware_in_slot + ) + decoy.when(labware_view.get_id_by_labware("just-labware-id")).then_raise( + errors.LabwareNotLoadedOnLabwareError("no more labware") + ) + decoy.when(labware_view.get("just-labware-id")).then_return(labware_in_slot) + decoy.when(labware_view.get_definition("just-labware-id")).then_return( + well_plate_def + ) + decoy.when(labware_view.get_labware_offset_vector("just-labware-id")).then_return( + calibration_offset + ) + decoy.when( + addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) + ).then_return(slot_pos) + + expected_highest_z = well_plate_def.dimensions.zDimension + 3 + 3 + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == expected_highest_z + ) + + +def test_get_highest_z_in_slot_with_single_module( + decoy: Decoy, + labware_view: LabwareView, + module_view: ModuleView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + ot2_standard_deck_def: DeckDefinitionV4, +) -> None: + """It should get the highest Z in slot with just a single module.""" + # Case: Slot has a module that doesn't have any labware on it. Highest z is equal to module height. + module_in_slot = LoadedModule.construct( + id="only-module", + model=ModuleModel.THERMOCYCLER_MODULE_V2, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + ) + + decoy.when(module_view.get_by_slot(DeckSlotName.SLOT_3)).then_return(module_in_slot) + decoy.when(labware_view.get_id_by_module("only-module")).then_raise( + errors.LabwareNotLoadedOnModuleError("only module") + ) + decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) + decoy.when( + module_view.get_module_highest_z( + module_id="only-module", deck_type=DeckType("ot2_standard") + ) + ).then_return(12345) + + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == 12345 + ) + + +# TODO (spp, 2023-12-05): this is mocking out too many things and is hard to follow. +# Create an integration test that loads labware and modules and tests the geometry +# in an easier-to-understand manner. +def test_get_highest_z_in_slot_with_stacked_labware_on_slot( + decoy: Decoy, + labware_view: LabwareView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + well_plate_def: LabwareDefinition, +) -> None: + """It should get the highest z in slot of the topmost labware in stack. + + Tests both `get_highest_z_in_slot` and `get_highest_z_of_labware_stack`. + """ + labware_in_slot = LoadedLabware( + id="bottom-labware-id", + loadName="bottom-labware-name", + definitionUri="bottom-definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), + offsetId="offset-id", + ) + middle_labware = LoadedLabware( + id="middle-labware-id", + loadName="middle-labware-name", + definitionUri="middle-definition-uri", + location=OnLabwareLocation(labwareId="bottom-labware-id"), + offsetId="offset-id", + ) + top_labware = LoadedLabware( + id="top-labware-id", + loadName="top-labware-name", + definitionUri="top-definition-uri", + location=OnLabwareLocation(labwareId="middle-labware-id"), + offsetId="offset-id", + ) + slot_pos = Point(11, 22, 33) + top_lw_lpc_offset = LabwareOffsetVector(x=1, y=-2, z=3) + + decoy.when(labware_view.get_by_slot(DeckSlotName.SLOT_3)).then_return( + labware_in_slot + ) + + decoy.when(labware_view.get_id_by_labware("bottom-labware-id")).then_return( + "middle-labware-id" + ) + decoy.when(labware_view.get_id_by_labware("middle-labware-id")).then_return( + "top-labware-id" + ) + decoy.when(labware_view.get_id_by_labware("top-labware-id")).then_raise( + errors.LabwareNotLoadedOnLabwareError("top labware") + ) + + decoy.when(labware_view.get("bottom-labware-id")).then_return(labware_in_slot) + decoy.when(labware_view.get("middle-labware-id")).then_return(middle_labware) + decoy.when(labware_view.get("top-labware-id")).then_return(top_labware) + + decoy.when(labware_view.get_definition("top-labware-id")).then_return( + well_plate_def + ) + decoy.when(labware_view.get_labware_offset_vector("top-labware-id")).then_return( + top_lw_lpc_offset + ) + decoy.when(labware_view.get_dimensions("middle-labware-id")).then_return( + Dimensions(x=10, y=20, z=30) + ) + decoy.when(labware_view.get_dimensions("bottom-labware-id")).then_return( + Dimensions(x=11, y=12, z=13) + ) + + decoy.when( + labware_view.get_labware_overlap_offsets( + "top-labware-id", below_labware_name="middle-labware-name" + ) + ).then_return(OverlapOffset(x=4, y=5, z=6)) + decoy.when( + labware_view.get_labware_overlap_offsets( + "middle-labware-id", below_labware_name="bottom-labware-name" + ) + ).then_return(OverlapOffset(x=7, y=8, z=9)) + + decoy.when( + addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) + ).then_return(slot_pos) + + expected_highest_z = ( + slot_pos.z + well_plate_def.dimensions.zDimension - 6 + 30 - 9 + 13 + 3 + ) + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == expected_highest_z + ) + + +# TODO (spp, 2023-12-05): this is mocking out too many things and is hard to follow. +# Create an integration test that loads labware and modules and tests the geometry +# in an easier-to-understand manner. +def test_get_highest_z_in_slot_with_labware_stack_on_module( + decoy: Decoy, + labware_view: LabwareView, + module_view: ModuleView, + addressable_area_view: AddressableAreaView, + subject: GeometryView, + well_plate_def: LabwareDefinition, + ot2_standard_deck_def: DeckDefinitionV4, +) -> None: + """It should get the highest z in slot of labware on module. + + Tests both `get_highest_z_in_slot` and `get_highest_z_of_labware_stack`. + """ + top_labware = LoadedLabware( + id="top-labware-id", + loadName="top-labware-name", + definitionUri="top-labware-uri", + location=OnLabwareLocation(labwareId="adapter-id"), + offsetId="offset-id1", + ) + adapter = LoadedLabware( + id="adapter-id", + loadName="adapter-name", + definitionUri="adapter-uri", + location=ModuleLocation(moduleId="module-id"), + offsetId="offset-id2", + ) + module_on_slot = LoadedModule.construct( + id="module-id", + model=ModuleModel.THERMOCYCLER_MODULE_V2, + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + ) + + slot_pos = Point(11, 22, 33) + top_lw_lpc_offset = LabwareOffsetVector(x=1, y=-2, z=3) + + decoy.when(module_view.get("module-id")).then_return(module_on_slot) + decoy.when(module_view.get_by_slot(DeckSlotName.SLOT_3)).then_return(module_on_slot) + + decoy.when(labware_view.get_id_by_module("module-id")).then_return("adapter-id") + decoy.when(labware_view.get_id_by_labware("adapter-id")).then_return( + "top-labware-id" + ) + decoy.when(labware_view.get_id_by_labware("top-labware-id")).then_raise( + errors.LabwareNotLoadedOnLabwareError("top labware") + ) + + decoy.when(labware_view.get_deck_definition()).then_return(ot2_standard_deck_def) + decoy.when(labware_view.get_definition("top-labware-id")).then_return( + well_plate_def + ) + + decoy.when(labware_view.get("adapter-id")).then_return(adapter) + decoy.when(labware_view.get("top-labware-id")).then_return(top_labware) + decoy.when(labware_view.get_labware_offset_vector("top-labware-id")).then_return( + top_lw_lpc_offset + ) + decoy.when(labware_view.get_dimensions("adapter-id")).then_return( + Dimensions(x=10, y=20, z=30) + ) + decoy.when( + labware_view.get_labware_overlap_offsets( + labware_id="top-labware-id", below_labware_name="adapter-name" + ) + ).then_return(OverlapOffset(x=4, y=5, z=6)) + + decoy.when(module_view.get_location("module-id")).then_return( + DeckSlotLocation(slotName=DeckSlotName.SLOT_3) + ) + decoy.when( + module_view.get_nominal_module_offset( + module_id="module-id", deck_type=DeckType("ot2_standard") + ) + ).then_return(LabwareOffsetVector(x=40, y=50, z=60)) + decoy.when(module_view.get_connected_model("module-id")).then_return( + ModuleModel.TEMPERATURE_MODULE_V2 + ) + + decoy.when( + labware_view.get_module_overlap_offsets( + "adapter-id", ModuleModel.TEMPERATURE_MODULE_V2 + ) + ).then_return(OverlapOffset(x=1.1, y=2.2, z=3.3)) + + decoy.when( + addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_3.id) + ).then_return(slot_pos) + + expected_highest_z = ( + slot_pos.z + 60 + 30 - 3.3 + well_plate_def.dimensions.zDimension - 6 + 3 + ) + assert ( + subject.get_highest_z_in_slot(DeckSlotLocation(slotName=DeckSlotName.SLOT_3)) + == expected_highest_z + ) + + @pytest.mark.parametrize( ["location", "min_z_height", "expected_min_z"], [ diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index e4498c0ec7d..586423a0d86 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -1,5 +1,6 @@ """Tests for module state accessors in the protocol engine state store.""" import pytest +from math import isclose from pytest_lazyfixture import lazy_fixture # type: ignore[import] from contextlib import nullcontext as does_not_raise @@ -1759,3 +1760,35 @@ def test_get_default_gripper_offsets( }, ) assert subject.get_default_gripper_offsets("module-1") == expected_offset_data + + +@pytest.mark.parametrize( + argnames=["deck_type", "slot_name", "expected_highest_z"], + argvalues=[ + (DeckType.OT2_STANDARD, DeckSlotName.SLOT_1, 84), + (DeckType.OT3_STANDARD, DeckSlotName.SLOT_D1, 12.91), + ], +) +def test_get_module_highest_z( + tempdeck_v2_def: ModuleDefinition, + deck_type: DeckType, + slot_name: DeckSlotName, + expected_highest_z: float, +) -> None: + """It should get the highest z point of the module.""" + subject = make_module_view( + slot_by_module_id={"module-id": slot_name}, + requested_model_by_module_id={ + "module-id": ModuleModel.TEMPERATURE_MODULE_V2, + }, + hardware_by_module_id={ + "module-id": HardwareModule( + serial_number="module-serial", + definition=tempdeck_v2_def, + ) + }, + ) + assert isclose( + subject.get_module_highest_z(module_id="module-id", deck_type=deck_type), + expected_highest_z, + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py index 4ddee00d410..f7b32c9d37e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py @@ -1,4 +1,6 @@ """Tests for pipette state accessors in the protocol_engine state store.""" +from collections import OrderedDict + import pytest from typing import cast, Dict, List, Optional @@ -6,7 +8,7 @@ from opentrons_shared_data.pipette import pipette_definition from opentrons.config.defaults_ot2 import Z_RETRACT_DISTANCE -from opentrons.types import MountType, Mount as HwMount +from opentrons.types import MountType, Mount as HwMount, Point from opentrons.hardware_control.dev_types import PipetteDict from opentrons.protocol_engine import errors from opentrons.protocol_engine.types import ( @@ -24,7 +26,7 @@ HardwarePipette, StaticPipetteConfig, ) -from opentrons.hardware_control.nozzle_manager import NozzleMap +from opentrons.hardware_control.nozzle_manager import NozzleMap, NozzleConfigurationType from opentrons.protocol_engine.errors import TipNotAttachedError, PipetteNotLoadedError @@ -512,3 +514,19 @@ def test_get_motor_axes( assert subject.get_z_axis("pipette-id") == expected_z_axis assert subject.get_plunger_axis("pipette-id") == expected_plunger_axis + + +def test_nozzle_configuration_getters() -> None: + """Test that pipette view returns correct nozzle configuration data.""" + nozzle_map = NozzleMap.build( + physical_nozzles=OrderedDict({"A1": Point(0, 0, 0)}), + physical_rows=OrderedDict({"A": ["A1"]}), + physical_columns=OrderedDict({"1": ["A1"]}), + starting_nozzle="A1", + back_left_nozzle="A1", + front_right_nozzle="A1", + ) + subject = get_pipette_view(nozzle_layout_by_id={"pipette-id": nozzle_map}) + assert subject.get_nozzle_layout_type("pipette-id") == NozzleConfigurationType.FULL + assert subject.get_is_partially_configured("pipette-id") is False + assert subject.get_primary_nozzle("pipette-id") == "A1" diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index b3cda3f232b..0db13d0bee5 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -42,7 +42,6 @@ "configured": "configured", "confirm_heater_shaker_module_modal_description": "Before the run begins, module should have both anchors fully extended for a firm attachment. The thermal adapter should be attached to the module. ", "confirm_heater_shaker_module_modal_title": "Confirm Heater-Shaker Module is attached", - "confirm_removal": "Confirm removal", "connect_all_hardware": "Connect and calibrate all hardware first", "connect_all_mod": "Connect all modules first", "connection_info_not_available": "Connection info not available once run has started", diff --git a/app/src/organisms/Devices/ProtocolRun/BackToTopButton.tsx b/app/src/organisms/Devices/ProtocolRun/BackToTopButton.tsx index d06cb18a651..7403f5a53e6 100644 --- a/app/src/organisms/Devices/ProtocolRun/BackToTopButton.tsx +++ b/app/src/organisms/Devices/ProtocolRun/BackToTopButton.tsx @@ -2,18 +2,12 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' -import { useHoverTooltip, SecondaryButton } from '@opentrons/components' +import { SecondaryButton } from '@opentrons/components' -import { Tooltip } from '../../../atoms/Tooltip' import { useTrackEvent, ANALYTICS_PROTOCOL_PROCEED_TO_RUN, } from '../../../redux/analytics' -import { - useUnmatchedModulesForProtocol, - useRunCalibrationStatus, - useRunHasStarted, -} from '../hooks' interface BackToTopButtonProps { protocolRunHeaderRef: React.RefObject | null @@ -29,34 +23,7 @@ export function BackToTopButton({ sourceLocation, }: BackToTopButtonProps): JSX.Element | null { const { t } = useTranslation('protocol_setup') - const [targetProps, tooltipProps] = useHoverTooltip() - const { missingModuleIds } = useUnmatchedModulesForProtocol(robotName, runId) const trackEvent = useTrackEvent() - const { complete: isCalibrationComplete } = useRunCalibrationStatus( - robotName, - runId - ) - const runHasStarted = useRunHasStarted(runId) - - const calibrationIncomplete = - missingModuleIds.length === 0 && !isCalibrationComplete - const moduleSetupIncomplete = - missingModuleIds.length > 0 && isCalibrationComplete - const moduleAndCalibrationIncomplete = - missingModuleIds.length > 0 && !isCalibrationComplete - - let proceedToRunDisabledReason = null - if (runHasStarted) { - proceedToRunDisabledReason = t('protocol_run_started') - } else if (moduleAndCalibrationIncomplete) { - proceedToRunDisabledReason = t( - 'run_disabled_modules_and_calibration_not_complete' - ) - } else if (calibrationIncomplete) { - proceedToRunDisabledReason = t('run_disabled_calibration_not_complete') - } else if (moduleSetupIncomplete) { - proceedToRunDisabledReason = t('run_disabled_modules_not_connected') - } return ( - + {t('back_to_top')} - {proceedToRunDisabledReason != null && ( - - {proceedToRunDisabledReason} - - )} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx index 0551b9fc22d..9f85cd93393 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx @@ -185,7 +185,7 @@ export const LocationConflictModal = ( /> @@ -282,9 +282,7 @@ export const LocationConflictModal = ( {i18n.format(t('shared:cancel'), 'capitalize')} - {requiredModule != null - ? t('confirm_removal') - : t('update_deck')} + {t('update_deck')} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx index 882ece03a2c..f79544bcddd 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/LocationConflictModal.test.tsx @@ -75,7 +75,7 @@ describe('LocationConflictModal', () => { getByText('Heater-Shaker Module GEN1') getByRole('button', { name: 'Cancel' }).click() expect(props.onCloseClick).toHaveBeenCalled() - getByRole('button', { name: 'Confirm removal' }).click() + getByRole('button', { name: 'Update deck' }).click() expect(mockUpdate).toHaveBeenCalled() }) it('should render correct info for a odd', () => { @@ -92,7 +92,7 @@ describe('LocationConflictModal', () => { getByText('Trash bin') getByText('Cancel').click() expect(props.onCloseClick).toHaveBeenCalled() - getByText('Confirm removal').click() + getByText('Update deck').click() expect(mockUpdate).toHaveBeenCalled() }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts index 2388de2b936..cfd9178347e 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts @@ -51,9 +51,14 @@ describe('getFixtureImage', () => { const result = getFixtureImage('wasteChuteRightAdapterNoCover') expect(result).toEqual('waste_chute.png') }) - it('should render the trash binimage', () => { + it('should render the waste chute staging area image', () => { + const result = getFixtureImage( + 'stagingAreaSlotWithWasteChuteRightAdapterCovered' + ) + expect(result).toEqual('waste_chute_with_staging_area.png') + }) + it('should render the trash bin image', () => { const result = getFixtureImage('trashBinAdapter') expect(result).toEqual('flex_trash_bin.png') }) - // TODO(jr, 10/17/23): add rest of the test cases when we add the assets }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts index ee5b72efee5..10bf9b5148d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts @@ -1,8 +1,8 @@ import { - SINGLE_SLOT_FIXTURES, STAGING_AREA_RIGHT_SLOT_FIXTURE, TRASH_BIN_ADAPTER_FIXTURE, - WASTE_CHUTE_FIXTURES, + WASTE_CHUTE_ONLY_FIXTURES, + WASTE_CHUTE_STAGING_AREA_FIXTURES, } from '@opentrons/shared-data' import magneticModule from '../../../../assets/images/magnetic_module_gen_2_transparent.png' @@ -14,15 +14,9 @@ import magneticBlockGen1 from '../../../../assets/images/magnetic_block_gen_1.pn import trashBin from '../../../../assets/images/flex_trash_bin.png' import stagingArea from '../../../../assets/images/staging_area_slot.png' import wasteChute from '../../../../assets/images/waste_chute.png' -// TODO(jr, 10/17/23): figure out if we need this asset, I'm stubbing it in for now -// import wasteChuteStagingArea from '../../../../assets/images/waste_chute_with_staging_area.png' +import wasteChuteStagingArea from '../../../../assets/images/waste_chute_with_staging_area.png' -import type { - CutoutFixtureId, - ModuleModel, - SingleSlotCutoutFixtureId, - WasteChuteCutoutFixtureId, -} from '@opentrons/shared-data' +import type { CutoutFixtureId, ModuleModel } from '@opentrons/shared-data' export function getModuleImage(model: ModuleModel): string { switch (model) { @@ -45,20 +39,14 @@ export function getModuleImage(model: ModuleModel): string { } } -// TODO(jr, 10/4/23): add correct assets for trashBin, standardSlot, wasteChuteAndStagingArea -export function getFixtureImage(fixture: CutoutFixtureId): string { - if (fixture === STAGING_AREA_RIGHT_SLOT_FIXTURE) { +export function getFixtureImage(cutoutFixtureId: CutoutFixtureId): string { + if (cutoutFixtureId === STAGING_AREA_RIGHT_SLOT_FIXTURE) { return stagingArea - } else if ( - WASTE_CHUTE_FIXTURES.includes(fixture as WasteChuteCutoutFixtureId) - ) { + } else if (WASTE_CHUTE_ONLY_FIXTURES.includes(cutoutFixtureId)) { return wasteChute - } else if ( - // TODO(bh, 2023-11-13): this asset probably won't exist - SINGLE_SLOT_FIXTURES.includes(fixture as SingleSlotCutoutFixtureId) - ) { - return stagingArea - } else if (fixture === TRASH_BIN_ADAPTER_FIXTURE) { + } else if (WASTE_CHUTE_STAGING_AREA_FIXTURES.includes(cutoutFixtureId)) { + return wasteChuteStagingArea + } else if (cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE) { return trashBin } else { return 'Error: unknown fixture' diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/BackToTopButton.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/BackToTopButton.test.tsx index 0e9023ebf3b..15faec46701 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/BackToTopButton.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/BackToTopButton.test.tsx @@ -9,11 +9,6 @@ import { useTrackEvent, ANALYTICS_PROTOCOL_PROCEED_TO_RUN, } from '../../../../redux/analytics' -import { - useRunCalibrationStatus, - useRunHasStarted, - useUnmatchedModulesForProtocol, -} from '../../hooks' import { BackToTopButton } from '../BackToTopButton' jest.mock('@opentrons/components', () => { @@ -24,17 +19,7 @@ jest.mock('@opentrons/components', () => { } }) jest.mock('../../../../redux/analytics') -jest.mock('../../hooks') -const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jest.MockedFunction< - typeof useUnmatchedModulesForProtocol -> -const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< - typeof useRunCalibrationStatus -> -const mockUseRunHasStarted = useRunHasStarted as jest.MockedFunction< - typeof useRunHasStarted -> const mockUseTrackEvent = useTrackEvent as jest.MockedFunction< typeof useTrackEvent > @@ -62,20 +47,6 @@ let mockTrackEvent: jest.Mock describe('BackToTopButton', () => { beforeEach(() => { - when(mockUseUnmatchedModulesForProtocol) - .calledWith(ROBOT_NAME, RUN_ID) - .mockReturnValue({ - missingModuleIds: [], - remainingAttachedModules: [], - }) - - when(mockUseRunCalibrationStatus) - .calledWith(ROBOT_NAME, RUN_ID) - .mockReturnValue({ - complete: true, - }) - when(mockUseRunHasStarted).calledWith(RUN_ID).mockReturnValue(false) - mockTrackEvent = jest.fn() when(mockUseTrackEvent).calledWith().mockReturnValue(mockTrackEvent) }) @@ -102,56 +73,9 @@ describe('BackToTopButton', () => { }) }) - it('should be disabled with modules not connected tooltip when there are missing moduleIds', () => { - when(mockUseUnmatchedModulesForProtocol) - .calledWith(ROBOT_NAME, RUN_ID) - .mockReturnValue({ - missingModuleIds: ['temperatureModuleV1'], - remainingAttachedModules: [], - }) - const { getByRole, getByText } = render() - const button = getByRole('button', { name: 'Back to top' }) - expect(button).toBeDisabled() - getByText('Make sure all modules are connected before proceeding to run') - }) - it('should be disabled with modules not connected and calibration not completed tooltip if missing cal and moduleIds', async () => { - when(mockUseUnmatchedModulesForProtocol) - .calledWith(ROBOT_NAME, RUN_ID) - .mockReturnValue({ - missingModuleIds: ['temperatureModuleV1'], - remainingAttachedModules: [], - }) - when(mockUseRunCalibrationStatus) - .calledWith(ROBOT_NAME, RUN_ID) - .mockReturnValue({ - complete: false, - }) - const { getByRole, getByText } = render() - const button = getByRole('button', { name: 'Back to top' }) - expect(button).toBeDisabled() - getByText( - 'Make sure robot calibration is complete and all modules are connected before proceeding to run' - ) - }) - it('should be disabled with calibration not complete tooltip when calibration not complete', async () => { - when(mockUseRunCalibrationStatus) - .calledWith(ROBOT_NAME, RUN_ID) - .mockReturnValue({ - complete: false, - }) - const { getByRole, getByText } = render() - const button = getByRole('button', { name: 'Back to top' }) - expect(button).toBeDisabled() - getByText( - 'Make sure robot calibration is complete before proceeding to run' - ) - }) - it('should be disabled with protocol run started tooltip when run has started', async () => { - when(mockUseRunHasStarted).calledWith(RUN_ID).mockReturnValue(true) - - const { getByRole, getByText } = render() + it('should always be enabled', () => { + const { getByRole } = render() const button = getByRole('button', { name: 'Back to top' }) - expect(button).toBeDisabled() - getByText('Protocol run started.') + expect(button).not.toBeDisabled() }) }) diff --git a/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx b/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx index 5dd209773c4..001c4c743ae 100644 --- a/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx +++ b/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx @@ -109,7 +109,10 @@ export const UnmountGripper = ( alignItems={isOnDevice ? ALIGN_CENTER : ALIGN_FLEX_END} gridGap={SPACING.spacing8} > - setShowGripperStillDetected(false)}> + setShowGripperStillDetected(false)} + > {t('shared:go_back')} diff --git a/app/src/organisms/PipetteWizardFlows/Results.tsx b/app/src/organisms/PipetteWizardFlows/Results.tsx index b7d4097e1f4..6f916aa2d6e 100644 --- a/app/src/organisms/PipetteWizardFlows/Results.tsx +++ b/app/src/organisms/PipetteWizardFlows/Results.tsx @@ -56,6 +56,7 @@ export const Results = (props: ResultsProps): JSX.Element => { hasCalData, isRobotMoving, requiredPipette, + errorMessage, setShowErrorMessage, nextMount, } = props @@ -296,7 +297,16 @@ export const Results = (props: ResultsProps): JSX.Element => { ) } if (isRobotMoving) return - + if (errorMessage != null) { + return ( + + ) + } return (