From 9797d7432861e173c6721b0d63a1347b23668a43 Mon Sep 17 00:00:00 2001 From: pmoegenburg Date: Thu, 17 Oct 2024 19:32:24 -0400 Subject: [PATCH] feat(api): add WellVolumeOffset to WellLocation (#16302) # Overview This PR enables static meniscus-relative aspirate and dispense via the Protocol API. To enable this, WellVolumeOffset has been added to WellLocation. This is a volume of liquid to account for when executing commands with an origin of WellOrigin.MENISCUS. Specifying `operationVolume` results in this class acting as a sentinel and should be used when volume can be determined from the command parameters, for example commanding Aspirate. A volume should be specified when it cannot be determined from the command parameters, for example commanding MoveToWell prior to AspirateInPlace. ## Test Plan and Hands on Testing Successfully tested the following protocol on a robot multiple times in a row. Aspiration from meniscus was not tested due to not-yet-available InnerWellGeometry: ``` from opentrons.protocol_api import ProtocolContext metadata = {"protocolName": "Test LLD"} requirements = {"robotType": "Flex", "apiLevel":"2.21"} def run(ctx: ProtocolContext) -> None: """Run.""" tiprack = ctx.load_labware(f"opentrons_flex_96_tiprack_1000uL", "A3") source = ctx.load_labware("nest_12_reservoir_15ml", "C2") sink = ctx.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "D2") pipette = ctx.load_instrument("flex_1channel_1000", "left", liquid_presence_detection = True) pipette.pick_up_tip(tiprack) pipette.measure_liquid_height(sink["A1"]) pipette.aspirate(10, source["A1"]) pipette.dispense(10, sink["A1"].meniscus(-2)) pipette.return_tip() ``` ## Changelog ## Review requests ## Risk assessment --- .../protocol_api/core/engine/instrument.py | 34 ++- .../core/engine/pipette_movement_conflict.py | 9 +- .../protocol_api/core/engine/well.py | 11 - .../opentrons/protocol_api/core/instrument.py | 2 + .../core/legacy/legacy_instrument_core.py | 2 + .../core/legacy/legacy_well_core.py | 4 - .../legacy_instrument_core.py | 2 + api/src/opentrons/protocol_api/core/well.py | 4 - .../protocol_api/instrument_context.py | 37 ++- api/src/opentrons/protocol_api/labware.py | 8 +- api/src/opentrons/protocol_api/validation.py | 5 +- api/src/opentrons/protocol_engine/__init__.py | 6 + .../protocol_engine/commands/aspirate.py | 11 +- .../protocol_engine/commands/dispense.py | 25 +- .../protocol_engine/commands/move_to_well.py | 34 ++- .../protocol_engine/commands/pick_up_tip.py | 16 +- .../commands/pipetting_common.py | 19 +- .../protocol_engine/errors/__init__.py | 10 +- .../protocol_engine/errors/exceptions.py | 32 +- .../protocol_engine/execution/movement.py | 7 +- .../protocol_engine/execution/pipetting.py | 4 +- .../protocol_engine/state/frustum_helpers.py | 14 +- .../protocol_engine/state/geometry.py | 257 ++++++++++++---- .../protocol_engine/state/labware.py | 28 +- .../opentrons/protocol_engine/state/motion.py | 14 +- api/src/opentrons/protocol_engine/types.py | 43 +++ api/src/opentrons/types.py | 16 +- .../core/engine/test_instrument_core.py | 49 ++- .../core/engine/test_well_core.py | 17 -- .../protocol_api/test_instrument_context.py | 50 +++ api/tests/opentrons/protocol_api/test_well.py | 5 +- .../protocol_engine/commands/test_aspirate.py | 92 +++++- .../protocol_engine/commands/test_blow_out.py | 7 +- .../protocol_engine/commands/test_dispense.py | 18 +- .../commands/test_move_to_well.py | 42 ++- .../commands/test_pick_up_tip.py | 21 +- .../execution/test_movement_handler.py | 2 + .../protocol_engine/state/command_fixtures.py | 9 +- .../state/test_geometry_view.py | 285 +++++++++++++++++- .../protocol_engine/state/test_motion_view.py | 10 +- .../protocol_runner/test_json_translator.py | 8 +- api/tests/opentrons/test_types.py | 9 +- .../protocols/test_v6_json_upload.tavern.yaml | 6 + .../test_v8_json_upload_flex.tavern.yaml | 6 + .../test_v8_json_upload_ot2.tavern.yaml | 6 + .../test_json_v6_protocol_run.tavern.yaml | 4 + .../runs/test_json_v6_run_failure.tavern.yaml | 1 + .../test_json_v7_protocol_run.tavern.yaml | 4 + .../runs/test_papi_v2_run_failure.tavern.yaml | 1 + shared-data/command/schemas/10.json | 90 +++++- 50 files changed, 1170 insertions(+), 226 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 8fe2b8d7f6e..4474a174a85 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -112,6 +112,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, + is_meniscus: Optional[bool] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -146,12 +147,11 @@ def aspirate( well_name = well_core.get_name() labware_id = well_core.labware_id - well_location = ( - self._engine_client.state.geometry.get_relative_well_location( - labware_id=labware_id, - well_name=well_name, - absolute_point=location.point, - ) + well_location = self._engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id=labware_id, + well_name=well_name, + absolute_point=location.point, + is_meniscus=is_meniscus, ) pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, @@ -182,6 +182,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], + is_meniscus: Optional[bool] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -237,12 +238,11 @@ def dispense( well_name = well_core.get_name() labware_id = well_core.labware_id - well_location = ( - self._engine_client.state.geometry.get_relative_well_location( - labware_id=labware_id, - well_name=well_name, - absolute_point=location.point, - ) + well_location = self._engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id=labware_id, + well_name=well_name, + absolute_point=location.point, + is_meniscus=is_meniscus, ) pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, @@ -416,10 +416,12 @@ def pick_up_tip( well_name = well_core.get_name() labware_id = well_core.labware_id - well_location = self._engine_client.state.geometry.get_relative_well_location( - labware_id=labware_id, - well_name=well_name, - absolute_point=location.point, + well_location = ( + self._engine_client.state.geometry.get_relative_pick_up_tip_well_location( + labware_id=labware_id, + well_name=well_name, + absolute_point=location.point, + ) ) pipette_movement_conflict.check_safe_for_tip_pickup_and_return( engine_state=self._engine_client.state, diff --git a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py index bfe98e1f217..46968c486d7 100644 --- a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py @@ -19,6 +19,8 @@ DeckSlotLocation, OnLabwareLocation, WellLocation, + LiquidHandlingWellLocation, + PickUpTipWellLocation, DropTipWellLocation, ) from opentrons.protocol_engine.types import ( @@ -66,7 +68,12 @@ def check_safe_for_pipette_movement( pipette_id: str, labware_id: str, well_name: str, - well_location: Union[WellLocation, DropTipWellLocation], + well_location: Union[ + WellLocation, + LiquidHandlingWellLocation, + PickUpTipWellLocation, + DropTipWellLocation, + ], ) -> None: """Check if the labware is safe to move to with a pipette in partial tip configuration. diff --git a/api/src/opentrons/protocol_api/core/engine/well.py b/api/src/opentrons/protocol_api/core/engine/well.py index ec7307a6a90..6743a8a39c5 100644 --- a/api/src/opentrons/protocol_api/core/engine/well.py +++ b/api/src/opentrons/protocol_api/core/engine/well.py @@ -125,17 +125,6 @@ def get_center(self) -> Point: well_location=WellLocation(origin=WellOrigin.CENTER), ) - def get_meniscus(self, z_offset: float) -> Point: - """Get the coordinate of the well's meniscus, with a z-offset.""" - return self._engine_client.state.geometry.get_well_position( - well_name=self._name, - labware_id=self._labware_id, - well_location=WellLocation( - origin=WellOrigin.MENISCUS, - offset=WellOffset(x=0, y=0, z=z_offset), - ), - ) - def load_liquid( self, liquid: Liquid, diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 1695f96e5db..7d1816e1044 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -33,6 +33,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, + is_meniscus: Optional[bool] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -55,6 +56,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], + is_meniscus: Optional[bool] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index a831a9113f2..ed1e0d607c9 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -80,6 +80,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, + is_meniscus: Optional[bool] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -122,6 +123,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], + is_meniscus: Optional[bool] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py index f37aefbd4be..a88dd2eee80 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py @@ -106,10 +106,6 @@ def get_center(self) -> Point: """Get the coordinate of the well's center.""" return self._geometry.center() - def get_meniscus(self, z_offset: float) -> Point: - """This will never be called because it was added in API 2.21.""" - assert False, "get_meniscus only supported in API 2.21 & later" - def load_liquid( self, liquid: Liquid, 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 1471af79fe8..55bde6c0a75 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 @@ -91,6 +91,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, + is_meniscus: Optional[bool] = None, ) -> None: if self.get_current_volume() == 0: # Make sure we're at the top of the labware and clear of any @@ -132,6 +133,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], + is_meniscus: Optional[bool] = None, ) -> None: if isinstance(location, (TrashBin, WasteChute)): raise APIVersionError( diff --git a/api/src/opentrons/protocol_api/core/well.py b/api/src/opentrons/protocol_api/core/well.py index 81dddede2f1..bd58963a59c 100644 --- a/api/src/opentrons/protocol_api/core/well.py +++ b/api/src/opentrons/protocol_api/core/well.py @@ -71,10 +71,6 @@ def get_bottom(self, z_offset: float) -> Point: def get_center(self) -> Point: """Get the coordinate of the well's center.""" - @abstractmethod - def get_meniscus(self, z_offset: float) -> Point: - """Get the coordinate of the well's meniscus, with an z-offset.""" - @abstractmethod def load_liquid( self, diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index b158ff8c75f..880626b53c9 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -217,8 +217,9 @@ def aspirate( ) ) - well: Optional[labware.Well] = None move_to_location: types.Location + well: Optional[labware.Well] = None + is_meniscus: Optional[bool] = None last_location = self._get_last_location_by_api_version() try: target = validation.validate_location( @@ -232,17 +233,13 @@ def aspirate( "knows where it is." ) from e - if isinstance(target, validation.WellTarget): - move_to_location = target.location or target.well.bottom( - z=self._well_bottom_clearances.aspirate - ) - well = target.well - if isinstance(target, validation.PointTarget): - move_to_location = target.location if isinstance(target, (TrashBin, WasteChute)): raise ValueError( "Trash Bin and Waste Chute are not acceptable location parameters for Aspirate commands." ) + move_to_location, well, is_meniscus = self._handle_aspirate_target( + target=target + ) if self.api_version >= APIVersion(2, 11): instrument.validate_takes_liquid( location=move_to_location, @@ -282,6 +279,7 @@ def aspirate( rate=rate, flow_rate=flow_rate, in_place=target.in_place, + is_meniscus=is_meniscus, ) return self @@ -384,6 +382,7 @@ def dispense( # noqa: C901 ) ) well: Optional[labware.Well] = None + is_meniscus: Optional[bool] = None last_location = self._get_last_location_by_api_version() try: @@ -402,6 +401,7 @@ def dispense( # noqa: C901 well = target.well if target.location: move_to_location = target.location + is_meniscus = target.location.is_meniscus elif well.parent._core.is_fixed_trash(): move_to_location = target.well.top() else: @@ -467,6 +467,7 @@ def dispense( # noqa: C901 flow_rate=flow_rate, in_place=target.in_place, push_out=push_out, + is_meniscus=is_meniscus, ) return self @@ -2191,6 +2192,26 @@ def _raise_if_configuration_not_supported_by_pipette( ) # SINGLE, QUADRANT and ALL are supported by all pipettes + def _handle_aspirate_target( + self, target: validation.ValidTarget + ) -> tuple[types.Location, Optional[labware.Well], Optional[bool]]: + move_to_location: types.Location + well: Optional[labware.Well] = None + is_meniscus: Optional[bool] = None + if isinstance(target, validation.WellTarget): + well = target.well + if target.location: + move_to_location = target.location + is_meniscus = target.location.is_meniscus + + else: + move_to_location = target.well.bottom( + z=self._well_bottom_clearances.aspirate + ) + if isinstance(target, validation.PointTarget): + move_to_location = target.location + return (move_to_location, well, is_meniscus) + class AutoProbeDisable: """Use this class to temporarily disable automatic liquid presence detection.""" diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 6f38c123c7a..0e8a17d07d3 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -226,13 +226,13 @@ def meniscus(self, z: float = 0.0) -> Location: """ :param z: An offset on the z-axis, in mm. Positive offsets are higher and negative offsets are lower. - :return: A :py:class:`~opentrons.types.Location` corresponding to the - absolute position of the meniscus-center of the well, plus the ``z`` offset - (if specified). + :return: A :py:class:`~opentrons.types.Location` that indicates location is meniscus and that holds the ``z`` offset in its point.z field. :meta private: """ - return Location(self._core.get_meniscus(z_offset=z), self) + return Location( + point=Point(x=0, y=0, z=z), labware=self, _ot_internal_is_meniscus=True + ) @requires_version(2, 8) def from_center_cartesian(self, x: float, y: float, z: float) -> Point: diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 43c83eca2e0..630211e9ac6 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -435,10 +435,13 @@ class LocationTypeError(TypeError): """Error representing that the location supplied is of different expected type.""" +ValidTarget = Union[WellTarget, PointTarget, TrashBin, WasteChute] + + def validate_location( location: Union[Location, Well, TrashBin, WasteChute, None], last_location: Optional[Location], -) -> Union[WellTarget, PointTarget, TrashBin, WasteChute]: +) -> ValidTarget: """Validate a given location for a liquid handling command. Args: diff --git a/api/src/opentrons/protocol_engine/__init__.py b/api/src/opentrons/protocol_engine/__init__.py index 14c5c3f3fc5..25599189916 100644 --- a/api/src/opentrons/protocol_engine/__init__.py +++ b/api/src/opentrons/protocol_engine/__init__.py @@ -47,9 +47,12 @@ LoadedPipette, MotorAxis, WellLocation, + LiquidHandlingWellLocation, + PickUpTipWellLocation, DropTipWellLocation, WellOrigin, DropTipWellOrigin, + PickUpTipWellOrigin, WellOffset, ModuleModel, ModuleDefinition, @@ -109,9 +112,12 @@ "LoadedPipette", "MotorAxis", "WellLocation", + "LiquidHandlingWellLocation", + "PickUpTipWellLocation", "DropTipWellLocation", "WellOrigin", "DropTipWellOrigin", + "PickUpTipWellOrigin", "WellOffset", "ModuleModel", "ModuleDefinition", diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 9876ce19bd3..14b59248216 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -9,7 +9,7 @@ PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin, - WellLocationMixin, + LiquidHandlingWellLocationMixin, BaseLiquidHandlingResult, DestinationPositionResult, ) @@ -38,7 +38,7 @@ class AspirateParams( - PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin, WellLocationMixin + PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin ): """Parameters required to aspirate from a specific well.""" @@ -112,12 +112,17 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_name=well_name, ) + well_location = params.wellLocation + if well_location.origin == WellOrigin.MENISCUS: + well_location.volumeOffset = "operationVolume" + position = await self._movement.move_to_well( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name, - well_location=params.wellLocation, + well_location=well_location, current_well=current_well, + operation_volume=-params.volume, ) deck_point = DeckPoint.construct(x=position.x, y=position.y, z=position.z) state_update.set_pipette_location( diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index ce3ce3cdab1..7e18cc6560b 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -13,7 +13,7 @@ PipetteIdMixin, DispenseVolumeMixin, FlowRateMixin, - WellLocationMixin, + LiquidHandlingWellLocationMixin, BaseLiquidHandlingResult, DestinationPositionResult, OverpressureError, @@ -30,13 +30,14 @@ if TYPE_CHECKING: from ..execution import MovementHandler, PipettingHandler from ..resources import ModelUtils + from ..state.state import StateView DispenseCommandType = Literal["dispense"] class DispenseParams( - PipetteIdMixin, DispenseVolumeMixin, FlowRateMixin, WellLocationMixin + PipetteIdMixin, DispenseVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin ): """Payload required to dispense to a specific well.""" @@ -63,11 +64,13 @@ class DispenseImplementation(AbstractCommandImpl[DispenseParams, _ExecuteReturn] def __init__( self, + state_view: StateView, movement: MovementHandler, pipetting: PipettingHandler, model_utils: ModelUtils, **kwargs: object, ) -> None: + self._state_view = state_view self._movement = movement self._pipetting = pipetting self._model_utils = model_utils @@ -75,25 +78,31 @@ def __init__( async def execute(self, params: DispenseParams) -> _ExecuteReturn: """Move to and dispense to the requested well.""" state_update = StateUpdate() + well_location = params.wellLocation + labware_id = params.labwareId + well_name = params.wellName + volume = params.volume + + # TODO(pbm, 10-15-24): call self._state_view.geometry.validate_dispense_volume_into_well() position = await self._movement.move_to_well( pipette_id=params.pipetteId, - labware_id=params.labwareId, - well_name=params.wellName, - well_location=params.wellLocation, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, ) deck_point = DeckPoint.construct(x=position.x, y=position.y, z=position.z) state_update.set_pipette_location( pipette_id=params.pipetteId, - new_labware_id=params.labwareId, - new_well_name=params.wellName, + new_labware_id=labware_id, + new_well_name=well_name, new_deck_point=deck_point, ) try: volume = await self._pipetting.dispense_in_place( pipette_id=params.pipetteId, - volume=params.volume, + volume=volume, flow_rate=params.flowRate, push_out=params.pushOut, ) diff --git a/api/src/opentrons/protocol_engine/commands/move_to_well.py b/api/src/opentrons/protocol_engine/commands/move_to_well.py index 9695ccb3bc0..309f2e89513 100644 --- a/api/src/opentrons/protocol_engine/commands/move_to_well.py +++ b/api/src/opentrons/protocol_engine/commands/move_to_well.py @@ -13,9 +13,11 @@ from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence from ..state import update_types +from ..errors import LabwareIsTipRackError if TYPE_CHECKING: from ..execution import MovementHandler + from ..state.state import StateView MoveToWellCommandType = Literal["moveToWell"] @@ -37,29 +39,45 @@ class MoveToWellImplementation( ): """Move to well command implementation.""" - def __init__(self, movement: MovementHandler, **kwargs: object) -> None: + def __init__( + self, state_view: StateView, movement: MovementHandler, **kwargs: object + ) -> None: + self._state_view = state_view self._movement = movement async def execute( self, params: MoveToWellParams ) -> SuccessData[MoveToWellResult, None]: """Move the requested pipette to the requested well.""" + pipette_id = params.pipetteId + labware_id = params.labwareId + well_name = params.wellName + well_location = params.wellLocation + state_update = update_types.StateUpdate() + if ( + self._state_view.labware.is_tiprack(labware_id) + and well_location.volumeOffset + ): + raise LabwareIsTipRackError( + "Cannot specify a WellLocation with a volumeOffset with movement to a tip rack" + ) + x, y, z = await self._movement.move_to_well( - pipette_id=params.pipetteId, - labware_id=params.labwareId, - well_name=params.wellName, - well_location=params.wellLocation, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, force_direct=params.forceDirect, minimum_z_height=params.minimumZHeight, speed=params.speed, ) deck_point = DeckPoint.construct(x=x, y=y, z=z) state_update.set_pipette_location( - pipette_id=params.pipetteId, - new_labware_id=params.labwareId, - new_well_name=params.wellName, + pipette_id=pipette_id, + new_labware_id=labware_id, + new_well_name=well_name, new_deck_point=deck_point, ) diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index c5019b3c590..5ccdcfc6f3a 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -9,10 +9,9 @@ from ..errors import ErrorOccurrence, TipNotAttachedError from ..resources import ModelUtils from ..state import update_types -from ..types import DeckPoint +from ..types import PickUpTipWellLocation, DeckPoint from .pipetting_common import ( PipetteIdMixin, - WellLocationMixin, DestinationPositionResult, ) from .command import ( @@ -31,10 +30,15 @@ PickUpTipCommandType = Literal["pickUpTip"] -class PickUpTipParams(PipetteIdMixin, WellLocationMixin): +class PickUpTipParams(PipetteIdMixin): """Payload needed to move a pipette to a specific well.""" - pass + labwareId: str = Field(..., description="Identifier of labware to use.") + wellName: str = Field(..., description="Name of well to use in labware.") + wellLocation: PickUpTipWellLocation = Field( + default_factory=PickUpTipWellLocation, + description="Relative well location at which to pick up the tip.", + ) class PickUpTipResult(DestinationPositionResult): @@ -110,10 +114,12 @@ async def execute( pipette_id = params.pipetteId labware_id = params.labwareId well_name = params.wellName - well_location = params.wellLocation state_update = update_types.StateUpdate() + well_location = self._state_view.geometry.convert_pick_up_tip_well_location( + well_location=params.wellLocation + ) position = await self._movement.move_to_well( pipette_id=pipette_id, labware_id=labware_id, diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index 3fbe2d9609d..2dafb4c81b2 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -5,7 +5,7 @@ from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence -from ..types import WellLocation, DeckPoint +from ..types import WellLocation, LiquidHandlingWellLocation, DeckPoint class PipetteIdMixin(BaseModel): @@ -68,6 +68,23 @@ class WellLocationMixin(BaseModel): ) +class LiquidHandlingWellLocationMixin(BaseModel): + """Mixin for command requests that take a location that's somewhere in a well.""" + + labwareId: str = Field( + ..., + description="Identifier of labware to use.", + ) + wellName: str = Field( + ..., + description="Name of well to use in labware.", + ) + wellLocation: LiquidHandlingWellLocation = Field( + default_factory=LiquidHandlingWellLocation, + description="Relative well location at which to perform the operation", + ) + + class MovementMixin(BaseModel): """Mixin for command requests that move a pipette.""" diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index e0f60a5cd45..304f7db1fff 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -71,7 +71,10 @@ CommandNotAllowedError, InvalidLiquidHeightFound, LiquidHeightUnknownError, - InvalidWellDefinitionError, + IncompleteLabwareDefinitionError, + IncompleteWellDefinitionError, + OperationLocationNotInWellError, + InvalidDispenseVolumeError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -151,5 +154,8 @@ "CommandNotAllowedError", "InvalidLiquidHeightFound", "LiquidHeightUnknownError", - "InvalidWellDefinitionError", + "IncompleteLabwareDefinitionError", + "IncompleteWellDefinitionError", + "OperationLocationNotInWellError", + "InvalidDispenseVolumeError", ] diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 72698ebf029..dd9dc6e1d51 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -1071,8 +1071,8 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) -class InvalidWellDefinitionError(ProtocolEngineError): - """Raised when an InnerWellGeometry definition is invalid.""" +class IncompleteLabwareDefinitionError(ProtocolEngineError): + """Raised when a labware definition lacks innerLabwareGeometry in general or for a specific well_id.""" def __init__( self, @@ -1080,5 +1080,31 @@ def __init__( details: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an InvalidWellDefinitionError.""" + """Build an IncompleteLabwareDefinitionError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + +class IncompleteWellDefinitionError(ProtocolEngineError): + """Raised when a well definition lacks a geometryDefinitionId.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an IncompleteWellDefinitionError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + +class OperationLocationNotInWellError(ProtocolEngineError): + """Raised when a calculated operation location is not within a well.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an OperationLocationNotInWellError.""" super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index a0ebbeac2b6..7eb35e5f911 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Optional, List +from typing import Optional, List, Union from opentrons.types import Point, MountType from opentrons.hardware_control import HardwareControlAPI @@ -10,6 +10,7 @@ from ..types import ( WellLocation, + LiquidHandlingWellLocation, DeckPoint, MovementAxis, MotorAxis, @@ -66,11 +67,12 @@ async def move_to_well( pipette_id: str, labware_id: str, well_name: str, - well_location: Optional[WellLocation] = None, + well_location: Optional[Union[WellLocation, LiquidHandlingWellLocation]] = None, current_well: Optional[CurrentWell] = None, force_direct: bool = False, minimum_z_height: Optional[float] = None, speed: Optional[float] = None, + operation_volume: Optional[float] = None, ) -> Point: """Move to a specific well.""" self._state_store.labware.raise_if_labware_inaccessible_by_pipette( @@ -129,6 +131,7 @@ async def move_to_well( current_well=current_well, force_direct=force_direct, minimum_z_height=minimum_z_height, + operation_volume=operation_volume, ) speed = self._state_store.pipettes.get_movement_speed( diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index ae35e779761..2964f02d183 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -288,8 +288,8 @@ async def liquid_probe_in_place( well_location: WellLocation, ) -> float: """Detect liquid level.""" - # TODO (pm, 6-18-24): return a value of worth if needed - return 0.0 + well_def = self._state_view.labware.get_well_definition(labware_id, well_name) + return well_def.depth def _validate_tip_attached(self, pipette_id: str, command_name: str) -> None: """Validate if there is a tip attached.""" diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 4f132ac3b40..09f726de767 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -375,18 +375,22 @@ def _find_height_in_partial_frustum( bottom_section_volume = 0.0 for section, capacity in zip(sorted_well, volumetric_capacity): section_top_height, section_volume = capacity - if bottom_section_volume < target_volume < section_volume: + if ( + bottom_section_volume + < target_volume + < (bottom_section_volume + section_volume) + ): relative_target_volume = target_volume - bottom_section_volume - relative_section_height = section.topHeight - section.bottomHeight + section_height = section.topHeight - section.bottomHeight partial_height = height_at_volume_within_section( section=section, target_volume_relative=relative_target_volume, - section_height=relative_section_height, + section_height=section_height, ) return partial_height + section.bottomHeight # bottom section volume should always be the volume enclosed in the previously # viewed section - bottom_section_volume = section_volume + bottom_section_volume += section_volume # if we've looked through all sections and can't find the target volume, raise an error raise InvalidLiquidHeightFound( @@ -399,7 +403,7 @@ def find_height_at_well_volume( ) -> float: """Find the height within a well, at a known volume.""" volumetric_capacity = get_well_volumetric_capacity(well_geometry) - max_volume = volumetric_capacity[-1][1] + max_volume = sum(row[1] for row in volumetric_capacity) if target_volume < 0 or target_volume > max_volume: raise InvalidLiquidHeightFound("Invalid target volume.") diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index e37a460d226..125be3339a9 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -9,6 +9,7 @@ from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN +from opentrons_shared_data.labware.labware_definition import InnerWellGeometry from opentrons_shared_data.deck.types import CutoutFixture from opentrons_shared_data.pipette import PIPETTE_X_SPAN from opentrons_shared_data.pipette.types import ChannelCount @@ -19,7 +20,7 @@ LabwareNotLoadedOnLabwareError, LabwareNotLoadedOnModuleError, LabwareMovementNotAllowedError, - InvalidWellDefinitionError, + OperationLocationNotInWellError, ) from ..resources import fixture_validation, labware_validation from ..types import ( @@ -27,7 +28,9 @@ LoadedLabware, LoadedModule, WellLocation, + LiquidHandlingWellLocation, DropTipWellLocation, + PickUpTipWellLocation, WellOrigin, DropTipWellOrigin, WellOffset, @@ -56,7 +59,6 @@ from .pipettes import PipetteView from .addressable_areas import AddressableAreaView from .frustum_helpers import ( - get_well_volumetric_capacity, find_volume_at_well_height, find_height_at_well_volume, ) @@ -421,11 +423,51 @@ def get_labware_position(self, labware_id: str) -> Point: z=origin_pos.z + cal_offset.z, ) + WellLocations = Union[ + WellLocation, LiquidHandlingWellLocation, PickUpTipWellLocation + ] + + def validate_well_position( + self, + well_location: WellLocations, + z_offset: float, + pipette_id: Optional[str] = None, + ) -> None: + """Raise exception if operation location is not within well. + + Primarily this checks if there is not enough liquid in a well to do meniscus-relative static aspiration. + """ + if well_location.origin == WellOrigin.MENISCUS: + assert pipette_id is not None + lld_min_height = self._pipettes.get_current_tip_lld_settings( + pipette_id=pipette_id + ) + if z_offset < lld_min_height: + if isinstance(well_location, PickUpTipWellLocation): + raise OperationLocationNotInWellError( + f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location that could be below the bottom of the well" + ) + else: + raise OperationLocationNotInWellError( + f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location that could be below the bottom of the well" + ) + elif z_offset < 0: + if isinstance(well_location, PickUpTipWellLocation): + raise OperationLocationNotInWellError( + f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location below the bottom of the well" + ) + else: + raise OperationLocationNotInWellError( + f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location below the bottom of the well" + ) + def get_well_position( self, labware_id: str, well_name: str, - well_location: Optional[WellLocation] = None, + well_location: Optional[WellLocations] = None, + operation_volume: Optional[float] = None, + pipette_id: Optional[str] = None, ) -> Point: """Given relative well location in a labware, get absolute position.""" labware_pos = self.get_labware_position(labware_id) @@ -435,20 +477,17 @@ def get_well_position( offset = WellOffset(x=0, y=0, z=well_depth) if well_location is not None: offset = well_location.offset - if well_location.origin == WellOrigin.TOP: - offset = offset.copy(update={"z": offset.z + well_depth}) - elif well_location.origin == WellOrigin.CENTER: - offset = offset.copy(update={"z": offset.z + well_depth / 2.0}) - elif well_location.origin == WellOrigin.MENISCUS: - liquid_height = self._wells.get_last_measured_liquid_height( - labware_id, well_name - ) - if liquid_height is not None: - offset = offset.copy(update={"z": offset.z + liquid_height}) - else: - raise errors.LiquidHeightUnknownError( - "Must liquid probe before specifying WellOrigin.MENISCUS." - ) + offset_adjustment = self.get_well_offset_adjustment( + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + well_depth=well_depth, + operation_volume=operation_volume, + ) + offset = offset.copy(update={"z": offset.z + offset_adjustment}) + self.validate_well_position( + well_location=well_location, z_offset=offset.z, pipette_id=pipette_id + ) return Point( x=labware_pos.x + offset.x + well_def.x, @@ -483,6 +522,41 @@ def get_relative_well_location( return WellLocation(offset=WellOffset(x=delta.x, y=delta.y, z=delta.z)) + def get_relative_liquid_handling_well_location( + self, + labware_id: str, + well_name: str, + absolute_point: Point, + is_meniscus: Optional[bool] = None, + ) -> LiquidHandlingWellLocation: + """Given absolute position, get relative location of a well in a labware. + + If is_meniscus is True, absolute_point will hold the z-offset in its z field. + """ + if is_meniscus: + return LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=0, y=0, z=absolute_point.z), + ) + else: + well_absolute_point = self.get_well_position(labware_id, well_name) + delta = absolute_point - well_absolute_point + return LiquidHandlingWellLocation( + offset=WellOffset(x=delta.x, y=delta.y, z=delta.z) + ) + + def get_relative_pick_up_tip_well_location( + self, + labware_id: str, + well_name: str, + absolute_point: Point, + ) -> PickUpTipWellLocation: + """Given absolute position, get relative location of a well in a labware.""" + well_absolute_point = self.get_well_position(labware_id, well_name) + delta = absolute_point - well_absolute_point + + return PickUpTipWellLocation(offset=WellOffset(x=delta.x, y=delta.y, z=delta.z)) + def get_well_height( self, labware_id: str, @@ -606,6 +680,14 @@ def get_checked_tip_drop_location( ), ) + def convert_pick_up_tip_well_location( + self, well_location: PickUpTipWellLocation + ) -> WellLocation: + """Convert PickUpTipWellLocation to WellLocation.""" + return WellLocation( + origin=WellOrigin(well_location.origin.value), offset=well_location.offset + ) + # TODO(jbl 11-30-2023) fold this function into get_ancestor_slot_name see RSS-411 def _get_staging_slot_name(self, labware_id: str) -> str: """Get the staging slot name that the labware is on.""" @@ -1278,48 +1360,117 @@ def get_offset_location(self, labware_id: str) -> Optional[LabwareOffsetLocation return None - def get_well_volumetric_capacity( - self, labware_id: str, well_id: str - ) -> List[Tuple[float, float]]: - """Return a map of heights to partial volumes.""" - labware_def = self._labware.get_definition(labware_id) - if labware_def.innerLabwareGeometry is None: - raise InvalidWellDefinitionError(message="No InnerLabwareGeometry found.") - well_geometry = labware_def.innerLabwareGeometry.get(well_id) - if well_geometry is None: - raise InvalidWellDefinitionError( - message=f"No InnerWellGeometry found for well id: {well_id}" + def get_well_offset_adjustment( + self, + labware_id: str, + well_name: str, + well_location: WellLocations, + well_depth: float, + operation_volume: Optional[float] = None, + ) -> float: + """Return a z-axis distance that accounts for well handling height and operation volume. + + Distance is with reference to the well bottom. + """ + initial_handling_height = self.get_well_handling_height( + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + well_depth=well_depth, + ) + if isinstance(well_location, PickUpTipWellLocation): + volume = 0.0 + elif isinstance(well_location.volumeOffset, float): + volume = well_location.volumeOffset + elif well_location.volumeOffset == "operationVolume": + volume = operation_volume or 0.0 + + if volume: + well_geometry = self._labware.get_well_geometry(labware_id, well_name) + return self.get_well_height_after_volume( + well_geometry=well_geometry, + initial_height=initial_handling_height, + volume=volume, ) - return get_well_volumetric_capacity(well_geometry) + else: + return initial_handling_height - def get_volume_at_height( - self, labware_id: str, well_id: str, target_height: float + def get_meniscus_height( + self, + labware_id: str, + well_name: str, ) -> float: - """Find the volume at any height within a well.""" - labware_def = self._labware.get_definition(labware_id) - if labware_def.innerLabwareGeometry is None: - raise InvalidWellDefinitionError(message="No InnerLabwareGeometry found.") - well_geometry = labware_def.innerLabwareGeometry.get(well_id) - if well_geometry is None: - raise InvalidWellDefinitionError( - message=f"No InnerWellGeometry found for well id: {well_id}" - ) - return find_volume_at_well_height( - target_height=target_height, well_geometry=well_geometry + """Returns stored meniscus height in specified well.""" + meniscus_height = self._wells.get_last_measured_liquid_height( + labware_id=labware_id, well_name=well_name ) + if meniscus_height is None: + raise errors.LiquidHeightUnknownError( + "Must liquid probe before specifying WellOrigin.MENISCUS." + ) + else: + return meniscus_height - def get_height_at_volume( - self, labware_id: str, well_id: str, target_volume: float + def get_well_handling_height( + self, + labware_id: str, + well_name: str, + well_location: WellLocations, + well_depth: float, ) -> float: - """Find the height from any volume in a well.""" - labware_def = self._labware.get_definition(labware_id) - if labware_def.innerLabwareGeometry is None: - raise InvalidWellDefinitionError(message="No InnerLabwareGeometry found.") - well_geometry = labware_def.innerLabwareGeometry.get(well_id) - if well_geometry is None: - raise InvalidWellDefinitionError( - message=f"No InnerWellGeometry found for well id: {well_id}" + """Return the handling height for a labware well (with reference to the well bottom).""" + handling_height = 0.0 + if well_location.origin == WellOrigin.TOP: + handling_height = well_depth + elif well_location.origin == WellOrigin.CENTER: + handling_height = well_depth / 2.0 + elif well_location.origin == WellOrigin.MENISCUS: + handling_height = self.get_meniscus_height( + labware_id=labware_id, well_name=well_name ) + return float(handling_height) + + def get_well_height_after_volume( + self, well_geometry: InnerWellGeometry, initial_height: float, volume: float + ) -> float: + """Return the height of liquid in a labware well after a given volume has been handled. + + This is given an initial handling height, with reference to the well bottom. + """ + initial_volume = find_volume_at_well_height( + target_height=initial_height, well_geometry=well_geometry + ) + final_volume = initial_volume + volume return find_height_at_well_volume( - target_volume=target_volume, well_geometry=well_geometry + target_volume=final_volume, well_geometry=well_geometry ) + + def validate_dispense_volume_into_well( + self, + labware_id: str, + well_name: str, + well_location: WellLocations, + volume: float, + ) -> None: + """Raise InvalidDispenseVolumeError if planned dispense volume will overflow well.""" + well_def = self._labware.get_well_definition(labware_id, well_name) + well_volumetric_capacity = well_def.totalLiquidVolume + if well_location.origin == WellOrigin.MENISCUS: + well_geometry = self._labware.get_well_geometry(labware_id, well_name) + meniscus_height = self.get_meniscus_height( + labware_id=labware_id, well_name=well_name + ) + meniscus_volume = find_volume_at_well_height( + target_height=meniscus_height, well_geometry=well_geometry + ) + remaining_volume = well_volumetric_capacity - meniscus_volume + if volume > remaining_volume: + raise errors.InvalidDispenseVolumeError( + f"Attempting to dispense {volume}µL of liquid into a well that can currently only hold {remaining_volume}µL (well {well_name} in labware_id: {labware_id})" + ) + else: + # TODO(pbm, 10-08-24): factor in well (LabwareStore) state volume + if volume > well_volumetric_capacity: + raise errors.InvalidDispenseVolumeError( + f"Attempting to dispense {volume}µL of liquid into a well that can only hold {well_volumetric_capacity}µL (well {well_name} in labware_id: {labware_id})" + ) diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 4614883fa6f..dad9fe54dd0 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -18,7 +18,10 @@ from opentrons.protocol_engine.state import update_types from opentrons_shared_data.deck.types import DeckDefinitionV5 from opentrons_shared_data.gripper.constants import LABWARE_GRIP_FORCE -from opentrons_shared_data.labware.labware_definition import LabwareRole +from opentrons_shared_data.labware.labware_definition import ( + LabwareRole, + InnerWellGeometry, +) from opentrons_shared_data.pipette.types import LabwareUri from opentrons.types import DeckSlotName, StagingSlotName, MountType @@ -482,6 +485,29 @@ def get_well_definition( f"{well_name} does not exist in {labware_id}." ) from e + def get_well_geometry( + self, labware_id: str, well_name: Optional[str] = None + ) -> InnerWellGeometry: + """Get a well's inner geometry by labware and well name.""" + labware_def = self.get_definition(labware_id) + if labware_def.innerLabwareGeometry is None: + raise errors.IncompleteLabwareDefinitionError( + message=f"No innerLabwareGeometry found in labware definition for labware_id: {labware_id}." + ) + well_def = self.get_well_definition(labware_id, well_name) + well_id = well_def.geometryDefinitionId + if well_id is None: + raise errors.IncompleteWellDefinitionError( + message=f"No geometryDefinitionId found in well definition for well: {well_name} in labware_id: {labware_id}" + ) + else: + well_geometry = labware_def.innerLabwareGeometry.get(well_id) + if well_geometry is None: + raise errors.IncompleteLabwareDefinitionError( + message=f"No innerLabwareGeometry found in labware definition for well_id: {well_id} in labware_id: {labware_id}" + ) + return well_geometry + def get_well_size( self, labware_id: str, well_name: str ) -> Tuple[float, float, float]: diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index c58ef24ac8a..c9aa146715b 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -1,6 +1,6 @@ """Motion state store and getters.""" from dataclasses import dataclass -from typing import List, Optional +from typing import List, Optional, Union from opentrons.types import MountType, Point from opentrons.hardware_control.types import CriticalPoint @@ -15,6 +15,7 @@ from ..types import ( MotorAxis, WellLocation, + LiquidHandlingWellLocation, CurrentWell, CurrentPipetteLocation, AddressableOffsetVector, @@ -89,13 +90,14 @@ def get_movement_waypoints_to_well( pipette_id: str, labware_id: str, well_name: str, - well_location: Optional[WellLocation], + well_location: Optional[Union[WellLocation, LiquidHandlingWellLocation]], origin: Point, origin_cp: Optional[CriticalPoint], max_travel_z: float, current_well: Optional[CurrentWell] = None, force_direct: bool = False, minimum_z_height: Optional[float] = None, + operation_volume: Optional[float] = None, ) -> List[motion_planning.Waypoint]: """Calculate waypoints to a destination that's specified as a well.""" location = current_well or self._pipettes.get_current_location() @@ -107,9 +109,11 @@ def get_movement_waypoints_to_well( destination_cp = CriticalPoint.XY_CENTER destination = self._geometry.get_well_position( - labware_id, - well_name, - well_location, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + operation_volume=operation_volume, + pipette_id=pipette_id, ) move_type = _move_types.get_move_type_to_well( diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index e011b43fd7f..72daafd3a52 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -205,6 +205,7 @@ class WellOrigin(str, Enum): TOP: the top-center of the well BOTTOM: the bottom-center of the well CENTER: the middle-center of the well + MENISCUS: the meniscus-center of the well """ TOP = "top" @@ -213,6 +214,20 @@ class WellOrigin(str, Enum): MENISCUS = "meniscus" +class PickUpTipWellOrigin(str, Enum): + """The origin of a PickUpTipWellLocation offset. + + Props: + TOP: the top-center of the well + BOTTOM: the bottom-center of the well + CENTER: the middle-center of the well + """ + + TOP = "top" + BOTTOM = "bottom" + CENTER = "center" + + class DropTipWellOrigin(str, Enum): """The origin of a DropTipWellLocation offset. @@ -244,6 +259,34 @@ class WellLocation(BaseModel): origin: WellOrigin = WellOrigin.TOP offset: WellOffset = Field(default_factory=WellOffset) + volumeOffset: float = Field( + default=0.0, + description="""A volume of liquid, in µL, to offset the z-axis offset.""", + ) + + +class LiquidHandlingWellLocation(BaseModel): + """A relative location in reference to a well's location. + + To be used with commands that handle liquids. + """ + + origin: WellOrigin = WellOrigin.TOP + offset: WellOffset = Field(default_factory=WellOffset) + volumeOffset: Union[float, Literal["operationVolume"]] = Field( + default=0.0, + description="""A volume of liquid, in µL, to offset the z-axis offset. When "operationVolume" is specified, this volume is pulled from the command volume parameter.""", + ) + + +class PickUpTipWellLocation(BaseModel): + """A relative location in reference to a well's location. + + To be used for picking up tips. + """ + + origin: PickUpTipWellOrigin = PickUpTipWellOrigin.TOP + offset: WellOffset = Field(default_factory=WellOffset) class DropTipWellLocation(BaseModel): diff --git a/api/src/opentrons/types.py b/api/src/opentrons/types.py index 324b6a23d23..22611393f40 100644 --- a/api/src/opentrons/types.py +++ b/api/src/opentrons/types.py @@ -1,7 +1,7 @@ from __future__ import annotations import enum from math import sqrt, isclose -from typing import TYPE_CHECKING, Any, NamedTuple, Iterator, Union, List +from typing import TYPE_CHECKING, Any, NamedTuple, Iterator, Union, List, Optional from opentrons_shared_data.robot.types import RobotType @@ -79,7 +79,9 @@ def magnitude_to(self, other: Any) -> float: class Location: - """A location to target as a motion. + """Location(point: Point, labware: Union["Labware", "Well", str, "ModuleGeometry", LabwareLike, None, "ModuleContext"]) + + A location to target as a motion. The location contains a :py:class:`.Point` (in :ref:`protocol-api-deck-coords`) and possibly an associated @@ -116,10 +118,13 @@ def __init__( None, "ModuleContext", ], + *, + _ot_internal_is_meniscus: Optional[bool] = None, ): self._point = point self._given_labware = labware self._labware = LabwareLike(labware) + self._is_meniscus = _ot_internal_is_meniscus # todo(mm, 2021-10-01): Figure out how to get .point and .labware to show up # in the rendered docs, and then update the class docstring to use cross-references. @@ -132,6 +137,10 @@ def point(self) -> Point: def labware(self) -> LabwareLike: return self._labware + @property + def is_meniscus(self) -> Optional[bool]: + return self._is_meniscus + def __iter__(self) -> Iterator[Union[Point, LabwareLike]]: """Iterable interface to support unpacking. Like a tuple. @@ -148,6 +157,7 @@ def __eq__(self, other: object) -> bool: isinstance(other, Location) and other._point == self._point and other._labware == self._labware + and other._is_meniscus == self._is_meniscus ) def move(self, point: Point) -> "Location": @@ -173,7 +183,7 @@ def move(self, point: Point) -> "Location": return Location(point=self.point + point, labware=self._given_labware) def __repr__(self) -> str: - return f"Location(point={repr(self._point)}, labware={self._labware})" + return f"Location(point={repr(self._point)}, labware={self._labware}, is_meniscus={self._is_meniscus if self._is_meniscus is not None else False})" # TODO(mc, 2020-10-22): use MountType implementation for Mount diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index cb68b77a96e..3d07bfe07d8 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -16,8 +16,11 @@ LoadedPipette, MotorAxis, WellLocation, + LiquidHandlingWellLocation, + PickUpTipWellLocation, WellOffset, WellOrigin, + PickUpTipWellOrigin, DropTipWellLocation, DropTipWellOrigin, ) @@ -258,12 +261,16 @@ def test_pick_up_tip( ) decoy.when( - mock_engine_client.state.geometry.get_relative_well_location( + mock_engine_client.state.geometry.get_relative_pick_up_tip_well_location( labware_id="labware-id", well_name="well-name", absolute_point=Point(1, 2, 3), ) - ).then_return(WellLocation(origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1))) + ).then_return( + PickUpTipWellLocation( + origin=PickUpTipWellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ) + ) subject.pick_up_tip( location=location, @@ -283,8 +290,8 @@ def test_pick_up_tip( pipette_id="abc123", labware_id="labware-id", well_name="well-name", - well_location=WellLocation( - origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + well_location=PickUpTipWellLocation( + origin=PickUpTipWellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) ), ), mock_engine_client.execute_command( @@ -292,8 +299,8 @@ def test_pick_up_tip( pipetteId="abc123", labwareId="labware-id", wellName="well-name", - wellLocation=WellLocation( - origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + wellLocation=PickUpTipWellLocation( + origin=PickUpTipWellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) ), ) ), @@ -491,10 +498,17 @@ def test_aspirate_from_well( ) decoy.when( - mock_engine_client.state.geometry.get_relative_well_location( - labware_id="123abc", well_name="my cool well", absolute_point=Point(1, 2, 3) + mock_engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id="123abc", + well_name="my cool well", + absolute_point=Point(1, 2, 3), + is_meniscus=None, ) - ).then_return(WellLocation(origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1))) + ).then_return( + LiquidHandlingWellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ) + ) subject.aspirate( location=location, @@ -520,7 +534,7 @@ def test_aspirate_from_well( pipetteId="abc123", labwareId="123abc", wellName="my cool well", - wellLocation=WellLocation( + wellLocation=LiquidHandlingWellLocation( origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) ), volume=12.34, @@ -715,10 +729,17 @@ def test_dispense_to_well( decoy.when(mock_protocol_core.api_version).then_return(MAX_SUPPORTED_VERSION) decoy.when( - mock_engine_client.state.geometry.get_relative_well_location( - labware_id="123abc", well_name="my cool well", absolute_point=Point(1, 2, 3) + mock_engine_client.state.geometry.get_relative_liquid_handling_well_location( + labware_id="123abc", + well_name="my cool well", + absolute_point=Point(1, 2, 3), + is_meniscus=None, ) - ).then_return(WellLocation(origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1))) + ).then_return( + LiquidHandlingWellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ) + ) subject.dispense( location=location, @@ -745,7 +766,7 @@ def test_dispense_to_well( pipetteId="abc123", labwareId="123abc", wellName="my cool well", - wellLocation=WellLocation( + wellLocation=LiquidHandlingWellLocation( origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) ), volume=12.34, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_well_core.py b/api/tests/opentrons/protocol_api/core/engine/test_well_core.py index 96efbbdde8d..31b562f7e81 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_well_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_well_core.py @@ -149,23 +149,6 @@ def test_get_center( assert subject.get_center() == Point(1, 2, 3) -def test_get_meniscus( - decoy: Decoy, mock_engine_client: EngineClient, subject: WellCore -) -> None: - """It should get a well bottom.""" - decoy.when( - mock_engine_client.state.geometry.get_well_position( - labware_id="labware-id", - well_name="well-name", - well_location=WellLocation( - origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=2.5) - ), - ) - ).then_return(Point(1, 2, 3)) - - assert subject.get_meniscus(z_offset=2.5) == Point(1, 2, 3) - - def test_has_tip( decoy: Decoy, mock_engine_client: EngineClient, subject: WellCore ) -> None: diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 4478c250b8c..069330036ec 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -339,6 +339,7 @@ def test_aspirate( volume=42.0, rate=1.23, flow_rate=5.67, + is_meniscus=None, ), times=1, ) @@ -376,6 +377,47 @@ def test_aspirate_well_location( volume=42.0, rate=1.23, flow_rate=5.67, + is_meniscus=None, + ), + times=1, + ) + + +def test_aspirate_meniscus_well_location( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, + mock_protocol_core: ProtocolCore, +) -> None: + """It should aspirate to a well.""" + mock_well = decoy.mock(cls=Well) + input_location = Location( + point=Point(2, 2, 2), labware=mock_well, _ot_internal_is_meniscus=True + ) + last_location = Location(point=Point(9, 9, 9), labware=None) + decoy.when(mock_instrument_core.get_mount()).then_return(Mount.RIGHT) + + decoy.when(mock_protocol_core.get_last_location(Mount.RIGHT)).then_return( + last_location + ) + decoy.when( + mock_validation.validate_location( + location=input_location, last_location=last_location + ) + ).then_return(WellTarget(well=mock_well, location=input_location, in_place=False)) + decoy.when(mock_instrument_core.get_aspirate_flow_rate(1.23)).then_return(5.67) + + subject.aspirate(volume=42.0, location=input_location, rate=1.23) + + decoy.verify( + mock_instrument_core.aspirate( + location=input_location, + well_core=mock_well._core, + in_place=False, + volume=42.0, + rate=1.23, + flow_rate=5.67, + is_meniscus=True, ), times=1, ) @@ -412,6 +454,7 @@ def test_aspirate_from_coordinates( volume=42.0, rate=1.23, flow_rate=5.67, + is_meniscus=None, ), times=1, ) @@ -925,6 +968,7 @@ def test_dispense_with_location( rate=1.23, flow_rate=5.67, push_out=None, + is_meniscus=None, ), times=1, ) @@ -963,6 +1007,7 @@ def test_dispense_with_well_location( rate=1.23, flow_rate=3.0, push_out=7, + is_meniscus=None, ), times=1, ) @@ -1003,6 +1048,7 @@ def test_dispense_with_well( rate=1.23, flow_rate=5.67, push_out=None, + is_meniscus=None, ), times=1, ) @@ -1257,6 +1303,7 @@ def test_dispense_0_volume_means_dispense_everything( rate=1.23, flow_rate=5.67, push_out=None, + is_meniscus=None, ), times=1, ) @@ -1286,6 +1333,7 @@ def test_dispense_0_volume_means_dispense_nothing( rate=1.23, flow_rate=5.67, push_out=None, + is_meniscus=None, ), times=1, ) @@ -1325,6 +1373,7 @@ def test_aspirate_0_volume_means_aspirate_everything( volume=200, rate=1.23, flow_rate=5.67, + is_meniscus=None, ), times=1, ) @@ -1364,6 +1413,7 @@ def test_aspirate_0_volume_means_aspirate_nothing( volume=0, rate=1.23, flow_rate=5.67, + is_meniscus=None, ), times=1, ) diff --git a/api/tests/opentrons/protocol_api/test_well.py b/api/tests/opentrons/protocol_api/test_well.py index 3a2ba81b9fa..ef1eed84c62 100644 --- a/api/tests/opentrons/protocol_api/test_well.py +++ b/api/tests/opentrons/protocol_api/test_well.py @@ -103,12 +103,11 @@ def test_well_center(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> N def test_well_meniscus(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None: """It should get a Location representing the meniscus of the well.""" - decoy.when(mock_well_core.get_meniscus(z_offset=4.2)).then_return(Point(1, 2, 3)) - result = subject.meniscus(4.2) assert isinstance(result, Location) - assert result.point == Point(1, 2, 3) + assert result.point == Point(0, 0, 4.2) + assert result.is_meniscus is True assert result.labware.as_well() is subject diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py index 779242ccb84..8d6f6d92179 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py @@ -8,7 +8,12 @@ from opentrons.protocol_engine.commands.pipetting_common import OverpressureError from opentrons.protocol_engine.state import update_types from opentrons.types import MountType, Point -from opentrons.protocol_engine import WellLocation, WellOrigin, WellOffset, DeckPoint +from opentrons.protocol_engine import ( + LiquidHandlingWellLocation, + WellOrigin, + WellOffset, + DeckPoint, +) from opentrons.protocol_engine.commands.aspirate import ( AspirateParams, @@ -59,7 +64,9 @@ async def test_aspirate_implementation_no_prep( mock_command_note_adder: CommandNoteAdder, ) -> None: """An Aspirate should have an execution implementation without preparing to aspirate.""" - location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) + location = LiquidHandlingWellLocation( + origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) + ) data = AspirateParams( pipetteId="abc", @@ -79,6 +86,7 @@ async def test_aspirate_implementation_no_prep( well_name="A3", well_location=location, current_well=None, + operation_volume=-50, ), ).then_return(Point(x=1, y=2, z=3)) @@ -116,7 +124,9 @@ async def test_aspirate_implementation_with_prep( subject: AspirateImplementation, ) -> None: """An Aspirate should have an execution implementation with preparing to aspirate.""" - location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) + location = LiquidHandlingWellLocation( + origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) + ) data = AspirateParams( pipetteId="abc", @@ -145,6 +155,7 @@ async def test_aspirate_implementation_with_prep( labware_id="123", well_name="A3", ), + operation_volume=-50, ), ).then_return(Point(x=1, y=2, z=3)) @@ -176,7 +187,7 @@ async def test_aspirate_implementation_with_prep( pipette_id="abc", labware_id="123", well_name="A3", - well_location=WellLocation(origin=WellOrigin.TOP), + well_location=LiquidHandlingWellLocation(origin=WellOrigin.TOP), ), await pipetting.prepare_for_aspirate(pipette_id="abc"), ) @@ -190,7 +201,9 @@ async def test_aspirate_raises_volume_error( subject: AspirateImplementation, ) -> None: """Should raise an assertion error for volume larger than working volume.""" - location = WellLocation(origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)) + location = LiquidHandlingWellLocation( + origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) + ) data = AspirateParams( pipetteId="abc", @@ -210,6 +223,7 @@ async def test_aspirate_raises_volume_error( well_name="A3", well_location=location, current_well=None, + operation_volume=-50, ), ).then_return(Point(1, 2, 3)) @@ -238,7 +252,7 @@ async def test_overpressure_error( pipette_id = "pipette-id" labware_id = "labware-id" well_name = "well-name" - well_location = WellLocation( + well_location = LiquidHandlingWellLocation( origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) ) @@ -267,6 +281,7 @@ async def test_overpressure_error( well_name=well_name, well_location=well_location, current_well=None, + operation_volume=-50, ), ).then_return(position) @@ -301,3 +316,68 @@ async def test_overpressure_error( ) ), ) + + +async def test_aspirate_implementation_meniscus( + decoy: Decoy, + state_view: StateView, + hardware_api: HardwareControlAPI, + movement: MovementHandler, + pipetting: PipettingHandler, + subject: AspirateImplementation, + mock_command_note_adder: CommandNoteAdder, +) -> None: + """Aspirate should update WellVolumeOffset when called with WellOrigin.MENISCUS.""" + location = LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=-1) + ) + updated_location = LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=0, y=0, z=-1), + volumeOffset="operationVolume", + ) + + data = AspirateParams( + pipetteId="abc", + labwareId="123", + wellName="A3", + wellLocation=location, + volume=50, + flowRate=1.23, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id="abc")).then_return(True) + + decoy.when( + await movement.move_to_well( + pipette_id="abc", + labware_id="123", + well_name="A3", + well_location=updated_location, + current_well=None, + operation_volume=-50, + ), + ).then_return(Point(x=1, y=2, z=3)) + + decoy.when( + await pipetting.aspirate_in_place( + pipette_id="abc", + volume=50, + flow_rate=1.23, + command_note_adder=mock_command_note_adder, + ), + ).then_return(50) + + result = await subject.execute(data) + + assert result == SuccessData( + public=AspirateResult(volume=50, position=DeckPoint(x=1, y=2, z=3)), + private=None, + state_update=update_types.StateUpdate( + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="abc", + new_location=update_types.Well(labware_id="123", well_name="A3"), + new_deck_point=DeckPoint(x=1, y=2, z=3), + ) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py index 0293897f4c4..93504c6904d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py @@ -5,7 +5,12 @@ from opentrons.protocol_engine.commands.pipetting_common import OverpressureError from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.types import Point -from opentrons.protocol_engine import WellLocation, WellOrigin, WellOffset, DeckPoint +from opentrons.protocol_engine import ( + WellLocation, + WellOrigin, + WellOffset, + DeckPoint, +) from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.commands import ( diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index 223cfcc78c9..167223e6d9d 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -6,9 +6,15 @@ from opentrons_shared_data.errors.exceptions import PipetteOverpressureError -from opentrons.protocol_engine import WellLocation, WellOrigin, WellOffset, DeckPoint +from opentrons.protocol_engine import ( + LiquidHandlingWellLocation, + WellOrigin, + WellOffset, + DeckPoint, +) from opentrons.protocol_engine.execution import MovementHandler, PipettingHandler from opentrons.protocol_engine.state import update_types +from opentrons.protocol_engine.state.state import StateView from opentrons.types import Point from opentrons.protocol_engine.commands.command import SuccessData, DefinedErrorData @@ -23,13 +29,17 @@ @pytest.fixture def subject( + state_view: StateView, movement: MovementHandler, pipetting: PipettingHandler, model_utils: ModelUtils, ) -> DispenseImplementation: """Get the implementation subject.""" return DispenseImplementation( - movement=movement, pipetting=pipetting, model_utils=model_utils + state_view=state_view, + movement=movement, + pipetting=pipetting, + model_utils=model_utils, ) @@ -40,7 +50,7 @@ async def test_dispense_implementation( subject: DispenseImplementation, ) -> None: """It should move to the target location and then dispense.""" - well_location = WellLocation( + well_location = LiquidHandlingWellLocation( origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) ) @@ -97,7 +107,7 @@ async def test_overpressure_error( pipette_id = "pipette-id" labware_id = "labware-id" well_name = "well-name" - well_location = WellLocation( + well_location = LiquidHandlingWellLocation( origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1) ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py b/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py index d91822979f2..1b01009dc0e 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_to_well.py @@ -1,7 +1,13 @@ """Test move to well commands.""" +import pytest from decoy import Decoy -from opentrons.protocol_engine import WellLocation, WellOffset, DeckPoint +from opentrons.protocol_engine import ( + WellLocation, + WellOffset, + DeckPoint, + errors, +) from opentrons.protocol_engine.execution import MovementHandler from opentrons.protocol_engine.state import update_types from opentrons.types import Point @@ -12,14 +18,22 @@ MoveToWellResult, MoveToWellImplementation, ) +from opentrons.protocol_engine.state.state import StateView + + +@pytest.fixture +def mock_state_view(decoy: Decoy) -> StateView: + """Get a mock StateView.""" + return decoy.mock(cls=StateView) async def test_move_to_well_implementation( decoy: Decoy, + state_view: StateView, movement: MovementHandler, ) -> None: """A MoveToWell command should have an execution implementation.""" - subject = MoveToWellImplementation(movement=movement) + subject = MoveToWellImplementation(state_view=state_view, movement=movement) data = MoveToWellParams( pipetteId="abc", @@ -56,3 +70,27 @@ async def test_move_to_well_implementation( ) ), ) + + +async def test_move_to_well_with_tip_rack_and_volume_offset( + decoy: Decoy, + mock_state_view: StateView, + movement: MovementHandler, +) -> None: + """It should disallow movement to a tip rack when volumeOffset is specified.""" + subject = MoveToWellImplementation(state_view=mock_state_view, movement=movement) + + data = MoveToWellParams( + pipetteId="abc", + labwareId="123", + wellName="A3", + wellLocation=WellLocation(offset=WellOffset(x=1, y=2, z=3), volumeOffset=-40.0), + forceDirect=True, + minimumZHeight=4.56, + speed=7.89, + ) + + decoy.when(mock_state_view.labware.is_tiprack("123")).then_return(True) + + with pytest.raises(errors.LabwareIsTipRackError): + await subject.execute(data) diff --git a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py index 55a4504d5a3..3771fe00eb1 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py @@ -5,7 +5,12 @@ from opentrons.types import MountType, Point -from opentrons.protocol_engine import WellLocation, WellOffset, DeckPoint +from opentrons.protocol_engine import ( + WellLocation, + PickUpTipWellLocation, + WellOffset, + DeckPoint, +) from opentrons.protocol_engine.errors import TipNotAttachedError from opentrons.protocol_engine.execution import MovementHandler, TipHandler from opentrons.protocol_engine.resources import ModelUtils @@ -39,6 +44,12 @@ async def test_success( decoy.when(state_view.pipettes.get_mount("pipette-id")).then_return(MountType.LEFT) + decoy.when( + state_view.geometry.convert_pick_up_tip_well_location( + well_location=PickUpTipWellLocation(offset=WellOffset(x=1, y=2, z=3)) + ) + ).then_return(WellLocation(offset=WellOffset(x=1, y=2, z=3))) + decoy.when( await movement.move_to_well( pipette_id="pipette-id", @@ -61,7 +72,7 @@ async def test_success( pipetteId="pipette-id", labwareId="labware-id", wellName="A3", - wellLocation=WellLocation(offset=WellOffset(x=1, y=2, z=3)), + wellLocation=PickUpTipWellLocation(offset=WellOffset(x=1, y=2, z=3)), ) ) @@ -111,6 +122,12 @@ async def test_tip_physically_missing_error( error_id = "error-id" error_created_at = datetime(1234, 5, 6) + decoy.when( + state_view.geometry.convert_pick_up_tip_well_location( + well_location=PickUpTipWellLocation(offset=WellOffset()) + ) + ).then_return(WellLocation(offset=WellOffset())) + decoy.when( await movement.move_to_well( pipette_id="pipette-id", 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 0fd5134aa27..73b293fdbef 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py @@ -149,6 +149,7 @@ async def test_move_to_well( current_well=None, force_direct=True, minimum_z_height=12.3, + operation_volume=None, ) ).then_return( [Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER), Waypoint(Point(4, 5, 6))] @@ -257,6 +258,7 @@ async def test_move_to_well_from_starting_location( well_location=well_location, force_direct=False, minimum_z_height=None, + operation_volume=None, ) ).then_return([Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER)]) diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 53025629920..9c4665d31a2 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -13,6 +13,7 @@ ModuleDefinition, MovementAxis, WellLocation, + LiquidHandlingWellLocation, LabwareLocation, DeckSlotLocation, LabwareMovementStrategy, @@ -211,7 +212,7 @@ def create_aspirate_command( flow_rate: float, labware_id: str = "labware-id", well_name: str = "A1", - well_location: Optional[WellLocation] = None, + well_location: Optional[LiquidHandlingWellLocation] = None, destination: DeckPoint = DeckPoint(x=0, y=0, z=0), ) -> cmd.Aspirate: """Get a completed Aspirate command.""" @@ -219,7 +220,7 @@ def create_aspirate_command( pipetteId=pipette_id, labwareId=labware_id, wellName=well_name, - wellLocation=well_location or WellLocation(), + wellLocation=well_location or LiquidHandlingWellLocation(), volume=volume, flowRate=flow_rate, ) @@ -264,7 +265,7 @@ def create_dispense_command( flow_rate: float, labware_id: str = "labware-id", well_name: str = "A1", - well_location: Optional[WellLocation] = None, + well_location: Optional[LiquidHandlingWellLocation] = None, destination: DeckPoint = DeckPoint(x=0, y=0, z=0), ) -> cmd.Dispense: """Get a completed Dispense command.""" @@ -272,7 +273,7 @@ def create_dispense_command( pipetteId=pipette_id, labwareId=labware_id, wellName=well_name, - wellLocation=well_location or WellLocation(), + wellLocation=well_location or LiquidHandlingWellLocation(), volume=volume, flowRate=flow_rate, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 3f824da7193..7a94f06ca09 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -40,6 +40,7 @@ LoadedModule, ModuleModel, WellLocation, + LiquidHandlingWellLocation, WellOrigin, DropTipWellLocation, DropTipWellOrigin, @@ -91,6 +92,7 @@ from ..pipette_fixtures import get_default_nozzle_map from ..mock_circular_frusta import TEST_EXAMPLES as CIRCULAR_TEST_EXAMPLES from ..mock_rectangular_frusta import TEST_EXAMPLES as RECTANGULAR_TEST_EXAMPLES +from ...protocol_runner.test_json_translator import _load_labware_definition_data @pytest.fixture @@ -1509,9 +1511,10 @@ def test_get_well_position_with_meniscus_offset( mock_labware_view: LabwareView, mock_well_view: WellView, mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, subject: GeometryView, ) -> None: - """It should be able to get the position of a well center in a labware.""" + """It should be able to get the position of a well meniscus in a labware.""" labware_data = LoadedLabware( id="labware-id", loadName="load-name", @@ -1539,6 +1542,9 @@ def test_get_well_position_with_meniscus_offset( decoy.when( mock_well_view.get_last_measured_liquid_height("labware-id", "B2") ).then_return(70.5) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) result = subject.get_well_position( labware_id="labware-id", @@ -1547,6 +1553,7 @@ def test_get_well_position_with_meniscus_offset( origin=WellOrigin.MENISCUS, offset=WellOffset(x=2, y=3, z=4), ), + pipette_id="pipette-id", ) assert result == Point( @@ -1556,6 +1563,198 @@ def test_get_well_position_with_meniscus_offset( ) +def test_get_well_position_with_meniscus_and_literal_volume_offset( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """It should be able to get the position of a well meniscus in a labware with a volume offset.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when( + mock_well_view.get_last_measured_liquid_height("labware-id", "B2") + ).then_return(45.0) + labware_def = _load_labware_definition_data() + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_return( + inner_well_def + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + + result = subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + volumeOffset="operationVolume", + ), + operation_volume=-1245.833, + pipette_id="pipette-id", + ) + + assert result == Point( + x=slot_pos[0] + 1 + well_def.x + 2, + y=slot_pos[1] - 2 + well_def.y + 3, + z=slot_pos[2] + 3 + well_def.z + 4 + 20.0, + ) + + +def test_get_well_position_with_meniscus_and_float_volume_offset( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """It should be able to get the position of a well meniscus in a labware with a volume offset.""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when( + mock_well_view.get_last_measured_liquid_height("labware-id", "B2") + ).then_return(45.0) + labware_def = _load_labware_definition_data() + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_return( + inner_well_def + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + + result = subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + volumeOffset=-1245.833, + ), + pipette_id="pipette-id", + ) + + assert result == Point( + x=slot_pos[0] + 1 + well_def.x + 2, + y=slot_pos[1] - 2 + well_def.y + 3, + z=slot_pos[2] + 3 + well_def.z + 4 + 20.0, + ) + + +def test_get_well_position_raises_validation_error( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_well_view: WellView, + mock_addressable_area_view: AddressableAreaView, + mock_pipette_view: PipetteView, + subject: GeometryView, +) -> None: + """It should raise a validation error when a volume offset is too large (ie location is below the well bottom).""" + labware_data = LoadedLabware( + id="labware-id", + loadName="load-name", + definitionUri="definition-uri", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId="offset-id", + ) + calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3) + slot_pos = Point(4, 5, 6) + well_def = well_plate_def.wells["B2"] + + decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return( + calibration_offset + ) + decoy.when( + mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id) + ).then_return(slot_pos) + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + decoy.when( + mock_well_view.get_last_measured_liquid_height("labware-id", "B2") + ).then_return(40.0) + labware_def = _load_labware_definition_data() + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + decoy.when(mock_labware_view.get_well_geometry("labware-id", "B2")).then_return( + inner_well_def + ) + decoy.when( + mock_pipette_view.get_current_tip_lld_settings(pipette_id="pipette-id") + ).then_return(0.5) + + with pytest.raises(errors.OperationLocationNotInWellError): + subject.get_well_position( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=-40), + volumeOffset="operationVolume", + ), + operation_volume=-100.0, + pipette_id="pipette-id", + ) + + def test_get_relative_well_location( decoy: Decoy, well_plate_def: LabwareDefinition, @@ -1609,6 +1808,31 @@ def test_get_relative_well_location( ) +def test_get_relative_liquid_handling_well_location( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + mock_addressable_area_view: AddressableAreaView, + subject: GeometryView, +) -> None: + """It should get the relative location of a well given an absolute position.""" + result = subject.get_relative_liquid_handling_well_location( + labware_id="labware-id", + well_name="B2", + absolute_point=Point(x=0, y=0, z=-2), + is_meniscus=True, + ) + + assert result == LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset.construct( + x=0.0, + y=0.0, + z=cast(float, pytest.approx(-2)), + ), + ) + + def test_get_nominal_effective_tip_length( decoy: Decoy, mock_labware_view: LabwareView, @@ -2864,3 +3088,62 @@ def _find_volume_from_height_(index: int) -> None: for i in range(len(frustum["height"])): _find_volume_from_height_(i) + + +def test_validate_dispense_volume_into_well_bottom( + decoy: Decoy, + well_plate_def: LabwareDefinition, + mock_labware_view: LabwareView, + subject: GeometryView, +) -> None: + """It should raise an InvalidDispenseVolumeError if too much volume is specified.""" + well_def = well_plate_def.wells["B2"] + decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return( + well_def + ) + + with pytest.raises(errors.InvalidDispenseVolumeError): + subject.validate_dispense_volume_into_well( + labware_id="labware-id", + well_name="B2", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.BOTTOM, + offset=WellOffset(x=2, y=3, z=4), + ), + volume=400.0, + ) + + +def test_validate_dispense_volume_into_well_meniscus( + decoy: Decoy, + mock_labware_view: LabwareView, + mock_well_view: WellView, + subject: GeometryView, +) -> None: + """It should raise an InvalidDispenseVolumeError if too much volume is specified.""" + labware_def = _load_labware_definition_data() + assert labware_def.wells is not None + well_def = labware_def.wells["A1"] + assert labware_def.innerLabwareGeometry is not None + inner_well_def = labware_def.innerLabwareGeometry["welldefinition1111"] + + decoy.when(mock_labware_view.get_well_definition("labware-id", "A1")).then_return( + well_def + ) + decoy.when(mock_labware_view.get_well_geometry("labware-id", "A1")).then_return( + inner_well_def + ) + decoy.when( + mock_well_view.get_last_measured_liquid_height("labware-id", "A1") + ).then_return(40.0) + + with pytest.raises(errors.InvalidDispenseVolumeError): + subject.validate_dispense_volume_into_well( + labware_id="labware-id", + well_name="A1", + well_location=LiquidHandlingWellLocation( + origin=WellOrigin.MENISCUS, + offset=WellOffset(x=2, y=3, z=4), + ), + volume=1100000.0, + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_motion_view.py b/api/tests/opentrons/protocol_engine/state/test_motion_view.py index 703d813373b..9e7307f29a7 100644 --- a/api/tests/opentrons/protocol_engine/state/test_motion_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_motion_view.py @@ -309,7 +309,9 @@ def test_get_movement_waypoints_to_well_for_y_center( ).then_return(False) decoy.when( - geometry_view.get_well_position("labware-id", "well-name", WellLocation()) + geometry_view.get_well_position( + "labware-id", "well-name", WellLocation(), None, "pipette-id" + ) ).then_return(Point(x=4, y=5, z=6)) decoy.when( @@ -391,7 +393,9 @@ def test_get_movement_waypoints_to_well_for_xy_center( ).then_return(True) decoy.when( - geometry_view.get_well_position("labware-id", "well-name", WellLocation()) + geometry_view.get_well_position( + "labware-id", "well-name", WellLocation(), None, "pipette-id" + ) ).then_return(Point(x=4, y=5, z=6)) decoy.when( @@ -460,6 +464,8 @@ def test_get_movement_waypoints_to_well_raises( labware_id="labware-id", well_name="A1", well_location=None, + operation_volume=None, + pipette_id="pipette-id", ) ).then_return(Point(x=4, y=5, z=6)) decoy.when(pipette_view.get_current_location()).then_return(None) diff --git a/api/tests/opentrons/protocol_runner/test_json_translator.py b/api/tests/opentrons/protocol_runner/test_json_translator.py index afaf105f347..e2735e4cdbc 100644 --- a/api/tests/opentrons/protocol_runner/test_json_translator.py +++ b/api/tests/opentrons/protocol_runner/test_json_translator.py @@ -40,6 +40,8 @@ DeckPoint, DeckSlotLocation, WellLocation, + LiquidHandlingWellLocation, + PickUpTipWellLocation, DropTipWellLocation, WellOrigin, DropTipWellOrigin, @@ -106,7 +108,7 @@ volume=1.23, flowRate=4.56, wellName="A1", - wellLocation=WellLocation( + wellLocation=LiquidHandlingWellLocation( origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=7.89), ), @@ -167,7 +169,7 @@ volume=1.23, flowRate=4.56, wellName="A1", - wellLocation=WellLocation( + wellLocation=LiquidHandlingWellLocation( origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=7.89), ), @@ -241,7 +243,7 @@ pipetteId="pipette-id-1", labwareId="labware-id-2", wellName="A1", - wellLocation=WellLocation(), + wellLocation=PickUpTipWellLocation(), ) ), ), diff --git a/api/tests/opentrons/test_types.py b/api/tests/opentrons/test_types.py index 6cd93dce125..77249fa0492 100644 --- a/api/tests/opentrons/test_types.py +++ b/api/tests/opentrons/test_types.py @@ -29,7 +29,7 @@ def test_location_repr_labware(min_lw: Labware) -> None: loc = Location(point=Point(x=1.1, y=2.1, z=3.5), labware=min_lw) assert ( f"{loc}" - == "Location(point=Point(x=1.1, y=2.1, z=3.5), labware=minimal labware on deck)" + == "Location(point=Point(x=1.1, y=2.1, z=3.5), labware=minimal labware on deck, is_meniscus=False)" ) @@ -38,14 +38,17 @@ def test_location_repr_well(min_lw: Labware) -> None: loc = Location(point=Point(x=1, y=2, z=3), labware=min_lw.wells()[0]) assert ( f"{loc}" - == "Location(point=Point(x=1, y=2, z=3), labware=A1 of minimal labware on deck)" + == "Location(point=Point(x=1, y=2, z=3), labware=A1 of minimal labware on deck, is_meniscus=False)" ) def test_location_repr_slot() -> None: """It should represent labware as a slot""" loc = Location(point=Point(x=-1, y=2, z=3), labware="1") - assert f"{loc}" == "Location(point=Point(x=-1, y=2, z=3), labware=1)" + assert ( + f"{loc}" + == "Location(point=Point(x=-1, y=2, z=3), labware=1, is_meniscus=False)" + ) @pytest.mark.parametrize( diff --git a/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml index 1b0c603b38a..717280a6703 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v6_json_upload.tavern.yaml @@ -310,6 +310,7 @@ stages: x: 0 y: 0 z: 2 + volumeOffset: 0 volume: 5 flowRate: 3 result: @@ -344,6 +345,7 @@ stages: x: 0 y: 0 z: 1 + volumeOffset: 0 volume: 4.5 flowRate: 2.5 result: @@ -367,6 +369,7 @@ stages: x: 0 y: 0 z: 11 + volumeOffset: 0 radius: 0.5 speed: 42.0 result: @@ -389,6 +392,7 @@ stages: x: 0 y: 0 z: 12 + volumeOffset: 0 flowRate: 2 result: position: { 'x': 284.635, 'y': 56.025, 'z': 169.25 } @@ -410,6 +414,7 @@ stages: x: 0 y: 0 z: 0 + volumeOffset: 0 forceDirect: false result: position: @@ -432,6 +437,7 @@ stages: x: 2 y: 3 z: 10 + volumeOffset: 0 minimumZHeight: 35 forceDirect: true speed: 12.3 diff --git a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml index e53e3014c7d..35801f8719a 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_flex.tavern.yaml @@ -329,6 +329,7 @@ stages: x: 0.0 y: 0.0 z: 2.0 + volumeOffset: 0 volume: 5.0 flowRate: 3.0 result: @@ -363,6 +364,7 @@ stages: x: 0.0 y: 0.0 z: 1.0 + volumeOffset: 0 volume: 4.5 flowRate: 2.5 result: @@ -386,6 +388,7 @@ stages: x: 0.0 y: 0.0 z: 11.0 + volumeOffset: 0 radius: 1.0 speed: 42.0 result: @@ -408,6 +411,7 @@ stages: x: 0.0 y: 0.0 z: 12.0 + volumeOffset: 0 flowRate: 2.0 result: position: { 'x': 342.38, 'y': 65.24, 'z': 51.05 } @@ -446,6 +450,7 @@ stages: x: 0 y: 0 z: 0 + volumeOffset: 0 forceDirect: false speed: 12.3 result: @@ -468,6 +473,7 @@ stages: x: 2.0 y: 3.0 z: 10.0 + volumeOffset: 0 minimumZHeight: 35.0 forceDirect: true result: diff --git a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml index 55d4378a2b2..f85e307e961 100644 --- a/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_v8_json_upload_ot2.tavern.yaml @@ -329,6 +329,7 @@ stages: x: 0.0 y: 0.0 z: 2.0 + volumeOffset: 0 volume: 5.0 flowRate: 3.0 result: @@ -368,6 +369,7 @@ stages: x: 0.0 y: 0.0 z: 1.0 + volumeOffset: 0 volume: 4.5 flowRate: 2.5 result: @@ -391,6 +393,7 @@ stages: x: 0.0 y: 0.0 z: 11.0 + volumeOffset: 0 radius: 1.0 result: position: { 'x': 280.805, 'y': 65.115, 'z': 94.3 } @@ -412,6 +415,7 @@ stages: x: 0.0 y: 0.0 z: 12.0 + volumeOffset: 0 flowRate: 2.0 result: position: { 'x': 280.805, 'y': 65.115, 'z': 95.3 } @@ -450,6 +454,7 @@ stages: x: 0 y: 0 z: 0 + volumeOffset: 0 forceDirect: false result: position: { 'x': 289.805, 'y': 65.115, 'z': 98.25 } @@ -471,6 +476,7 @@ stages: x: 2.0 y: 3.0 z: 10.0 + volumeOffset: 0 minimumZHeight: 35.0 forceDirect: true result: diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml index 28d39bcfa77..48e1088eb4c 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml @@ -322,6 +322,7 @@ stages: x: 0 'y': 0 z: 2 + volumeOffset: 0 volume: 5 flowRate: 3 - id: !anystr @@ -342,6 +343,7 @@ stages: x: 0 'y': 0 z: 1 + volumeOffset: 0 volume: 4.5 flowRate: 2.5 - id: !anystr @@ -362,6 +364,7 @@ stages: x: 0 'y': 0 z: 0 + volumeOffset: 0 forceDirect: false - id: !anystr key: !anystr @@ -381,6 +384,7 @@ stages: x: 2 y: 3 z: 10 + volumeOffset: 0 minimumZHeight: 35 forceDirect: true speed: 12.3 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 681e25042d0..e89681be0ac 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 @@ -121,5 +121,6 @@ stages: x: 0 y: 0 z: 1 + volumeOffset: 0 flowRate: 3.78 volume: 100 diff --git a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml index 70737a7f6c3..0915fb69f12 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml @@ -322,6 +322,7 @@ stages: x: 0 'y': 0 z: 2 + volumeOffset: 0 volume: 5 flowRate: 3 - id: !anystr @@ -342,6 +343,7 @@ stages: x: 0 'y': 0 z: 1 + volumeOffset: 0 volume: 4.5 flowRate: 2.5 - id: !anystr @@ -362,6 +364,7 @@ stages: x: 0 'y': 0 z: 0 + volumeOffset: 0 forceDirect: false - id: !anystr key: !anystr @@ -381,6 +384,7 @@ stages: x: 2 y: 3 z: 10 + volumeOffset: 0 minimumZHeight: 35 forceDirect: true speed: 12.3 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 468b5b46209..804b0b0e620 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 @@ -122,5 +122,6 @@ stages: x: 0 y: 0 z: 0 + volumeOffset: 0 flowRate: 150 volume: 100 diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index 6eb524b9a45..6508269ac62 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -300,7 +300,7 @@ "definitions": { "WellOrigin": { "title": "WellOrigin", - "description": "Origin of WellLocation offset.\n\nProps:\n TOP: the top-center of the well\n BOTTOM: the bottom-center of the well\n CENTER: the middle-center of the well", + "description": "Origin of WellLocation offset.\n\nProps:\n TOP: the top-center of the well\n BOTTOM: the bottom-center of the well\n CENTER: the middle-center of the well\n MENISCUS: the meniscus-center of the well", "enum": ["top", "bottom", "center", "meniscus"], "type": "string" }, @@ -326,9 +326,9 @@ } } }, - "WellLocation": { - "title": "WellLocation", - "description": "A relative location in reference to a well's location.", + "LiquidHandlingWellLocation": { + "title": "LiquidHandlingWellLocation", + "description": "A relative location in reference to a well's location.\n\nTo be used with commands that handle liquids.", "type": "object", "properties": { "origin": { @@ -341,6 +341,20 @@ }, "offset": { "$ref": "#/definitions/WellOffset" + }, + "volumeOffset": { + "title": "Volumeoffset", + "description": "A volume of liquid, in \u00b5L, to offset the z-axis offset. When \"operationVolume\" is specified, this volume is pulled from the command volume parameter.", + "default": 0.0, + "anyOf": [ + { + "type": "number" + }, + { + "enum": ["operationVolume"], + "type": "string" + } + ] } } }, @@ -364,7 +378,7 @@ "description": "Relative well location at which to perform the operation", "allOf": [ { - "$ref": "#/definitions/WellLocation" + "$ref": "#/definitions/LiquidHandlingWellLocation" } ] }, @@ -800,7 +814,7 @@ "description": "Relative well location at which to perform the operation", "allOf": [ { - "$ref": "#/definitions/WellLocation" + "$ref": "#/definitions/LiquidHandlingWellLocation" } ] }, @@ -919,6 +933,30 @@ }, "required": ["params"] }, + "WellLocation": { + "title": "WellLocation", + "description": "A relative location in reference to a well's location.", + "type": "object", + "properties": { + "origin": { + "default": "top", + "allOf": [ + { + "$ref": "#/definitions/WellOrigin" + } + ] + }, + "offset": { + "$ref": "#/definitions/WellOffset" + }, + "volumeOffset": { + "title": "Volumeoffset", + "description": "A volume of liquid, in \u00b5L, to offset the z-axis offset.", + "default": 0.0, + "type": "number" + } + } + }, "BlowOutParams": { "title": "BlowOutParams", "description": "Payload required to blow-out a specific well.", @@ -2410,11 +2448,40 @@ }, "required": ["params"] }, + "PickUpTipWellOrigin": { + "title": "PickUpTipWellOrigin", + "description": "The origin of a PickUpTipWellLocation offset.\n\nProps:\n TOP: the top-center of the well\n BOTTOM: the bottom-center of the well\n CENTER: the middle-center of the well", + "enum": ["top", "bottom", "center"], + "type": "string" + }, + "PickUpTipWellLocation": { + "title": "PickUpTipWellLocation", + "description": "A relative location in reference to a well's location.\n\nTo be used for picking up tips.", + "type": "object", + "properties": { + "origin": { + "default": "top", + "allOf": [ + { + "$ref": "#/definitions/PickUpTipWellOrigin" + } + ] + }, + "offset": { + "$ref": "#/definitions/WellOffset" + } + } + }, "PickUpTipParams": { "title": "PickUpTipParams", "description": "Payload needed to move a pipette to a specific well.", "type": "object", "properties": { + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + }, "labwareId": { "title": "Labwareid", "description": "Identifier of labware to use.", @@ -2427,20 +2494,15 @@ }, "wellLocation": { "title": "Welllocation", - "description": "Relative well location at which to perform the operation", + "description": "Relative well location at which to pick up the tip.", "allOf": [ { - "$ref": "#/definitions/WellLocation" + "$ref": "#/definitions/PickUpTipWellLocation" } ] - }, - "pipetteId": { - "title": "Pipetteid", - "description": "Identifier of pipette to use for liquid handling.", - "type": "string" } }, - "required": ["labwareId", "wellName", "pipetteId"] + "required": ["pipetteId", "labwareId", "wellName"] }, "PickUpTipCreate": { "title": "PickUpTipCreate",