diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index d42ab954510..8b8a7df79e7 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -24,7 +24,6 @@ DEFAULT_LIQUID_PROBE_SETTINGS: Final[LiquidProbeSettings] = LiquidProbeSettings( starting_mount_height=100, - max_z_distance=40, mount_speed=10, plunger_speed=5, sensor_threshold_pascals=40, @@ -335,7 +334,6 @@ def _build_default_liquid_probe( starting_mount_height=from_conf.get( "starting_mount_height", default.starting_mount_height ), - max_z_distance=from_conf.get("max_z_distance", default.max_z_distance), mount_speed=from_conf.get("mount_speed", default.mount_speed), plunger_speed=from_conf.get("plunger_speed", default.plunger_speed), sensor_threshold_pascals=from_conf.get( diff --git a/api/src/opentrons/config/types.py b/api/src/opentrons/config/types.py index b696eab674c..64007756f72 100644 --- a/api/src/opentrons/config/types.py +++ b/api/src/opentrons/config/types.py @@ -132,7 +132,6 @@ class ZSenseSettings: @dataclass class LiquidProbeSettings: starting_mount_height: float - max_z_distance: float mount_speed: float plunger_speed: float sensor_threshold_pascals: float diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 791f81b1542..73029593fce 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -191,7 +191,7 @@ PipetteOverpressureError, FirmwareUpdateRequiredError, FailedGripperPickupError, - LiquidNotFoundError, + PipetteLiquidNotFoundError, CommunicationError, PythonException, UnsupportedHardwareCommand, @@ -1412,7 +1412,7 @@ async def liquid_probe( or positions[head_node].move_ack == MoveCompleteAck.complete_without_condition ): - raise LiquidNotFoundError( + raise PipetteLiquidNotFoundError( "Liquid not found during probe.", { str(node_to_axis(node)): str(point.motor_position) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 2539e0d80e0..7fb76e8f7d7 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -52,7 +52,7 @@ GripperNotPresentError, InvalidActuator, FirmwareUpdateFailedError, - LiquidNotFoundError, + PipetteLiquidNotFoundError, ) from .util import use_or_initialize_loop, check_motion_bounds @@ -2594,7 +2594,8 @@ def _get_probe_distances( async def liquid_probe( self, - mount: OT3Mount, + mount: Union[top_types.Mount, OT3Mount], + max_z_dist: float, probe_settings: Optional[LiquidProbeSettings] = None, probe: Optional[InstrumentProbeType] = None, ) -> float: @@ -2605,7 +2606,7 @@ async def liquid_probe( reading from the pressure sensor. If the move is completed without the specified threshold being triggered, a - LiquidNotFoundError error will be thrown. + PipetteLiquidNotFoundError error will be thrown. Otherwise, the function will stop moving once the threshold is triggered, and return the position of the @@ -2622,21 +2623,21 @@ async def liquid_probe( if not probe_settings: probe_settings = self.config.liquid_sense - pos = await self.gantry_position(mount, refresh=True) + pos = await self.gantry_position(checked_mount, refresh=True) probe_start_pos = pos._replace(z=probe_settings.starting_mount_height) - await self.move_to(mount, probe_start_pos) - total_z_travel = probe_settings.max_z_distance + await self.move_to(checked_mount, probe_start_pos) + total_z_travel = max_z_dist z_travels = self._get_probe_distances( checked_mount, total_z_travel, probe_settings.plunger_speed, probe_settings.mount_speed, ) - error: Optional[LiquidNotFoundError] = None + error: Optional[PipetteLiquidNotFoundError] = None for z_travel in z_travels: if probe_settings.aspirate_while_sensing: - await self._move_to_plunger_bottom(mount, rate=1.0) + await self._move_to_plunger_bottom(checked_mount, rate=1.0) else: # find the ideal travel distance by multiplying the plunger speed # by the time it will take to complete the z move. @@ -2656,7 +2657,7 @@ async def liquid_probe( await self._move(target_pos, speed=speed, acquire_lock=True) try: height = await self._liquid_probe_pass( - mount, + checked_mount, probe_settings, probe if probe else InstrumentProbeType.PRIMARY, z_travel, @@ -2664,9 +2665,9 @@ async def liquid_probe( # if we made it here without an error we found the liquid error = None break - except LiquidNotFoundError as lnfe: + except PipetteLiquidNotFoundError as lnfe: error = lnfe - await self.move_to(mount, probe_start_pos) + await self.move_to(checked_mount, probe_start_pos) if error is not None: # if we never found an liquid raise an error raise error diff --git a/api/src/opentrons/hardware_control/protocols/liquid_handler.py b/api/src/opentrons/hardware_control/protocols/liquid_handler.py index e55dbb88440..1aae0ec77ed 100644 --- a/api/src/opentrons/hardware_control/protocols/liquid_handler.py +++ b/api/src/opentrons/hardware_control/protocols/liquid_handler.py @@ -171,3 +171,16 @@ async def drop_tip( the ejector shroud after a drop. """ ... + + async def liquid_probe( + self, + mount: MountArgType, + max_z_dist: float, + ) -> float: + """Search for and return liquid level height using this pipette + at the current location. + + mount : Mount.LEFT or Mount.RIGHT + max_z_dist : maximum depth to probe for liquid + """ + ... diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index f061c96eee5..09ad591277e 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -325,6 +325,14 @@ VerifyTipPresenceCommandType, ) +from .liquid_probe import ( + LiquidProbe, + LiquidProbeParams, + LiquidProbeCreate, + LiquidProbeResult, + LiquidProbeCommandType, +) + __all__ = [ # command type unions "Command", @@ -566,4 +574,10 @@ "VerifyTipPresenceParams", "VerifyTipPresenceResult", "VerifyTipPresenceCommandType", + # liquid probe command bundle + "LiquidProbe", + "LiquidProbeParams", + "LiquidProbeCreate", + "LiquidProbeResult", + "LiquidProbeCommandType", ] diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 3f6c3db7574..68e59d5e3c5 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -7,7 +7,12 @@ from opentrons.util.get_union_elements import get_union_elements from .command import DefinedErrorData -from .pipetting_common import OverpressureError, OverpressureErrorInternalData +from .pipetting_common import ( + OverpressureError, + OverpressureErrorInternalData, + LiquidNotFoundError, + LiquidNotFoundErrorInternalData, +) from . import absorbance_reader from . import heater_shaker @@ -302,6 +307,14 @@ GetTipPresenceCommandType, ) +from .liquid_probe import ( + LiquidProbe, + LiquidProbeParams, + LiquidProbeCreate, + LiquidProbeResult, + LiquidProbeCommandType, +) + Command = Annotated[ Union[ Aspirate, @@ -339,6 +352,7 @@ SetStatusBar, VerifyTipPresence, GetTipPresence, + LiquidProbe, heater_shaker.WaitForTemperature, heater_shaker.SetTargetTemperature, heater_shaker.DeactivateHeater, @@ -406,6 +420,7 @@ SetStatusBarParams, VerifyTipPresenceParams, GetTipPresenceParams, + LiquidProbeParams, heater_shaker.WaitForTemperatureParams, heater_shaker.SetTargetTemperatureParams, heater_shaker.DeactivateHeaterParams, @@ -471,6 +486,7 @@ SetStatusBarCommandType, VerifyTipPresenceCommandType, GetTipPresenceCommandType, + LiquidProbeCommandType, heater_shaker.WaitForTemperatureCommandType, heater_shaker.SetTargetTemperatureCommandType, heater_shaker.DeactivateHeaterCommandType, @@ -537,6 +553,7 @@ SetStatusBarCreate, VerifyTipPresenceCreate, GetTipPresenceCreate, + LiquidProbeCreate, heater_shaker.WaitForTemperatureCreate, heater_shaker.SetTargetTemperatureCreate, heater_shaker.DeactivateHeaterCreate, @@ -604,6 +621,7 @@ SetStatusBarResult, VerifyTipPresenceResult, GetTipPresenceResult, + LiquidProbeResult, heater_shaker.WaitForTemperatureResult, heater_shaker.SetTargetTemperatureResult, heater_shaker.DeactivateHeaterResult, @@ -648,6 +666,7 @@ CommandDefinedErrorData = Union[ DefinedErrorData[TipPhysicallyMissingError, TipPhysicallyMissingErrorInternalData], DefinedErrorData[OverpressureError, OverpressureErrorInternalData], + DefinedErrorData[LiquidNotFoundError, LiquidNotFoundErrorInternalData], ] diff --git a/api/src/opentrons/protocol_engine/commands/liquid_probe.py b/api/src/opentrons/protocol_engine/commands/liquid_probe.py new file mode 100644 index 00000000000..9da9fe31908 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/liquid_probe.py @@ -0,0 +1,154 @@ +"""Liquid-probe command for OT3 hardware. request, result, and implementation models.""" +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Type, Union +from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError +from typing_extensions import Literal + +from pydantic import Field + +from ..types import WellLocation, WellOrigin, CurrentWell, DeckPoint +from .pipetting_common import ( + LiquidNotFoundError, + LiquidNotFoundErrorInternalData, + PipetteIdMixin, + WellLocationMixin, + DestinationPositionResult, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + DefinedErrorData, + SuccessData, +) +from ..errors.error_occurrence import ErrorOccurrence + +if TYPE_CHECKING: + from ..execution import MovementHandler, PipettingHandler + from ..resources import ModelUtils + + +LiquidProbeCommandType = Literal["liquidProbe"] + + +class LiquidProbeParams(PipetteIdMixin, WellLocationMixin): + """Parameters required to liquid probe a specific well.""" + + pass + + +class LiquidProbeResult(DestinationPositionResult): + """Result data from the execution of a liquid-probe command.""" + + z_position: float = Field( + ..., description="The Z coordinate, in mm, of the found liquid in deck space." + ) + + +_ExecuteReturn = Union[ + SuccessData[LiquidProbeResult, None], + DefinedErrorData[LiquidNotFoundError, LiquidNotFoundErrorInternalData], +] + + +class LiquidProbeImplementation(AbstractCommandImpl[LiquidProbeParams, _ExecuteReturn]): + """The implementation of a `liquidProbe` command.""" + + def __init__( + self, + movement: MovementHandler, + pipetting: PipettingHandler, + model_utils: ModelUtils, + **kwargs: object, + ) -> None: + self._movement = movement + self._pipetting = pipetting + self._model_utils = model_utils + + async def execute(self, params: LiquidProbeParams) -> _ExecuteReturn: + """Move to and liquid probe the requested well. + + Return the z-position of the found liquid. + + Raises: + LiquidNotFoundError: if liquid is not found during the probe process. + """ + pipette_id = params.pipetteId + labware_id = params.labwareId + well_name = params.wellName + + ready_to_probe = self._pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id) + + current_well = None + + if not ready_to_probe: + await self._movement.move_to_well( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=WellLocation(origin=WellOrigin.TOP), + ) + + current_well = CurrentWell( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + ) + + # liquid_probe process start position + position = await self._movement.move_to_well( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=params.wellLocation, + current_well=current_well, + ) + + try: + z_pos = await self._pipetting.liquid_probe_in_place( + pipette_id=pipette_id, labware_id=labware_id, well_name=well_name + ) + except PipetteLiquidNotFoundError as e: + return DefinedErrorData( + public=LiquidNotFoundError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + ), + private=LiquidNotFoundErrorInternalData( + position=DeckPoint(x=position.x, y=position.y, z=position.z) + ), + ) + else: + return SuccessData( + public=LiquidProbeResult( + z_position=z_pos, + position=DeckPoint(x=position.x, y=position.y, z=position.z), + ), + private=None, + ) + + +class LiquidProbe(BaseCommand[LiquidProbeParams, LiquidProbeResult, ErrorOccurrence]): + """LiquidProbe command model.""" + + commandType: LiquidProbeCommandType = "liquidProbe" + params: LiquidProbeParams + result: Optional[LiquidProbeResult] + + _ImplementationCls: Type[LiquidProbeImplementation] = LiquidProbeImplementation + + +class LiquidProbeCreate(BaseCommandCreate[LiquidProbeParams]): + """Create LiquidProbe command request model.""" + + commandType: LiquidProbeCommandType = "liquidProbe" + params: LiquidProbeParams + + _CommandCls: Type[LiquidProbe] = LiquidProbe diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index 9b080275898..6b0e8833fb9 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -145,3 +145,25 @@ class OverpressureErrorInternalData: position: DeckPoint """Same meaning as DestinationPositionResult.position.""" + + +class LiquidNotFoundError(ErrorOccurrence): + """Returned when no liquid is detected during the liquid probe process/move. + + After a failed probing, the pipette returns to the process start position. + """ + + isDefined: bool = True + + errorType: Literal["LiquidNotFound"] = "LiquidNotFound" + + errorCode: str = ErrorCodes.PIPETTE_LIQUID_NOT_FOUND.value.code + detail: str = ErrorCodes.PIPETTE_LIQUID_NOT_FOUND.value.detail + + +@dataclass(frozen=True) +class LiquidNotFoundErrorInternalData: + """Internal-to-ProtocolEngine data about a LiquidNotFoundError.""" + + position: DeckPoint + """Same meaning as DestinationPositionResult.position.""" diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index ab57f6e8b68..05a217b45ee 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -60,6 +60,14 @@ async def blow_out_in_place( ) -> None: """Set flow rate and blow-out.""" + async def liquid_probe_in_place( + self, + pipette_id: str, + labware_id: str, + well_name: str, + ) -> float: + """Detect liquid level.""" + class HardwarePipettingHandler(PipettingHandler): """Liquid handling, using the Hardware API.""" "" @@ -156,6 +164,24 @@ async def blow_out_in_place( with self._set_flow_rate(pipette=hw_pipette, blow_out_flow_rate=flow_rate): await self._hardware_api.blow_out(mount=hw_pipette.mount) + async def liquid_probe_in_place( + self, + pipette_id: str, + labware_id: str, + well_name: str, + ) -> float: + """Detect liquid level.""" + hw_pipette = self._state_view.pipettes.get_hardware_pipette( + pipette_id=pipette_id, + attached_pipettes=self._hardware_api.attached_instruments, + ) + well_def = self._state_view.labware.get_well_definition(labware_id, well_name) + well_depth = well_def.depth + z_pos = await self._hardware_api.liquid_probe( + mount=hw_pipette.mount, max_z_dist=well_depth + ) + return float(z_pos) + @contextmanager def _set_flow_rate( self, @@ -245,6 +271,16 @@ async def blow_out_in_place( ) -> None: """Virtually blow out (no-op).""" + async def liquid_probe_in_place( + self, + pipette_id: str, + labware_id: str, + well_name: str, + ) -> float: + """Detect liquid level.""" + # TODO (pm, 6-18-24): return a value of worth if needed + return 0.0 + def _validate_tip_attached(self, pipette_id: str, command_name: str) -> None: """Validate if there is a tip attached.""" tip_geometry = self._state_view.pipettes.get_attached_tip(pipette_id) diff --git a/api/tests/opentrons/config/ot3_settings.py b/api/tests/opentrons/config/ot3_settings.py index dccb3bac320..2ca346d854e 100644 --- a/api/tests/opentrons/config/ot3_settings.py +++ b/api/tests/opentrons/config/ot3_settings.py @@ -119,7 +119,6 @@ "gripper_mount_offset": (1, 1, 1), "liquid_sense": { "starting_mount_height": 80, - "max_z_distance": 20, "mount_speed": 10, "plunger_speed": 10, "sensor_threshold_pascals": 17, diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index 1fb0c6fa60b..c53a326cd64 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -95,7 +95,7 @@ EStopNotPresentError, FirmwareUpdateRequiredError, FailedGripperPickupError, - LiquidNotFoundError, + PipetteLiquidNotFoundError, ) from opentrons_hardware.hardware_control.move_group_runner import MoveGroupRunner @@ -177,7 +177,6 @@ def controller( def fake_liquid_settings() -> LiquidProbeSettings: return LiquidProbeSettings( starting_mount_height=100, - max_z_distance=15, mount_speed=40, plunger_speed=10, sensor_threshold_pascals=15, @@ -712,16 +711,17 @@ async def test_liquid_probe( mock_move_group_run: mock.AsyncMock, mock_send_stop_threshold: mock.AsyncMock, ) -> None: + fake_max_z_dist = 15.0 try: await controller.liquid_probe( mount=mount, - max_z_distance=fake_liquid_settings.max_z_distance, + max_z_distance=fake_max_z_dist, mount_speed=fake_liquid_settings.mount_speed, plunger_speed=fake_liquid_settings.plunger_speed, threshold_pascals=fake_liquid_settings.sensor_threshold_pascals, output_option=fake_liquid_settings.output_option, ) - except LiquidNotFoundError: + except PipetteLiquidNotFoundError: # the move raises a liquid not found now since we don't call the move group and it doesn't # get any positions back pass diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 338d529caef..695630dad98 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -74,7 +74,7 @@ GripperNotPresentError, CommandPreconditionViolated, CommandParameterLimitViolated, - LiquidNotFoundError, + PipetteLiquidNotFoundError, ) from opentrons_shared_data.gripper.gripper_definition import GripperModel from opentrons_shared_data.pipette.types import ( @@ -116,7 +116,6 @@ def fake_settings() -> CapacitivePassSettings: def fake_liquid_settings() -> LiquidProbeSettings: return LiquidProbeSettings( starting_mount_height=100, - max_z_distance=15, mount_speed=40, plunger_speed=10, sensor_threshold_pascals=15, @@ -825,7 +824,6 @@ async def test_liquid_probe( mock_liquid_probe.return_value = return_dict fake_settings_aspirate = LiquidProbeSettings( starting_mount_height=100, - max_z_distance=15, mount_speed=40, plunger_speed=10, sensor_threshold_pascals=15, @@ -833,11 +831,12 @@ async def test_liquid_probe( aspirate_while_sensing=True, data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) - await ot3_hardware.liquid_probe(mount, fake_settings_aspirate) + fake_max_z_dist = 10.0 + await ot3_hardware.liquid_probe(mount, fake_max_z_dist, fake_settings_aspirate) mock_move_to_plunger_bottom.assert_called_once() mock_liquid_probe.assert_called_once_with( mount, - fake_settings_aspirate.max_z_distance, + fake_max_z_dist, fake_settings_aspirate.mount_speed, (fake_settings_aspirate.plunger_speed * -1), fake_settings_aspirate.sensor_threshold_pascals, @@ -849,7 +848,7 @@ async def test_liquid_probe( return_dict[head_node], return_dict[pipette_node] = 142, 142 mock_liquid_probe.return_value = return_dict await ot3_hardware.liquid_probe( - mount, fake_liquid_settings + mount, fake_max_z_dist, fake_liquid_settings ) # should raise no exceptions @@ -883,13 +882,16 @@ async def test_multi_liquid_probe( NodeId.gantry_y: 0, NodeId.pipette_left: 0, } - side_effects = [LiquidNotFoundError(), LiquidNotFoundError(), return_dict] + side_effects = [ + PipetteLiquidNotFoundError(), + PipetteLiquidNotFoundError(), + return_dict, + ] # make sure aspirate while sensing reverses direction mock_liquid_probe.side_effect = side_effects fake_settings_aspirate = LiquidProbeSettings( starting_mount_height=100, - max_z_distance=3, mount_speed=1, plunger_speed=71.5, sensor_threshold_pascals=15, @@ -897,7 +899,10 @@ async def test_multi_liquid_probe( aspirate_while_sensing=True, data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) - await ot3_hardware.liquid_probe(OT3Mount.LEFT, fake_settings_aspirate) + fake_max_z_dist = 10.0 + await ot3_hardware.liquid_probe( + OT3Mount.LEFT, fake_max_z_dist, fake_settings_aspirate + ) assert mock_move_to_plunger_bottom.call_count == 3 mock_liquid_probe.assert_called_with( OT3Mount.LEFT, @@ -946,16 +951,15 @@ async def test_liquid_not_found( NodeId.pipette_left: 0, } side_effects = [ - LiquidNotFoundError(), - LiquidNotFoundError(), - LiquidNotFoundError(), + PipetteLiquidNotFoundError(), + PipetteLiquidNotFoundError(), + PipetteLiquidNotFoundError(), ] # make sure aspirate while sensing reverses direction mock_liquid_probe.side_effect = side_effects fake_settings_aspirate = LiquidProbeSettings( starting_mount_height=100, - max_z_distance=3, mount_speed=1, plunger_speed=71.5, sensor_threshold_pascals=15, @@ -963,8 +967,11 @@ async def test_liquid_not_found( aspirate_while_sensing=True, data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) - with pytest.raises(LiquidNotFoundError): - await ot3_hardware.liquid_probe(OT3Mount.LEFT, fake_settings_aspirate) + fake_max_z_dist = 3.0 + with pytest.raises(PipetteLiquidNotFoundError): + await ot3_hardware.liquid_probe( + OT3Mount.LEFT, fake_max_z_dist, fake_settings_aspirate + ) assert mock_move_to_plunger_bottom.call_count == 3 mock_liquid_probe.assert_called_with( OT3Mount.LEFT, diff --git a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py new file mode 100644 index 00000000000..63988487e93 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py @@ -0,0 +1,214 @@ +"""Test LiquidProbe commands.""" +from datetime import datetime + +from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError +from decoy import matchers, Decoy +import pytest + +from opentrons.protocol_engine.commands.pipetting_common import ( + LiquidNotFoundError, + LiquidNotFoundErrorInternalData, +) +from opentrons.types import MountType, Point +from opentrons.protocol_engine import WellLocation, WellOrigin, WellOffset, DeckPoint + +from opentrons.protocol_engine.commands.liquid_probe import ( + LiquidProbeParams, + LiquidProbeResult, + LiquidProbeImplementation, +) +from opentrons.protocol_engine.commands.command import DefinedErrorData, SuccessData + +from opentrons.protocol_engine.state import StateView + +from opentrons.protocol_engine.execution import ( + MovementHandler, + PipettingHandler, +) +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.types import CurrentWell, LoadedPipette + + +@pytest.fixture +def subject( + movement: MovementHandler, + pipetting: PipettingHandler, + model_utils: ModelUtils, +) -> LiquidProbeImplementation: + """Get the implementation subject.""" + return LiquidProbeImplementation( + pipetting=pipetting, + movement=movement, + model_utils=model_utils, + ) + + +async def test_liquid_probe_implementation_no_prep( + decoy: Decoy, + movement: MovementHandler, + pipetting: PipettingHandler, + subject: LiquidProbeImplementation, +) -> None: + """A Liquid Probe should have an execution implementation without preparing to aspirate.""" + location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) + + data = LiquidProbeParams( + pipetteId="abc", + labwareId="123", + wellName="A3", + wellLocation=location, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id="abc")).then_return(True) + + decoy.when( + await movement.move_to_well( + pipette_id="abc", + labware_id="123", + well_name="A3", + well_location=location, + current_well=None, + ), + ).then_return(Point(x=1, y=2, z=3)) + + decoy.when( + await pipetting.liquid_probe_in_place( + pipette_id="abc", + labware_id="123", + well_name="A3", + ), + ).then_return(15.0) + + result = await subject.execute(data) + + assert result == SuccessData( + public=LiquidProbeResult(z_position=15.0, position=DeckPoint(x=1, y=2, z=3)), + private=None, + ) + + +async def test_liquid_probe_implementation_with_prep( + decoy: Decoy, + state_view: StateView, + movement: MovementHandler, + pipetting: PipettingHandler, + subject: LiquidProbeImplementation, +) -> None: + """A Liquid Probe should have an execution implementation with preparing to aspirate.""" + location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) + + data = LiquidProbeParams( + pipetteId="abc", + labwareId="123", + wellName="A3", + wellLocation=location, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id="abc")).then_return(False) + + decoy.when(state_view.pipettes.get(pipette_id="abc")).then_return( + LoadedPipette.construct( # type:ignore[call-arg] + mount=MountType.LEFT + ) + ) + decoy.when( + await movement.move_to_well( + pipette_id="abc", + labware_id="123", + well_name="A3", + well_location=location, + current_well=CurrentWell( + pipette_id="abc", + labware_id="123", + well_name="A3", + ), + ), + ).then_return(Point(x=1, y=2, z=3)) + + decoy.when( + await pipetting.liquid_probe_in_place( + pipette_id="abc", + labware_id="123", + well_name="A3", + ), + ).then_return(15.0) + + result = await subject.execute(data) + + assert result == SuccessData( + public=LiquidProbeResult(z_position=15.0, position=DeckPoint(x=1, y=2, z=3)), + private=None, + ) + + decoy.verify( + await movement.move_to_well( + pipette_id="abc", + labware_id="123", + well_name="A3", + well_location=WellLocation(origin=WellOrigin.TOP), + ), + ) + + +async def test_liquid_not_found_error( + decoy: Decoy, + movement: MovementHandler, + pipetting: PipettingHandler, + subject: LiquidProbeImplementation, + model_utils: ModelUtils, +) -> None: + """It should return a liquid not found error if the hardware API indicates that.""" + pipette_id = "pipette-id" + labware_id = "labware-id" + well_name = "well-name" + well_location = WellLocation( + origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) + ) + + position = Point(x=1, y=2, z=3) + + error_id = "error-id" + error_timestamp = datetime(year=2020, month=1, day=2) + + data = LiquidProbeParams( + pipetteId=pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id)).then_return( + True + ) + + decoy.when( + await movement.move_to_well( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + current_well=None, + ), + ).then_return(position) + + decoy.when( + await pipetting.liquid_probe_in_place( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + ), + ).then_raise(PipetteLiquidNotFoundError()) + + decoy.when(model_utils.generate_id()).then_return(error_id) + decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) + + result = await subject.execute(data) + + assert result == DefinedErrorData( + public=LiquidNotFoundError.construct( + id=error_id, createdAt=error_timestamp, wrappedErrors=[matchers.Anything()] + ), + private=LiquidNotFoundErrorInternalData( + position=DeckPoint(x=position.x, y=position.y, z=position.z) + ), + ) diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index 85add128ab4..909739c7b31 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -177,7 +177,6 @@ def _get_liquid_probe_settings( ][cfg.tip_volume] return LiquidProbeSettings( starting_mount_height=well.top().point.z, - max_z_distance=min(well.depth, lqid_cfg["max_z_distance"]), mount_speed=lqid_cfg["mount_speed"], plunger_speed=lqid_cfg["plunger_speed"], sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index 842788bfd5b..02510c99f24 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -200,11 +200,11 @@ def _sense_liquid_height( lps = config._get_liquid_probe_settings(cfg, well) # NOTE: very important that probing is done only 1x time, # with a DRY tip, for reliability - probed_z = hwapi.liquid_probe(OT3Mount.LEFT, lps) + probed_z = hwapi.liquid_probe(OT3Mount.LEFT, well.depth, lps) if ctx.is_simulating(): probed_z = well.top().point.z - 1 liq_height = probed_z - well.bottom().point.z - if abs(liq_height - lps.max_z_distance) < 0.01: + if abs(liq_height - well.depth) < 0.01: raise RuntimeError("unable to probe liquid, reach max travel distance") return liq_height diff --git a/hardware-testing/hardware_testing/liquid_sense/execute.py b/hardware-testing/hardware_testing/liquid_sense/execute.py index ef9bd76a836..027d25bc633 100644 --- a/hardware-testing/hardware_testing/liquid_sense/execute.py +++ b/hardware-testing/hardware_testing/liquid_sense/execute.py @@ -26,7 +26,7 @@ from opentrons.protocol_api import ProtocolContext, Well, Labware -from opentrons_shared_data.errors.exceptions import LiquidNotFoundError +from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError try: from abr_testing.automation import google_sheets_tool @@ -406,7 +406,6 @@ def _run_trial( for z_dist in z_distances: lps = LiquidProbeSettings( starting_mount_height=start_height, - max_z_distance=z_dist, mount_speed=run_args.z_speed, plunger_speed=plunger_speed, sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], @@ -419,8 +418,8 @@ def _run_trial( run_args.recorder.set_sample_tag(f"trial-{trial}-{tip}ul") # TODO add in stuff for secondary probe try: - height = hw_api.liquid_probe(hw_mount, lps, probe_target) - except LiquidNotFoundError as lnf: + height = hw_api.liquid_probe(hw_mount, z_dist, lps, probe_target) + except PipetteLiquidNotFoundError as lnf: ui.print_info(f"Liquid not found current position {lnf.detail}") start_height -= z_dist else: diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index 856ac153c74..e477389667f 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -1363,7 +1363,7 @@ async def _test_liquid_probe( } hover_mm = 3 max_submerge_mm = -3 - max_z_distance_machine_coords = hover_mm - max_submerge_mm + max_z_distance_machine_coords = hover_mm - max_submerge_mm # FIXME: deck coords assert CALIBRATED_LABWARE_LOCATIONS.plate_primary is not None if InstrumentProbeType.SECONDARY in probes: assert CALIBRATED_LABWARE_LOCATIONS.plate_secondary is not None @@ -1376,7 +1376,6 @@ async def _test_liquid_probe( probe_cfg = PROBE_SETTINGS[pip_vol][tip_volume] probe_settings = LiquidProbeSettings( starting_mount_height=start_pos.z, - max_z_distance=max_z_distance_machine_coords, # FIXME: deck coords mount_speed=probe_cfg.mount_speed, plunger_speed=probe_cfg.plunger_speed, sensor_threshold_pascals=probe_cfg.sensor_threshold_pascals, @@ -1384,7 +1383,9 @@ async def _test_liquid_probe( aspirate_while_sensing=False, data_files=None, ) - end_z = await api.liquid_probe(mount, probe_settings, probe=probe) + end_z = await api.liquid_probe( + mount, max_z_distance_machine_coords, probe_settings, probe=probe + ) if probe == InstrumentProbeType.PRIMARY: pz = CALIBRATED_LABWARE_LOCATIONS.plate_primary.z else: diff --git a/hardware/opentrons_hardware/hardware_control/sensor_utils.py b/hardware/opentrons_hardware/hardware_control/sensor_utils.py index f3aec6c6ad0..215a664856a 100644 --- a/hardware/opentrons_hardware/hardware_control/sensor_utils.py +++ b/hardware/opentrons_hardware/hardware_control/sensor_utils.py @@ -2,7 +2,7 @@ from opentrons_shared_data.errors.exceptions import ( TipHitWellBottomError, - LiquidNotFoundError, + PipetteLiquidNotFoundError, ) @@ -14,7 +14,7 @@ def did_tip_hit_liquid( ) -> bool: """Detects if tip has hit liquid or solid based on given pressure data.""" if len(pressure_readings) < 5: - raise LiquidNotFoundError( + raise PipetteLiquidNotFoundError( "Liquid not found. Not enough data to calculate pressure change", ) pressure_difference = np.gradient(pressure_readings[-5:], 1) diff --git a/shared-data/command/schemas/8.json b/shared-data/command/schemas/8.json index dc58d544510..11f0edf7b69 100644 --- a/shared-data/command/schemas/8.json +++ b/shared-data/command/schemas/8.json @@ -40,6 +40,7 @@ "setStatusBar": "#/definitions/SetStatusBarCreate", "verifyTipPresence": "#/definitions/VerifyTipPresenceCreate", "getTipPresence": "#/definitions/GetTipPresenceCreate", + "liquidProbe": "#/definitions/LiquidProbeCreate", "heaterShaker/waitForTemperature": "#/definitions/opentrons__protocol_engine__commands__heater_shaker__wait_for_temperature__WaitForTemperatureCreate", "heaterShaker/setTargetTemperature": "#/definitions/opentrons__protocol_engine__commands__heater_shaker__set_target_temperature__SetTargetTemperatureCreate", "heaterShaker/deactivateHeater": "#/definitions/DeactivateHeaterCreate", @@ -175,6 +176,9 @@ { "$ref": "#/definitions/GetTipPresenceCreate" }, + { + "$ref": "#/definitions/LiquidProbeCreate" + }, { "$ref": "#/definitions/opentrons__protocol_engine__commands__heater_shaker__wait_for_temperature__WaitForTemperatureCreate" }, @@ -2746,6 +2750,68 @@ }, "required": ["params"] }, + "LiquidProbeParams": { + "title": "LiquidProbeParams", + "description": "Parameters required to liquid probe a specific well.", + "type": "object", + "properties": { + "labwareId": { + "title": "Labwareid", + "description": "Identifier of labware to use.", + "type": "string" + }, + "wellName": { + "title": "Wellname", + "description": "Name of well to use in labware.", + "type": "string" + }, + "wellLocation": { + "title": "Welllocation", + "description": "Relative well location at which to perform the operation", + "allOf": [ + { + "$ref": "#/definitions/WellLocation" + } + ] + }, + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + } + }, + "required": ["labwareId", "wellName", "pipetteId"] + }, + "LiquidProbeCreate": { + "title": "LiquidProbeCreate", + "description": "Create LiquidProbe command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "liquidProbe", + "enum": ["liquidProbe"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/LiquidProbeParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] + }, "opentrons__protocol_engine__commands__heater_shaker__wait_for_temperature__WaitForTemperatureParams": { "title": "WaitForTemperatureParams", "description": "Input parameters to wait for a Heater-Shaker's target temperature.", diff --git a/shared-data/python/opentrons_shared_data/errors/codes.py b/shared-data/python/opentrons_shared_data/errors/codes.py index 53dec3d5c62..3a3d5176794 100644 --- a/shared-data/python/opentrons_shared_data/errors/codes.py +++ b/shared-data/python/opentrons_shared_data/errors/codes.py @@ -60,7 +60,7 @@ class ErrorCodes(Enum): EXECUTION_CANCELLED = _code_from_dict_entry("2014") FAILED_GRIPPER_PICKUP_ERROR = _code_from_dict_entry("2015") MOTOR_DRIVER_ERROR = _code_from_dict_entry("2016") - LIQUID_NOT_FOUND = _code_from_dict_entry("2017") + PIPETTE_LIQUID_NOT_FOUND = _code_from_dict_entry("2017") TIP_HIT_WELL_BOTTOM = _code_from_dict_entry("2018") ROBOTICS_INTERACTION_ERROR = _code_from_dict_entry("3000") LABWARE_DROPPED = _code_from_dict_entry("3001") diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 83529b3741b..43d94e11a0b 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -611,7 +611,7 @@ def __init__( super().__init__(ErrorCodes.MOTOR_DRIVER_ERROR, message, detail, wrapping) -class LiquidNotFoundError(RoboticsControlError): +class PipetteLiquidNotFoundError(RoboticsControlError): """Error raised if liquid sensing move completes without detecting liquid.""" def __init__( @@ -620,9 +620,9 @@ def __init__( detail: Optional[Dict[str, str]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Initialize LiquidNotFoundError.""" + """Initialize PipetteLiquidNotFoundError.""" super().__init__( - ErrorCodes.LIQUID_NOT_FOUND, + ErrorCodes.PIPETTE_LIQUID_NOT_FOUND, message, detail, wrapping,