From ebcbf2b4ba13a088dd9428ed6fd0235294bef9b4 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 26 Jul 2024 15:27:20 -0400 Subject: [PATCH 1/8] feat(api): Add HCPAPI to update from encoders We have encoders, at least on the flex; we now need to use them in actually reasonably public APIs. Add the minimal API to take advantage of the encoders with a wrapper around the already-existing backend update_position_estimators function that takes a list of axes to update the estimators for. This will cause these axes to update their open-loop estimators from the encoders. We don't want to do this frequently because the encoders have a coarser resolution than the internal position accumulators, so this will be a net accuracy loss; these axes should be homed before being used for something important. However, the encoder is plenty accurate for blowing out or dropping tips to make the pipette safe to home, and that's what it's for. Also slightly refactor the hardware protocols so this and the other encoder stuff is in its own protocol facet. --- api/src/opentrons/hardware_control/ot3api.py | 6 ++- .../protocols/position_estimator.py | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 api/src/opentrons/hardware_control/protocols/position_estimator.py diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index df4f5e71b55..b17c3b297f5 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -761,7 +761,7 @@ async def reset_tip_detectors( @ExecutionManagerProvider.wait_for_running async def _update_position_estimation( - self, axes: Optional[List[Axis]] = None + self, axes: Optional[Sequence[Axis]] = None ) -> None: """ Function to update motor estimation for a set of axes @@ -1141,6 +1141,10 @@ async def gantry_position( z=cur_pos[Axis.by_mount(realmount)], ) + async def update_axis_position_estimations(self, axes: Sequence[Axis]) -> None: + """Update specified axes position estimators from their encoders.""" + await self._update_position_estimation(axes) + async def move_to( self, mount: Union[top_types.Mount, OT3Mount], diff --git a/api/src/opentrons/hardware_control/protocols/position_estimator.py b/api/src/opentrons/hardware_control/protocols/position_estimator.py new file mode 100644 index 00000000000..04d551020c3 --- /dev/null +++ b/api/src/opentrons/hardware_control/protocols/position_estimator.py @@ -0,0 +1,43 @@ +from typing import Protocol, Sequence + +from ..types import Axis + + +class PositionEstimator(Protocol): + """Position-control extensions for harwdare with encoders.""" + + async def update_axis_position_estimations(self, axes: Sequence[Axis]) -> None: + """Update the specified axes' position estimators from their encoders. + + This will allow these axes to make a non-home move even if they do not currently have + a position estimation (unless there is no tracked poition from the encoders, as would be + true immediately after boot). + + Axis encoders have less precision than their position estimators. Calling this function will + cause absolute position drift. After this function is called, the axis should be homed before + it is relied upon for accurate motion. + + This function updates only the requested axes. If other axes have bad position estimation, + moves that require those axes or attempts to get the position of those axes will still fail. + """ + ... + + def motor_status_ok(self, axis: Axis) -> bool: + """Return whether an axis' position estimator is healthy. + + The position estimator is healthy if the axis has + 1) been homed + 2) not suffered a loss-of-positioning (from a cancel or stall, for instance) since being homed + + If this function returns false, getting the position of this axis or asking it to move will fail. + """ + ... + + def encoder_status_ok(self, axis: Axis) -> bool: + """Return whether an axis' position encoder tracking is healthy. + + The encoder status is healthy if the axis has been homed since booting up. + + If this function returns false, updating the estimator from the encoder will fail. + """ + ... From 2892642c98e350e15be53fa12a2bc22c6e87a875 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 26 Jul 2024 15:33:01 -0400 Subject: [PATCH 2/8] feat(api): add unsafe/blowOutInPlace Adds a new command that is capable (on Flex) of blowing out a pipette even when the plunger position is not known, i.e. after motion suddenly stopped during a plunger move. This is part of a new command domain called unsafe/ that will contain commands that are generally not good ideas to run during protocols for one reason or another. The reason this one's in there is that the encoder doesn't have the resolution of the plunger's internal position accumulator, so after calling this there will be some static position offset until the plunger is homed. You wouldn't want to do this during a protocol. Commands in the unsafe/ domain will not ever get Python API bindings; they should probably not be used by anything but clients handling error cases. --- .../hardware_control/protocols/__init__.py | 10 +- .../protocol_engine/commands/__init__.py | 3 + .../commands/command_unions.py | 6 ++ .../commands/unsafe/__init__.py | 18 ++++ .../unsafe/unsafe_blow_out_in_place.py | 93 +++++++++++++++++++ .../protocol_engine/state/pipettes.py | 4 +- .../unsafe/test_unsafe_blow_out_in_place.py | 49 ++++++++++ .../protocol_engine/state/command_fixtures.py | 20 ++++ .../state/test_pipette_store.py | 2 + shared-data/command/schemas/9.json | 55 ++++++++++- shared-data/command/types/index.ts | 4 + shared-data/command/types/unsafe.ts | 21 +++++ 12 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/unsafe/__init__.py create mode 100644 api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py create mode 100644 api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py create mode 100644 shared-data/command/types/unsafe.ts diff --git a/api/src/opentrons/hardware_control/protocols/__init__.py b/api/src/opentrons/hardware_control/protocols/__init__.py index 41de2b54506..cff17ff1d9a 100644 --- a/api/src/opentrons/hardware_control/protocols/__init__.py +++ b/api/src/opentrons/hardware_control/protocols/__init__.py @@ -1,8 +1,6 @@ """Typing protocols describing a hardware controller.""" from typing_extensions import Protocol, Type -from opentrons.hardware_control.types import Axis - from .module_provider import ModuleProvider from .hardware_manager import HardwareManager from .chassis_accessory_manager import ChassisAccessoryManager @@ -20,6 +18,7 @@ from .gripper_controller import GripperController from .flex_calibratable import FlexCalibratable from .flex_instrument_configurer import FlexInstrumentConfigurer +from .position_estimator import PositionEstimator from .types import ( CalibrationType, @@ -64,6 +63,7 @@ def cache_tip(self, mount: MountArgType, tip_length: float) -> None: class FlexHardwareControlInterface( + PositionEstimator, ModuleProvider, ExecutionControllable, LiquidHandler[CalibrationType, MountArgType, ConfigType], @@ -87,12 +87,6 @@ class FlexHardwareControlInterface( def get_robot_type(self) -> Type[FlexRobotType]: return FlexRobotType - def motor_status_ok(self, axis: Axis) -> bool: - ... - - def encoder_status_ok(self, axis: Axis) -> bool: - ... - def cache_tip(self, mount: MountArgType, tip_length: float) -> None: ... diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index 75904ab00a3..d0550fce8c5 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -19,6 +19,7 @@ from . import temperature_module from . import thermocycler from . import calibration +from . import unsafe from .hash_command_params import hash_protocol_command_params from .generate_command_schema import generate_command_schema @@ -548,6 +549,8 @@ "thermocycler", # calibration command bundle "calibration", + # unsafe command bundle + "unsafe", # configure pipette volume command bundle "ConfigureForVolume", "ConfigureForVolumeCreate", diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index d20b64f363b..dccb15ea60b 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -22,6 +22,7 @@ from . import thermocycler from . import calibration +from . import unsafe from .set_rail_lights import ( SetRailLights, @@ -387,6 +388,7 @@ calibration.CalibratePipette, calibration.CalibrateModule, calibration.MoveToMaintenancePosition, + unsafe.UnsafeBlowOutInPlace, ], Field(discriminator="commandType"), ] @@ -456,6 +458,7 @@ calibration.CalibratePipetteParams, calibration.CalibrateModuleParams, calibration.MoveToMaintenancePositionParams, + unsafe.UnsafeBlowOutInPlaceParams, ] CommandType = Union[ @@ -523,6 +526,7 @@ calibration.CalibratePipetteCommandType, calibration.CalibrateModuleCommandType, calibration.MoveToMaintenancePositionCommandType, + unsafe.UnsafeBlowOutInPlaceCommandType, ] CommandCreate = Annotated[ @@ -591,6 +595,7 @@ calibration.CalibratePipetteCreate, calibration.CalibrateModuleCreate, calibration.MoveToMaintenancePositionCreate, + unsafe.UnsafeBlowOutInPlaceCreate, ], Field(discriminator="commandType"), ] @@ -660,6 +665,7 @@ calibration.CalibratePipetteResult, calibration.CalibrateModuleResult, calibration.MoveToMaintenancePositionResult, + unsafe.UnsafeBlowOutInPlaceResult ] # todo(mm, 2024-06-12): Ideally, command return types would have specific diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py new file mode 100644 index 00000000000..4856c185ec9 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -0,0 +1,18 @@ +"""Commands that will cause inaccuracy or incorrect behavior but are still necessary.""" + +from .unsafe_blow_out_in_place import ( + UnsafeBlowOutInPlaceCommandType, + UnsafeBlowOutInPlaceParams, + UnsafeBlowOutInPlaceResult, + UnsafeBlowOutInPlace, + UnsafeBlowOutInPlaceCreate, +) + +__all__ = [ + # Unsafe blow-out-in-place command models + "UnsafeBlowOutInPlaceCommandType", + "UnsafeBlowOutInPlaceParams", + "UnsafeBlowOutInPlaceResult", + "UnsafeBlowOutInPlace", + "UnsafeBlowOutInPlaceCreate", +] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py new file mode 100644 index 00000000000..cbf17ff1026 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py @@ -0,0 +1,93 @@ +"""Command models to blow out in place while plunger positions are unknown.""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Type +from typing_extensions import Literal + +from pydantic import BaseModel + +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ..pipetting_common import PipetteIdMixin, FlowRateMixin +from ...resources import ensure_ot3_hardware +from ...errors.error_occurrence import ErrorOccurrence + +from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.types import Axis + +if TYPE_CHECKING: + from ...execution import PipettingHandler + from ...state import StateView + + +UnsafeBlowOutInPlaceCommandType = Literal["unsafe/blowOutInPlace"] + + +class UnsafeBlowOutInPlaceParams(PipetteIdMixin, FlowRateMixin): + """Payload required to blow-out in place while position is unknown.""" + + pass + + +class UnsafeBlowOutInPlaceResult(BaseModel): + """Result data from an UnsafeBlowOutInPlace command.""" + + pass + + +class UnsafeBlowOutInPlaceImplementation( + AbstractCommandImpl[ + UnsafeBlowOutInPlaceParams, SuccessData[UnsafeBlowOutInPlaceResult, None] + ] +): + """UnsafeBlowOutInPlace command implementation.""" + + def __init__( + self, + pipetting: PipettingHandler, + state_view: StateView, + hardware_api: HardwareControlAPI, + **kwargs: object, + ) -> None: + self._pipetting = pipetting + self._state_view = state_view + self._hardware_api = hardware_api + + async def execute( + self, params: UnsafeBlowOutInPlaceParams + ) -> SuccessData[UnsafeBlowOutInPlaceResult, None]: + """Blow-out without moving the pipette even when position is unknown.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + pipette_location = self._state_view.motion.get_pipette_location( + params.pipetteId + ) + await ot3_hardware_api.update_axis_position_estimations( + [Axis.of_main_tool_actuator(pipette_location.mount.to_hw_mount())] + ) + await self._pipetting.blow_out_in_place( + pipette_id=params.pipetteId, flow_rate=params.flowRate + ) + + return SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + + +class UnsafeBlowOutInPlace( + BaseCommand[UnsafeBlowOutInPlaceParams, UnsafeBlowOutInPlaceResult, ErrorOccurrence] +): + """UnsafeBlowOutInPlace command model.""" + + commandType: UnsafeBlowOutInPlaceCommandType = "unsafe/blowOutInPlace" + params: UnsafeBlowOutInPlaceParams + result: Optional[UnsafeBlowOutInPlaceResult] + + _ImplementationCls: Type[ + UnsafeBlowOutInPlaceImplementation + ] = UnsafeBlowOutInPlaceImplementation + + +class UnsafeBlowOutInPlaceCreate(BaseCommandCreate[UnsafeBlowOutInPlaceParams]): + """UnsafeBlowOutInPlace command request model.""" + + commandType: UnsafeBlowOutInPlaceCommandType = "unsafe/blowOutInPlace" + params: UnsafeBlowOutInPlaceParams + + _CommandCls: Type[UnsafeBlowOutInPlace] = UnsafeBlowOutInPlace diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index e1dd00bbfb2..6e169f95a5e 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -53,6 +53,7 @@ RetractAxisResult, BlowOutResult, BlowOutInPlaceResult, + unsafe, TouchTipResult, thermocycler, heater_shaker, @@ -483,7 +484,8 @@ def _update_volumes( self._state.aspirated_volume_by_id[pipette_id] = next_volume elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, (BlowOutResult, BlowOutInPlaceResult) + action.command.result, + (BlowOutResult, BlowOutInPlaceResult, unsafe.UnsafeBlowOutInPlaceResult), ): pipette_id = action.command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py new file mode 100644 index 00000000000..f25d8d06169 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py @@ -0,0 +1,49 @@ +"""Test blow-out-in-place commands.""" +from decoy import Decoy + +from opentrons.types import MountType +from opentrons.protocol_engine.state import StateView +from opentrons.protocol_engine.commands.unsafe.unsafe_blow_out_in_place import ( + UnsafeBlowOutInPlaceParams, + UnsafeBlowOutInPlaceResult, + UnsafeBlowOutInPlaceImplementation, +) +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.execution import ( + PipettingHandler, +) +from opentrons.protocol_engine.state.motion import PipetteLocationData +from opentrons.hardware_control import OT3HardwareControlAPI +from opentrons.hardware_control.types import Axis + + +async def test_blow_out_in_place_implementation( + decoy: Decoy, + state_view: StateView, + ot3_hardware_api: OT3HardwareControlAPI, + pipetting: PipettingHandler, +) -> None: + """Test UnsafeBlowOut command execution.""" + subject = UnsafeBlowOutInPlaceImplementation( + state_view=state_view, + hardware_api=ot3_hardware_api, + pipetting=pipetting, + ) + + data = UnsafeBlowOutInPlaceParams( + pipetteId="pipette-id", + flowRate=1.234, + ) + + decoy.when( + state_view.motion.get_pipette_location(pipette_id="pipette-id") + ).then_return(PipetteLocationData(mount=MountType.LEFT, critical_point=None)) + + result = await subject.execute(data) + + assert result == SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + + decoy.verify( + await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), + await pipetting.blow_out_in_place(pipette_id="pipette-id", flow_rate=1.234), + ) diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 66c6a34fe9f..d374bb8d13e 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -603,3 +603,23 @@ def create_reload_labware_command( params=params, result=result, ) + + +def create_unsafe_blow_out_in_place_command( + pipette_id: str, + flow_rate: float, +) -> cmd.unsafe.UnsafeBlowOutInPlace: + """Create a completed UnsafeBlowOutInPlace command.""" + params = cmd.unsafe.UnsafeBlowOutInPlaceParams( + pipetteId=pipette_id, flowRate=flow_rate + ) + result = cmd.unsafe.UnsafeBlowOutInPlaceResult() + + return cmd.unsafe.UnsafeBlowOutInPlace( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index 6e7428719ec..d1c3486a547 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -58,6 +58,7 @@ create_move_to_coordinates_command, create_move_relative_command, create_prepare_to_aspirate_command, + create_unsafe_blow_out_in_place_command, ) from ..pipette_fixtures import get_default_nozzle_map @@ -261,6 +262,7 @@ def test_dispense_subtracts_volume( [ create_blow_out_command("pipette-id", 1.23), create_blow_out_in_place_command("pipette-id", 1.23), + create_unsafe_blow_out_in_place_command("pipette-id", 1.23), ], ) def test_blow_out_clears_volume( diff --git a/shared-data/command/schemas/9.json b/shared-data/command/schemas/9.json index 6466287e030..e2a587d40e5 100644 --- a/shared-data/command/schemas/9.json +++ b/shared-data/command/schemas/9.json @@ -68,7 +68,8 @@ "calibration/calibrateGripper": "#/definitions/CalibrateGripperCreate", "calibration/calibratePipette": "#/definitions/CalibratePipetteCreate", "calibration/calibrateModule": "#/definitions/CalibrateModuleCreate", - "calibration/moveToMaintenancePosition": "#/definitions/MoveToMaintenancePositionCreate" + "calibration/moveToMaintenancePosition": "#/definitions/MoveToMaintenancePositionCreate", + "unsafe/blowOutInPlace": "#/definitions/UnsafeBlowOutInPlaceCreate" } }, "oneOf": [ @@ -263,6 +264,9 @@ }, { "$ref": "#/definitions/MoveToMaintenancePositionCreate" + }, + { + "$ref": "#/definitions/UnsafeBlowOutInPlaceCreate" } ], "definitions": { @@ -4220,6 +4224,55 @@ } }, "required": ["params"] + }, + "UnsafeBlowOutInPlaceParams": { + "title": "UnsafeBlowOutInPlaceParams", + "description": "Payload required to blow-out in place while position is unknown.", + "type": "object", + "properties": { + "flowRate": { + "title": "Flowrate", + "description": "Speed in \u00b5L/s configured for the pipette", + "exclusiveMinimum": 0, + "type": "number" + }, + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + } + }, + "required": ["flowRate", "pipetteId"] + }, + "UnsafeBlowOutInPlaceCreate": { + "title": "UnsafeBlowOutInPlaceCreate", + "description": "UnsafeBlowOutInPlace command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/blowOutInPlace", + "enum": ["unsafe/blowOutInPlace"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafeBlowOutInPlaceParams" + }, + "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"] } }, "$id": "opentronsCommandSchemaV9", diff --git a/shared-data/command/types/index.ts b/shared-data/command/types/index.ts index 2970a8e5185..f668c602f35 100644 --- a/shared-data/command/types/index.ts +++ b/shared-data/command/types/index.ts @@ -19,6 +19,7 @@ import type { CalibrationRunTimeCommand, CalibrationCreateCommand, } from './calibration' +import type { UnsafeRunTimeCommand, UnsafeCreateCommand } from './unsafe' export * from './annotation' export * from './calibration' @@ -28,6 +29,7 @@ export * from './module' export * from './pipetting' export * from './setup' export * from './timing' +export * from './unsafe' // NOTE: these key/value pairs will only be present on commands at analysis/run time // they pertain only to the actual execution status of a command on hardware, as opposed to @@ -67,6 +69,7 @@ export type CreateCommand = | CalibrationCreateCommand // for automatic pipette calibration | AnnotationCreateCommand // annotating command execution | IncidentalCreateCommand // command with only incidental effects (status bar animations) + | UnsafeCreateCommand // command providing capabilities that are not safe for scientific uses // commands will be required to have a key, but will not be created with one export type RunTimeCommand = @@ -78,6 +81,7 @@ export type RunTimeCommand = | CalibrationRunTimeCommand // for automatic pipette calibration | AnnotationRunTimeCommand // annotating command execution | IncidentalRunTimeCommand // command with only incidental effects (status bar animations) + | UnsafeRunTimeCommand // command providing capabilities that are not safe for scientific uses export type RunCommandError = | RunCommandErrorUndefined diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts new file mode 100644 index 00000000000..0c969f95286 --- /dev/null +++ b/shared-data/command/types/unsafe.ts @@ -0,0 +1,21 @@ +import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' + +export type UnsafeRunTimeCommand = UnsafeBlowoutInPlaceRunTimeCommand + +export type UnsafeCreateCommand = UnsafeBlowoutInPlaceCreateCommand + +export interface UnsafeBlowoutInPlaceParams { + pipetteId: string + flowRate: number // µL/s +} + +export interface UnsafeBlowoutInPlaceCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/blowOutInPlace' + params: UnsafeBlowoutInPlaceParams +} +export interface UnsafeBlowoutInPlaceRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeBlowoutInPlaceCreateCommand { + result?: {} +} From c29b97329ab02c5f690917637da07efa96363c4b Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 26 Jul 2024 16:57:26 -0400 Subject: [PATCH 3/8] feat(api): add unsafe/dropTipInPlace This is a special command that (on Flex) will make the machine reload its plunger position from its encoder and then drop tip. This makes this command always runnable even if there's no position known for the plunger axis, as there wouldn't be after an error during a liquid handling command. This lets us call it unconditionally during the drop tip wizard. --- .../commands/command_unions.py | 7 +- .../commands/unsafe/__init__.py | 13 +++ .../unsafe/unsafe_drop_tip_in_place.py | 98 +++++++++++++++++++ .../protocol_engine/state/pipettes.py | 5 +- .../opentrons/protocol_engine/state/tips.py | 6 +- .../unsafe/test_unsafe_drop_tip_in_place.py | 53 ++++++++++ .../protocol_engine/state/command_fixtures.py | 18 ++++ .../state/test_pipette_store.py | 37 +++++++ .../protocol_engine/state/test_tip_state.py | 26 +++++ shared-data/command/schemas/9.json | 54 +++++++++- shared-data/command/types/unsafe.ts | 23 ++++- 11 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py create mode 100644 api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index dccb15ea60b..0ef2ec49144 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -389,6 +389,7 @@ calibration.CalibrateModule, calibration.MoveToMaintenancePosition, unsafe.UnsafeBlowOutInPlace, + unsafe.UnsafeDropTipInPlace, ], Field(discriminator="commandType"), ] @@ -459,6 +460,7 @@ calibration.CalibrateModuleParams, calibration.MoveToMaintenancePositionParams, unsafe.UnsafeBlowOutInPlaceParams, + unsafe.UnsafeDropTipInPlaceParams, ] CommandType = Union[ @@ -527,6 +529,7 @@ calibration.CalibrateModuleCommandType, calibration.MoveToMaintenancePositionCommandType, unsafe.UnsafeBlowOutInPlaceCommandType, + unsafe.UnsafeDropTipInPlaceCommandType, ] CommandCreate = Annotated[ @@ -596,6 +599,7 @@ calibration.CalibrateModuleCreate, calibration.MoveToMaintenancePositionCreate, unsafe.UnsafeBlowOutInPlaceCreate, + unsafe.UnsafeDropTipInPlaceCreate, ], Field(discriminator="commandType"), ] @@ -665,7 +669,8 @@ calibration.CalibratePipetteResult, calibration.CalibrateModuleResult, calibration.MoveToMaintenancePositionResult, - unsafe.UnsafeBlowOutInPlaceResult + unsafe.UnsafeBlowOutInPlaceResult, + unsafe.UnsafeDropTipInPlaceResult, ] # todo(mm, 2024-06-12): Ideally, command return types would have specific diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py index 4856c185ec9..32aec858d7f 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -7,6 +7,13 @@ UnsafeBlowOutInPlace, UnsafeBlowOutInPlaceCreate, ) +from .unsafe_drop_tip_in_place import ( + UnsafeDropTipInPlaceCommandType, + UnsafeDropTipInPlaceParams, + UnsafeDropTipInPlaceResult, + UnsafeDropTipInPlace, + UnsafeDropTipInPlaceCreate, +) __all__ = [ # Unsafe blow-out-in-place command models @@ -15,4 +22,10 @@ "UnsafeBlowOutInPlaceResult", "UnsafeBlowOutInPlace", "UnsafeBlowOutInPlaceCreate", + # Unsafe drop-tip command models + "UnsafeDropTipInPlaceCommandType", + "UnsafeDropTipInPlaceParams", + "UnsafeDropTipInPlaceResult", + "UnsafeDropTipInPlace", + "UnsafeDropTipInPlaceCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py new file mode 100644 index 00000000000..2cb3fa78dd8 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py @@ -0,0 +1,98 @@ +"""Command models to drop tip in place while plunger positions are unknown.""" +from __future__ import annotations +from pydantic import Field, BaseModel +from typing import TYPE_CHECKING, Optional, Type +from typing_extensions import Literal + +from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.types import Axis + +from ..pipetting_common import PipetteIdMixin +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware + +if TYPE_CHECKING: + from ...execution import TipHandler + from ...state import StateView + + +UnsafeDropTipInPlaceCommandType = Literal["unsafe/dropTipInPlace"] + + +class UnsafeDropTipInPlaceParams(PipetteIdMixin): + """Payload required to drop a tip in place even if the plunger position is not known.""" + + homeAfter: Optional[bool] = Field( + None, + description=( + "Whether to home this pipette's plunger after dropping the tip." + " You should normally leave this unspecified to let the robot choose" + " a safe default depending on its hardware." + ), + ) + + +class UnsafeDropTipInPlaceResult(BaseModel): + """Result data from the execution of an UnsafeDropTipInPlace command.""" + + pass + + +class UnsafeDropTipInPlaceImplementation( + AbstractCommandImpl[ + UnsafeDropTipInPlaceParams, SuccessData[UnsafeDropTipInPlaceResult, None] + ] +): + """Unsafe drop tip in place command implementation.""" + + def __init__( + self, + tip_handler: TipHandler, + state_view: StateView, + hardware_api: HardwareControlAPI, + **kwargs: object, + ) -> None: + self._state_view = state_view + self._tip_handler = tip_handler + self._hardware_api = hardware_api + + async def execute( + self, params: UnsafeDropTipInPlaceParams + ) -> SuccessData[UnsafeDropTipInPlaceResult, None]: + """Drop a tip using the requested pipette, even if the plunger position is not known.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + pipette_location = self._state_view.motion.get_pipette_location( + params.pipetteId + ) + await ot3_hardware_api.update_axis_position_estimations( + [Axis.of_main_tool_actuator(pipette_location.mount.to_hw_mount())] + ) + await self._tip_handler.drop_tip( + pipette_id=params.pipetteId, home_after=params.homeAfter + ) + + return SuccessData(public=UnsafeDropTipInPlaceResult(), private=None) + + +class UnsafeDropTipInPlace( + BaseCommand[UnsafeDropTipInPlaceParams, UnsafeDropTipInPlaceResult, ErrorOccurrence] +): + """Drop tip in place command model.""" + + commandType: UnsafeDropTipInPlaceCommandType = "unsafe/dropTipInPlace" + params: UnsafeDropTipInPlaceParams + result: Optional[UnsafeDropTipInPlaceResult] + + _ImplementationCls: Type[ + UnsafeDropTipInPlaceImplementation + ] = UnsafeDropTipInPlaceImplementation + + +class UnsafeDropTipInPlaceCreate(BaseCommandCreate[UnsafeDropTipInPlaceParams]): + """Drop tip in place command creation request model.""" + + commandType: UnsafeDropTipInPlaceCommandType = "unsafe/dropTipInPlace" + params: UnsafeDropTipInPlaceParams + + _CommandCls: Type[UnsafeDropTipInPlace] = UnsafeDropTipInPlace diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 6e169f95a5e..60720c917ec 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -279,7 +279,10 @@ def _handle_command( # noqa: C901 default_dispense=tip_configuration.default_dispense_flowrate.values_by_api_level, ) - elif isinstance(command.result, (DropTipResult, DropTipInPlaceResult)): + elif isinstance( + command.result, + (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), + ): pipette_id = command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index 85d437888fb..9911b1f85b3 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -17,6 +17,7 @@ PickUpTipResult, DropTipResult, DropTipInPlaceResult, + unsafe, ) from ..commands.configuring_common import ( PipetteConfigUpdateResultMixin, @@ -126,7 +127,10 @@ def _handle_succeeded_command(self, command: Command) -> None: ) self._state.length_by_pipette_id[pipette_id] = length - elif isinstance(command.result, (DropTipResult, DropTipInPlaceResult)): + elif isinstance( + command.result, + (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), + ): pipette_id = command.params.pipetteId self._state.length_by_pipette_id.pop(pipette_id, None) diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py new file mode 100644 index 00000000000..3659dd2db31 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py @@ -0,0 +1,53 @@ +"""Test unsafe drop tip in place commands.""" +import pytest +from decoy import Decoy + +from opentrons.types import MountType +from opentrons.protocol_engine.state import StateView + +from opentrons.protocol_engine.execution import TipHandler + +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.unsafe.unsafe_drop_tip_in_place import ( + UnsafeDropTipInPlaceParams, + UnsafeDropTipInPlaceResult, + UnsafeDropTipInPlaceImplementation, +) +from opentrons.protocol_engine.state.motion import PipetteLocationData +from opentrons.hardware_control import OT3HardwareControlAPI +from opentrons.hardware_control.types import Axis + + +@pytest.fixture +def mock_tip_handler(decoy: Decoy) -> TipHandler: + """Get a mock TipHandler.""" + return decoy.mock(cls=TipHandler) + + +async def test_drop_tip_implementation( + decoy: Decoy, + mock_tip_handler: TipHandler, + state_view: StateView, + ot3_hardware_api: OT3HardwareControlAPI, +) -> None: + """A DropTip command should have an execution implementation.""" + subject = UnsafeDropTipInPlaceImplementation( + tip_handler=mock_tip_handler, + state_view=state_view, + hardware_api=ot3_hardware_api, + ) + + params = UnsafeDropTipInPlaceParams(pipetteId="abc", homeAfter=False) + decoy.when(state_view.motion.get_pipette_location(pipette_id="abc")).then_return( + PipetteLocationData(mount=MountType.LEFT, critical_point=None) + ) + + result = await subject.execute(params) + + assert result == SuccessData(public=UnsafeDropTipInPlaceResult(), private=None) + + decoy.verify( + await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), + await mock_tip_handler.drop_tip(pipette_id="abc", home_after=False), + times=1, + ) diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index d374bb8d13e..845b33f18d8 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -623,3 +623,21 @@ def create_unsafe_blow_out_in_place_command( params=params, result=result, ) + + +def create_unsafe_drop_tip_in_place_command( + pipette_id: str, +) -> cmd.unsafe.UnsafeDropTipInPlace: + """Get a completed UnsafeDropTipInPlace command.""" + params = cmd.unsafe.UnsafeDropTipInPlaceParams(pipetteId=pipette_id) + + result = cmd.unsafe.UnsafeDropTipInPlaceResult() + + return cmd.unsafe.UnsafeDropTipInPlace( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index d1c3486a547..c8d60395b3b 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -50,6 +50,7 @@ create_pick_up_tip_command, create_drop_tip_command, create_drop_tip_in_place_command, + create_unsafe_drop_tip_in_place_command, create_touch_tip_command, create_move_to_well_command, create_blow_out_command, @@ -177,6 +178,42 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: assert subject.state.aspirated_volume_by_id["xyz"] is None +def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: + """It should clear tip and volume details after a drop tip in place.""" + load_pipette_command = create_load_pipette_command( + pipette_id="xyz", + pipette_name=PipetteNameType.P300_SINGLE, + mount=MountType.LEFT, + ) + + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="xyz", tip_volume=42, tip_length=101, tip_diameter=8.0 + ) + + unsafe_drop_tip_in_place_command = create_unsafe_drop_tip_in_place_command( + pipette_id="xyz", + ) + + subject.handle_action( + SucceedCommandAction(private_result=None, command=load_pipette_command) + ) + subject.handle_action( + SucceedCommandAction(private_result=None, command=pick_up_tip_command) + ) + assert subject.state.attached_tip_by_id["xyz"] == TipGeometry( + volume=42, length=101, diameter=8.0 + ) + assert subject.state.aspirated_volume_by_id["xyz"] == 0 + + subject.handle_action( + SucceedCommandAction( + private_result=None, command=unsafe_drop_tip_in_place_command + ) + ) + assert subject.state.attached_tip_by_id["xyz"] is None + assert subject.state.aspirated_volume_by_id["xyz"] is None + + @pytest.mark.parametrize( "aspirate_command", [ diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index b4b9968a82d..da570c940cd 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -109,6 +109,17 @@ def drop_tip_in_place_command() -> commands.DropTipInPlace: ) +@pytest.fixture +def unsafe_drop_tip_in_place_command() -> commands.unsafe.UnsafeDropTipInPlace: + """Get an unsafe drop-tip-in-place command.""" + return commands.unsafe.UnsafeDropTipInPlace.construct( # type: ignore[call-arg] + params=commands.unsafe.UnsafeDropTipInPlaceParams.construct( + pipetteId="pipette-id" + ), + result=commands.unsafe.UnsafeDropTipInPlaceResult.construct(), + ) + + @pytest.mark.parametrize( "labware_definition", [ @@ -903,6 +914,7 @@ def test_drop_tip( pick_up_tip_command: commands.PickUpTip, drop_tip_command: commands.DropTip, drop_tip_in_place_command: commands.DropTipInPlace, + unsafe_drop_tip_in_place_command: commands.unsafe.UnsafeDropTipInPlace, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should be clear tip length when a tip is dropped.""" @@ -968,6 +980,20 @@ def test_drop_tip( result = TipView(subject.state).get_tip_length("pipette-id") assert result == 0 + subject.handle_action( + actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) + ) + result = TipView(subject.state).get_tip_length("pipette-id") + assert result == 1.23 + + subject.handle_action( + actions.SucceedCommandAction( + private_result=None, command=unsafe_drop_tip_in_place_command + ) + ) + result = TipView(subject.state).get_tip_length("pipette-id") + assert result == 0 + @pytest.mark.parametrize( argnames=["nozzle_map", "expected_channels"], diff --git a/shared-data/command/schemas/9.json b/shared-data/command/schemas/9.json index e2a587d40e5..215d33244a1 100644 --- a/shared-data/command/schemas/9.json +++ b/shared-data/command/schemas/9.json @@ -69,7 +69,8 @@ "calibration/calibratePipette": "#/definitions/CalibratePipetteCreate", "calibration/calibrateModule": "#/definitions/CalibrateModuleCreate", "calibration/moveToMaintenancePosition": "#/definitions/MoveToMaintenancePositionCreate", - "unsafe/blowOutInPlace": "#/definitions/UnsafeBlowOutInPlaceCreate" + "unsafe/blowOutInPlace": "#/definitions/UnsafeBlowOutInPlaceCreate", + "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate" } }, "oneOf": [ @@ -267,6 +268,9 @@ }, { "$ref": "#/definitions/UnsafeBlowOutInPlaceCreate" + }, + { + "$ref": "#/definitions/UnsafeDropTipInPlaceCreate" } ], "definitions": { @@ -4273,6 +4277,54 @@ } }, "required": ["params"] + }, + "UnsafeDropTipInPlaceParams": { + "title": "UnsafeDropTipInPlaceParams", + "description": "Payload required to drop a tip in place even if the plunger position is not known.", + "type": "object", + "properties": { + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + }, + "homeAfter": { + "title": "Homeafter", + "description": "Whether to home this pipette's plunger after dropping the tip. You should normally leave this unspecified to let the robot choose a safe default depending on its hardware.", + "type": "boolean" + } + }, + "required": ["pipetteId"] + }, + "UnsafeDropTipInPlaceCreate": { + "title": "UnsafeDropTipInPlaceCreate", + "description": "Drop tip in place command creation request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/dropTipInPlace", + "enum": ["unsafe/dropTipInPlace"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafeDropTipInPlaceParams" + }, + "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"] } }, "$id": "opentronsCommandSchemaV9", diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index 0c969f95286..6f245fc8b14 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -1,8 +1,12 @@ import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' -export type UnsafeRunTimeCommand = UnsafeBlowoutInPlaceRunTimeCommand +export type UnsafeRunTimeCommand = + | UnsafeBlowoutInPlaceRunTimeCommand + | UnsafeDropTipInPlaceRunTimeCommand -export type UnsafeCreateCommand = UnsafeBlowoutInPlaceCreateCommand +export type UnsafeCreateCommand = + | UnsafeBlowoutInPlaceCreateCommand + | UnsafeDropTipInPlaceCreateCommand export interface UnsafeBlowoutInPlaceParams { pipetteId: string @@ -19,3 +23,18 @@ export interface UnsafeBlowoutInPlaceRunTimeCommand UnsafeBlowoutInPlaceCreateCommand { result?: {} } + +export interface UnsafeDropTipInPlaceParams { + pipetteId: string +} + +export interface UnsafeDropTipInPlaceCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/dropTipInPlace' + params: UnsafeDropTipInPlaceParams +} +export interface UnsafeDropTipInPlaceRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeDropTipInPlaceCreateCommand { + result?: any +} From 8941345d4bf6ff2ba3730f1978738f843f53069a Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 26 Jul 2024 17:03:52 -0400 Subject: [PATCH 4/8] feat(app): DTWiz: use unsafe commands By using the new unsafe/blowOutInPlace and unsafe/dropTipInPlace commands in the drop tip wizard's blowout and drop tip modals, we can make sure that it will work on a flex even if the machine was interrupted while executing a liquid handling command. Closes EXEC-401 --- .../useDropTipWithType/useDropTipCommands.ts | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts index 12946613b58..d980490e4ba 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts @@ -5,12 +5,16 @@ import { useDeleteMaintenanceRunMutation } from '@opentrons/react-api-client' import { MANAGED_PIPETTE_ID, POSITION_AND_BLOWOUT } from '../../constants' import { getAddressableAreaFromConfig } from '../../getAddressableAreaFromConfig' import { useNotifyDeckConfigurationQuery } from '../../../../resources/deck_configuration' - import type { CreateCommand, AddressableAreaName, PipetteModelSpecs, + BlowoutInPlaceCreateCommand, + UnsafeBlowoutInPlaceCreateCommand, + DropTipInPlaceCreateCommand, + UnsafeDropTipInPlaceCreateCommand, } from '@opentrons/shared-data' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { CommandData, PipetteData } from '@opentrons/api-client' import type { Axis, @@ -61,6 +65,7 @@ export function useDropTipCommands({ robotType, fixitCommandTypeUtils, }: UseDropTipSetupCommandsParams): UseDropTipCommandsResult { + const isFlex = robotType === FLEX_ROBOT_TYPE const [hasSeenClose, setHasSeenClose] = React.useState(false) const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation({ @@ -177,10 +182,12 @@ export function useDropTipCommands({ proceed: () => void ): Promise => { return new Promise((resolve, reject) => { - const blowoutCommand = buildBlowoutInPlaceCommand(instrumentModelSpecs) - chainRunCommands( - [currentStep === POSITION_AND_BLOWOUT ? blowoutCommand : DROP_TIP], + [ + currentStep === POSITION_AND_BLOWOUT + ? buildBlowoutInPlaceCommand(instrumentModelSpecs, isFlex) + : buildDropTipInPlaceCommand(isFlex), + ], true ) .then((commandData: CommandData[]) => { @@ -260,22 +267,38 @@ const HOME_EXCEPT_PLUNGERS: CreateCommand = { params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, } -const DROP_TIP: CreateCommand = { - commandType: 'dropTipInPlace', - params: { pipetteId: MANAGED_PIPETTE_ID }, -} +const buildDropTipInPlaceCommand = ( + isFlex: boolean +): DropTipInPlaceCreateCommand | UnsafeDropTipInPlaceCreateCommand => + isFlex + ? { + commandType: 'unsafe/dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, + } + : { + commandType: 'dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, + } const buildBlowoutInPlaceCommand = ( - specs: PipetteModelSpecs -): CreateCommand => { - return { - commandType: 'blowOutInPlace', - params: { - pipetteId: MANAGED_PIPETTE_ID, - flowRate: specs.defaultBlowOutFlowRate.value, - }, - } -} + specs: PipetteModelSpecs, + isFlex: boolean +): BlowoutInPlaceCreateCommand | UnsafeBlowoutInPlaceCreateCommand => + isFlex + ? { + commandType: 'unsafe/blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: specs.defaultBlowOutFlowRate.value, + }, + } + : { + commandType: 'blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: specs.defaultBlowOutFlowRate.value, + }, + } const buildMoveToAACommand = ( addressableAreaFromConfig: AddressableAreaName From ab2c61ee1c10e8bc394ce9612365e0df4324ab59 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 29 Jul 2024 09:59:31 -0400 Subject: [PATCH 5/8] format --- shared-data/command/types/unsafe.ts | 38 ++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index 6f245fc8b14..6a3ac0a6538 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -1,40 +1,40 @@ import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' export type UnsafeRunTimeCommand = - | UnsafeBlowoutInPlaceRunTimeCommand - | UnsafeDropTipInPlaceRunTimeCommand + | UnsafeBlowoutInPlaceRunTimeCommand + | UnsafeDropTipInPlaceRunTimeCommand export type UnsafeCreateCommand = - | UnsafeBlowoutInPlaceCreateCommand - | UnsafeDropTipInPlaceCreateCommand + | UnsafeBlowoutInPlaceCreateCommand + | UnsafeDropTipInPlaceCreateCommand export interface UnsafeBlowoutInPlaceParams { - pipetteId: string - flowRate: number // µL/s + pipetteId: string + flowRate: number // µL/s } export interface UnsafeBlowoutInPlaceCreateCommand - extends CommonCommandCreateInfo { - commandType: 'unsafe/blowOutInPlace' - params: UnsafeBlowoutInPlaceParams + extends CommonCommandCreateInfo { + commandType: 'unsafe/blowOutInPlace' + params: UnsafeBlowoutInPlaceParams } export interface UnsafeBlowoutInPlaceRunTimeCommand - extends CommonCommandRunTimeInfo, - UnsafeBlowoutInPlaceCreateCommand { - result?: {} + extends CommonCommandRunTimeInfo, + UnsafeBlowoutInPlaceCreateCommand { + result?: {} } export interface UnsafeDropTipInPlaceParams { - pipetteId: string + pipetteId: string } export interface UnsafeDropTipInPlaceCreateCommand - extends CommonCommandCreateInfo { - commandType: 'unsafe/dropTipInPlace' - params: UnsafeDropTipInPlaceParams + extends CommonCommandCreateInfo { + commandType: 'unsafe/dropTipInPlace' + params: UnsafeDropTipInPlaceParams } export interface UnsafeDropTipInPlaceRunTimeCommand - extends CommonCommandRunTimeInfo, - UnsafeDropTipInPlaceCreateCommand { - result?: any + extends CommonCommandRunTimeInfo, + UnsafeDropTipInPlaceCreateCommand { + result?: any } From c0d7e0d3c1c346b7adc798321ef164a8d90b6e5d Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 29 Jul 2024 14:16:06 -0400 Subject: [PATCH 6/8] feat(api): add unsafe/UpdatePositionEstimators Adds a command that will update all specified axis position estimators from their encoders. This is something that a client can use when it knows it's about to be moving multiple axes. As with the other unsafe commands, it's best to home after doing this before doing something that requires accuracy. --- .../commands/command_unions.py | 5 ++ .../commands/unsafe/__init__.py | 14 +++ .../unsafe/update_position_estimators.py | 87 +++++++++++++++++++ .../protocol_engine/execution/gantry_mover.py | 12 +++ .../unsafe/test_update_position_estimators.py | 54 ++++++++++++ shared-data/command/schemas/9.json | 51 ++++++++++- shared-data/command/types/unsafe.ts | 16 ++++ 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py create mode 100644 api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 0ef2ec49144..eeafb1770b6 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -390,6 +390,7 @@ calibration.MoveToMaintenancePosition, unsafe.UnsafeBlowOutInPlace, unsafe.UnsafeDropTipInPlace, + unsafe.UpdatePositionEstimators, ], Field(discriminator="commandType"), ] @@ -461,6 +462,7 @@ calibration.MoveToMaintenancePositionParams, unsafe.UnsafeBlowOutInPlaceParams, unsafe.UnsafeDropTipInPlaceParams, + unsafe.UpdatePositionEstimatorsParams, ] CommandType = Union[ @@ -530,6 +532,7 @@ calibration.MoveToMaintenancePositionCommandType, unsafe.UnsafeBlowOutInPlaceCommandType, unsafe.UnsafeDropTipInPlaceCommandType, + unsafe.UpdatePositionEstimatorsCommandType, ] CommandCreate = Annotated[ @@ -600,6 +603,7 @@ calibration.MoveToMaintenancePositionCreate, unsafe.UnsafeBlowOutInPlaceCreate, unsafe.UnsafeDropTipInPlaceCreate, + unsafe.UpdatePositionEstimatorsCreate, ], Field(discriminator="commandType"), ] @@ -671,6 +675,7 @@ calibration.MoveToMaintenancePositionResult, unsafe.UnsafeBlowOutInPlaceResult, unsafe.UnsafeDropTipInPlaceResult, + unsafe.UpdatePositionEstimatorsResult, ] # todo(mm, 2024-06-12): Ideally, command return types would have specific diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py index 32aec858d7f..2875d38cb8e 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -15,6 +15,14 @@ UnsafeDropTipInPlaceCreate, ) +from .update_position_estimators import ( + UpdatePositionEstimatorsCommandType, + UpdatePositionEstimatorsParams, + UpdatePositionEstimatorsResult, + UpdatePositionEstimators, + UpdatePositionEstimatorsCreate, +) + __all__ = [ # Unsafe blow-out-in-place command models "UnsafeBlowOutInPlaceCommandType", @@ -28,4 +36,10 @@ "UnsafeDropTipInPlaceResult", "UnsafeDropTipInPlace", "UnsafeDropTipInPlaceCreate", + # Update position estimate command models + "UpdatePositionEstimatorsCommandType", + "UpdatePositionEstimatorsParams", + "UpdatePositionEstimatorsResult", + "UpdatePositionEstimators", + "UpdatePositionEstimatorsCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py new file mode 100644 index 00000000000..96be2eb8551 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py @@ -0,0 +1,87 @@ +"""Update position estimators payload, result, and implementaiton.""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import TYPE_CHECKING, Optional, List, Type +from typing_extensions import Literal + +from ...types import MotorAxis +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware + +from opentrons.hardware_control import HardwareControlAPI + +if TYPE_CHECKING: + from ...execution import GantryMover + + +UpdatePositionEstimatorsCommandType = Literal["unsafe/updatePositionEstimators"] + + +class UpdatePositionEstimatorsParams(BaseModel): + """Payload required for an UpdatePositionEstimators command.""" + + axes: List[MotorAxis] = Field( + ..., description="The axes for which to update the position estimators." + ) + + +class UpdatePositionEstimatorsResult(BaseModel): + """Result data from the execution of an UpdatePositionEstimators command.""" + + +class UpdatePositionEstimatorsImplementation( + AbstractCommandImpl[ + UpdatePositionEstimatorsParams, + SuccessData[UpdatePositionEstimatorsResult, None], + ] +): + """Update position estimators command implementation.""" + + def __init__( + self, + hardware_api: HardwareControlAPI, + gantry_mover: GantryMover, + **kwargs: object, + ) -> None: + self._hardware_api = hardware_api + self._gantry_mover = gantry_mover + + async def execute( + self, params: UpdatePositionEstimatorsParams + ) -> SuccessData[UpdatePositionEstimatorsResult, None]: + """Update axis position estimators from their encoders.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + await ot3_hardware_api.update_axis_position_estimations( + [ + self._gantry_mover.motor_axis_to_hardware_axis(axis) + for axis in params.axes + ] + ) + return SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + + +class UpdatePositionEstimators( + BaseCommand[ + UpdatePositionEstimatorsParams, UpdatePositionEstimatorsResult, ErrorOccurrence + ] +): + """UpdatePositionEstimators command model.""" + + commandType: UpdatePositionEstimatorsCommandType = "unsafe/updatePositionEstimators" + params: UpdatePositionEstimatorsParams + result: Optional[UpdatePositionEstimatorsResult] + + _ImplementationCls: Type[ + UpdatePositionEstimatorsImplementation + ] = UpdatePositionEstimatorsImplementation + + +class UpdatePositionEstimatorsCreate(BaseCommandCreate[UpdatePositionEstimatorsParams]): + """UpdatePositionEstimators command request model.""" + + commandType: UpdatePositionEstimatorsCommandType = "unsafe/updatePositionEstimators" + params: UpdatePositionEstimatorsParams + + _CommandCls: Type[UpdatePositionEstimators] = UpdatePositionEstimators diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index 7e05c8db247..26ab20f69de 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -81,6 +81,10 @@ async def prepare_for_mount_movement(self, mount: Mount) -> None: """Retract the 'idle' mount if necessary.""" ... + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: + """Transform an engine motor axis into a hardware axis.""" + ... + class HardwareGantryMover(GantryMover): """Hardware API based gantry movement handler.""" @@ -89,6 +93,10 @@ def __init__(self, hardware_api: HardwareControlAPI, state_view: StateView) -> N self._hardware_api = hardware_api self._state_view = state_view + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: + """Transform an engine motor axis into a hardware axis.""" + return _MOTOR_AXIS_TO_HARDWARE_AXIS[motor_axis] + async def get_position( self, pipette_id: str, @@ -227,6 +235,10 @@ class VirtualGantryMover(GantryMover): def __init__(self, state_view: StateView) -> None: self._state_view = state_view + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: + """Transform an engine motor axis into a hardware axis.""" + return _MOTOR_AXIS_TO_HARDWARE_AXIS[motor_axis] + async def get_position( self, pipette_id: str, diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py new file mode 100644 index 00000000000..da7ffe75012 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py @@ -0,0 +1,54 @@ +"""Test update-position-estimator commands.""" +from decoy import Decoy + +from opentrons.protocol_engine.commands.unsafe.update_position_estimators import ( + UpdatePositionEstimatorsParams, + UpdatePositionEstimatorsResult, + UpdatePositionEstimatorsImplementation, +) +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.execution import GantryMover +from opentrons.protocol_engine.types import MotorAxis +from opentrons.hardware_control import OT3HardwareControlAPI +from opentrons.hardware_control.types import Axis + + +async def test_update_position_estimators_implementation( + decoy: Decoy, ot3_hardware_api: OT3HardwareControlAPI, gantry_mover: GantryMover +) -> None: + """Test UnsafeBlowOut command execution.""" + subject = UpdatePositionEstimatorsImplementation( + hardware_api=ot3_hardware_api, gantry_mover=gantry_mover + ) + + data = UpdatePositionEstimatorsParams( + axes=[MotorAxis.LEFT_Z, MotorAxis.LEFT_PLUNGER, MotorAxis.X, MotorAxis.Y] + ) + + decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_Z)).then_return( + Axis.Z_L + ) + decoy.when( + gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_PLUNGER) + ).then_return(Axis.P_L) + decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.X)).then_return( + Axis.X + ) + decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.Y)).then_return( + Axis.Y + ) + decoy.when( + await ot3_hardware_api.update_axis_position_estimations( + [Axis.Z_L, Axis.P_L, Axis.X, Axis.Y] + ) + ).then_return(None) + + result = await subject.execute(data) + + assert result == SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + + decoy.verify( + await ot3_hardware_api.update_axis_position_estimations( + [Axis.Z_L, Axis.P_L, Axis.X, Axis.Y] + ), + ) diff --git a/shared-data/command/schemas/9.json b/shared-data/command/schemas/9.json index 215d33244a1..1cb30c99d69 100644 --- a/shared-data/command/schemas/9.json +++ b/shared-data/command/schemas/9.json @@ -70,7 +70,8 @@ "calibration/calibrateModule": "#/definitions/CalibrateModuleCreate", "calibration/moveToMaintenancePosition": "#/definitions/MoveToMaintenancePositionCreate", "unsafe/blowOutInPlace": "#/definitions/UnsafeBlowOutInPlaceCreate", - "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate" + "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate", + "unsafe/updatePositionEstimators": "#/definitions/UpdatePositionEstimatorsCreate" } }, "oneOf": [ @@ -271,6 +272,9 @@ }, { "$ref": "#/definitions/UnsafeDropTipInPlaceCreate" + }, + { + "$ref": "#/definitions/UpdatePositionEstimatorsCreate" } ], "definitions": { @@ -4325,6 +4329,51 @@ } }, "required": ["params"] + }, + "UpdatePositionEstimatorsParams": { + "title": "UpdatePositionEstimatorsParams", + "description": "Payload required for an UpdatePositionEstimators command.", + "type": "object", + "properties": { + "axes": { + "description": "The axes for which to update the position estimators.", + "type": "array", + "items": { + "$ref": "#/definitions/MotorAxis" + } + } + }, + "required": ["axes"] + }, + "UpdatePositionEstimatorsCreate": { + "title": "UpdatePositionEstimatorsCreate", + "description": "UpdatePositionEstimators command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/updatePositionEstimators", + "enum": ["unsafe/updatePositionEstimators"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UpdatePositionEstimatorsParams" + }, + "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"] } }, "$id": "opentronsCommandSchemaV9", diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index 6a3ac0a6538..d4340e2b68b 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -1,4 +1,5 @@ import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' +import type { MotorAxes } from '../../js/types' export type UnsafeRunTimeCommand = | UnsafeBlowoutInPlaceRunTimeCommand @@ -38,3 +39,18 @@ export interface UnsafeDropTipInPlaceRunTimeCommand UnsafeDropTipInPlaceCreateCommand { result?: any } + +export interface UnsafeUpdatePositionEstimatorsParams { + axes: MotorAxes +} + +export interface UnsafeUpdatePositionEstimatorsCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/updatePositionEstimators' + params: UnsafeUpdatePositionEstimatorsParams +} +export interface UnsafeUpdatePositionEstimatorsRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeUpdatePositionEstimatorsCreateCommand { + result?: any +} From 956836d3a0b0cad08089763848eb4ae323c63a2d Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 29 Jul 2024 15:17:08 -0400 Subject: [PATCH 7/8] feat(app): use update-estimators in DTWiz This will let you move around and blowout and drop tips even if the robot has lost positioning. --- .../hooks/useDropTipWithType/useDropTipCommands.ts | 13 +++++++++++-- shared-data/command/types/unsafe.ts | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts index d980490e4ba..4bb8f6c96ae 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts @@ -119,8 +119,12 @@ export function useDropTipCommands({ if (addressableAreaFromConfig != null) { const moveToAACommand = buildMoveToAACommand(addressableAreaFromConfig) - - return chainRunCommands([moveToAACommand], true) + return chainRunCommands( + isFlex + ? [UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, moveToAACommand] + : [moveToAACommand], + true + ) .then((commandData: CommandData[]) => { const error = commandData[0].data.error if (error != null) { @@ -267,6 +271,11 @@ const HOME_EXCEPT_PLUNGERS: CreateCommand = { params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, } +const UPDATE_ESTIMATORS_EXCEPT_PLUNGERS: CreateCommand = { + commandType: 'unsafe/updatePositionEstimators' as const, + params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, +} + const buildDropTipInPlaceCommand = ( isFlex: boolean ): DropTipInPlaceCreateCommand | UnsafeDropTipInPlaceCreateCommand => diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index d4340e2b68b..8ff4d7e74aa 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -4,10 +4,12 @@ import type { MotorAxes } from '../../js/types' export type UnsafeRunTimeCommand = | UnsafeBlowoutInPlaceRunTimeCommand | UnsafeDropTipInPlaceRunTimeCommand + | UnsafeUpdatePositionEstimatorsRunTimeCommand export type UnsafeCreateCommand = | UnsafeBlowoutInPlaceCreateCommand | UnsafeDropTipInPlaceCreateCommand + | UnsafeUpdatePositionEstimatorsCreateCommand export interface UnsafeBlowoutInPlaceParams { pipetteId: string From a2116908e01fe8154d9cd456cb63d8aa1fd68fb1 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 29 Jul 2024 15:17:34 -0400 Subject: [PATCH 8/8] fix(api): reload position after estimators When we reload the estimators, we need to update our local cache of their state. --- api/src/opentrons/hardware_control/ot3api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index b17c3b297f5..4f0cf262775 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -1144,6 +1144,8 @@ async def gantry_position( async def update_axis_position_estimations(self, axes: Sequence[Axis]) -> None: """Update specified axes position estimators from their encoders.""" await self._update_position_estimation(axes) + await self._cache_current_position() + await self._cache_encoder_position() async def move_to( self,