From 8569e32d2d918abb1f232f48a7b28385021215fd Mon Sep 17 00:00:00 2001 From: Alise Au <20424172+ahiuchingau@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:47:04 -0400 Subject: [PATCH] refactor(api): hardware controller use error codes (#13318) --- .../opentrons/hardware_control/__init__.py | 4 - api/src/opentrons/hardware_control/api.py | 77 +++++++---- .../hardware_control/backends/controller.py | 12 +- .../backends/ot3controller.py | 26 ++-- .../hardware_control/backends/simulator.py | 12 +- .../backends/subsystem_manager.py | 5 + api/src/opentrons/hardware_control/errors.py | 123 ++++-------------- .../hardware_control/execution_manager.py | 2 +- .../instruments/ot2/pipette.py | 6 +- .../instruments/ot2/pipette_handler.py | 24 ++-- .../instruments/ot3/gripper.py | 27 +++- .../instruments/ot3/gripper_handler.py | 41 +++--- .../instruments/ot3/pipette.py | 6 +- .../instruments/ot3/pipette_handler.py | 22 ++-- api/src/opentrons/hardware_control/ot3api.py | 76 ++++++----- .../protocols/motion_controller.py | 4 +- api/src/opentrons/hardware_control/util.py | 34 ++--- .../legacy_instrument_core.py | 17 ++- .../protocol_api/instrument_context.py | 15 ++- .../protocol_engine/errors/exceptions.py | 6 +- .../protocol_engine/execution/gantry_mover.py | 12 +- .../protocol_engine/execution/movement.py | 4 +- .../protocols/execution/execute_python.py | 2 +- .../backends/test_ot3_controller.py | 6 +- .../test_execution_manager.py | 2 +- .../opentrons/hardware_control/test_moves.py | 16 ++- .../hardware_control/test_ot3_api.py | 30 ++--- .../core/simulator/test_instrument_context.py | 20 +-- .../protocol_api_old/test_context.py | 7 +- .../execution/test_gantry_mover.py | 6 +- .../execution/test_movement_handler.py | 4 +- .../scripts/speed_accel_profile.py | 4 +- .../robot_server/errors/exception_handlers.py | 7 +- .../runs/test_json_v6_run_failure.tavern.yaml | 21 ++- .../runs/test_papi_v2_run_failure.tavern.yaml | 7 +- shared-data/errors/definitions/1/errors.json | 8 ++ .../opentrons_shared_data/errors/codes.py | 2 + .../errors/exceptions.py | 74 ++++++++++- 38 files changed, 433 insertions(+), 338 deletions(-) diff --git a/api/src/opentrons/hardware_control/__init__.py b/api/src/opentrons/hardware_control/__init__.py index b8e1c015bbb..356923f1aff 100644 --- a/api/src/opentrons/hardware_control/__init__.py +++ b/api/src/opentrons/hardware_control/__init__.py @@ -14,7 +14,6 @@ from .pause_manager import PauseManager from .backends import Controller, Simulator from .types import CriticalPoint, ExecutionState -from .errors import ExecutionCancelledError, NoTipAttachedError, TipAttachedError from .constants import DROP_TIP_RELEASE_DISTANCE from .thread_manager import ThreadManager from .execution_manager import ExecutionManager @@ -48,13 +47,10 @@ "SynchronousAdapter", "HardwareControlAPI", "CriticalPoint", - "NoTipAttachedError", - "TipAttachedError", "DROP_TIP_RELEASE_DISTANCE", "ThreadManager", "ExecutionManager", "ExecutionState", - "ExecutionCancelledError", "ThreadedAsyncLock", "ThreadedAsyncForbidden", "ThreadManagedHardware", diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index b0765e590c0..cce958663c4 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -20,6 +20,10 @@ cast, ) +from opentrons_shared_data.errors.exceptions import ( + PositionUnknownError, + UnsupportedHardwareCommand, +) from opentrons_shared_data.pipette import ( pipette_load_name_conversions as pipette_load_name, ) @@ -54,10 +58,6 @@ SubSystem, SubSystemState, ) -from .errors import ( - MustHomeError, - NotSupportedByHardware, -) from . import modules from .robot_calibration import ( RobotCalibrationProvider, @@ -601,10 +601,13 @@ async def home(self, axes: Optional[List[Axis]] = None) -> None: # No internal code passes OT3 axes as arguments on an OT2. But a user/ client # can still explicitly specify an OT3 axis even when working on an OT2. # Adding this check in order to prevent misuse of axes types. - if axes and any(axis not in Axis.ot2_axes() for axis in axes): - raise NotSupportedByHardware( - f"At least one axis in {axes} is not supported on the OT2." - ) + if axes: + unsupported = list(axis not in Axis.ot2_axes() for axis in axes) + if any(unsupported): + raise UnsupportedHardwareCommand( + message=f"At least one axis in {axes} is not supported on the OT2.", + detail={"unsupported_axes": unsupported}, + ) self._reset_last_mount() # Initialize/update current_position checked_axes = axes or [ax for ax in Axis.ot2_axes()] @@ -641,16 +644,24 @@ async def current_position( plunger_ax = Axis.of_plunger(mount) position_axes = [Axis.X, Axis.Y, z_ax, plunger_ax] - if fail_on_not_homed and ( - not self._backend.is_homed([ot2_axis_to_string(a) for a in position_axes]) - or not self._current_position - ): - raise MustHomeError( - f"Current position of {str(mount)} pipette is unknown, please home." - ) - + if fail_on_not_homed: + if not self._current_position: + raise PositionUnknownError( + message=f"Current position of {str(mount)} pipette is unknown," + " please home.", + detail={"mount": str(mount), "missing_axes": position_axes}, + ) + axes_str = [ot2_axis_to_string(a) for a in position_axes] + if not self._backend.is_homed(axes_str): + unhomed = self._backend._unhomed_axes(axes_str) + raise PositionUnknownError( + message=f"{str(mount)} pipette axes ({unhomed}) must be homed.", + detail={"mount": str(mount), "unhomed_axes": unhomed}, + ) elif not self._current_position and not refresh: - raise MustHomeError("Current position is unknown; please home motors.") + raise PositionUnknownError( + message="Current position is unknown; please home motors." + ) async with self._motion_lock: if refresh: smoothie_pos = await self._backend.update_position() @@ -730,7 +741,10 @@ async def move_axes( The effector of the x,y axis is the center of the carriage. The effector of the pipette mount axis are the mount critical points but only in z. """ - raise NotSupportedByHardware("move_axes is not supported on the OT-2.") + raise UnsupportedHardwareCommand( + message="move_axes is not supported on the OT-2.", + detail={"axes_commanded": list(position.keys())}, + ) async def move_rel( self, @@ -748,23 +762,32 @@ async def move_rel( # TODO: Remove the fail_on_not_homed and make this the behavior all the time. # Having the optional arg makes the bug stick around in existing code and we # really want to fix it when we're not gearing up for a release. - mhe = MustHomeError( - "Cannot make a relative move because absolute position is unknown" - ) if not self._current_position: if fail_on_not_homed: - raise mhe + raise PositionUnknownError( + message="Cannot make a relative move because absolute position" + " is unknown.", + detail={ + "mount": str(mount), + "fail_on_not_homed": fail_on_not_homed, + }, + ) else: await self.home() target_position = target_position_from_relative( mount, delta, self._current_position ) + axes_moving = [Axis.X, Axis.Y, Axis.by_mount(mount)] - if fail_on_not_homed and not self._backend.is_homed( - [ot2_axis_to_string(axis) for axis in axes_moving if axis is not None] - ): - raise mhe + axes_str = [ot2_axis_to_string(a) for a in axes_moving] + if fail_on_not_homed and not self._backend.is_homed(axes_str): + unhomed = self._backend._unhomed_axes(axes_str) + raise PositionUnknownError( + message=f"{str(mount)} pipette axes ({unhomed}) must be homed.", + detail={"mount": str(mount), "unhomed_axes": unhomed}, + ) + await self._cache_and_maybe_retract_mount(mount) await self._move( target_position, @@ -937,7 +960,7 @@ async def prepare_for_aspirate( Prepare the pipette for aspiration. """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.PREPARE_ASPIRATE) + self.ready_for_tip_action(instrument, HardwareAction.PREPARE_ASPIRATE, mount) if instrument.current_volume == 0: speed = self.plunger_speed( diff --git a/api/src/opentrons/hardware_control/backends/controller.py b/api/src/opentrons/hardware_control/backends/controller.py index f3e73ecbea7..b029e1a15c4 100644 --- a/api/src/opentrons/hardware_control/backends/controller.py +++ b/api/src/opentrons/hardware_control/backends/controller.py @@ -145,11 +145,15 @@ async def update_position(self) -> Dict[str, float]: await self._smoothie_driver.update_position() return self._smoothie_driver.position + def _unhomed_axes(self, axes: Sequence[str]) -> List[str]: + return list( + axis + for axis in axes + if not self._smoothie_driver.homed_flags.get(axis, False) + ) + def is_homed(self, axes: Sequence[str]) -> bool: - for axis in axes: - if not self._smoothie_driver.homed_flags.get(axis, False): - return False - return True + return not any(self._unhomed_axes(axes)) async def move( self, diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 29f7ac2e83b..b3ac52f4308 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -132,8 +132,6 @@ from opentrons.hardware_control.errors import ( InvalidPipetteName, InvalidPipetteModel, - FirmwareUpdateRequired, - OverPressureDetected, ) from opentrons_hardware.hardware_control.motion import ( MoveStopCondition, @@ -173,6 +171,8 @@ EStopActivatedError, EStopNotPresentError, UnmatchedTipPresenceStates, + PipetteOverpressureError, + FirmwareUpdateRequiredError, ) from .subsystem_manager import SubsystemManager @@ -191,12 +191,15 @@ def requires_update(func: Wrapped) -> Wrapped: - """Decorator that raises FirmwareUpdateRequired if the update_required flag is set.""" + """Decorator that raises FirmwareUpdateRequiredError if the update_required flag is set.""" @wraps(func) async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: if self.update_required and self.initialized: - raise FirmwareUpdateRequired() + raise FirmwareUpdateRequiredError( + func.__name__, + self.subsystems_to_update, + ) return await func(self, *args, **kwargs) return cast(Wrapped, wrapper) @@ -347,6 +350,10 @@ def eeprom_data(self) -> EEPROMData: def update_required(self) -> bool: return self._subsystem_manager.update_required and self._check_updates + @property + def subsystems_to_update(self) -> List[SubSystem]: + return self._subsystem_manager.subsystems_to_update + @staticmethod def _build_system_hardware( can_messenger: CanMessenger, @@ -776,7 +783,7 @@ def _build_attached_pip( attached: ohc_tool_types.PipetteInformation, mount: OT3Mount ) -> AttachedPipette: if attached.name == FirmwarePipetteName.unknown: - raise InvalidPipetteName(name=attached.name_int, mount=mount) + raise InvalidPipetteName(name=attached.name_int, mount=mount.name) try: # TODO (lc 12-8-2022) We should return model as an int rather than # a string. @@ -797,7 +804,7 @@ def _build_attached_pip( } except KeyError: raise InvalidPipetteModel( - name=attached.name.name, model=attached.model, mount=mount + name=attached.name.name, model=attached.model, mount=mount.name ) @staticmethod @@ -1115,6 +1122,7 @@ def _axis_map_to_present_nodes( @asynccontextmanager async def _monitor_overpressure(self, mounts: List[NodeId]) -> AsyncIterator[None]: + msg = "The pressure sensor on the {} mount has exceeded operational limits." if ff.overpressure_detection_enabled() and mounts: tools_with_id = map_pipette_type_to_sensor_id( mounts, self._subsystem_manager.device_info @@ -1140,8 +1148,10 @@ def _pop_queue() -> Optional[Tuple[NodeId, ErrorCode]]: q_msg = _pop_queue() if q_msg: mount = Axis.to_ot3_mount(node_to_axis(q_msg[0])) - raise OverPressureDetected(mount.name) - + raise PipetteOverpressureError( + message=msg.format(str(mount)), + detail={"mount": mount}, + ) else: yield diff --git a/api/src/opentrons/hardware_control/backends/simulator.py b/api/src/opentrons/hardware_control/backends/simulator.py index 7766260ab11..38b27778e86 100644 --- a/api/src/opentrons/hardware_control/backends/simulator.py +++ b/api/src/opentrons/hardware_control/backends/simulator.py @@ -187,11 +187,15 @@ def module_controls(self, module_controls: AttachedModulesControl) -> None: async def update_position(self) -> Dict[str, float]: return self._position + def _unhomed_axes(self, axes: Sequence[str]) -> List[str]: + return list( + axis + for axis in axes + if not self._smoothie_driver.homed_flags.get(axis, False) + ) + def is_homed(self, axes: Sequence[str]) -> bool: - for axis in axes: - if not self._smoothie_driver.homed_flags.get(axis, False): - return False - return True + return not any(self._unhomed_axes(axes)) @ensure_yield async def move( diff --git a/api/src/opentrons/hardware_control/backends/subsystem_manager.py b/api/src/opentrons/hardware_control/backends/subsystem_manager.py index 130f265b0f9..1cc663ae99f 100644 --- a/api/src/opentrons/hardware_control/backends/subsystem_manager.py +++ b/api/src/opentrons/hardware_control/backends/subsystem_manager.py @@ -13,6 +13,7 @@ Callable, AsyncIterator, Union, + List, ) from opentrons_hardware.hardware_control import network, tools @@ -144,6 +145,10 @@ def _next_version(target: FirmwareTarget, current: int) -> int: def update_required(self) -> bool: return bool(self._updates_required) + @property + def subsystems_to_update(self) -> List[SubSystem]: + return [target_to_subsystem(t) for t in self._updates_required.keys()] + async def start(self) -> None: await self._probe_network_and_cache_fw_updates( self._expected_core_targets, True diff --git a/api/src/opentrons/hardware_control/errors.py b/api/src/opentrons/hardware_control/errors.py index 2a45464c745..f678167cf28 100644 --- a/api/src/opentrons/hardware_control/errors.py +++ b/api/src/opentrons/hardware_control/errors.py @@ -1,114 +1,43 @@ -from opentrons_shared_data.errors.exceptions import PipetteOverpressureError -from .types import OT3Mount +from typing import Optional, Dict, Any +from opentrons_shared_data.errors.exceptions import ( + MotionPlanningFailureError, + InvalidInstrumentData, + RobotInUseError, +) -class OutOfBoundsMove(RuntimeError): - def __init__(self, message: str): - self.message = message - super().__init__() +class OutOfBoundsMove(MotionPlanningFailureError): + def __init__(self, message: str, detail: Dict[str, Any]): + super().__init__(message=message, detail=detail) - def __str__(self) -> str: - return f"OutOfBoundsMove: {self.message}" - def __repr__(self) -> str: - return f"<{str(self.__class__)}: {self.message}>" - - -class ExecutionCancelledError(RuntimeError): - pass - - -class MustHomeError(RuntimeError): - pass - - -class NoTipAttachedError(RuntimeError): - pass - - -class TipAttachedError(RuntimeError): - pass - - -class InvalidMoveError(ValueError): - pass - - -class NotSupportedByHardware(ValueError): - """Error raised when attempting to use arguments and values not supported by the specific hardware.""" - - -class GripperNotAttachedError(Exception): - """An error raised if a gripper is accessed that is not attached.""" - - pass - - -class AxisNotPresentError(Exception): - """An error raised if an axis that is not present.""" - - pass - - -class FirmwareUpdateRequired(RuntimeError): - """An error raised when the firmware of the submodules needs to be updated.""" - - pass - - -class FirmwareUpdateFailed(RuntimeError): - """An error raised when a firmware update fails.""" - - pass - - -class OverPressureDetected(PipetteOverpressureError): - """An error raised when the pressure sensor max value is exceeded.""" - - def __init__(self, mount: str) -> None: - return super().__init__( - message=f"The pressure sensor on the {mount} mount has exceeded operational limits.", - detail={"mount": mount}, +class InvalidCriticalPoint(MotionPlanningFailureError): + def __init__(self, cp_name: str, instr: str, message: Optional[str] = None): + super().__init__( + message=(message or f"Critical point {cp_name} is invalid for a {instr}."), + detail={"instrument": instr, "critical point": cp_name}, ) -class InvalidPipetteName(KeyError): +class InvalidPipetteName(InvalidInstrumentData): """Raised for an invalid pipette.""" - def __init__(self, name: int, mount: OT3Mount) -> None: - self.name = name - self.mount = mount - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: name={self.name} mount={self.mount}>" - - def __str__(self) -> str: - return f"{self.__class__.__name__}: Pipette name key {self.name} on mount {self.mount.name} is not valid" + def __init__(self, name: int, mount: str) -> None: + super().__init__( + message=f"Invalid pipette name key {name} on mount {mount}", + detail={"mount": mount, "name": name}, + ) -class InvalidPipetteModel(KeyError): +class InvalidPipetteModel(InvalidInstrumentData): """Raised for a pipette with an unknown model.""" - def __init__(self, name: str, model: str, mount: OT3Mount) -> None: - self.name = name - self.model = model - self.mount = mount + def __init__(self, name: str, model: str, mount: str) -> None: + super().__init__(detail={"mount": mount, "name": name, "model": model}) - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: name={self.name}, model={self.model}, mount={self.mount}>" - def __str__(self) -> str: - return f"{self.__class__.__name__}: {self.name} on {self.mount.name} has an unknown model {self.model}" - - -class UpdateOngoingError(RuntimeError): +class UpdateOngoingError(RobotInUseError): """Error when an update is already happening.""" - def __init__(self, msg: str) -> None: - self.msg = msg - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: msg={self.msg}>" - - def __str__(self) -> str: - return self.msg + def __init__(self, message: str) -> None: + super().__init__(message=message) diff --git a/api/src/opentrons/hardware_control/execution_manager.py b/api/src/opentrons/hardware_control/execution_manager.py index 02cdaf9b8e1..0e051799fbc 100644 --- a/api/src/opentrons/hardware_control/execution_manager.py +++ b/api/src/opentrons/hardware_control/execution_manager.py @@ -2,7 +2,7 @@ import functools from typing import Set, TypeVar, Type, cast, Callable, Any, Awaitable, overload from .types import ExecutionState -from .errors import ExecutionCancelledError +from opentrons_shared_data.errors.exceptions import ExecutionCancelledError TaskContents = TypeVar("TaskContents") diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py index e94f4c68f06..5185fce9f3e 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py @@ -45,7 +45,7 @@ CriticalPoint, BoardRevision, ) -from opentrons.hardware_control.errors import InvalidMoveError +from opentrons.hardware_control.errors import InvalidCriticalPoint from opentrons_shared_data.pipette.dev_types import ( @@ -331,9 +331,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: CriticalPoint.GRIPPER_FRONT_CALIBRATION_PIN, CriticalPoint.GRIPPER_REAR_CALIBRATION_PIN, ]: - raise InvalidMoveError( - f"Critical point {cp_override.name} is not valid for a pipette" - ) + raise InvalidCriticalPoint(cp_override.name, "pipette") if not self.has_tip or cp_override == CriticalPoint.NOZZLE: cp_type = CriticalPoint.NOZZLE diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index a73c0cdf5e2..193b3ae0737 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -17,6 +17,10 @@ ) import numpy +from opentrons_shared_data.errors.exceptions import ( + UnexpectedTipRemovalError, + UnexpectedTipAttachError, +) from opentrons_shared_data.pipette.dev_types import UlPerMmAction from opentrons_shared_data.pipette.types import Quirks from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated @@ -28,10 +32,6 @@ Axis, OT3Mount, ) -from opentrons.hardware_control.errors import ( - TipAttachedError, - NoTipAttachedError, -) from opentrons.hardware_control.constants import ( SHAKE_OFF_TIPS_SPEED, SHAKE_OFF_TIPS_PICKUP_DISTANCE, @@ -432,9 +432,11 @@ def critical_point_for( # configured such that the end of a p300 single gen1's tip is 0. return top_types.Point(0, 0, 30) - def ready_for_tip_action(self, target: Pipette, action: HardwareAction) -> None: + def ready_for_tip_action( + self, target: Pipette, action: HardwareAction, mount: MountType + ) -> None: if not target.has_tip: - raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + raise UnexpectedTipRemovalError(action.name, target.name, mount.name) if ( action == HardwareAction.ASPIRATE and target.current_volume == 0 @@ -497,7 +499,7 @@ def plan_check_aspirate( # type: ignore[no-untyped-def] - Plunger distances (possibly calling an overridden plunger_volume) """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE) + self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE, mount) if volume is None: self._ihp_log.debug( "No aspirate volume defined. Aspirating up to " @@ -579,7 +581,7 @@ def plan_check_dispense( # type: ignore[no-untyped-def] """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DISPENSE) + self.ready_for_tip_action(instrument, HardwareAction.DISPENSE, mount) if volume is None: disp_vol = instrument.current_volume @@ -660,7 +662,7 @@ def plan_check_blow_out(self, mount: OT3Mount) -> LiquidActionSpec: def plan_check_blow_out(self, mount): # type: ignore[no-untyped-def] """Check preconditions and calculate values for blowout.""" instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT) + self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT, mount) speed = self.plunger_speed( instrument, instrument.blow_out_flow_rate, "dispense" ) @@ -736,7 +738,7 @@ def plan_check_pick_up_tip( # type: ignore[no-untyped-def] # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: - raise TipAttachedError("Cannot pick up tip with a tip attached") + raise UnexpectedTipAttachError("pick_up_tip", instrument.name, mount.name) self._ihp_log.debug(f"Picking up tip on {mount.name}") if presses is None or presses < 0: @@ -894,7 +896,7 @@ def plan_check_drop_tip( # type: ignore[no-untyped-def] home_after, ): instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DROPTIP) + self.ready_for_tip_action(instrument, HardwareAction.DROPTIP, mount) bottom = instrument.plunger_positions.bottom droptip = instrument.plunger_positions.drop_tip diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py index 11d715b4aee..7eb757fd333 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py @@ -12,7 +12,9 @@ CriticalPoint, GripperJawState, ) -from opentrons.hardware_control.errors import InvalidMoveError +from opentrons.hardware_control.errors import ( + InvalidCriticalPoint, +) from .instrument_calibration import ( GripperCalibrationOffset, load_gripper_calibration_offset, @@ -20,6 +22,7 @@ ) from ..instrument_abc import AbstractInstrument from opentrons.hardware_control.dev_types import AttachedGripper, GripperDict +from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated from opentrons_shared_data.gripper import ( GripperDefinition, @@ -177,9 +180,21 @@ def save_offset(self, delta: Point) -> GripperCalibrationOffset: def check_calibration_pin_location_is_accurate(self) -> None: if not self.attached_probe: - raise RuntimeError("must attach a probe before starting calibration") + raise CommandPreconditionViolated( + "Cannot calibrate gripper without attaching a calibration probe", + detail={ + "probe": self._attached_probe, + "jaw_state": self.state, + }, + ) if self.state != GripperJawState.GRIPPING: - raise RuntimeError("must grip the jaws before starting calibration") + raise CommandPreconditionViolated( + "Cannot calibrate gripper if jaw is not in gripping state", + detail={ + "probe": self._attached_probe, + "jaw_state": self.state, + }, + ) def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: """ @@ -187,9 +202,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: between the center of the gripper engagement volume and the calibration pins. """ if cp_override in [CriticalPoint.NOZZLE, CriticalPoint.TIP]: - raise InvalidMoveError( - f"Critical point {cp_override.name} is not valid for a gripper" - ) + raise InvalidCriticalPoint(cp_override.name, "gripper") if not self._attached_probe: cp = cp_override or CriticalPoint.GRIPPER_JAW_CENTER @@ -216,7 +229,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: - Point(y=self.current_jaw_displacement) ) else: - raise InvalidMoveError(f"Critical point {cp_override} is not valid") + raise InvalidCriticalPoint(cp.name, "gripper") def duty_cycle_by_force(self, newton: float) -> float: return gripper_config.duty_cycle_by_force(newton, self.grip_force_profile) diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py index 8de02635b7d..51778b08b92 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py @@ -13,9 +13,10 @@ GripperJawState, GripperProbe, ) -from opentrons.hardware_control.errors import ( - InvalidMoveError, - GripperNotAttachedError, +from opentrons.hardware_control.errors import InvalidCriticalPoint +from opentrons_shared_data.errors.exceptions import ( + GripperNotPresentError, + CommandPreconditionViolated, ) from .gripper import Gripper @@ -23,18 +24,6 @@ MOD_LOG = logging.getLogger(__name__) -class GripError(Exception): - """An error raised if a gripper action is blocked""" - - pass - - -class CalibrationError(Exception): - """An error raised if a gripper calibration is blocked""" - - pass - - class GripperHandler: GH_LOG = MOD_LOG.getChild("GripperHandler") @@ -48,8 +37,8 @@ def has_gripper(self) -> bool: def get_gripper(self) -> Gripper: gripper = self._gripper if not gripper: - raise GripperNotAttachedError( - "Cannot perform action without gripper attached" + raise GripperNotPresentError( + message="Cannot perform action without gripper attached" ) return gripper @@ -94,9 +83,13 @@ def save_instrument_offset(self, delta: Point) -> GripperCalibrationOffset: def get_critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: if not self._gripper: - raise GripperNotAttachedError() + raise GripperNotPresentError() if cp_override == CriticalPoint.MOUNT: - raise InvalidMoveError("The gripper mount may not be moved directly.") + raise InvalidCriticalPoint( + cp_override.name, + "gripper", + "The gripper mount may not be moved directly.", + ) return self._gripper.critical_point(cp_override) def get_gripper_dict(self) -> Optional[GripperDict]: @@ -132,11 +125,17 @@ def check_ready_for_calibration(self) -> None: gripper = self.get_gripper() gripper.check_calibration_pin_location_is_accurate() - def check_ready_for_jaw_move(self) -> None: + def check_ready_for_jaw_move(self, command: str) -> None: """Raise an exception if it is not currently valid to move the jaw.""" gripper = self.get_gripper() if gripper.state == GripperJawState.UNHOMED: - raise GripError("Gripper jaw must be homed before moving") + raise CommandPreconditionViolated( + message=f"Cannot {command} gripper jaw before homing", + detail={ + "command": command, + "jaw_state": gripper.state, + }, + ) def is_ready_for_idle(self) -> bool: """Gripper can idle when the jaw is not currently gripping.""" diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py index 7da7c4c5222..57b131ecc41 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py @@ -45,7 +45,7 @@ types as pip_types, ) from opentrons.hardware_control.types import CriticalPoint, OT3Mount -from opentrons.hardware_control.errors import InvalidMoveError +from opentrons.hardware_control.errors import InvalidCriticalPoint mod_log = logging.getLogger(__name__) @@ -321,9 +321,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: CriticalPoint.GRIPPER_FRONT_CALIBRATION_PIN, CriticalPoint.GRIPPER_REAR_CALIBRATION_PIN, ]: - raise InvalidMoveError( - f"Critical point {cp_override.name} is not valid for a pipette" - ) + raise InvalidCriticalPoint(cp_override.name, "pipette") if not self.has_tip or cp_override == CriticalPoint.NOZZLE: cp_type = CriticalPoint.NOZZLE tip_length = 0.0 diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 1f1b70ac4fa..d5d7a607b2f 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -20,6 +20,8 @@ from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, + UnexpectedTipRemovalError, + UnexpectedTipAttachError, ) from opentrons_shared_data.pipette.pipette_definition import ( liquid_class_for_volume_between_default_and_defaultlowvolume, @@ -32,10 +34,6 @@ Axis, OT3Mount, ) -from opentrons.hardware_control.errors import ( - TipAttachedError, - NoTipAttachedError, -) from opentrons.hardware_control.constants import ( SHAKE_OFF_TIPS_SPEED, SHAKE_OFF_TIPS_PICKUP_DISTANCE, @@ -487,9 +485,11 @@ def critical_point_for( else: return top_types.Point(0, 0, 0) - def ready_for_tip_action(self, target: Pipette, action: HardwareAction) -> None: + def ready_for_tip_action( + self, target: Pipette, action: HardwareAction, mount: OT3Mount + ) -> None: if not target.has_tip: - raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + raise UnexpectedTipRemovalError(str(action), target.name, mount.name) if ( action == HardwareAction.ASPIRATE and target.current_volume == 0 @@ -545,7 +545,7 @@ def plan_check_aspirate( - Plunger distances (possibly calling an overridden plunger_volume) """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE) + self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE, mount) if volume is None: self._ihp_log.debug( "No aspirate volume defined. Aspirating up to " @@ -606,7 +606,7 @@ def plan_check_dispense( """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DISPENSE) + self.ready_for_tip_action(instrument, HardwareAction.DISPENSE, mount) if volume is None: disp_vol = instrument.current_volume @@ -674,7 +674,7 @@ def plan_check_blow_out( ) -> LiquidActionSpec: """Check preconditions and calculate values for blowout.""" instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT) + self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT, mount) speed = self.plunger_speed(instrument, instrument.blow_out_flow_rate, "blowout") acceleration = self.plunger_acceleration( instrument, instrument.flow_acceleration @@ -734,7 +734,7 @@ def plan_check_pick_up_tip( # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: - raise TipAttachedError("Cannot pick up tip with a tip attached") + raise UnexpectedTipAttachError("pick_up_tip", instrument.name, mount.name) self._ihp_log.debug(f"Picking up tip on {mount.name}") def add_tip_to_instr() -> None: @@ -880,7 +880,7 @@ def plan_check_drop_tip( home_after: bool, ) -> Tuple[DropTipSpec, Callable[[], None]]: instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DROPTIP) + self.ready_for_tip_action(instrument, HardwareAction.DROPTIP, mount) is_96_chan = instrument.channels == 96 diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index b3ab2cd417a..dab277c8c29 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -54,6 +54,14 @@ ) from opentrons_hardware.hardware_control.motion import MoveStopCondition +from opentrons_shared_data.errors.exceptions import ( + EnumeratedError, + PythonException, + PositionUnknownError, + GripperNotPresentError, + InvalidActuator, + FirmwareUpdateFailedError, +) from .util import use_or_initialize_loop, check_motion_bounds @@ -104,11 +112,7 @@ EstopState, ) from .errors import ( - MustHomeError, - GripperNotAttachedError, - AxisNotPresentError, UpdateOngoingError, - FirmwareUpdateFailed, ) from . import modules from .ot3_calibration import OT3Transforms, OT3RobotCalibrationProvider @@ -494,9 +498,14 @@ async def update_firmware( yield update_status except SubsystemUpdating as e: raise UpdateOngoingError(e.msg) from e - except Exception as e: + except EnumeratedError: + raise + except BaseException as e: mod_log.exception("Firmware update failed") - raise FirmwareUpdateFailed() from e + raise FirmwareUpdateFailedError( + message="Update failed because of uncaught error", + wrapping=[PythonException(e)], + ) from e # Incidentals (i.e. not motion) API @@ -818,7 +827,7 @@ async def home_gripper_jaw(self) -> None: gripper.default_home_force ) await self._ungrip(duty_cycle=dc) - except GripperNotAttachedError: + except GripperNotPresentError: pass async def home_plunger(self, mount: Union[top_types.Mount, OT3Mount]) -> None: @@ -896,17 +905,20 @@ async def current_position_ot3( specified mount. """ if mount == OT3Mount.GRIPPER and not self._gripper_handler.has_gripper(): - raise GripperNotAttachedError( - f"Cannot return position for {mount} if no gripper is attached" + raise GripperNotPresentError( + message=f"Cannot return position for {mount} if no gripper is attached", + detail={"mount": str(mount)}, ) - + mount_axes = [Axis.X, Axis.Y, Axis.by_mount(mount)] if refresh: await self.refresh_positions() elif not self._current_position: - raise MustHomeError( - f"Motor positions for {str(mount)} are missing; must first home motors." + raise PositionUnknownError( + message=f"Motor positions for {str(mount)} mount are missing (" + f"{mount_axes}); must first home motors.", + detail={"mount": str(mount), "missing_axes": mount_axes}, ) - self._assert_motor_ok([Axis.X, Axis.Y, Axis.by_mount(mount)]) + self._assert_motor_ok(mount_axes) return self._effector_pos_from_carriage_pos( OT3Mount.from_mount(mount), self._current_position, critical_point @@ -924,7 +936,7 @@ async def _refresh_jaw_state(self) -> None: try: gripper = self._gripper_handler.get_gripper() gripper.state = await self._backend.get_jaw_state() - except GripperNotAttachedError: + except GripperNotPresentError: pass async def _cache_current_position(self) -> Dict[Axis, float]: @@ -947,16 +959,18 @@ def _assert_motor_ok(self, axes: Sequence[Axis]) -> None: invalid_axes = self._backend.get_invalid_motor_axes(axes) if invalid_axes: axes_str = ",".join([ax.name for ax in invalid_axes]) - raise MustHomeError( - f"Motor position of axes ({axes_str}) is invalid; please home motors." + raise PositionUnknownError( + message=f"Motor position of axes ({axes_str}) is invalid; please home motors.", + detail={"axes": axes_str}, ) def _assert_encoder_ok(self, axes: Sequence[Axis]) -> None: invalid_axes = self._backend.get_invalid_motor_axes(axes) if invalid_axes: axes_str = ",".join([ax.name for ax in invalid_axes]) - raise MustHomeError( - f"Encoder position of axes ({axes_str}) is invalid; please home motors." + raise PositionUnknownError( + message=f"Encoder position of axes ({axes_str}) is invalid; please home motors.", + detail={"axes": axes_str}, ) async def encoder_current_position( @@ -982,13 +996,15 @@ async def encoder_current_position_ot3( if refresh: await self.refresh_positions() elif not self._encoder_position: - raise MustHomeError( - f"Encoder positions for {str(mount)} are missing; must first home motors." + raise PositionUnknownError( + message=f"Encoder positions for {str(mount)} are missing; must first home motors.", + detail={"mount": str(mount)}, ) if mount == OT3Mount.GRIPPER and not self._gripper_handler.has_gripper(): - raise GripperNotAttachedError( - f"Cannot return encoder position for {mount} if no gripper is attached" + raise GripperNotPresentError( + message=f"Cannot return encoder position for {mount} if no gripper is attached", + detail={"mount": str(mount)}, ) self._assert_encoder_ok([Axis.X, Axis.Y, Axis.by_mount(mount)]) @@ -1118,7 +1134,9 @@ async def move_axes( # noqa: C901 for axis in position.keys(): if not self._backend.axis_is_present(axis): - raise AxisNotPresentError(f"{axis} is not present") + raise InvalidActuator( + message=f"{axis} is not present", detail={"axis": str(axis)} + ) if not self._backend.check_encoder_status(list(position.keys())): await self.home() @@ -1236,7 +1254,7 @@ async def idle_gripper(self) -> None: force_newtons=gripper.default_idle_force, stay_engaged=False, ) - except GripperNotAttachedError: + except GripperNotPresentError: pass def _build_moves( @@ -1610,7 +1628,7 @@ async def _hold_jaw_width(self, jaw_width_mm: float) -> None: async def grip( self, force_newtons: Optional[float] = None, stay_engaged: bool = True ) -> None: - self._gripper_handler.check_ready_for_jaw_move() + self._gripper_handler.check_ready_for_jaw_move("grip") dc = self._gripper_handler.get_duty_cycle_by_grip_force( force_newtons or self._gripper_handler.get_gripper().default_grip_force ) @@ -1623,7 +1641,7 @@ async def ungrip(self, force_newtons: Optional[float] = None) -> None: To simply open the jaw, use `home_gripper_jaw` instead. """ # get default grip force for release if not provided - self._gripper_handler.check_ready_for_jaw_move() + self._gripper_handler.check_ready_for_jaw_move("ungrip") # TODO: check jaw width to make sure it is actually gripping something dc = self._gripper_handler.get_duty_cycle_by_grip_force( force_newtons or self._gripper_handler.get_gripper().default_home_force @@ -1631,7 +1649,7 @@ async def ungrip(self, force_newtons: Optional[float] = None) -> None: await self._ungrip(duty_cycle=dc) async def hold_jaw_width(self, jaw_width_mm: int) -> None: - self._gripper_handler.check_ready_for_jaw_move() + self._gripper_handler.check_ready_for_jaw_move("hold_jaw_width") await self._hold_jaw_width(jaw_width_mm) async def _move_to_plunger_bottom( @@ -1735,7 +1753,7 @@ async def prepare_for_aspirate( checked_mount = OT3Mount.from_mount(mount) instrument = self._pipette_handler.get_pipette(checked_mount) self._pipette_handler.ready_for_tip_action( - instrument, HardwareAction.PREPARE_ASPIRATE + instrument, HardwareAction.PREPARE_ASPIRATE, checked_mount ) if instrument.current_volume == 0: await self._move_to_plunger_bottom(checked_mount, rate) @@ -2309,7 +2327,7 @@ async def liquid_probe( checked_mount = OT3Mount.from_mount(mount) instrument = self._pipette_handler.get_pipette(checked_mount) self._pipette_handler.ready_for_tip_action( - instrument, HardwareAction.LIQUID_PROBE + instrument, HardwareAction.LIQUID_PROBE, checked_mount ) if not probe_settings: diff --git a/api/src/opentrons/hardware_control/protocols/motion_controller.py b/api/src/opentrons/hardware_control/protocols/motion_controller.py index 8fbae1b9a1b..ba2e4913f60 100644 --- a/api/src/opentrons/hardware_control/protocols/motion_controller.py +++ b/api/src/opentrons/hardware_control/protocols/motion_controller.py @@ -90,7 +90,7 @@ async def current_position( specified mount but `CriticalPoint.TIP` was specified, the position of the nozzle will be returned. - If `fail_on_not_homed` is `True`, this method will raise a `MustHomeError` + If `fail_on_not_homed` is `True`, this method will raise a `PositionUnknownError` if any of the relavent axes are not homed, regardless of `refresh`. """ ... @@ -186,7 +186,7 @@ async def move_rel( axes are to be moved, they will do so at the same speed. If fail_on_not_homed is True (default False), if an axis that is not - homed moves it will raise a MustHomeError. Otherwise, it will home the axis. + homed moves it will raise a PositionUnknownError. Otherwise, it will home the axis. """ ... diff --git a/api/src/opentrons/hardware_control/util.py b/api/src/opentrons/hardware_control/util.py index d2d5a1f16d2..a7965853212 100644 --- a/api/src/opentrons/hardware_control/util.py +++ b/api/src/opentrons/hardware_control/util.py @@ -78,27 +78,29 @@ def check_motion_bounds( ) for ax in target_smoothie.keys(): if target_smoothie[ax] < bounds[ax][0]: - bounds_message = bounds_message_format.format( - axis=ax, - tsp=target_smoothie[ax], - tdp=target_deck.get(ax, "unknown"), - dir="low", - limsp=bounds.get(ax, ("unknown",))[0], - ) + format_detail = { + "axis": ax, + "tsp": target_smoothie[ax], + "tdp": target_deck.get(ax, "unknown"), + "dir": "low", + "limsp": bounds.get(ax, ("unknown",))[0], + } + bounds_message = bounds_message_format.format(**format_detail) mod_log.warning(bounds_message) if checks.value & MotionChecks.LOW.value: - raise OutOfBoundsMove(bounds_message) + raise OutOfBoundsMove(bounds_message, format_detail) elif target_smoothie[ax] > bounds[ax][1]: - bounds_message = bounds_message_format.format( - axis=ax, - tsp=target_smoothie[ax], - tdp=target_deck.get(ax, "unknown"), - dir="high", - limsp=bounds.get(ax, (None, "unknown"))[1], - ) + format_detail = { + "axis": ax, + "tsp": target_smoothie[ax], + "tdp": target_deck.get(ax, "unknown"), + "dir": "high", + "limsp": bounds.get(ax, (None, "unknown"))[1], + } + bounds_message = bounds_message_format.format(**format_detail) mod_log.warning(bounds_message) if checks.value & MotionChecks.HIGH.value: - raise OutOfBoundsMove(bounds_message) + raise OutOfBoundsMove(bounds_message, format_detail) def ot2_axis_to_string(axis: Axis) -> str: 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 e852564f9eb..1fef95066d8 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 @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Optional from opentrons import types -from opentrons.hardware_control import NoTipAttachedError, TipAttachedError from opentrons.hardware_control.dev_types import PipetteDict from opentrons.hardware_control.types import HardwareAction from opentrons.protocols.api_support import instrument as instrument_support @@ -17,6 +16,10 @@ APIVersionError, ) from opentrons.protocols.geometry import planning +from opentrons_shared_data.errors.exceptions import ( + UnexpectedTipRemovalError, + UnexpectedTipAttachError, +) from ..instrument import AbstractInstrument @@ -409,14 +412,18 @@ def _update_flow_rate(self) -> None: self._pipette_dict["blow_out_speed"] = p["blow_out_speed"] def _raise_if_no_tip(self, action: str) -> None: - """Raise NoTipAttachedError if no tip.""" + """Raise UnexpectedTipRemovalError if no tip.""" if not self.has_tip(): - raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + raise UnexpectedTipRemovalError( + action, self._instrument_name, self._mount.name + ) def _raise_if_tip(self, action: str) -> None: - """Raise TipAttachedError if tip.""" + """Raise UnexpectedTipAttachError if tip.""" if self.has_tip(): - raise TipAttachedError(f"Cannot {action} with a tip attached") + raise UnexpectedTipAttachError( + action, self._instrument_name, self._mount.name + ) def configure_for_volume(self, volume: float) -> None: """This will never be called because it was added in API 2.15.""" diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index a275ef96e1f..e3e463ecfc1 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -9,7 +9,7 @@ ) from opentrons.broker import Broker from opentrons.hardware_control.dev_types import PipetteDict -from opentrons import types, hardware_control as hc +from opentrons import types from opentrons.commands import commands as cmds from opentrons.commands import publisher @@ -25,6 +25,7 @@ requires_version, APIVersionError, ) +from opentrons_shared_data.errors.exceptions import UnexpectedTipRemovalError from .core.common import InstrumentCore, ProtocolCore from .core.engine import ENGINE_CORE_API_VERSION @@ -394,7 +395,7 @@ def mix( `rate` * :py:attr:`flow_rate.aspirate `, and when dispensing, it will be `rate` * :py:attr:`flow_rate.dispense `. - :raises: ``NoTipAttachedError`` -- if no tip is attached to the pipette. + :raises: ``UnexpectedTipRemovalError`` -- if no tip is attached to the pipette. :returns: This instance .. note:: @@ -418,7 +419,7 @@ def mix( ) ) if not self._core.has_tip(): - raise hc.NoTipAttachedError("Pipette has no tip. Aborting mix()") + raise UnexpectedTipRemovalError("mix", self.name, self.mount) c_vol = self._core.get_available_volume() if not volume else volume @@ -539,7 +540,7 @@ def touch_tip( :param speed: The speed for touch tip motion, in mm/s. Default: 60.0 mm/s, Max: 80.0 mm/s, Min: 20.0 mm/s :type speed: float - :raises: ``NoTipAttachedError`` -- if no tip is attached to the pipette + :raises: ``UnexpectedTipRemovalError`` -- if no tip is attached to the pipette :raises RuntimeError: If no location is specified and location cache is None. This should happen if `touch_tip` is called without first calling a method that takes a @@ -554,7 +555,7 @@ def touch_tip( """ if not self._core.has_tip(): - raise hc.NoTipAttachedError("Pipette has no tip to touch_tip()") + raise UnexpectedTipRemovalError("touch_tip", self.name, self.mount) checked_speed = self._determine_speed(speed) @@ -612,7 +613,7 @@ def air_gap( to air-gap aspirate. (Default: 5mm above current Well) :type height: float - :raises: ``NoTipAttachedError`` -- if no tip is attached to the pipette + :raises: ``UnexpectedTipRemovalError`` -- if no tip is attached to the pipette :raises RuntimeError: If location cache is None. This should happen if `touch_tip` is called @@ -633,7 +634,7 @@ def air_gap( """ if not self._core.has_tip(): - raise hc.NoTipAttachedError("Pipette has no tip. Aborting air_gap") + raise UnexpectedTipRemovalError("air_gap", self.name, self.mount) if height is None: height = 5 diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 3464e0b6960..27eab6a640f 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -398,7 +398,7 @@ def __init__( wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a MustHomeError.""" - super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + super().__init__(ErrorCodes.POSITION_UNKNOWN, message, details, wrapping) class SetupCommandNotAllowedError(ProtocolEngineError): @@ -738,7 +738,9 @@ def __init__( wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a LocationIsOccupiedError.""" - super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + super().__init__( + ErrorCodes.FIRMWARE_UPDATE_REQUIRED, message, details, wrapping + ) class PipetteNotReadyToAspirateError(ProtocolEngineError): diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index cca93b8be63..d8e2ea8afc5 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -6,7 +6,7 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import Axis as HardwareAxis -from opentrons.hardware_control.errors import MustHomeError as HardwareMustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from opentrons.motion_planning import Waypoint @@ -95,7 +95,7 @@ async def get_position( Args: pipette_id: Pipette ID to get location data for. current_well: Optional parameter for getting pipette location data, effects critical point. - fail_on_not_homed: Raise HardwareMustHomeError if gantry position is not known. + fail_on_not_homed: Raise PositionUnknownError if gantry position is not known. """ pipette_location = self._state_view.motion.get_pipette_location( pipette_id=pipette_id, @@ -107,8 +107,8 @@ async def get_position( critical_point=pipette_location.critical_point, fail_on_not_homed=fail_on_not_homed, ) - except HardwareMustHomeError as e: - raise MustHomeError(str(e)) from e + except PositionUnknownError as e: + raise MustHomeError(message=str(e), wrapping=[e]) def get_max_travel_z(self, pipette_id: str) -> float: """Get the maximum allowed z-height for pipette movement. @@ -167,8 +167,8 @@ async def move_relative( critical_point=critical_point, fail_on_not_homed=True, ) - except HardwareMustHomeError as e: - raise MustHomeError(str(e)) from e + except PositionUnknownError as e: + raise MustHomeError(message=str(e), wrapping=[e]) return point diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index b24e6eece64..d0caac1f55a 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -6,7 +6,7 @@ from opentrons.types import Point, MountType from opentrons.hardware_control import HardwareControlAPI -from opentrons.hardware_control.errors import MustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from ..types import ( WellLocation, @@ -217,6 +217,6 @@ async def check_for_valid_position(self, mount: MountType) -> bool: await self._hardware_api.gantry_position( mount=mount.to_hw_mount(), fail_on_not_homed=True ) - except MustHomeError: + except PositionUnknownError: return False return True diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 1d01a7120cd..894af6dd6e2 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -9,7 +9,7 @@ from opentrons.protocol_api import ProtocolContext from opentrons.protocols.execution.errors import ExceptionInProtocolError from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError -from opentrons.hardware_control import ExecutionCancelledError +from opentrons_shared_data.errors.exceptions import ExecutionCancelledError MODULE_LOG = logging.getLogger(__name__) 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 e6d5503ceb2..dfdaa9a8072 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -54,7 +54,6 @@ EstopState, ) from opentrons.hardware_control.errors import ( - FirmwareUpdateRequired, InvalidPipetteName, InvalidPipetteModel, ) @@ -84,6 +83,7 @@ from opentrons_shared_data.errors.exceptions import ( EStopActivatedError, EStopNotPresentError, + FirmwareUpdateRequiredError, ) from opentrons_hardware.hardware_control.move_group_runner import MoveGroupRunner @@ -915,7 +915,7 @@ async def test_update_required_flag( axes = [Axis.X, Axis.Y] decoy.when(mock_subsystem_manager.update_required).then_return(True) controller._initialized = True - with pytest.raises(FirmwareUpdateRequired): + with pytest.raises(FirmwareUpdateRequiredError): await controller.home(axes, gantry_load=GantryLoad.LOW_THROUGHPUT) @@ -938,7 +938,7 @@ async def _mock_update() -> AsyncIterator[UpdateStatus]: pass # raise FirmwareUpdateRequired if the _update_required flag is set controller._initialized = True - with pytest.raises(FirmwareUpdateRequired): + with pytest.raises(FirmwareUpdateRequiredError): await controller.home([Axis.X], gantry_load=GantryLoad.LOW_THROUGHPUT) diff --git a/api/tests/opentrons/hardware_control/test_execution_manager.py b/api/tests/opentrons/hardware_control/test_execution_manager.py index de106c7ac8e..9775fa895c4 100644 --- a/api/tests/opentrons/hardware_control/test_execution_manager.py +++ b/api/tests/opentrons/hardware_control/test_execution_manager.py @@ -3,8 +3,8 @@ from opentrons.hardware_control import ( ExecutionManager, ExecutionState, - ExecutionCancelledError, ) +from opentrons_shared_data.errors.exceptions import ExecutionCancelledError async def test_state_machine(): diff --git a/api/tests/opentrons/hardware_control/test_moves.py b/api/tests/opentrons/hardware_control/test_moves.py index 47b25859cf5..b438ecee12c 100644 --- a/api/tests/opentrons/hardware_control/test_moves.py +++ b/api/tests/opentrons/hardware_control/test_moves.py @@ -17,8 +17,7 @@ MotionChecks, ) from opentrons.hardware_control.errors import ( - MustHomeError, - InvalidMoveError, + InvalidCriticalPoint, OutOfBoundsMove, ) from opentrons.hardware_control.robot_calibration import ( @@ -26,7 +25,10 @@ DeckCalibration, ) -from opentrons_shared_data.errors.exceptions import MoveConditionNotMetError +from opentrons_shared_data.errors.exceptions import ( + MoveConditionNotMetError, + PositionUnknownError, +) async def test_controller_must_home(hardware_api): @@ -196,7 +198,7 @@ async def test_gripper_critical_points_fail_on_pipettes( types.Mount.RIGHT: {"model": "p10_single_v1", "id": "testyness"}, } await hardware_api.cache_instruments() - with pytest.raises(InvalidMoveError): + with pytest.raises(InvalidCriticalPoint): await hardware_api.move_to( types.Mount.RIGHT, types.Point(0, 0, 0), critical_point=critical_point ) @@ -494,7 +496,7 @@ async def test_move_rel_homing_failures(hardware_api): "C": False, } # If one axis being used isn't homed, we must get an exception - with pytest.raises(MustHomeError): + with pytest.raises(PositionUnknownError): await hardware_api.move_rel( types.Mount.LEFT, types.Point(0, 0, 2000), fail_on_not_homed=True ) @@ -517,13 +519,13 @@ async def test_current_position_homing_failures(hardware_api): } # If one axis being used isn't homed, we must get an exception - with pytest.raises(MustHomeError): + with pytest.raises(PositionUnknownError): await hardware_api.current_position( mount=types.Mount.LEFT, fail_on_not_homed=True, ) - with pytest.raises(MustHomeError): + with pytest.raises(PositionUnknownError): await hardware_api.gantry_position( mount=types.Mount.LEFT, fail_on_not_homed=True, diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 384f1bf1284..4d8ca82cb4e 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -20,10 +20,7 @@ GripperDict, ) from opentrons.hardware_control.motion_utilities import target_position_from_plunger -from opentrons.hardware_control.instruments.ot3.gripper_handler import ( - GripError, - GripperHandler, -) +from opentrons.hardware_control.instruments.ot3.gripper_handler import GripperHandler from opentrons.hardware_control.instruments.ot3.instrument_calibration import ( GripperCalibrationOffset, PipetteOffsetByPipetteMount, @@ -48,10 +45,7 @@ EstopState, EstopStateNotification, ) -from opentrons.hardware_control.errors import ( - GripperNotAttachedError, - InvalidMoveError, -) +from opentrons.hardware_control.errors import InvalidCriticalPoint from opentrons.hardware_control.ot3api import OT3API from opentrons.hardware_control import ThreadManager from opentrons.hardware_control.backends.ot3utils import ( @@ -64,6 +58,11 @@ from opentrons_hardware.hardware_control.motion_planning.types import Move from opentrons.config import gripper_config as gc +from opentrons_shared_data.errors.exceptions import ( + GripperNotPresentError, + CommandPreconditionViolated, + CommandParameterLimitViolated, +) from opentrons_shared_data.gripper.gripper_definition import GripperModel from opentrons_shared_data.pipette.types import ( PipetteModelType, @@ -74,7 +73,6 @@ from opentrons_shared_data.pipette import ( load_data as load_pipette_data, ) -from opentrons_shared_data.errors.exceptions import CommandParameterLimitViolated from opentrons.hardware_control.modules import ( Thermocycler, TempDeck, @@ -924,13 +922,13 @@ async def test_gripper_action_fails_with_no_gripper( mock_ungrip: AsyncMock, ) -> None: with pytest.raises( - GripperNotAttachedError, match="Cannot perform action without gripper attached" + GripperNotPresentError, match="Cannot perform action without gripper attached" ): await ot3_hardware.grip(5.0) mock_grip.assert_not_called() with pytest.raises( - GripperNotAttachedError, match="Cannot perform action without gripper attached" + GripperNotPresentError, match="Cannot perform action without gripper attached" ): await ot3_hardware.ungrip() mock_ungrip.assert_not_called() @@ -952,7 +950,9 @@ async def test_gripper_action_works_with_gripper( } await ot3_hardware.cache_gripper(instr_data) - with pytest.raises(GripError, match="Gripper jaw must be homed before moving"): + with pytest.raises( + CommandPreconditionViolated, match="Cannot grip gripper jaw before homing" + ): await ot3_hardware.grip(5.0) await ot3_hardware.home_gripper_jaw() mock_ungrip.assert_called_once() @@ -981,7 +981,7 @@ async def test_gripper_move_fails_with_no_gripper( ot3_hardware: ThreadManager[OT3API], ) -> None: assert not ot3_hardware._gripper_handler.gripper - with pytest.raises(GripperNotAttachedError): + with pytest.raises(GripperNotPresentError): await ot3_hardware.move_to(OT3Mount.GRIPPER, Point(0, 0, 0)) @@ -992,7 +992,7 @@ async def test_gripper_mount_not_movable( instr_data = AttachedGripper(config=gripper_config, id="g12345") await ot3_hardware.cache_gripper(instr_data) assert ot3_hardware._gripper_handler.gripper - with pytest.raises(InvalidMoveError): + with pytest.raises(InvalidCriticalPoint): await ot3_hardware.move_to( OT3Mount.GRIPPER, Point(0, 0, 0), critical_point=CriticalPoint.MOUNT ) @@ -1013,7 +1013,7 @@ async def test_gripper_fails_for_pipette_cps( instr_data = AttachedGripper(config=gripper_config, id="g12345") await ot3_hardware.cache_gripper(instr_data) assert ot3_hardware._gripper_handler.gripper - with pytest.raises(InvalidMoveError): + with pytest.raises(InvalidCriticalPoint): await ot3_hardware.move_to( OT3Mount.GRIPPER, Point(0, 0, 0), critical_point=critical_point ) diff --git a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py index 35a152839c8..4932edb855b 100644 --- a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py @@ -4,12 +4,12 @@ import pytest from pytest_lazyfixture import lazy_fixture # type: ignore[import] -from opentrons.hardware_control import ( - NoTipAttachedError, - TipAttachedError, -) from opentrons.protocol_api.core.common import InstrumentCore, LabwareCore from opentrons.types import Location, Point +from opentrons_shared_data.errors.exceptions import ( + UnexpectedTipRemovalError, + UnexpectedTipAttachError, +) # TODO (lc 12-8-2022) Not sure if we plan to keep these tests, but if we do # we should re-write them to be agnostic to the underlying hardware. Otherwise @@ -40,7 +40,9 @@ def test_same_pipette( def test_prepare_to_aspirate_no_tip(subject: InstrumentCore) -> None: """It should raise an error if a tip is not attached.""" - with pytest.raises(NoTipAttachedError, match="Cannot perform PREPARE_ASPIRATE"): + with pytest.raises( + UnexpectedTipRemovalError, match="Cannot perform PREPARE_ASPIRATE" + ): subject.prepare_for_aspirate() # type: ignore[attr-defined] @@ -48,7 +50,7 @@ def test_dispense_no_tip(subject: InstrumentCore) -> None: """It should raise an error if a tip is not attached.""" subject.home() location = Location(point=Point(1, 2, 3), labware=None) - with pytest.raises(NoTipAttachedError, match="Cannot perform DISPENSE"): + with pytest.raises(UnexpectedTipRemovalError, match="Cannot perform DISPENSE"): subject.dispense( volume=1, rate=1, @@ -65,13 +67,13 @@ def test_drop_tip_no_tip(subject: InstrumentCore, tip_rack: LabwareCore) -> None tip_core = tip_rack.get_well_core("A1") subject.home() - with pytest.raises(NoTipAttachedError, match="Cannot perform DROPTIP"): + with pytest.raises(UnexpectedTipRemovalError, match="Cannot perform DROPTIP"): subject.drop_tip(location=None, well_core=tip_core, home_after=False) def test_blow_out_no_tip(subject: InstrumentCore, labware: LabwareCore) -> None: """It should raise an error if a tip is not attached.""" - with pytest.raises(NoTipAttachedError, match="Cannot perform BLOWOUT"): + with pytest.raises(UnexpectedTipRemovalError, match="Cannot perform BLOWOUT"): subject.blow_out( location=Location(point=Point(1, 2, 3), labware=None), well_core=labware.get_well_core("A1"), @@ -91,7 +93,7 @@ def test_pick_up_tip_no_tip(subject: InstrumentCore, tip_rack: LabwareCore) -> N increment=None, prep_after=False, ) - with pytest.raises(TipAttachedError): + with pytest.raises(UnexpectedTipAttachError): subject.pick_up_tip( location=Location(point=tip_core.get_top(z_offset=0), labware=None), well_core=tip_core, diff --git a/api/tests/opentrons/protocol_api_old/test_context.py b/api/tests/opentrons/protocol_api_old/test_context.py index 3d5faed74fb..abcdd06a6d8 100644 --- a/api/tests/opentrons/protocol_api_old/test_context.py +++ b/api/tests/opentrons/protocol_api_old/test_context.py @@ -6,6 +6,7 @@ from opentrons_shared_data import load_shared_data from opentrons_shared_data.pipette.dev_types import LabwareUri +from opentrons_shared_data.errors.exceptions import UnexpectedTipRemovalError import opentrons.protocol_api as papi import opentrons.protocols.api_support as papi_support @@ -17,7 +18,7 @@ HeaterShakerContext, ) from opentrons.types import Mount, Point, Location, TransferTipPolicy -from opentrons.hardware_control import API, NoTipAttachedError, ThreadManagedHardware +from opentrons.hardware_control import API, ThreadManagedHardware from opentrons.hardware_control.instruments.ot2.pipette import Pipette from opentrons.hardware_control.types import Axis from opentrons.protocols.advanced_control import transfers as tf @@ -581,7 +582,7 @@ def test_prevent_liquid_handling_without_tip(ctx): plate = ctx.load_labware("corning_384_wellplate_112ul_flat", "2") pipR = ctx.load_instrument("p300_single", Mount.RIGHT, tip_racks=[tr]) - with pytest.raises(NoTipAttachedError): + with pytest.raises(UnexpectedTipRemovalError): pipR.aspirate(100, plate.wells()[0]) pipR.pick_up_tip() @@ -589,7 +590,7 @@ def test_prevent_liquid_handling_without_tip(ctx): pipR.aspirate(100, plate.wells()[0]) pipR.drop_tip() - with pytest.raises(NoTipAttachedError): + with pytest.raises(UnexpectedTipRemovalError): pipR.dispense(100, plate.wells()[1]) diff --git a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py index bb356d7f0a1..01a3ca6e3a5 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py +++ b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py @@ -11,7 +11,7 @@ CriticalPoint, Axis as HardwareAxis, ) -from opentrons.hardware_control.errors import MustHomeError as HardwareMustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from opentrons.motion_planning import Waypoint @@ -143,7 +143,7 @@ async def test_get_position_raises( critical_point=CriticalPoint.NOZZLE, fail_on_not_homed=False, ) - ).then_raise(HardwareMustHomeError("oh no")) + ).then_raise(PositionUnknownError("oh no")) with pytest.raises(MustHomeError, match="oh no"): await hardware_subject.get_position("pipette-id") @@ -266,7 +266,7 @@ async def test_move_relative_must_home( fail_on_not_homed=True, speed=456.7, ) - ).then_raise(HardwareMustHomeError("oh no")) + ).then_raise(PositionUnknownError("oh no")) with pytest.raises(MustHomeError, match="oh no"): await hardware_subject.move_relative( diff --git a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py index ac24a5810e1..e53242c93e7 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py @@ -6,7 +6,6 @@ from opentrons.types import MountType, Point, DeckSlotName, Mount from opentrons.hardware_control import API as HardwareAPI from opentrons.hardware_control.types import CriticalPoint -from opentrons.hardware_control.errors import MustHomeError from opentrons.motion_planning import Waypoint from opentrons.protocol_engine.types import ( @@ -31,6 +30,7 @@ HeaterShakerMovementFlagger, ) from opentrons.protocol_engine.execution.gantry_mover import GantryMover +from opentrons_shared_data.errors.exceptions import PositionUnknownError @pytest.fixture @@ -445,7 +445,7 @@ async def test_check_valid_position( ).then_return(Point(0, 0, 0)) decoy.when( await hardware_api.gantry_position(mount=Mount.RIGHT, fail_on_not_homed=True) - ).then_raise(MustHomeError()) + ).then_raise(PositionUnknownError()) assert await subject.check_for_valid_position(MountType.LEFT) assert not await subject.check_for_valid_position(MountType.RIGHT) diff --git a/hardware-testing/hardware_testing/scripts/speed_accel_profile.py b/hardware-testing/hardware_testing/scripts/speed_accel_profile.py index 28900de5012..c00a68038cd 100644 --- a/hardware-testing/hardware_testing/scripts/speed_accel_profile.py +++ b/hardware-testing/hardware_testing/scripts/speed_accel_profile.py @@ -8,7 +8,7 @@ from typing import Tuple, Dict from opentrons.hardware_control.ot3api import OT3API -from opentrons.hardware_control.errors import MustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from hardware_testing.opentrons_api.types import GantryLoad, OT3Mount, Axis, Point from hardware_testing.opentrons_api.helpers_ot3 import ( @@ -247,7 +247,7 @@ async def _single_axis_move( await api.move_rel( mount=MOUNT, delta=move_error_correction, speed=35 ) - except MustHomeError: + except PositionUnknownError: await api.home([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) if DELAY > 0: diff --git a/robot-server/robot_server/errors/exception_handlers.py b/robot-server/robot_server/errors/exception_handlers.py index 2e21f1cb8d6..821c9514c23 100644 --- a/robot-server/robot_server/errors/exception_handlers.py +++ b/robot-server/robot_server/errors/exception_handlers.py @@ -8,6 +8,9 @@ from typing import Any, Callable, Coroutine, Dict, Optional, Sequence, Type, Union from opentrons_shared_data.errors import ErrorCodes, EnumeratedError, PythonException +from opentrons_shared_data.errors.exceptions import ( + FirmwareUpdateRequiredError as HWFirmwareUpdateRequired, +) from robot_server.versioning import ( API_VERSION, @@ -23,10 +26,6 @@ FirmwareUpdateRequired, ) -from opentrons.hardware_control.errors import ( - FirmwareUpdateRequired as HWFirmwareUpdateRequired, -) - from .error_responses import ( ApiError, ErrorSource, diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml index dd73ae706bf..29377c8b9fe 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml @@ -56,9 +56,14 @@ stages: status: failed errors: - id: !anystr - errorType: PythonException createdAt: !anystr - detail: 'opentrons.hardware_control.errors.NoTipAttachedError: Cannot perform DROPTIP without a tip attached' + errorType: 'UnexpectedTipRemovalError' + detail: 'Cannot perform DROPTIP without a tip attached.' + errorInfo: + mount: 'LEFT' + pipette_name: 'p10_single' + errorCode: '3005' + wrappedErrors: [] - name: Verify commands contain the expected results request: url: '{ot2_server_base_url}/runs/{run_id}/commands' @@ -116,12 +121,14 @@ stages: status: failed error: id: !anystr - errorType: PythonException createdAt: !anystr - detail: 'opentrons.hardware_control.errors.NoTipAttachedError: Cannot perform DROPTIP without a tip attached' - errorCode: '4000' - errorInfo: !anydict - wrappedErrors: !anylist + errorType: 'UnexpectedTipRemovalError' + detail: 'Cannot perform DROPTIP without a tip attached.' + errorInfo: + mount: 'LEFT' + pipette_name: 'p10_single' + errorCode: '3005' + wrappedErrors: [] params: pipetteId: pipetteId labwareId: tipRackId diff --git a/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml index 10d71ae5121..273274b1a54 100644 --- a/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml @@ -58,8 +58,9 @@ stages: - id: !anystr errorType: ExceptionInProtocolError createdAt: !anystr - detail: 'NoTipAttachedError [line 9]: Cannot perform DROPTIP without a tip attached' + detail: 'UnexpectedTipRemovalError [line 9]: Error 3005 UNEXPECTED_TIP_REMOVAL (UnexpectedTipRemovalError): Cannot perform DROPTIP without a tip attached.' errorCode: '4000' + wrappedErrors: !anylist - name: Verify commands contain the expected results request: @@ -116,8 +117,8 @@ stages: id: !anystr errorType: LegacyContextCommandError createdAt: !anystr - detail: 'Cannot perform DROPTIP without a tip attached' - errorCode: '4000' + detail: 'Cannot perform DROPTIP without a tip attached.' + errorCode: '3005' errorInfo: !anydict wrappedErrors: !anylist params: diff --git a/shared-data/errors/definitions/1/errors.json b/shared-data/errors/definitions/1/errors.json index 726e663363a..8c92d9a0486 100644 --- a/shared-data/errors/definitions/1/errors.json +++ b/shared-data/errors/definitions/1/errors.json @@ -106,6 +106,14 @@ "detail": "Unmatched tip presence states", "category": "roboticsControlError" }, + "2013": { + "detail": "Position Unknown", + "category": "roboticsControlError" + }, + "2014": { + "detail": "Execution Cancelled", + "category": "roboticsControlError" + }, "3000": { "detail": "A robotics interaction error occurred.", "category": "roboticsInteractionError" diff --git a/shared-data/python/opentrons_shared_data/errors/codes.py b/shared-data/python/opentrons_shared_data/errors/codes.py index b49ec7d3f78..0b9473f8f18 100644 --- a/shared-data/python/opentrons_shared_data/errors/codes.py +++ b/shared-data/python/opentrons_shared_data/errors/codes.py @@ -56,6 +56,8 @@ class ErrorCodes(Enum): INACCURATE_NON_CONTACT_SWEEP = _code_from_dict_entry("2010") MISALIGNED_GANTRY = _code_from_dict_entry("2011") UNMATCHED_TIP_PRESENCE_STATES = _code_from_dict_entry("2012") + POSITION_UNKNOWN = _code_from_dict_entry("2013") + EXECUTION_CANCELLED = _code_from_dict_entry("2014") ROBOTICS_INTERACTION_ERROR = _code_from_dict_entry("3000") LABWARE_DROPPED = _code_from_dict_entry("3001") LABWARE_NOT_PICKED_UP = _code_from_dict_entry("3002") diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 5f1364f6d68..f43ba260d3f 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -350,7 +350,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build a FirmwareUpdateFailedError.""" + """Build a MotionFailedError.""" super().__init__(ErrorCodes.MOTION_FAILED, message, detail, wrapping) @@ -363,7 +363,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build a FirmwareUpdateFailedError.""" + """Build a HomingFailedError.""" super().__init__(ErrorCodes.HOMING_FAILED, message, detail, wrapping) @@ -556,6 +556,32 @@ def __init__( ) +class PositionUnknownError(RoboticsControlError): + """An error indicating that the robot's position is unknown.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PositionUnknownError.""" + super().__init__(ErrorCodes.POSITION_UNKNOWN, message, detail, wrapping) + + +class ExecutionCancelledError(RoboticsControlError): + """An error indicating that the robot's execution manager has been cancelled.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ExecutionCancelledError.""" + super().__init__(ErrorCodes.EXECUTION_CANCELLED, message, detail, wrapping) + + class LabwareDroppedError(RoboticsInteractionError): """An error indicating that the gripper dropped labware it was holding.""" @@ -600,12 +626,20 @@ class UnexpectedTipRemovalError(RoboticsInteractionError): def __init__( self, - message: Optional[str] = None, + action: str, + pipette_name: str, + mount: str, detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an UnexpectedTipRemovalError.""" - super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, detail, wrapping) + checked_detail: Dict[str, Any] = detail or {} + checked_detail["pipette_name"] = pipette_name + checked_detail["mount"] = mount + message = f"Cannot perform {action} without a tip attached." + super().__init__( + ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, checked_detail, wrapping + ) class UnexpectedTipAttachError(RoboticsInteractionError): @@ -613,11 +647,18 @@ class UnexpectedTipAttachError(RoboticsInteractionError): def __init__( self, + action: str, + pipette_name: str, + mount: str, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an UnexpectedTipAttachError.""" + checked_detail: Dict[str, Any] = detail or {} + checked_detail["pipette_name"] = pipette_name + checked_detail["mount"] = mount + message = f"Cannot perform {action} with a tip already attached." super().__init__(ErrorCodes.UNEXPECTED_TIP_ATTACH, message, detail, wrapping) @@ -626,11 +667,17 @@ class FirmwareUpdateRequiredError(RoboticsInteractionError): def __init__( self, + action: str, + subsystems_to_update: List[Any], message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a FirmwareUpdateRequiredError.""" + checked_detail: Dict[str, Any] = detail or {} + checked_detail["identifier"] = action + checked_detail["subsystems_to_update"] = subsystems_to_update + message = f"Cannot perform {action} until {subsystems_to_update} are updated." super().__init__(ErrorCodes.FIRMWARE_UPDATE_REQUIRED, message, detail, wrapping) @@ -708,7 +755,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an GripperNotPresentError.""" + """Build an InvalidActuator.""" super().__init__(ErrorCodes.INVALID_ACTUATOR, message, detail, wrapping) @@ -740,7 +787,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an GripperNotPresentError.""" + """Build an InvalidInstrumentData.""" super().__init__(ErrorCodes.INVALID_INSTRUMENT_DATA, message, detail, wrapping) @@ -825,3 +872,18 @@ def __init__( for e in wrapping_checked ], ) + + +class UnsupportedHardwareCommand(GeneralError): + """An error indicating that a command being executed is not supported by the hardware.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an UnsupportedHardwareCommand.""" + super().__init__( + ErrorCodes.NOT_SUPPORTED_ON_ROBOT_TYPE, message, detail, wrapping + )