From 2730547c585b28fd1c84e18925c2ead842aa2298 Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Fri, 20 Dec 2024 15:43:21 -0500 Subject: [PATCH 01/10] just gotta used shared data to figure out distance and speed params from volume and flow rate --- .../backends/ot3controller.py | 28 ++++ api/src/opentrons/hardware_control/ot3api.py | 41 +++++- .../protocol_api/core/engine/instrument.py | 2 + .../opentrons/protocol_api/core/instrument.py | 1 + .../core/legacy/legacy_instrument_core.py | 1 + .../legacy_instrument_core.py | 1 + .../protocol_api/instrument_context.py | 131 ++++++++++++++++++ .../protocol_engine/commands/aspirate.py | 56 +++++--- .../commands/movement_common.py | 2 + .../commands/pipetting_common.py | 54 ++++++++ .../protocol_engine/execution/movement.py | 1 + .../protocol_engine/execution/pipetting.py | 63 +++++++-- .../protocol_engine/state/geometry.py | 5 + .../opentrons/protocol_engine/state/motion.py | 2 + .../hardware_control/tool_sensors.py | 26 ++++ 15 files changed, 387 insertions(+), 27 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 70d895560ee..1158472fcfb 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -24,6 +24,7 @@ KeysView, Union, Mapping, + Literal, ) from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config @@ -167,6 +168,7 @@ liquid_probe, check_overpressure, grab_pressure, + move_plunger_while_tracking_z, ) from opentrons_hardware.hardware_control.rear_panel_settings import ( get_door_state, @@ -720,6 +722,32 @@ def _build_move_gear_axis_runner( True, ) + @requires_update + @requires_estop + async def aspirate_while_tracking( + self, + mount: OT3Mount, + distance: float, + speed: float, + direction: Union[Literal[1], Literal[-1]], + duration: float, + ) -> None: + head_node = axis_to_node(Axis.by_mount(mount)) + tool = sensor_node_for_pipette(OT3Mount(mount.value)) + async with self._monitor_overpressure([tool]): + positions = await move_plunger_while_tracking_z( + messenger=self._messenger, + tool=tool, + head_node=head_node, + distance=distance, + speed=speed, + direction=direction, + duration=duration, + ) + for node, point in positions.items(): + self._position.update({node: point.motor_position}) + self._encoder_position.update({node: point.encoder_position}) + @requires_update @requires_estop async def move( diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 6295757e7ab..75b68ee22ef 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2750,7 +2750,7 @@ async def liquid_probe( # noqa: C901 if not probe_settings: probe_settings = deepcopy(self.config.liquid_sense) - # We need to significatly slow down the 96 channel liquid probe + # We need to significantly slow down the 96 channel liquid probe if self.gantry_load == GantryLoad.HIGH_THROUGHPUT: max_plunger_speed = self.config.motion_settings.max_speed_discontinuity[ GantryLoad.HIGH_THROUGHPUT @@ -2964,6 +2964,45 @@ async def capacitive_sweep( AMKey = TypeVar("AMKey") + async def aspirate_while_tracking( + self, + mount: Union[top_types.Mount, OT3Mount], + distance: float, + rate: float, + volume: Optional[float] = None, + ) -> None: + """ + Aspirate a volume of liquid (in microliters/uL) using this pipette.""" + realmount = OT3Mount.from_mount(mount) + aspirate_spec = self._pipette_handler.plan_check_aspirate( + realmount, volume, rate + ) + if not aspirate_spec: + return + + try: + await self._backend.set_active_current( + {aspirate_spec.axis: aspirate_spec.current} + ) + async with self.restore_system_constrants(): + await self.set_system_constraints_for_plunger_acceleration( + realmount, aspirate_spec.acceleration + ) + await self._backend.aspirate_while_tracking( + mount=mount, + distance=distance, + speed=rate, + direction=-1, + # have to actually determine duration here + duration=0.0, + ) + except Exception: + self._log.exception("Aspirate failed") + aspirate_spec.instr.set_current_volume(0) + raise + else: + aspirate_spec.instr.add_current_volume(aspirate_spec.volume) + @property def attached_subsystems(self) -> Dict[SubSystem, SubSystemState]: """Get a view of the state of the currently-attached subsystems.""" diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 010f3110fdb..67c68b37bfa 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -135,6 +135,7 @@ def aspirate( flow_rate: float, in_place: bool, is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = False, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -192,6 +193,7 @@ def aspirate( wellLocation=well_location, volume=volume, flowRate=flow_rate, + is_tracking=is_tracking if is_tracking else False, ) ) diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index bc1ec3669df..b512251967b 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -42,6 +42,7 @@ def aspirate( flow_rate: float, in_place: bool, is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = False, ) -> None: """Aspirate a given volume of liquid from 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 d2d25051d49..bb5223558cb 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 @@ -85,6 +85,7 @@ def aspirate( flow_rate: float, in_place: bool, is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: 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 ec194874528..0edb3af7cc1 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 @@ -96,6 +96,7 @@ def aspirate( flow_rate: float, in_place: bool, is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = None, ) -> None: if self.get_current_volume() == 0: # Make sure we're at the top of the labware and clear of any diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 9c6338270c7..937b79210c2 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -292,6 +292,137 @@ def aspirate( return self + ######## + + @requires_version(2, 0) + def aspirate_while_tracking( + self, + volume: Optional[float] = None, + location: Optional[Union[types.Location, labware.Well]] = None, + rate: float = 1.0, + ) -> InstrumentContext: + """ + Draw liquid into a pipette tip. + + See :ref:`new-aspirate` for more details and examples. + + :param volume: The volume to aspirate, measured in µL. If unspecified, + defaults to the maximum volume for the pipette and its currently + attached tip. + + If ``aspirate`` is called with a volume of precisely 0, its behavior + depends on the API level of the protocol. On API levels below 2.16, + it will behave the same as a volume of ``None``/unspecified: aspirate + until the pipette is full. On API levels at or above 2.16, no liquid + will be aspirated. + :type volume: int or float + :param location: Tells the robot where to aspirate from. The location can be + a :py:class:`.Well` or a :py:class:`.Location`. + + - If the location is a ``Well``, the robot will aspirate at + or above the bottom center of the well. The distance (in mm) + from the well bottom is specified by + :py:obj:`well_bottom_clearance.aspirate + `. + + - If the location is a ``Location`` (e.g., the result of + :py:meth:`.Well.top` or :py:meth:`.Well.bottom`), the robot + will aspirate from that specified position. + + - If the ``location`` is unspecified, the robot will + aspirate from its current position. + :param rate: A multiplier for the default flow rate of the pipette. Calculated + as ``rate`` multiplied by :py:attr:`flow_rate.aspirate + `. If not specified, defaults to 1.0. See + :ref:`new-plunger-flow-rates`. + :type rate: float + :returns: This instance. + + .. note:: + + If ``aspirate`` is called with a single, unnamed argument, it will treat + that argument as ``volume``. If you want to call ``aspirate`` with only + ``location``, specify it as a keyword argument: + ``pipette.aspirate(location=plate['A1'])`` + + """ + _log.debug( + "aspirate {} from {} at {}".format( + volume, location if location else "current position", rate + ) + ) + + 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( + location=location, last_location=last_location + ) + except validation.NoLocationError as e: + raise RuntimeError( + "If aspirate is called without an explicit location, another" + " method that moves to a location (such as move_to or " + "dispense) must previously have been called so the robot " + "knows where it is." + ) from e + + 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, + reject_module=self.api_version >= APIVersion(2, 13), + reject_adapter=self.api_version >= APIVersion(2, 15), + ) + + if self.api_version >= APIVersion(2, 16): + c_vol = self._core.get_available_volume() if volume is None else volume + else: + c_vol = self._core.get_available_volume() if not volume else volume + flow_rate = self._core.get_aspirate_flow_rate(rate) + + if ( + self.api_version >= APIVersion(2, 20) + and well is not None + and self.liquid_presence_detection + and self._core.nozzle_configuration_valid_for_lld() + and self._core.get_current_volume() == 0 + ): + self._raise_if_pressure_not_supported_by_pipette() + self.require_liquid_presence(well=well) + + with publisher.publish_context( + broker=self.broker, + command=cmds.aspirate( + instrument=self, + volume=c_vol, + location=move_to_location, + flow_rate=flow_rate, + rate=rate, + ), + ): + self._core.aspirate( + location=move_to_location, + well_core=well._core if well is not None else None, + volume=c_vol, + rate=rate, + flow_rate=flow_rate, + in_place=target.in_place, + is_meniscus=is_meniscus, + is_tracking=True, + ) + + return self + + ######## + @requires_version(2, 0) def dispense( # noqa: C901 self, diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 9664d733b8a..724798b2571 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -11,7 +11,9 @@ FlowRateMixin, BaseLiquidHandlingResult, aspirate_in_place, + aspirate_while_tracking, prepare_for_aspirate, + IsTrackingMixin, ) from .movement_common import ( LiquidHandlingWellLocationMixin, @@ -47,7 +49,11 @@ class AspirateParams( - PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin + PipetteIdMixin, + AspirateVolumeMixin, + FlowRateMixin, + LiquidHandlingWellLocationMixin, + IsTrackingMixin, ): """Parameters required to aspirate from a specific well.""" @@ -158,6 +164,7 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_location=well_location, current_well=current_well, operation_volume=-params.volume, + is_tracking=params.is_tracking, ) state_update.append(move_result.state_update) if isinstance(move_result, DefinedErrorData): @@ -165,21 +172,38 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: public=move_result.public, state_update=state_update ) - aspirate_result = await aspirate_in_place( - pipette_id=pipette_id, - volume=params.volume, - flow_rate=params.flowRate, - location_if_error={ - "retryLocation": ( - move_result.public.position.x, - move_result.public.position.y, - move_result.public.position.z, - ) - }, - command_note_adder=self._command_note_adder, - pipetting=self._pipetting, - model_utils=self._model_utils, - ) + if params.is_tracking: + aspirate_result = await aspirate_while_tracking( + pipette_id=pipette_id, + volume=params.volume, + flow_rate=params.flowRate, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + command_note_adder=self._command_note_adder, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) + else: + aspirate_result = await aspirate_in_place( + pipette_id=pipette_id, + volume=params.volume, + flow_rate=params.flowRate, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + command_note_adder=self._command_note_adder, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) state_update.append(aspirate_result.state_update) if isinstance(aspirate_result, DefinedErrorData): state_update.set_liquid_operated( diff --git a/api/src/opentrons/protocol_engine/commands/movement_common.py b/api/src/opentrons/protocol_engine/commands/movement_common.py index babf70b29d9..ebb55128c3b 100644 --- a/api/src/opentrons/protocol_engine/commands/movement_common.py +++ b/api/src/opentrons/protocol_engine/commands/movement_common.py @@ -152,6 +152,7 @@ async def move_to_well( minimum_z_height: Optional[float] = None, speed: Optional[float] = None, operation_volume: Optional[float] = None, + is_tracking: Optional[bool] = False, ) -> MoveToWellOperationReturn: """Execute a move to well microoperation.""" try: @@ -165,6 +166,7 @@ async def move_to_well( minimum_z_height=minimum_z_height, speed=speed, operation_volume=operation_volume, + is_tracking=is_tracking, ) except StallOrCollisionDetectedError as e: return DefinedErrorData( diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index c373642a02e..af43eb11b91 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -63,6 +63,15 @@ class FlowRateMixin(BaseModel): ) +class IsTrackingMixin(BaseModel): + """Mixin for the 'is_tracking' field of aspirate commands.""" + + is_tracking: bool = Field( + False, + description="Whether or not the pipette should move with the liquid while aspirating.", + ) + + class BaseLiquidHandlingResult(BaseModel): """Base properties of a liquid handling result.""" @@ -217,6 +226,51 @@ async def aspirate_in_place( ) +async def aspirate_while_tracking( + pipette_id: str, + volume: float, + flow_rate: float, + location_if_error: ErrorLocationInfo, + command_note_adder: CommandNoteAdder, + pipetting: PipettingHandler, + model_utils: ModelUtils, +) -> SuccessData[BaseLiquidHandlingResult] | DefinedErrorData[OverpressureError]: + """Execute an aspirate in place microoperation.""" + try: + volume_aspirated = await pipetting.aspirate_while_tracking( + pipette_id=pipette_id, + volume=volume, + flow_rate=flow_rate, + command_note_adder=command_note_adder, + ) + except PipetteOverpressureError as e: + return DefinedErrorData( + public=OverpressureError( + id=model_utils.generate_id(), + createdAt=model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=model_utils.generate_id(), + createdAt=model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=location_if_error, + ), + state_update=StateUpdate().set_fluid_unknown(pipette_id=pipette_id), + ) + else: + return SuccessData( + public=BaseLiquidHandlingResult( + volume=volume_aspirated, + ), + state_update=StateUpdate().set_fluid_aspirated( + pipette_id=pipette_id, + fluid=AspiratedFluid(kind=FluidKind.LIQUID, volume=volume_aspirated), + ), + ) + + async def dispense_in_place( pipette_id: str, volume: float, diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index be8bbbb8de2..983fa0af3f4 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -74,6 +74,7 @@ async def move_to_well( minimum_z_height: Optional[float] = None, speed: Optional[float] = None, operation_volume: Optional[float] = None, + is_tracking: Optional[bool] = False, ) -> Point: """Move to a specific well.""" self._state_store.labware.raise_if_labware_inaccessible_by_pipette( diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index 10d613e4dcf..4d088bb63d1 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -1,5 +1,5 @@ """Pipetting command handling.""" -from typing import Optional, Iterator +from typing import Optional, Iterator, Tuple from typing_extensions import Protocol as TypingProtocol from contextlib import contextmanager @@ -45,6 +45,15 @@ async def aspirate_in_place( ) -> float: """Set flow-rate and aspirate.""" + async def aspirate_while_tracking( + self, + pipette_id: str, + volume: float, + flow_rate: float, + command_note_adder: CommandNoteAdder, + ) -> float: + """Set flow-rate and aspirate while tracking.""" + async def dispense_in_place( self, pipette_id: str, @@ -99,7 +108,25 @@ async def prepare_for_aspirate(self, pipette_id: str) -> None: hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() await self._hardware_api.prepare_for_aspirate(mount=hw_mount) - async def aspirate_in_place( + def get_hw_aspirate_params( + self, + pipette_id: str, + volume: float, + command_note_adder: CommandNoteAdder, + ) -> Tuple[HardwarePipette, float]: + _adjusted_volume = _validate_aspirate_volume( + state_view=self._state_view, + pipette_id=pipette_id, + aspirate_volume=volume, + command_note_adder=command_note_adder, + ) + _hw_pipette = self._state_view.pipettes.get_hardware_pipette( + pipette_id=pipette_id, + attached_pipettes=self._hardware_api.attached_instruments, + ) + return _hw_pipette, _adjusted_volume + + async def aspirate_while_tracking( self, pipette_id: str, volume: float, @@ -112,15 +139,31 @@ async def aspirate_in_place( PipetteOverpressureError, propagated as-is from the hardware controller. """ # get mount and config data from state and hardware controller - adjusted_volume = _validate_aspirate_volume( - state_view=self._state_view, - pipette_id=pipette_id, - aspirate_volume=volume, - command_note_adder=command_note_adder, + hw_pipette, adjusted_volume = self.get_hw_aspirate_params( + pipette_id, volume, command_note_adder ) - hw_pipette = self._state_view.pipettes.get_hardware_pipette( - pipette_id=pipette_id, - attached_pipettes=self._hardware_api.attached_instruments, + with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate): + await self._hardware_api.aspirate_while_tracking( + mount=hw_pipette.mount, volume=adjusted_volume + ) + + return adjusted_volume + + async def aspirate_in_place( + self, + pipette_id: str, + volume: float, + flow_rate: float, + command_note_adder: CommandNoteAdder, + ) -> float: + """Set flow-rate and aspirate. + + Raises: + PipetteOverpressureError, propagated as-is from the hardware controller. + """ + # get mount and config data from state and hardware controller + hw_pipette, adjusted_volume = self.get_hw_aspirate_params( + pipette_id, volume, command_note_adder ) with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate): await self._hardware_api.aspirate( diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 7c725d88c62..9e708b1a646 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -473,6 +473,7 @@ def get_well_position( well_location: Optional[WellLocations] = None, operation_volume: Optional[float] = None, pipette_id: Optional[str] = None, + is_tracking: Optional[bool] = False, ) -> Point: """Given relative well location in a labware, get absolute position.""" labware_pos = self.get_labware_position(labware_id) @@ -488,6 +489,7 @@ def get_well_position( well_location=well_location, well_depth=well_depth, operation_volume=operation_volume, + is_tracking=is_tracking, ) offset = offset.model_copy(update={"z": offset.z + offset_adjustment}) self.validate_well_position( @@ -1411,6 +1413,7 @@ def get_well_offset_adjustment( well_location: WellLocations, well_depth: float, operation_volume: Optional[float] = None, + is_tracking: Optional[bool] = False, ) -> float: """Return a z-axis distance that accounts for well handling height and operation volume. @@ -1423,6 +1426,8 @@ def get_well_offset_adjustment( well_location=well_location, well_depth=well_depth, ) + if is_tracking: + return initial_handling_height if isinstance(well_location, PickUpTipWellLocation): volume = 0.0 elif isinstance(well_location.volumeOffset, float): diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index 855025d01b6..f7d478415cf 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -98,6 +98,7 @@ def get_movement_waypoints_to_well( force_direct: bool = False, minimum_z_height: Optional[float] = None, operation_volume: Optional[float] = None, + is_tracking: Optional[bool] = False, ) -> List[motion_planning.Waypoint]: """Calculate waypoints to a destination that's specified as a well.""" location = current_well or self._pipettes.get_current_location() @@ -114,6 +115,7 @@ def get_movement_waypoints_to_well( well_location=well_location, operation_volume=operation_volume, pipette_id=pipette_id, + is_tracking=is_tracking, ) move_type = _move_types.get_move_type_to_well( diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 9df634f11b1..08197df33be 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -258,6 +258,32 @@ async def finalize_logs( for listener in listeners.values(): await listener.wait_for_complete() +async def move_plunger_while_tracking_z( + messenger: CanMessenger, + tool: PipetteProbeTarget, + head_node: NodeId, + distance: float, + speed: float, + direction: Union[Literal[1], Literal[-1]], + duration: float, +) -> Dict[NodeId, MotorPositionStatus]: + liquid_action_step = create_step( + distance={ + tool: float64(abs(distance) * direction), + head_node: float64(abs(distance) * direction) + }, + velocity={ + tool: float64(abs(speed) * direction), + head_node: float64(abs(speed) * direction) + }, + acceleration={}, + duration=float64(duration), + present_nodes=[tool], + ) + runner = MoveGroupRunner(move_groups=[[liquid_action_step]]) + positions = await runner.run(can_messenger=messenger) + return positions + async def liquid_probe( messenger: CanMessenger, From c40139e1e21680a8b11f9264bc55d721e2e1041f Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Tue, 7 Jan 2025 16:12:32 -0500 Subject: [PATCH 02/10] set distance and speed for z and plunger axes --- .../backends/flex_protocol.py | 14 ++++++++++ .../backends/ot3controller.py | 12 +++++--- api/src/opentrons/hardware_control/ot3api.py | 22 +++++++++------ .../protocols/liquid_handler.py | 9 ++++++ .../protocol_engine/commands/aspirate.py | 2 ++ .../commands/pipetting_common.py | 6 +++- .../protocol_engine/execution/pipetting.py | 28 ++++++++++++++++++- .../protocol_engine/state/geometry.py | 19 +++++++++++++ .../hardware_control/tool_sensors.py | 15 ++++++---- 9 files changed, 106 insertions(+), 21 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index ef38af631e7..06b168805e6 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -11,6 +11,8 @@ Tuple, Set, TypeVar, + Literal, + Union, ) from opentrons_shared_data.pipette.types import ( PipetteName, @@ -462,3 +464,15 @@ async def set_hepa_uv_state(self, light_on: bool, uv_duration_s: int) -> bool: async def get_hepa_uv_state(self) -> Optional[HepaUVState]: ... + + async def aspirate_while_tracking( + self, + mount: OT3Mount, + z_distance: float, + z_speed: float, + plunger_distance: float, + plunger_speed: float, + direction: Union[Literal[1], Literal[-1]], + duration: float, + ) -> None: + ... diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 1158472fcfb..951bde677b2 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -727,8 +727,10 @@ def _build_move_gear_axis_runner( async def aspirate_while_tracking( self, mount: OT3Mount, - distance: float, - speed: float, + z_distance: float, + z_speed: float, + plunger_distance: float, + plunger_speed: float, direction: Union[Literal[1], Literal[-1]], duration: float, ) -> None: @@ -739,8 +741,10 @@ async def aspirate_while_tracking( messenger=self._messenger, tool=tool, head_node=head_node, - distance=distance, - speed=speed, + z_distance=z_distance, + z_speed=z_speed, + plunger_distance=plunger_distance, + plunger_speed=plunger_speed, direction=direction, duration=duration, ) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 75b68ee22ef..5e54cfdced5 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2967,16 +2967,19 @@ async def capacitive_sweep( async def aspirate_while_tracking( self, mount: Union[top_types.Mount, OT3Mount], - distance: float, - rate: float, - volume: Optional[float] = None, + z_distance: float, + flow_rate: float, + volume: float, ) -> None: """ Aspirate a volume of liquid (in microliters/uL) using this pipette.""" realmount = OT3Mount.from_mount(mount) aspirate_spec = self._pipette_handler.plan_check_aspirate( - realmount, volume, rate + realmount, volume, flow_rate ) + # what is aspirate_spec.volume for + aspirate_duration = volume / flow_rate + z_speed = z_distance / aspirate_duration if not aspirate_spec: return @@ -2989,12 +2992,13 @@ async def aspirate_while_tracking( realmount, aspirate_spec.acceleration ) await self._backend.aspirate_while_tracking( - mount=mount, - distance=distance, - speed=rate, + mount=realmount, + z_distance=z_distance, + z_speed=z_speed, + plunger_distance=aspirate_spec.plunger_distance, + plunger_speed=aspirate_spec.speed, direction=-1, - # have to actually determine duration here - duration=0.0, + duration=aspirate_duration, ) except Exception: self._log.exception("Aspirate failed") diff --git a/api/src/opentrons/hardware_control/protocols/liquid_handler.py b/api/src/opentrons/hardware_control/protocols/liquid_handler.py index 2aea15bd55b..0db466fa497 100644 --- a/api/src/opentrons/hardware_control/protocols/liquid_handler.py +++ b/api/src/opentrons/hardware_control/protocols/liquid_handler.py @@ -215,3 +215,12 @@ async def liquid_probe( max_z_dist : maximum depth to probe for liquid """ ... + + async def aspirate_while_tracking( + self, + mount: MountArgType, + z_distance: float, + flow_rate: float, + volume: float, + ) -> None: + ... diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 724798b2571..d8f53df13af 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -175,6 +175,8 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: if params.is_tracking: aspirate_result = await aspirate_while_tracking( pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, volume=params.volume, flow_rate=params.flowRate, location_if_error={ diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index af43eb11b91..21834d5835b 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -228,6 +228,8 @@ async def aspirate_in_place( async def aspirate_while_tracking( pipette_id: str, + labware_id: str, + well_name: str, volume: float, flow_rate: float, location_if_error: ErrorLocationInfo, @@ -235,10 +237,12 @@ async def aspirate_while_tracking( pipetting: PipettingHandler, model_utils: ModelUtils, ) -> SuccessData[BaseLiquidHandlingResult] | DefinedErrorData[OverpressureError]: - """Execute an aspirate in place microoperation.""" + """Execute an aspirate while tracking microoperation.""" try: volume_aspirated = await pipetting.aspirate_while_tracking( pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, volume=volume, flow_rate=flow_rate, command_note_adder=command_note_adder, diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index 4d088bb63d1..f230ae091e9 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -48,6 +48,8 @@ async def aspirate_in_place( async def aspirate_while_tracking( self, pipette_id: str, + labware_id: str, + well_name: str, volume: float, flow_rate: float, command_note_adder: CommandNoteAdder, @@ -114,6 +116,7 @@ def get_hw_aspirate_params( volume: float, command_note_adder: CommandNoteAdder, ) -> Tuple[HardwarePipette, float]: + """Get params for hardware aspirate.""" _adjusted_volume = _validate_aspirate_volume( state_view=self._state_view, pipette_id=pipette_id, @@ -129,6 +132,8 @@ def get_hw_aspirate_params( async def aspirate_while_tracking( self, pipette_id: str, + labware_id: str, + well_name: str, volume: float, flow_rate: float, command_note_adder: CommandNoteAdder, @@ -142,9 +147,18 @@ async def aspirate_while_tracking( hw_pipette, adjusted_volume = self.get_hw_aspirate_params( pipette_id, volume, command_note_adder ) + + aspirate_z_distance = self._state_view.geometry.get_liquid_handling_z_change( + labware_id=labware_id, + well_name=well_name, # make sure the protocol engine actually has the well name atp ? + operation_volume=volume, + ) with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate): await self._hardware_api.aspirate_while_tracking( - mount=hw_pipette.mount, volume=adjusted_volume + mount=hw_pipette.mount, + z_distance=aspirate_z_distance, + flow_rate=flow_rate, + volume=adjusted_volume, ) return adjusted_volume @@ -346,6 +360,18 @@ def _validate_tip_attached(self, pipette_id: str, command_name: str) -> None: f"Cannot perform {command_name} without a tip attached" ) + async def aspirate_while_tracking( + self, + pipette_id: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: float, + command_note_adder: CommandNoteAdder, + ) -> float: + """Aspirate while moving the z stage with the liquid meniscus.""" + return 0.0 + def create_pipetting_handler( state_view: StateView, hardware_api: HardwareControlAPI diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 9e708b1a646..0ed3c54cb6f 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -1406,6 +1406,25 @@ def get_offset_location(self, labware_id: str) -> Optional[LabwareOffsetLocation return None + def get_liquid_handling_z_change( + self, + labware_id: str, + well_name: str, + operation_volume: float, + ) -> float: + """Get the change in height from a liquid handling operation.""" + initial_handling_height = self.get_meniscus_height( + labware_id=labware_id, well_name=well_name + ) + final_height = self.get_well_height_after_volume( + labware_id=labware_id, + well_name=well_name, + initial_height=initial_handling_height, + volume=operation_volume, + ) + # make sure we handle aspirate and dispense both directions + return initial_handling_height - final_height + def get_well_offset_adjustment( self, labware_id: str, diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 08197df33be..0e90c08f996 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -258,23 +258,26 @@ async def finalize_logs( for listener in listeners.values(): await listener.wait_for_complete() + async def move_plunger_while_tracking_z( messenger: CanMessenger, tool: PipetteProbeTarget, head_node: NodeId, - distance: float, - speed: float, + z_distance: float, + z_speed: float, + plunger_distance: float, + plunger_speed: float, direction: Union[Literal[1], Literal[-1]], duration: float, ) -> Dict[NodeId, MotorPositionStatus]: liquid_action_step = create_step( distance={ - tool: float64(abs(distance) * direction), - head_node: float64(abs(distance) * direction) + tool: float64(abs(plunger_distance) * direction), + head_node: float64(abs(z_distance) * direction) }, velocity={ - tool: float64(abs(speed) * direction), - head_node: float64(abs(speed) * direction) + tool: float64(abs(plunger_speed) * direction), + head_node: float64(abs(z_speed) * direction) }, acceleration={}, duration=float64(duration), From 50692e7124d37bc1eee0d3be4333fdb9f86b7c4d Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Wed, 8 Jan 2025 16:20:40 -0500 Subject: [PATCH 03/10] use normal motion blending for tracking movement. --- .../backends/flex_protocol.py | 14 -------- .../backends/ot3controller.py | 32 ------------------- .../hardware_control/motion_utilities.py | 16 ++++++++++ api/src/opentrons/hardware_control/ot3api.py | 23 ++++++------- .../hardware_control/tool_sensors.py | 29 ----------------- 5 files changed, 28 insertions(+), 86 deletions(-) diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index 06b168805e6..ef38af631e7 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -11,8 +11,6 @@ Tuple, Set, TypeVar, - Literal, - Union, ) from opentrons_shared_data.pipette.types import ( PipetteName, @@ -464,15 +462,3 @@ async def set_hepa_uv_state(self, light_on: bool, uv_duration_s: int) -> bool: async def get_hepa_uv_state(self) -> Optional[HepaUVState]: ... - - async def aspirate_while_tracking( - self, - mount: OT3Mount, - z_distance: float, - z_speed: float, - plunger_distance: float, - plunger_speed: float, - direction: Union[Literal[1], Literal[-1]], - duration: float, - ) -> None: - ... diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 951bde677b2..70d895560ee 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -24,7 +24,6 @@ KeysView, Union, Mapping, - Literal, ) from opentrons.config.types import OT3Config, GantryLoad from opentrons.config import gripper_config @@ -168,7 +167,6 @@ liquid_probe, check_overpressure, grab_pressure, - move_plunger_while_tracking_z, ) from opentrons_hardware.hardware_control.rear_panel_settings import ( get_door_state, @@ -722,36 +720,6 @@ def _build_move_gear_axis_runner( True, ) - @requires_update - @requires_estop - async def aspirate_while_tracking( - self, - mount: OT3Mount, - z_distance: float, - z_speed: float, - plunger_distance: float, - plunger_speed: float, - direction: Union[Literal[1], Literal[-1]], - duration: float, - ) -> None: - head_node = axis_to_node(Axis.by_mount(mount)) - tool = sensor_node_for_pipette(OT3Mount(mount.value)) - async with self._monitor_overpressure([tool]): - positions = await move_plunger_while_tracking_z( - messenger=self._messenger, - tool=tool, - head_node=head_node, - z_distance=z_distance, - z_speed=z_speed, - plunger_distance=plunger_distance, - plunger_speed=plunger_speed, - direction=direction, - duration=duration, - ) - for node, point in positions.items(): - self._position.update({node: point.motor_position}) - self._encoder_position.update({node: point.encoder_position}) - @requires_update @requires_estop async def move( diff --git a/api/src/opentrons/hardware_control/motion_utilities.py b/api/src/opentrons/hardware_control/motion_utilities.py index dd59437f7dc..0dc1bd489c1 100644 --- a/api/src/opentrons/hardware_control/motion_utilities.py +++ b/api/src/opentrons/hardware_control/motion_utilities.py @@ -192,6 +192,22 @@ def target_position_from_plunger( return all_axes_pos +def target_positions_from_plunger_tracking( + mount: Union[Mount, OT3Mount], + plunger_delta: float, + z_delta: float, + current_position: Dict[Axis, float], +) -> "OrderedDict[Axis, float]": + """Create a target position for machine axes including plungers. + + The x/y axis remain constant but the plunger and Z move to create a tracking action. + """ + all_axes_pos = target_position_from_plunger(mount, plunger_delta, current_position) + z_ax = Axis.by_mount(mount) + all_axes_pos[z_ax] = current_position[z_ax] + z_delta + return all_axes_pos + + def deck_point_from_machine_point( machine_point: Point, attitude: AttitudeMatrix, offset: Point ) -> Point: diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 5e54cfdced5..06c4ae8330a 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -126,6 +126,7 @@ target_position_from_absolute, target_position_from_relative, target_position_from_plunger, + target_positions_from_plunger_tracking, offset_for_mount, deck_from_machine, machine_from_deck, @@ -2977,12 +2978,16 @@ async def aspirate_while_tracking( aspirate_spec = self._pipette_handler.plan_check_aspirate( realmount, volume, flow_rate ) - # what is aspirate_spec.volume for - aspirate_duration = volume / flow_rate - z_speed = z_distance / aspirate_duration if not aspirate_spec: return + target_pos = target_positions_from_plunger_tracking( + realmount, + aspirate_spec.plunger_distance, + z_distance, + self._current_position, + ) + try: await self._backend.set_active_current( {aspirate_spec.axis: aspirate_spec.current} @@ -2991,14 +2996,10 @@ async def aspirate_while_tracking( await self.set_system_constraints_for_plunger_acceleration( realmount, aspirate_spec.acceleration ) - await self._backend.aspirate_while_tracking( - mount=realmount, - z_distance=z_distance, - z_speed=z_speed, - plunger_distance=aspirate_spec.plunger_distance, - plunger_speed=aspirate_spec.speed, - direction=-1, - duration=aspirate_duration, + await self._move( + target_pos, + speed=aspirate_spec.speed, + home_flagged_axes=False, ) except Exception: self._log.exception("Aspirate failed") diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 0e90c08f996..9df634f11b1 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -259,35 +259,6 @@ async def finalize_logs( await listener.wait_for_complete() -async def move_plunger_while_tracking_z( - messenger: CanMessenger, - tool: PipetteProbeTarget, - head_node: NodeId, - z_distance: float, - z_speed: float, - plunger_distance: float, - plunger_speed: float, - direction: Union[Literal[1], Literal[-1]], - duration: float, -) -> Dict[NodeId, MotorPositionStatus]: - liquid_action_step = create_step( - distance={ - tool: float64(abs(plunger_distance) * direction), - head_node: float64(abs(z_distance) * direction) - }, - velocity={ - tool: float64(abs(plunger_speed) * direction), - head_node: float64(abs(z_speed) * direction) - }, - acceleration={}, - duration=float64(duration), - present_nodes=[tool], - ) - runner = MoveGroupRunner(move_groups=[[liquid_action_step]]) - positions = await runner.run(can_messenger=messenger) - return positions - - async def liquid_probe( messenger: CanMessenger, tool: PipetteProbeTarget, From fe16ba41e63cbb7ed2478d24a0c778d1c8353525 Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Mon, 13 Jan 2025 14:21:05 -0500 Subject: [PATCH 04/10] lint not yelling --- api/src/opentrons/hardware_control/ot3api.py | 44 +++++ .../protocols/liquid_handler.py | 10 ++ .../protocol_api/core/engine/instrument.py | 6 + .../opentrons/protocol_api/core/instrument.py | 5 + .../core/legacy/legacy_instrument_core.py | 5 + .../legacy_instrument_core.py | 1 + .../protocol_api/instrument_context.py | 151 +++++++++++++++++- .../protocol_engine/commands/dispense.py | 59 +++++-- .../commands/pipetting_common.py | 49 ++++++ .../protocol_engine/execution/pipetting.py | 81 +++++++++- 10 files changed, 384 insertions(+), 27 deletions(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 06c4ae8330a..1edc984df65 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -3008,6 +3008,50 @@ async def aspirate_while_tracking( else: aspirate_spec.instr.add_current_volume(aspirate_spec.volume) + async def dispense_while_tracking( + self, + mount: Union[top_types.Mount, OT3Mount], + z_distance: float, + flow_rate: float, + volume: float, + push_out: Optional[float], + ) -> None: + """ + Aspirate a volume of liquid (in microliters/uL) using this pipette.""" + realmount = OT3Mount.from_mount(mount) + dispense_spec = self._pipette_handler.plan_check_dispense( + realmount, volume, flow_rate, push_out + ) + if not dispense_spec: + return + + target_pos = target_positions_from_plunger_tracking( + realmount, + dispense_spec.plunger_distance, + z_distance, + self._current_position, + ) + + try: + await self._backend.set_active_current( + {dispense_spec.axis: dispense_spec.current} + ) + async with self.restore_system_constrants(): + await self.set_system_constraints_for_plunger_acceleration( + realmount, dispense_spec.acceleration + ) + await self._move( + target_pos, + speed=dispense_spec.speed, + home_flagged_axes=False, + ) + except Exception: + self._log.exception("dispense failed") + dispense_spec.instr.set_current_volume(0) + raise + else: + dispense_spec.instr.add_current_volume(dispense_spec.volume) + @property def attached_subsystems(self) -> Dict[SubSystem, SubSystemState]: """Get a view of the state of the currently-attached subsystems.""" diff --git a/api/src/opentrons/hardware_control/protocols/liquid_handler.py b/api/src/opentrons/hardware_control/protocols/liquid_handler.py index 0db466fa497..a1fa54db7fe 100644 --- a/api/src/opentrons/hardware_control/protocols/liquid_handler.py +++ b/api/src/opentrons/hardware_control/protocols/liquid_handler.py @@ -224,3 +224,13 @@ async def aspirate_while_tracking( volume: float, ) -> None: ... + + async def dispense_while_tracking( + self, + mount: MountArgType, + z_distance: float, + flow_rate: float, + volume: float, + push_out: Optional[float], + ) -> None: + ... diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 67c68b37bfa..8d016269370 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -145,6 +145,8 @@ def aspirate( rate: Not used in this core. flow_rate: The flow rate in µL/s to aspirate at. in_place: whether this is a in-place command. + is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. + is_tracking: whether the z motor is to move with the liquid meniscus. """ if well_core is None: if not in_place: @@ -209,6 +211,7 @@ def dispense( in_place: bool, push_out: Optional[float], is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = False, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -219,6 +222,8 @@ def dispense( flow_rate: The flow rate in µL/s to dispense at. in_place: whether this is a in-place command. push_out: The amount to push the plunger below bottom position. + is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. + is_tracking: whether the z motor is to move with the liquid meniscus. """ if self._protocol_core.api_version < _DISPENSE_VOLUME_VALIDATION_ADDED_IN: # In older API versions, when you try to dispense more than you can, @@ -286,6 +291,7 @@ def dispense( volume=volume, flowRate=flow_rate, pushOut=push_out, + is_tracking=is_tracking if is_tracking else False, ) ) diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index b512251967b..6b2f8e854a9 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -52,6 +52,8 @@ def aspirate( rate: The rate for how quickly to aspirate. flow_rate: The flow rate in µL/s to aspirate at. in_place: Whether this is in-place. + is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. + is_tracking: whether the z motor is to move with the liquid meniscus. """ ... @@ -66,6 +68,7 @@ def dispense( in_place: bool, push_out: Optional[float], is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -76,6 +79,8 @@ def dispense( flow_rate: The flow rate in µL/s to dispense at. in_place: Whether this is in-place. push_out: The amount to push the plunger below bottom position. + is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. + is_tracking: whether the z motor is to move with the liquid meniscus. """ ... 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 bb5223558cb..44e88309385 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 @@ -95,6 +95,8 @@ def aspirate( rate: The rate in µL/s to aspirate at. flow_rate: Not used in this core. in_place: Whether we should move_to location. + is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. + is_tracking: whether the z motor is to move with the liquid meniscus. """ if self.get_current_volume() == 0: # Make sure we're at the top of the labware and clear of any @@ -129,6 +131,7 @@ def dispense( in_place: bool, push_out: Optional[float], is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -139,6 +142,8 @@ def dispense( flow_rate: Not used in this core. in_place: Whether we should move_to location. push_out: The amount to push the plunger below bottom position. + is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. + is_tracking: whether the z motor is to move with the liquid meniscus. """ if isinstance(location, (TrashBin, WasteChute)): raise APIVersionError( 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 0edb3af7cc1..70bba0f5dd2 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 @@ -139,6 +139,7 @@ def dispense( in_place: bool, push_out: Optional[float], is_meniscus: Optional[bool] = None, + is_tracking: Optional[bool] = None, ) -> None: if isinstance(location, (TrashBin, WasteChute)): raise APIVersionError( diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 937b79210c2..33b5874fa76 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -292,8 +292,6 @@ def aspirate( return self - ######## - @requires_version(2, 0) def aspirate_while_tracking( self, @@ -421,7 +419,134 @@ def aspirate_while_tracking( return self - ######## + @requires_version(2, 0) + def dispense_while_tracking( + self, + volume: Optional[float] = None, + location: Optional[Union[types.Location, labware.Well]] = None, + rate: float = 1.0, + push_out: Optional[float] = None, + ) -> InstrumentContext: + """ + Draw liquid into a pipette tip. + + See :ref:`new-dispense` for more details and examples. + + :param volume: The volume to dispense, measured in µL. If unspecified, + defaults to the maximum volume for the pipette and its currently + attached tip. + + If ``dispense`` is called with a volume of precisely 0, its behavior + depends on the API level of the protocol. On API levels below 2.16, + it will behave the same as a volume of ``None``/unspecified: dispense + until the pipette is full. On API levels at or above 2.16, no liquid + will be dispensed. + :type volume: int or float + :param location: Tells the robot where to dispense from. The location can be + a :py:class:`.Well` or a :py:class:`.Location`. + + - If the location is a ``Well``, the robot will dispense at + or above the bottom center of the well. The distance (in mm) + from the well bottom is specified by + :py:obj:`well_bottom_clearance.dispense + `. + + - If the location is a ``Location`` (e.g., the result of + :py:meth:`.Well.top` or :py:meth:`.Well.bottom`), the robot + will dispense from that specified position. + + - If the ``location`` is unspecified, the robot will + dispense from its current position. + :param rate: A multiplier for the default flow rate of the pipette. Calculated + as ``rate`` multiplied by :py:attr:`flow_rate.dispense + `. If not specified, defaults to 1.0. See + :ref:`new-plunger-flow-rates`. + :type rate: float + :returns: This instance. + + .. note:: + + If ``dispense`` is called with a single, unnamed argument, it will treat + that argument as ``volume``. If you want to call ``dispense`` with only + ``location``, specify it as a keyword argument: + ``pipette.dispense(location=plate['A1'])`` + + """ + _log.debug( + "dispense {} from {} at {}".format( + volume, location if location else "current position", rate + ) + ) + + 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( + location=location, last_location=last_location + ) + except validation.NoLocationError as e: + raise RuntimeError( + "If dispense is called without an explicit location, another" + " method that moves to a location (such as move_to or " + "dispense) must previously have been called so the robot " + "knows where it is." + ) from e + + if isinstance(target, (TrashBin, WasteChute)): + raise ValueError( + "Trash Bin and Waste Chute are not acceptable location parameters for dispense commands." + ) + move_to_location, well, is_meniscus = self._handle_dispense_target( + target=target + ) + if self.api_version >= APIVersion(2, 11): + instrument.validate_takes_liquid( + location=move_to_location, + reject_module=self.api_version >= APIVersion(2, 13), + reject_adapter=self.api_version >= APIVersion(2, 15), + ) + + if self.api_version >= APIVersion(2, 16): + c_vol = self._core.get_available_volume() if volume is None else volume + else: + c_vol = self._core.get_available_volume() if not volume else volume + flow_rate = self._core.get_dispense_flow_rate(rate) + + if ( + self.api_version >= APIVersion(2, 20) + and well is not None + and self.liquid_presence_detection + and self._core.nozzle_configuration_valid_for_lld() + and self._core.get_current_volume() == 0 + ): + self._raise_if_pressure_not_supported_by_pipette() + self.require_liquid_presence(well=well) + + with publisher.publish_context( + broker=self.broker, + command=cmds.dispense( + instrument=self, + volume=c_vol, + location=move_to_location, + flow_rate=flow_rate, + rate=rate, + ), + ): + self._core.dispense( + location=move_to_location, + well_core=well._core if well is not None else None, + volume=c_vol, + rate=rate, + flow_rate=flow_rate, + in_place=target.in_place, + push_out=push_out, + is_meniscus=is_meniscus, + is_tracking=True, + ) + + return self @requires_version(2, 0) def dispense( # noqa: C901 @@ -2457,6 +2582,26 @@ def _handle_aspirate_target( move_to_location = target.location return (move_to_location, well, is_meniscus) + def _handle_dispense_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.dispense + ) + 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_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index 8ad2365ccb5..ba98f2f0e5b 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -16,6 +16,8 @@ BaseLiquidHandlingResult, OverpressureError, dispense_in_place, + dispense_while_tracking, + IsTrackingMixin, ) from .movement_common import ( LiquidHandlingWellLocationMixin, @@ -45,7 +47,11 @@ def _remove_default(s: dict[str, Any]) -> None: class DispenseParams( - PipetteIdMixin, DispenseVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin + PipetteIdMixin, + DispenseVolumeMixin, + FlowRateMixin, + LiquidHandlingWellLocationMixin, + IsTrackingMixin, ): """Payload required to dispense to a specific well.""" @@ -89,7 +95,6 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: 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() @@ -100,24 +105,44 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: labware_id=labware_id, well_name=well_name, well_location=well_location, + is_tracking=params.is_tracking, ) if isinstance(move_result, DefinedErrorData): return move_result - dispense_result = await dispense_in_place( - pipette_id=params.pipetteId, - volume=volume, - flow_rate=params.flowRate, - push_out=params.pushOut, - location_if_error={ - "retryLocation": ( - move_result.public.position.x, - move_result.public.position.y, - move_result.public.position.z, - ) - }, - pipetting=self._pipetting, - model_utils=self._model_utils, - ) + if params.is_tracking: + dispense_result = await dispense_while_tracking( + pipette_id=params.pipetteId, + labware_id=labware_id, + well_name=well_name, + volume=params.volume, + flow_rate=params.flowRate, + push_out=params.pushOut, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) + else: + dispense_result = await dispense_in_place( + pipette_id=params.pipetteId, + volume=params.volume, + flow_rate=params.flowRate, + push_out=params.pushOut, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) if isinstance(dispense_result, DefinedErrorData): return DefinedErrorData( diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index 21834d5835b..cfaf67f6375 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -275,6 +275,55 @@ async def aspirate_while_tracking( ) +async def dispense_while_tracking( + pipette_id: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: float, + push_out: float | None, + location_if_error: ErrorLocationInfo, + pipetting: PipettingHandler, + model_utils: ModelUtils, +) -> SuccessData[BaseLiquidHandlingResult] | DefinedErrorData[OverpressureError]: + """Execute an dispense while tracking microoperation.""" + try: + volume_dispensed = await pipetting.dispense_while_tracking( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + volume=volume, + flow_rate=flow_rate, + push_out=push_out, + ) + except PipetteOverpressureError as e: + return DefinedErrorData( + public=OverpressureError( + id=model_utils.generate_id(), + createdAt=model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=model_utils.generate_id(), + createdAt=model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=location_if_error, + ), + state_update=StateUpdate().set_fluid_unknown(pipette_id=pipette_id), + ) + else: + return SuccessData( + public=BaseLiquidHandlingResult( + volume=volume_dispensed, + ), + state_update=StateUpdate().set_fluid_ejected( + pipette_id=pipette_id, + volume=volume_dispensed, + ), + ) + + async def dispense_in_place( pipette_id: str, volume: float, diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index f230ae091e9..69e0354f624 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -56,6 +56,17 @@ async def aspirate_while_tracking( ) -> float: """Set flow-rate and aspirate while tracking.""" + async def dispense_while_tracking( + self, + pipette_id: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: float, + push_out: Optional[float], + ) -> float: + """Set flow-rate and dispense while tracking.""" + async def dispense_in_place( self, pipette_id: str, @@ -129,6 +140,23 @@ def get_hw_aspirate_params( ) return _hw_pipette, _adjusted_volume + def get_hw_dispense_params( + self, + pipette_id: str, + volume: float, + ) -> Tuple[HardwarePipette, float]: + """Get params for hardware dispense.""" + _adjusted_volume = _validate_dispense_volume( + state_view=self._state_view, + pipette_id=pipette_id, + dispense_volume=volume, + ) + _hw_pipette = self._state_view.pipettes.get_hardware_pipette( + pipette_id=pipette_id, + attached_pipettes=self._hardware_api.attached_instruments, + ) + return _hw_pipette, _adjusted_volume + async def aspirate_while_tracking( self, pipette_id: str, @@ -163,6 +191,39 @@ async def aspirate_while_tracking( return adjusted_volume + async def dispense_while_tracking( + self, + pipette_id: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: float, + push_out: Optional[float], + ) -> float: + """Set flow-rate and dispense. + + Raises: + PipetteOverpressureError, propagated as-is from the hardware controller. + """ + # get mount and config data from state and hardware controller + hw_pipette, adjusted_volume = self.get_hw_dispense_params(pipette_id, volume) + + dispense_z_distance = self._state_view.geometry.get_liquid_handling_z_change( + labware_id=labware_id, + well_name=well_name, # make sure the protocol engine actually has the well name atp ? + operation_volume=volume, + ) + with self._set_flow_rate(pipette=hw_pipette, dispense_flow_rate=flow_rate): + await self._hardware_api.dispense_while_tracking( + mount=hw_pipette.mount, + z_distance=dispense_z_distance, + flow_rate=flow_rate, + volume=adjusted_volume, + push_out=push_out, + ) + + return adjusted_volume + async def aspirate_in_place( self, pipette_id: str, @@ -194,13 +255,7 @@ async def dispense_in_place( push_out: Optional[float], ) -> float: """Dispense liquid without moving the pipette.""" - adjusted_volume = _validate_dispense_volume( - state_view=self._state_view, pipette_id=pipette_id, dispense_volume=volume - ) - hw_pipette = self._state_view.pipettes.get_hardware_pipette( - pipette_id=pipette_id, - attached_pipettes=self._hardware_api.attached_instruments, - ) + hw_pipette, adjusted_volume = self.get_hw_dispense_params(pipette_id, volume) # TODO (tz, 8-23-23): add a check for push_out not larger that the max volume allowed when working on this https://opentrons.atlassian.net/browse/RSS-329 if push_out and push_out < 0: raise InvalidPushOutVolumeError( @@ -372,6 +427,18 @@ async def aspirate_while_tracking( """Aspirate while moving the z stage with the liquid meniscus.""" return 0.0 + async def dispense_while_tracking( + self, + pipette_id: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: float, + push_out: Optional[float], + ) -> float: + """Dispense while moving the z stage with the liquid meniscus.""" + return 0.0 + def create_pipetting_handler( state_view: StateView, hardware_api: HardwareControlAPI From 65c9758f48cbccb177ca31ac9a0d4d8be9e2bf41 Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Tue, 14 Jan 2025 11:17:35 -0500 Subject: [PATCH 05/10] works but messy --- api/src/opentrons/hardware_control/ot3api.py | 2 +- .../opentrons/protocol_engine/execution/movement.py | 1 + .../opentrons/protocol_engine/execution/pipetting.py | 6 +++--- .../protocol_engine/state/frustum_helpers.py | 4 ++-- api/src/opentrons/protocol_engine/state/geometry.py | 12 +++++++++++- api/src/opentrons/protocol_engine/state/motion.py | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 1edc984df65..9c27af722d9 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -3050,7 +3050,7 @@ async def dispense_while_tracking( dispense_spec.instr.set_current_volume(0) raise else: - dispense_spec.instr.add_current_volume(dispense_spec.volume) + dispense_spec.instr.remove_current_volume(dispense_spec.volume) @property def attached_subsystems(self) -> Dict[SubSystem, SubSystemState]: diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index 983fa0af3f4..6424226fcf9 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -138,6 +138,7 @@ async def move_to_well( force_direct=force_direct, minimum_z_height=minimum_z_height, operation_volume=operation_volume, + is_tracking=is_tracking ) 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 69e0354f624..cada272db5b 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -179,12 +179,12 @@ async def aspirate_while_tracking( aspirate_z_distance = self._state_view.geometry.get_liquid_handling_z_change( labware_id=labware_id, well_name=well_name, # make sure the protocol engine actually has the well name atp ? - operation_volume=volume, + operation_volume=volume * -1, # try this tomorrow ) with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate): await self._hardware_api.aspirate_while_tracking( mount=hw_pipette.mount, - z_distance=aspirate_z_distance, + z_distance=aspirate_z_distance * -1, flow_rate=flow_rate, volume=adjusted_volume, ) @@ -216,7 +216,7 @@ async def dispense_while_tracking( with self._set_flow_rate(pipette=hw_pipette, dispense_flow_rate=flow_rate): await self._hardware_api.dispense_while_tracking( mount=hw_pipette.mount, - z_distance=dispense_z_distance, + z_distance=(dispense_z_distance * -1), flow_rate=flow_rate, volume=adjusted_volume, push_out=push_out, diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index b28fb936be7..f59f3fb430e 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -362,7 +362,7 @@ def find_volume_at_well_height( volumetric_capacity = get_well_volumetric_capacity(well_geometry) max_height = volumetric_capacity[-1][0] if target_height < 0 or target_height > max_height: - raise InvalidLiquidHeightFound("Invalid target height.") + raise InvalidLiquidHeightFound(f"Invalid target height {target_height}.") # volumes in volumetric_capacity are relative to each frustum, # so we have to find the volume of all the full sections enclosed # beneath the target height @@ -423,7 +423,7 @@ def find_height_at_well_volume( volumetric_capacity = get_well_volumetric_capacity(well_geometry) max_volume = sum(row[1] for row in volumetric_capacity) if target_volume < 0 or target_volume > max_volume: - raise InvalidLiquidHeightFound("Invalid target volume.") + raise InvalidLiquidHeightFound(f"Invalid target volume {target_volume}, max vol {max_volume}.") sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) # find the section the target volume is in and compute the height diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 0ed3c54cb6f..05cdbbe323b 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -1,6 +1,7 @@ """Geometry state getters.""" import enum +# import logging from numpy import array, dot, double as npdouble from numpy.typing import NDArray from typing import Optional, List, Tuple, Union, cast, TypeVar, Dict @@ -1422,7 +1423,8 @@ def get_liquid_handling_z_change( initial_height=initial_handling_height, volume=operation_volume, ) - # make sure we handle aspirate and dispense both directions + # uncomment this + # return final_height - initial_handling_height return initial_handling_height - final_height def get_well_offset_adjustment( @@ -1445,6 +1447,10 @@ def get_well_offset_adjustment( well_location=well_location, well_depth=well_depth, ) + # _log = logging.getLogger(__name__) + # raise ValueError( + # f"initial handling height {initial_handling_height} \n is_tracking {is_tracking}" + # ) if is_tracking: return initial_handling_height if isinstance(well_location, PickUpTipWellLocation): @@ -1473,6 +1479,9 @@ def get_meniscus_height( well_liquid = self._wells.get_well_liquid_info( labware_id=labware_id, well_name=well_name ) + # raise ValueError(f"well = {well_liquid}") + # raise ValueError(f"prbed_height not none{well_liquid.probed_height is not None}\n \ + # height.height is not None {well_liquid.probed_height.height is not None}") if ( well_liquid.probed_height is not None and well_liquid.probed_height.height is not None @@ -1515,6 +1524,7 @@ def get_well_handling_height( elif well_location.origin == WellOrigin.CENTER: handling_height = well_depth / 2.0 elif well_location.origin == WellOrigin.MENISCUS: + # baddie here handling_height = self.get_meniscus_height( labware_id=labware_id, well_name=well_name ) diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index f7d478415cf..9cb566156ec 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -131,7 +131,7 @@ def get_movement_waypoints_to_well( extra_waypoints = self._geometry.get_extra_waypoints( location=location, to_slot=destination_slot ) - + # baddie here try: return motion_planning.get_waypoints( move_type=move_type, From bea6ff0d50c01990d28c4d71833e9ac1628bf81d Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Tue, 14 Jan 2025 11:42:21 -0500 Subject: [PATCH 06/10] math fix --- .../opentrons/protocol_engine/execution/movement.py | 2 +- .../opentrons/protocol_engine/execution/pipetting.py | 10 +++++----- .../opentrons/protocol_engine/state/frustum_helpers.py | 4 +++- api/src/opentrons/protocol_engine/state/geometry.py | 5 ++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index 6424226fcf9..b5e3c5aeeac 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -138,7 +138,7 @@ async def move_to_well( force_direct=force_direct, minimum_z_height=minimum_z_height, operation_volume=operation_volume, - is_tracking=is_tracking + is_tracking=is_tracking, ) 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 cada272db5b..a64cd8b0141 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -178,13 +178,13 @@ async def aspirate_while_tracking( aspirate_z_distance = self._state_view.geometry.get_liquid_handling_z_change( labware_id=labware_id, - well_name=well_name, # make sure the protocol engine actually has the well name atp ? - operation_volume=volume * -1, # try this tomorrow + well_name=well_name, + operation_volume=volume * -1, ) with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate): await self._hardware_api.aspirate_while_tracking( mount=hw_pipette.mount, - z_distance=aspirate_z_distance * -1, + z_distance=aspirate_z_distance, flow_rate=flow_rate, volume=adjusted_volume, ) @@ -210,13 +210,13 @@ async def dispense_while_tracking( dispense_z_distance = self._state_view.geometry.get_liquid_handling_z_change( labware_id=labware_id, - well_name=well_name, # make sure the protocol engine actually has the well name atp ? + well_name=well_name, operation_volume=volume, ) with self._set_flow_rate(pipette=hw_pipette, dispense_flow_rate=flow_rate): await self._hardware_api.dispense_while_tracking( mount=hw_pipette.mount, - z_distance=(dispense_z_distance * -1), + z_distance=dispense_z_distance, flow_rate=flow_rate, volume=adjusted_volume, push_out=push_out, diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index f59f3fb430e..11dbc1f0588 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -423,7 +423,9 @@ def find_height_at_well_volume( volumetric_capacity = get_well_volumetric_capacity(well_geometry) max_volume = sum(row[1] for row in volumetric_capacity) if target_volume < 0 or target_volume > max_volume: - raise InvalidLiquidHeightFound(f"Invalid target volume {target_volume}, max vol {max_volume}.") + raise InvalidLiquidHeightFound( + f"Invalid target volume {target_volume}, max vol {max_volume}." + ) sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) # find the section the target volume is in and compute the height diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 05cdbbe323b..d3633044e31 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -1,6 +1,7 @@ """Geometry state getters.""" import enum + # import logging from numpy import array, dot, double as npdouble from numpy.typing import NDArray @@ -1423,9 +1424,7 @@ def get_liquid_handling_z_change( initial_height=initial_handling_height, volume=operation_volume, ) - # uncomment this - # return final_height - initial_handling_height - return initial_handling_height - final_height + return final_height - initial_handling_height def get_well_offset_adjustment( self, From 7623191ffebb9b4dd248c53e19122a96d341a827 Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Tue, 14 Jan 2025 15:44:55 -0500 Subject: [PATCH 07/10] rest of mock fixes --- .../opentrons/protocol_engine/commands/test_aspirate.py | 9 +++++++++ .../opentrons/protocol_engine/commands/test_blow_out.py | 3 +++ .../opentrons/protocol_engine/commands/test_dispense.py | 3 +++ .../opentrons/protocol_engine/commands/test_drop_tip.py | 4 ++++ .../protocol_engine/commands/test_liquid_probe.py | 3 +++ .../protocol_engine/commands/test_move_to_well.py | 2 ++ .../protocol_engine/commands/test_pick_up_tip.py | 3 +++ .../opentrons/protocol_engine/commands/test_touch_tip.py | 2 ++ .../protocol_engine/execution/test_movement_handler.py | 2 ++ .../opentrons/protocol_engine/state/test_motion_view.py | 5 +++-- 10 files changed, 34 insertions(+), 2 deletions(-) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py index 4a8adbcdc76..6954a74cf57 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py @@ -117,6 +117,7 @@ async def test_aspirate_implementation_no_prep( minimum_z_height=None, speed=None, operation_volume=-50, + is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -209,6 +210,7 @@ async def test_aspirate_implementation_with_prep( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_return(Point()) @@ -227,6 +229,7 @@ async def test_aspirate_implementation_with_prep( minimum_z_height=None, speed=None, operation_volume=-volume, + is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -317,6 +320,7 @@ async def test_aspirate_raises_volume_error( minimum_z_height=None, speed=None, operation_volume=-50, + is_tracking=False, ), ).then_return(Point(1, 2, 3)) @@ -393,6 +397,7 @@ async def test_overpressure_error( minimum_z_height=None, speed=None, operation_volume=-50, + is_tracking=False, ), ).then_return(position) @@ -493,6 +498,7 @@ async def test_aspirate_implementation_meniscus( minimum_z_height=None, speed=None, operation_volume=-50, + is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -572,6 +578,7 @@ async def test_stall_during_final_movement( minimum_z_height=None, speed=None, operation_volume=-50, + is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) @@ -632,6 +639,7 @@ async def test_stall_during_preparation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) decoy.when(model_utils.generate_id()).then_return(error_id) @@ -704,6 +712,7 @@ async def test_overpressure_during_preparation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_return(prep_location) 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 7549141be5b..b7731f1d93b 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py @@ -80,6 +80,7 @@ async def test_blow_out_implementation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -152,6 +153,7 @@ async def test_overpressure_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -227,6 +229,7 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index 5b60b61d4df..2c48c6359de 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -80,6 +80,7 @@ async def test_dispense_implementation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -188,6 +189,7 @@ async def test_overpressure_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_return(position) @@ -279,6 +281,7 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py index 430fa8dff32..bf908dceb9e 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py @@ -132,6 +132,7 @@ async def test_drop_tip_implementation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) @@ -218,6 +219,7 @@ async def test_drop_tip_with_alternating_locations( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) @@ -289,6 +291,7 @@ async def test_tip_attached_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) decoy.when( @@ -385,6 +388,7 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py index c9661512aaa..eed15d26abb 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py +++ b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py @@ -155,6 +155,7 @@ async def test_liquid_probe_implementation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -315,6 +316,7 @@ async def test_liquid_not_found_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_return(position) @@ -715,6 +717,7 @@ async def test_liquid_probe_stall( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) 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 56a2691bbee..8e0f3b6b735 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 @@ -72,6 +72,7 @@ async def test_move_to_well_implementation( speed=7.89, current_well=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=9, y=8, z=7)) @@ -136,6 +137,7 @@ async def test_move_to_well_stall_defined_error( speed=7.89, current_well=None, operation_volume=None, + is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) decoy.when(mock_model_utils.generate_id()).then_return(error_id) 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 d4c53ea5992..df99b42c927 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 @@ -67,6 +67,7 @@ async def test_success( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) @@ -152,6 +153,7 @@ async def test_tip_physically_missing_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) decoy.when( @@ -244,6 +246,7 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py index 5756810c9ee..0d343c8179b 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py @@ -88,6 +88,7 @@ async def test_touch_tip_implementation( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -178,6 +179,7 @@ async def test_touch_tip_implementation_with_mm_to_edge( minimum_z_height=None, speed=None, operation_volume=None, + is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) 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 73b293fdbef..e775382ecf0 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py @@ -150,6 +150,7 @@ async def test_move_to_well( force_direct=True, minimum_z_height=12.3, operation_volume=None, + is_tracking=False, ) ).then_return( [Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER), Waypoint(Point(4, 5, 6))] @@ -259,6 +260,7 @@ async def test_move_to_well_from_starting_location( force_direct=False, minimum_z_height=None, operation_volume=None, + is_tracking=False, ) ).then_return([Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER)]) 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 3e9d60da79a..65aa2192846 100644 --- a/api/tests/opentrons/protocol_engine/state/test_motion_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_motion_view.py @@ -310,7 +310,7 @@ def test_get_movement_waypoints_to_well_for_y_center( decoy.when( geometry_view.get_well_position( - "labware-id", "well-name", WellLocation(), None, "pipette-id" + "labware-id", "well-name", WellLocation(), None, "pipette-id", False ) ).then_return(Point(x=4, y=5, z=6)) @@ -394,7 +394,7 @@ def test_get_movement_waypoints_to_well_for_xy_center( decoy.when( geometry_view.get_well_position( - "labware-id", "well-name", WellLocation(), None, "pipette-id" + "labware-id", "well-name", WellLocation(), None, "pipette-id", False ) ).then_return(Point(x=4, y=5, z=6)) @@ -466,6 +466,7 @@ def test_get_movement_waypoints_to_well_raises( well_location=None, operation_volume=None, pipette_id="pipette-id", + is_tracking=False, ) ).then_return(Point(x=4, y=5, z=6)) decoy.when(pipette_view.get_current_location()).then_return(None) From 8b2858fe9f57351da8ba31bae79665c49eb7d642 Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Tue, 14 Jan 2025 16:36:09 -0500 Subject: [PATCH 08/10] separate aspirateWhileTracking and DispenseWhileTracking commands --- .../protocol_engine/commands/aspirate.py | 54 ++-- .../commands/aspirate_while_tracking.py | 246 ++++++++++++++++++ .../protocol_engine/commands/dispense.py | 53 ++-- .../commands/dispense_while_tracking.py | 197 ++++++++++++++ 4 files changed, 475 insertions(+), 75 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py create mode 100644 api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index d8f53df13af..5b47b78efbb 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -11,9 +11,7 @@ FlowRateMixin, BaseLiquidHandlingResult, aspirate_in_place, - aspirate_while_tracking, prepare_for_aspirate, - IsTrackingMixin, ) from .movement_common import ( LiquidHandlingWellLocationMixin, @@ -53,7 +51,6 @@ class AspirateParams( AspirateVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin, - IsTrackingMixin, ): """Parameters required to aspirate from a specific well.""" @@ -164,7 +161,7 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_location=well_location, current_well=current_well, operation_volume=-params.volume, - is_tracking=params.is_tracking, + is_tracking=False, ) state_update.append(move_result.state_update) if isinstance(move_result, DefinedErrorData): @@ -172,40 +169,21 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: public=move_result.public, state_update=state_update ) - if params.is_tracking: - aspirate_result = await aspirate_while_tracking( - pipette_id=pipette_id, - labware_id=labware_id, - well_name=well_name, - volume=params.volume, - flow_rate=params.flowRate, - location_if_error={ - "retryLocation": ( - move_result.public.position.x, - move_result.public.position.y, - move_result.public.position.z, - ) - }, - command_note_adder=self._command_note_adder, - pipetting=self._pipetting, - model_utils=self._model_utils, - ) - else: - aspirate_result = await aspirate_in_place( - pipette_id=pipette_id, - volume=params.volume, - flow_rate=params.flowRate, - location_if_error={ - "retryLocation": ( - move_result.public.position.x, - move_result.public.position.y, - move_result.public.position.z, - ) - }, - command_note_adder=self._command_note_adder, - pipetting=self._pipetting, - model_utils=self._model_utils, - ) + aspirate_result = await aspirate_in_place( + pipette_id=pipette_id, + volume=params.volume, + flow_rate=params.flowRate, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + command_note_adder=self._command_note_adder, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) state_update.append(aspirate_result.state_update) if isinstance(aspirate_result, DefinedErrorData): state_update.set_liquid_operated( diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py new file mode 100644 index 00000000000..1d9b517a0c2 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py @@ -0,0 +1,246 @@ +"""Aspirate command request, result, and implementation models.""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Type, Union +from typing_extensions import Literal + +from .pipetting_common import ( + OverpressureError, + PipetteIdMixin, + AspirateVolumeMixin, + FlowRateMixin, + BaseLiquidHandlingResult, + aspirate_while_tracking, + prepare_for_aspirate, +) +from .movement_common import ( + LiquidHandlingWellLocationMixin, + DestinationPositionResult, + StallOrCollisionError, + move_to_well, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + DefinedErrorData, + SuccessData, +) + +from opentrons.hardware_control import HardwareControlAPI + +from ..state.update_types import StateUpdate, CLEAR +from ..types import ( + WellLocation, + WellOrigin, + CurrentWell, +) + +if TYPE_CHECKING: + from ..execution import MovementHandler, PipettingHandler + from ..resources import ModelUtils + from ..state.state import StateView + from ..notes import CommandNoteAdder + + +AspirateCommandType = Literal["aspirate"] + + +class AspirateWhileTrackingParams( + PipetteIdMixin, + AspirateVolumeMixin, + FlowRateMixin, + LiquidHandlingWellLocationMixin, +): + """Parameters required to aspirate from a specific well.""" + + pass + + +class AspirateResult(BaseLiquidHandlingResult, DestinationPositionResult): + """Result data from execution of an Aspirate command.""" + + pass + + +_ExecuteReturn = Union[ + SuccessData[AspirateResult], + DefinedErrorData[OverpressureError] | DefinedErrorData[StallOrCollisionError], +] + + +class AspirateImplementation(AbstractCommandImpl[AspirateWhileTrackingParams, _ExecuteReturn]): + """Aspirate command implementation.""" + + def __init__( + self, + pipetting: PipettingHandler, + state_view: StateView, + hardware_api: HardwareControlAPI, + movement: MovementHandler, + command_note_adder: CommandNoteAdder, + model_utils: ModelUtils, + **kwargs: object, + ) -> None: + self._pipetting = pipetting + self._state_view = state_view + self._hardware_api = hardware_api + self._movement = movement + self._command_note_adder = command_note_adder + self._model_utils = model_utils + + async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: + """Move to and aspirate from the requested well. + + Raises: + TipNotAttachedError: if no tip is attached to the pipette. + """ + pipette_id = params.pipetteId + labware_id = params.labwareId + well_name = params.wellName + well_location = params.wellLocation + + state_update = StateUpdate() + + final_location = self._state_view.geometry.get_well_position( + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + operation_volume=-params.volume, + pipette_id=pipette_id, + ) + + ready_to_aspirate = self._pipetting.get_is_ready_to_aspirate( + pipette_id=pipette_id + ) + + current_well = None + + if not ready_to_aspirate: + move_result = await move_to_well( + movement=self._movement, + model_utils=self._model_utils, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=WellLocation(origin=WellOrigin.TOP), + ) + state_update.append(move_result.state_update) + if isinstance(move_result, DefinedErrorData): + return DefinedErrorData(move_result.public, state_update=state_update) + + prepare_result = await prepare_for_aspirate( + pipette_id=pipette_id, + pipetting=self._pipetting, + model_utils=self._model_utils, + # Note that the retryLocation is the final location, inside the liquid, + # because that's where we'd want the client to try re-aspirating if this + # command fails and the run enters error recovery. + location_if_error={"retryLocation": final_location}, + ) + state_update.append(prepare_result.state_update) + if isinstance(prepare_result, DefinedErrorData): + return DefinedErrorData( + public=prepare_result.public, state_update=state_update + ) + + # set our current deck location to the well now that we've made + # an intermediate move for the "prepare for aspirate" step + current_well = CurrentWell( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + ) + + move_result = await move_to_well( + movement=self._movement, + model_utils=self._model_utils, + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + current_well=current_well, + operation_volume=-params.volume, + is_tracking=True, + ) + state_update.append(move_result.state_update) + if isinstance(move_result, DefinedErrorData): + return DefinedErrorData( + public=move_result.public, state_update=state_update + ) + + aspirate_result = await aspirate_while_tracking( + pipette_id=pipette_id, + labware_id=labware_id, + well_name=well_name, + volume=params.volume, + flow_rate=params.flowRate, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + command_note_adder=self._command_note_adder, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) + state_update.append(aspirate_result.state_update) + if isinstance(aspirate_result, DefinedErrorData): + state_update.set_liquid_operated( + labware_id=labware_id, + well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( + labware_id, + well_name, + params.pipetteId, + ), + volume_added=CLEAR, + ) + return DefinedErrorData( + public=aspirate_result.public, state_update=state_update + ) + + state_update.set_liquid_operated( + labware_id=labware_id, + well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( + labware_id, well_name, pipette_id + ), + volume_added=-aspirate_result.public.volume + * self._state_view.geometry.get_nozzles_per_well( + labware_id, + well_name, + params.pipetteId, + ), + ) + + return SuccessData( + public=AspirateResult( + volume=aspirate_result.public.volume, + position=move_result.public.position, + ), + state_update=state_update, + ) + + +class Aspirate( + BaseCommand[ + AspirateWhileTrackingParams, AspirateResult, OverpressureError | StallOrCollisionError + ] +): + """Aspirate command model.""" + + commandType: AspirateCommandType = "aspirate" + params: AspirateWhileTrackingParams + result: Optional[AspirateResult] = None + + _ImplementationCls: Type[AspirateImplementation] = AspirateImplementation + + +class AspirateCreate(BaseCommandCreate[AspirateWhileTrackingParams]): + """Create aspirate command request model.""" + + commandType: AspirateCommandType = "aspirate" + params: AspirateWhileTrackingParams + + _CommandCls: Type[Aspirate] = Aspirate diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index ba98f2f0e5b..3acb789b7f4 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -16,7 +16,6 @@ BaseLiquidHandlingResult, OverpressureError, dispense_in_place, - dispense_while_tracking, IsTrackingMixin, ) from .movement_common import ( @@ -51,7 +50,6 @@ class DispenseParams( DispenseVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin, - IsTrackingMixin, ): """Payload required to dispense to a specific well.""" @@ -105,44 +103,25 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: labware_id=labware_id, well_name=well_name, well_location=well_location, - is_tracking=params.is_tracking, + is_tracking=False, ) if isinstance(move_result, DefinedErrorData): return move_result - if params.is_tracking: - dispense_result = await dispense_while_tracking( - pipette_id=params.pipetteId, - labware_id=labware_id, - well_name=well_name, - volume=params.volume, - flow_rate=params.flowRate, - push_out=params.pushOut, - location_if_error={ - "retryLocation": ( - move_result.public.position.x, - move_result.public.position.y, - move_result.public.position.z, - ) - }, - pipetting=self._pipetting, - model_utils=self._model_utils, - ) - else: - dispense_result = await dispense_in_place( - pipette_id=params.pipetteId, - volume=params.volume, - flow_rate=params.flowRate, - push_out=params.pushOut, - location_if_error={ - "retryLocation": ( - move_result.public.position.x, - move_result.public.position.y, - move_result.public.position.z, - ) - }, - pipetting=self._pipetting, - model_utils=self._model_utils, - ) + dispense_result = await dispense_in_place( + pipette_id=params.pipetteId, + volume=params.volume, + flow_rate=params.flowRate, + push_out=params.pushOut, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) if isinstance(dispense_result, DefinedErrorData): return DefinedErrorData( diff --git a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py new file mode 100644 index 00000000000..5f7f8dcb87a --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py @@ -0,0 +1,197 @@ +"""Dispense command request, result, and implementation models.""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Type, Union, Any +from typing_extensions import Literal + + +from pydantic import Field +from pydantic.json_schema import SkipJsonSchema + +from ..state.update_types import StateUpdate, CLEAR +from .pipetting_common import ( + PipetteIdMixin, + DispenseVolumeMixin, + FlowRateMixin, + BaseLiquidHandlingResult, + OverpressureError, + dispense_while_tracking, +) +from .movement_common import ( + LiquidHandlingWellLocationMixin, + DestinationPositionResult, + StallOrCollisionError, + move_to_well, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + DefinedErrorData, + SuccessData, +) + +if TYPE_CHECKING: + from ..execution import MovementHandler, PipettingHandler + from ..resources import ModelUtils + from ..state.state import StateView + + +DispenseCommandType = Literal["dispense"] + + +def _remove_default(s: dict[str, Any]) -> None: + s.pop("default", None) + + +class DispenseWhileTrackingParams( + PipetteIdMixin, + DispenseVolumeMixin, + FlowRateMixin, + LiquidHandlingWellLocationMixin, +): + """Payload required to dispense to a specific well.""" + + pushOut: float | SkipJsonSchema[None] = Field( + None, + description="push the plunger a small amount farther than necessary for accurate low-volume dispensing", + json_schema_extra=_remove_default, + ) + + +class DispenseWhileTrackingResult(BaseLiquidHandlingResult, DestinationPositionResult): + """Result data from the execution of a Dispense command.""" + + pass + + +_ExecuteReturn = Union[ + SuccessData[DispenseWhileTrackingResult], + DefinedErrorData[OverpressureError] | DefinedErrorData[StallOrCollisionError], +] + + +class DispenseWhileTrackingImplementation(AbstractCommandImpl[DispenseWhileTrackingParams, _ExecuteReturn]): + """Dispense command implementation.""" + + 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 + + async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: + """Move to and dispense to the requested well.""" + well_location = params.wellLocation + labware_id = params.labwareId + well_name = params.wellName + + # TODO(pbm, 10-15-24): call self._state_view.geometry.validate_dispense_volume_into_well() + + move_result = await move_to_well( + movement=self._movement, + model_utils=self._model_utils, + pipette_id=params.pipetteId, + labware_id=labware_id, + well_name=well_name, + well_location=well_location, + is_tracking=True, + ) + if isinstance(move_result, DefinedErrorData): + return move_result + dispense_result = await dispense_while_tracking( + pipette_id=params.pipetteId, + labware_id=labware_id, + well_name=well_name, + volume=params.volume, + flow_rate=params.flowRate, + push_out=params.pushOut, + location_if_error={ + "retryLocation": ( + move_result.public.position.x, + move_result.public.position.y, + move_result.public.position.z, + ) + }, + pipetting=self._pipetting, + model_utils=self._model_utils, + ) + + if isinstance(dispense_result, DefinedErrorData): + return DefinedErrorData( + public=dispense_result.public, + state_update=( + StateUpdate.reduce( + move_result.state_update, dispense_result.state_update + ).set_liquid_operated( + labware_id=labware_id, + well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( + labware_id, well_name, params.pipetteId + ), + volume_added=CLEAR, + ) + ), + state_update_if_false_positive=StateUpdate.reduce( + move_result.state_update, + dispense_result.state_update_if_false_positive, + ), + ) + else: + volume_added = ( + self._state_view.pipettes.get_liquid_dispensed_by_ejecting_volume( + pipette_id=params.pipetteId, volume=dispense_result.public.volume + ) + ) + if volume_added is not None: + volume_added *= self._state_view.geometry.get_nozzles_per_well( + labware_id, well_name, params.pipetteId + ) + return SuccessData( + public=DispenseWhileTrackingResult( + volume=dispense_result.public.volume, + position=move_result.public.position, + ), + state_update=( + StateUpdate.reduce( + move_result.state_update, dispense_result.state_update + ).set_liquid_operated( + labware_id=labware_id, + well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well( + labware_id, well_name, params.pipetteId + ), + volume_added=volume_added + if volume_added is not None + else CLEAR, + ) + ), + ) + + +class DispenseWhileTracking( + BaseCommand[ + DispenseWhileTrackingParams, DispenseWhileTrackingResult, OverpressureError | StallOrCollisionError + ] +): + """Dispense command model.""" + + commandType: DispenseCommandType = "dispense" + params: DispenseWhileTrackingParams + result: Optional[DispenseWhileTrackingResult] = None + + _ImplementationCls: Type[DispenseWhileTrackingImplementation] = DispenseWhileTrackingImplementation + + +class DispenseWhileTrackingCreate(BaseCommandCreate[DispenseWhileTrackingParams]): + """Create dispense command request model.""" + + commandType: DispenseCommandType = "dispense" + params: DispenseWhileTrackingParams + + _CommandCls: Type[DispenseWhileTracking] = DispenseWhileTracking From 022e748f989c25266911f1110f1b253493714e4b Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Wed, 15 Jan 2025 14:13:50 -0500 Subject: [PATCH 09/10] instrument ctx test data function --- .../protocol_api/core/engine/instrument.py | 39 +- .../opentrons/protocol_api/core/instrument.py | 11 +- .../protocol_api/instrument_context.py | 26 +- .../commands/aspirate_while_tracking.py | 8 +- .../commands/dispense_while_tracking.py | 12 +- .../protocol_engine/state/frustum_helpers.py | 2 + .../liquid_relative_pipetting/__init__.py | 0 .../liquid_relative_pipetting/__main__.py | 393 ++++++++++++++++++ .../2/general/single_channel/p1000/3_6.json | 2 +- .../2/geometry/single_channel/p1000/3_6.json | 6 +- 10 files changed, 487 insertions(+), 12 deletions(-) create mode 100644 hardware-testing/hardware_testing/liquid_relative_pipetting/__init__.py create mode 100644 hardware-testing/hardware_testing/liquid_relative_pipetting/__main__.py diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 8d016269370..89996dc75c1 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional, TYPE_CHECKING, cast, Union, List +from typing import Optional, TYPE_CHECKING, cast, Union, List, Tuple from opentrons.types import Location, Mount, NozzleConfigurationType, NozzleMapInterface from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.dev_types import PipetteDict @@ -976,6 +976,43 @@ def liquid_probe_with_recovery(self, well_core: WellCore, loc: Location) -> None self._protocol_core.set_last_location(location=loc, mount=self.get_mount()) + def liquid_probe_testing_data( + self, + well_core: WellCore, + loc: Location, + operation_volume: float, + ) -> Tuple[float, float, float]: + labware_id = well_core.labware_id + well_name = well_core.get_name() + well_location = WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=2) + ) + result = self._engine_client.execute_command_without_recovery( + cmd.LiquidProbeParams( + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + pipetteId=self.pipette_id, + ) + ) + + self._protocol_core.set_last_location(location=loc, mount=self.get_mount()) + projected_current_volume = ( + self._engine_client.state.geometry.get_well_volume_at_height( + labware_id=labware_id, well_name=well_name, height=result.z_position + ) + ) + projected_final_height = ( + self._engine_client.state.geometry.get_well_height_after_volume( + labware_id=labware_id, + well_name=well_name, + initial_height=result.z_position, + volume=operation_volume, + ) + ) + + return result.z_position, projected_current_volume, projected_final_height + def liquid_probe_without_recovery( self, well_core: WellCore, loc: Location ) -> float: diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 6b2f8e854a9..941e3aa583d 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod, ABC -from typing import Any, Generic, Optional, TypeVar, Union, List +from typing import Any, Generic, Optional, TypeVar, Union, List, Tuple from opentrons import types from opentrons.hardware_control.dev_types import PipetteDict @@ -371,6 +371,15 @@ def liquid_probe_without_recovery( """Do a liquid probe to find the level of the liquid in the well.""" ... + @abstractmethod + def liquid_probe_testing_data( + self, + well_core: types.WellCore, + loc: types.Location, + operation_volume: float, + ) -> Tuple[float, float, float]: + ... + @abstractmethod def nozzle_configuration_valid_for_lld(self) -> bool: """Check if the nozzle configuration currently supports LLD.""" diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 33b5874fa76..453ed1c8edc 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging from contextlib import ExitStack -from typing import Any, List, Optional, Sequence, Union, cast, Dict +from typing import Any, List, Optional, Sequence, Union, cast, Dict, Tuple from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -2538,8 +2538,32 @@ def measure_liquid_height(self, well: labware.Well) -> float: self._raise_if_pressure_not_supported_by_pipette() loc = well.top() height = self._core.liquid_probe_without_recovery(well._core, loc) + # return current projected volume, height of liquid found, projected height after aspirate return height + @requires_version(2, 20) + def hw_testing_liquid_probe( + self, well: labware.Well, operation_volume: float + ) -> Tuple[float, float, float]: + """Check the height of the liquid within a well. + + :returns: The height, in mm, of the liquid from the deck. + + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + self._raise_if_pressure_not_supported_by_pipette() + loc = well.top() + ( + current_height, + estimated_current_volume, + projected_final_height, + ) = self._core.liquid_probe_testing_data( + well_core=well._core, loc=loc, operation_volume=operation_volume + ) + return current_height, estimated_current_volume, projected_final_height + def _raise_if_configuration_not_supported_by_pipette( self, style: NozzleLayout ) -> None: diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py index 1d9b517a0c2..2d6f6a7e567 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py @@ -69,7 +69,9 @@ class AspirateResult(BaseLiquidHandlingResult, DestinationPositionResult): ] -class AspirateImplementation(AbstractCommandImpl[AspirateWhileTrackingParams, _ExecuteReturn]): +class AspirateImplementation( + AbstractCommandImpl[AspirateWhileTrackingParams, _ExecuteReturn] +): """Aspirate command implementation.""" def __init__( @@ -225,7 +227,9 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: class Aspirate( BaseCommand[ - AspirateWhileTrackingParams, AspirateResult, OverpressureError | StallOrCollisionError + AspirateWhileTrackingParams, + AspirateResult, + OverpressureError | StallOrCollisionError, ] ): """Aspirate command model.""" diff --git a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py index 5f7f8dcb87a..30357e936ba 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py @@ -71,7 +71,9 @@ class DispenseWhileTrackingResult(BaseLiquidHandlingResult, DestinationPositionR ] -class DispenseWhileTrackingImplementation(AbstractCommandImpl[DispenseWhileTrackingParams, _ExecuteReturn]): +class DispenseWhileTrackingImplementation( + AbstractCommandImpl[DispenseWhileTrackingParams, _ExecuteReturn] +): """Dispense command implementation.""" def __init__( @@ -176,7 +178,9 @@ async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: class DispenseWhileTracking( BaseCommand[ - DispenseWhileTrackingParams, DispenseWhileTrackingResult, OverpressureError | StallOrCollisionError + DispenseWhileTrackingParams, + DispenseWhileTrackingResult, + OverpressureError | StallOrCollisionError, ] ): """Dispense command model.""" @@ -185,7 +189,9 @@ class DispenseWhileTracking( params: DispenseWhileTrackingParams result: Optional[DispenseWhileTrackingResult] = None - _ImplementationCls: Type[DispenseWhileTrackingImplementation] = DispenseWhileTrackingImplementation + _ImplementationCls: Type[ + DispenseWhileTrackingImplementation + ] = DispenseWhileTrackingImplementation class DispenseWhileTrackingCreate(BaseCommandCreate[DispenseWhileTrackingParams]): diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 11dbc1f0588..3c1fa867759 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -362,6 +362,8 @@ def find_volume_at_well_height( volumetric_capacity = get_well_volumetric_capacity(well_geometry) max_height = volumetric_capacity[-1][0] if target_height < 0 or target_height > max_height: + # find where we fail bc height < lld minimum and kick off error recovery + # to allow the pipette to go to well.bottom() rather than probe in the future raise InvalidLiquidHeightFound(f"Invalid target height {target_height}.") # volumes in volumetric_capacity are relative to each frustum, # so we have to find the volume of all the full sections enclosed diff --git a/hardware-testing/hardware_testing/liquid_relative_pipetting/__init__.py b/hardware-testing/hardware_testing/liquid_relative_pipetting/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/hardware-testing/hardware_testing/liquid_relative_pipetting/__main__.py b/hardware-testing/hardware_testing/liquid_relative_pipetting/__main__.py new file mode 100644 index 00000000000..084f99eadee --- /dev/null +++ b/hardware-testing/hardware_testing/liquid_relative_pipetting/__main__.py @@ -0,0 +1,393 @@ +"""Liquid sense testing.""" +import argparse +from dataclasses import dataclass +from json import load as json_load +from pathlib import Path +import subprocess +from time import sleep +import os +from typing import List, Any, Optional, Dict +import traceback +import sys + +from hardware_testing.opentrons_api import helpers_ot3 +from hardware_testing.gravimetric import helpers, workarounds +from hardware_testing.data.csv_report import CSVReport +from hardware_testing.gravimetric.measurement.record import GravimetricRecorder +from hardware_testing.gravimetric.measurement.scale import Scale +from hardware_testing.drivers import ( + asair_sensor, + mitutoyo_digimatic_indicator, + list_ports_and_select, +) +from hardware_testing.data import ( + ui, + create_run_id_and_start_time, + get_git_description, + get_testing_data_directory, +) +from opentrons_hardware.hardware_control.motion_planning import move_utils + +from opentrons.protocol_api import InstrumentContext, ProtocolContext +from opentrons.protocol_engine.types import LabwareOffset + +from hardware_testing.liquid_sense import execute +from .report import build_ls_report, store_config, store_serial_numbers +from .post_process import process_csv_directory, process_google_sheet + +from hardware_testing.protocols.liquid_sense_lpc import ( + liquid_sense_ot3_p50_single_vial, + liquid_sense_ot3_p50_multi_vial, + liquid_sense_ot3_p1000_96_vial, + liquid_sense_ot3_p1000_single_vial, + liquid_sense_ot3_p1000_multi_vial, +) + +try: + from abr_testing.automation import google_sheets_tool, google_drive_tool +except ImportError: + ui.print_error( + "Unable to import abr repo if this isn't a simulation push the abr_testing package" + ) + pass + +CREDENTIALS_PATH = "/var/lib/jupyter/notebooks/abr.json" + +API_LEVEL = "2.18" + +LABWARE_OFFSETS: List[LabwareOffset] = [] + +# NOTE: (sigler) plunger on 1ch/8ch won't move faster than ~20mm second +# which means it take ~3.5 seconds to reach full plunger travel. +# Therefore, there is no need for any probing in this test script to +# take longer than 3.5 seconds. +# NOTE: (sigler) configuring the starting height of each probing sequence +# not based on millimeters but instead on the number seconds it takes +# before the tip contacts the meniscus will help make sure that adjusting +# the Z-speed will inadvertently affect the pressure's rate-of-change +# (which could happen if the meniscus seal is formed at wildly different +# positions along the plunger travel). +MAX_PROBE_SECONDS = 3.5 + + +LIQUID_SENSE_CFG: Dict[int, Dict[int, Any]] = { + 50: { + 1: liquid_sense_ot3_p50_single_vial, + 8: liquid_sense_ot3_p50_multi_vial, + }, + 1000: { + 1: liquid_sense_ot3_p1000_single_vial, + 8: liquid_sense_ot3_p1000_multi_vial, + 96: liquid_sense_ot3_p1000_96_vial, + }, +} + +PIPETTE_MODEL_NAME = { + 50: { + 1: "p50_single_flex", + 8: "p50_multi_flex", + }, + 1000: { + 1: "p1000_single_flex", + 8: "p1000_multi_flex", + 96: "p1000_96_flex", + }, +} + + +@dataclass +class RunArgs: + """Common resources across multiple runs.""" + + tip_volumes: List[int] + run_id: str + pipette: InstrumentContext + pipette_tag: str + git_description: str + recorder: GravimetricRecorder + pipette_volume: int + pipette_channels: int + name: str + environment_sensor: asair_sensor.AsairSensorBase + trials: int + z_speed: float + return_tip: bool + ctx: ProtocolContext + protocol_cfg: Any + test_report: CSVReport + aspirate: bool + dial_indicator: Optional[mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator] + plunger_speed: float + trials_before_jog: int + no_multi_pass: int + test_well: str + wet: bool + dial_front_channel: bool + + @classmethod + def _get_protocol_context(cls, args: argparse.Namespace) -> ProtocolContext: + if not args.simulate and not args.skip_labware_offsets: + # getting labware offsets must be done before creating the protocol context + # because it requires the robot-server to be running + ui.print_title("SETUP") + ui.print_info( + "Starting opentrons-robot-server, so we can http GET labware offsets" + ) + LABWARE_OFFSETS.extend(workarounds.http_get_all_labware_offsets()) + ui.print_info(f"found {len(LABWARE_OFFSETS)} offsets:") + for offset in LABWARE_OFFSETS: + ui.print_info(f"\t{offset.createdAt}:") + ui.print_info(f"\t\t{offset.definitionUri}") + ui.print_info(f"\t\t{offset.vector}") + # gather the custom labware (for simulation) + custom_defs = {} + if args.simulate: + labware_dir = Path(__file__).parent.parent / "labware" + custom_def_uris = [ + "radwag_pipette_calibration_vial", + "dial_indicator", + ] + for def_uri in custom_def_uris: + with open(labware_dir / def_uri / "1.json", "r") as f: + custom_def = json_load(f) + custom_defs[def_uri] = custom_def + _ctx = helpers.get_api_context( + API_LEVEL, # type: ignore[attr-defined] + is_simulating=args.simulate, + pipette_left=PIPETTE_MODEL_NAME[args.pipette][args.channels], + extra_labware=custom_defs, + ) + for offset in LABWARE_OFFSETS: + engine = _ctx._core._engine_client._transport._engine # type: ignore[attr-defined] + engine.state_view._labware_store._add_labware_offset(offset) + return _ctx + + @classmethod + def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": + """Build.""" + _ctx = RunArgs._get_protocol_context(args) + run_id, start_time = create_run_id_and_start_time() + environment_sensor = asair_sensor.BuildAsairSensor(simulate=True) + git_description = get_git_description() + protocol_cfg = LIQUID_SENSE_CFG[args.pipette][args.channels] + name = protocol_cfg.metadata["protocolName"] # type: ignore[union-attr] + ui.print_header("LOAD PIPETTE") + pipette = _ctx.load_instrument( + f"flex_{args.channels}channel_{args.pipette}", args.mount + ) + loaded_labwares = _ctx.loaded_labwares + if 12 in loaded_labwares.keys(): + trash = loaded_labwares[12] + else: + trash = _ctx.load_labware("opentrons_1_trash_3200ml_fixed", "A3") + pipette.trash_container = trash + pipette_tag = helpers._get_tag_from_pipette(pipette, False, False) + + trials = args.trials + + if args.tip == 0: + if args.pipette == 1000: + tip_volumes: List[int] = [50, 200, 1000] + else: + tip_volumes = [50] + else: + tip_volumes = [args.tip] + + scale = Scale.build(simulate=True) + recorder: GravimetricRecorder = execute._load_scale( + name, + scale, + run_id, + pipette_tag, + start_time, + simulating=True, + ) + dial: Optional[mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator] = None + if not _ctx.is_simulating(): + dial_port = list_ports_and_select("Dial Indicator") + dial = mitutoyo_digimatic_indicator.Mitutoyo_Digimatic_Indicator( + port=dial_port + ) + dial.connect() + ui.print_info(f"pipette_tag {pipette_tag}") + report = build_ls_report(name, run_id, trials, tip_volumes) + report.set_tag(name) + # go ahead and store the meta data now + store_serial_numbers( + report, + pipette_tag, + scale.read_serial_number(), + environment_sensor.get_serial(), + git_description, + ) + + store_config( + report, + name, + args.pipette, + tip_volumes, + trials, + "aspirate" if args.aspirate else "dispense", + args.liquid, + protocol_cfg.LABWARE_ON_SCALE, # type: ignore[union-attr] + args.z_speed, + ) + return RunArgs( + tip_volumes=tip_volumes, + run_id=run_id, + pipette=pipette, + pipette_tag=pipette_tag, + git_description=git_description, + recorder=recorder, + pipette_volume=args.pipette, + pipette_channels=args.channels, + name=name, + environment_sensor=environment_sensor, + trials=trials, + z_speed=args.z_speed, + return_tip=args.return_tip, + ctx=_ctx, + protocol_cfg=protocol_cfg, + test_report=report, + aspirate=args.aspirate, + dial_indicator=dial, + plunger_speed=args.plunger_speed, + trials_before_jog=args.trials_before_jog, + no_multi_pass=args.no_multi_pass, + test_well=args.test_well, + wet=args.wet, + dial_front_channel=args.dial_front_channel, + ) + + +if __name__ == "__main__": + move_utils.MINIMUM_DISPLACEMENT = 0.01 + + parser = argparse.ArgumentParser("Pipette Testing") + parser.add_argument("--simulate", action="store_true") + parser.add_argument("--pipette", type=int, choices=[50, 1000], required=True) + parser.add_argument("--mount", type=str, choices=["left", "right"], default="left") + parser.add_argument("--channels", type=int, choices=[1, 8, 96], default=1) + parser.add_argument("--tip", type=int, choices=[0, 50, 200, 1000], default=0) + parser.add_argument("--return-tip", action="store_true") + parser.add_argument("--trials", type=int, default=7) + parser.add_argument("--trials-before-jog", type=int, default=7) + parser.add_argument("--z-speed", type=float, default=5) + parser.add_argument("--aspirate", action="store_true") + parser.add_argument("--plunger-speed", type=float, default=15) + parser.add_argument("--no-multi-pass", action="store_true") + parser.add_argument("--wet", action="store_true") + parser.add_argument("--starting-tip", type=str, default="A1") + parser.add_argument("--test-well", type=str, default="A1") + parser.add_argument("--p-solo-time", type=float, default=0) + parser.add_argument("--google-sheet-name", type=str, default="LLD-Shared-Data") + parser.add_argument( + "--gd-parent-folder", type=str, default="1b2V85fDPA0tNqjEhyHOGCWRZYgn8KsGf" + ) + parser.add_argument("--liquid", type=str, default="unknown") + parser.add_argument("--skip-labware-offsets", action="store_true") + parser.add_argument("--dial-front-channel", action="store_true") + + args = parser.parse_args() + + run_args = RunArgs.build_run_args(args) + exit_error = 0 + serial_logger: Optional[subprocess.Popen] = None + data_dir = get_testing_data_directory() + data_file = f"/{data_dir}/{run_args.name}/{run_args.run_id}/serial.log" + try: + if not run_args.ctx.is_simulating(): + ui.print_info(f"logging can data to {data_file}") + serial_logger = subprocess.Popen( + [f"python3 -m opentrons_hardware.scripts.can_mon > {data_file}"], + shell=True, + ) + sleep(1) + # Connect to Google Sheet + ui.print_info(f"robot has credentials: {os.path.exists(CREDENTIALS_PATH)}") + google_sheet: Optional[ + google_sheets_tool.google_sheet + ] = google_sheets_tool.google_sheet( + CREDENTIALS_PATH, args.google_sheet_name, 0 + ) + sheet_id = google_sheet.create_worksheet(run_args.run_id) # type: ignore[union-attr] + try: + sys.path.insert(0, "/var/lib/jupyter/notebooks/") + + google_drive: Optional[ + google_drive_tool.google_drive + ] = google_drive_tool.google_drive( + CREDENTIALS_PATH, + args.gd_parent_folder, + "rhyann.clarke@opentrons.com", + ) + except ImportError: + raise ImportError( + "Run on robot. Make sure google_drive_tool.py is in jupyter notebook." + ) + else: + google_sheet = None + sheet_id = None + google_drive = None + hw = run_args.ctx._core.get_hardware() + ui.print_info("homing...") + run_args.ctx.home() + for tip in run_args.tip_volumes: + execute.run(tip, run_args, google_sheet, sheet_id, args.starting_tip) + except Exception as e: + ui.print_error(f"got error {e}") + ui.print_error(traceback.format_exc()) + exit_error = 1 + finally: + if run_args.recorder is not None: + ui.print_info("ending recording") + if not run_args.ctx.is_simulating() and serial_logger: + ui.print_info("killing serial log") + serial_logger.terminate() + if run_args.dial_indicator is not None: + run_args.dial_indicator.disconnect() + run_args.test_report.save_to_disk() + run_args.test_report.print_results() + ui.print_info("done\n\n") + if not run_args.ctx.is_simulating(): + new_folder_name = ( + f"MS{args.z_speed}_PS{args.plunger_speed}_{run_args.run_id}" + ) + try: + process_csv_directory( + f"{data_dir}/{run_args.name}/{run_args.run_id}", + run_args.tip_volumes, + run_args.trials, + google_sheet, + google_drive, + run_args.run_id, + sheet_id, + new_folder_name, + make_graph=False, + ) + # Log to Google Sheet + if args.aspirate is False: + plunger_direction = "dispense" + else: + plunger_direction = "aspirate" + test_info = [ + run_args.run_id, + run_args.pipette_tag, + args.pipette, + args.tip, + args.z_speed, + args.plunger_speed, + "threshold", + plunger_direction, + ] + process_google_sheet(google_sheet, run_args, test_info, sheet_id) + except Exception as e: + ui.print_error("error making graphs or logging data on google sheet") + ui.print_error(f"got error {e}") + ui.print_error(traceback.format_exc()) + exit_error = 2 + + run_args.ctx.cleanup() + if not args.simulate: + helpers_ot3.restart_server_ot3() + os._exit(exit_error) diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_6.json index f0ebd0e00a3..7c6db0dfa1c 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_6.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_6.json @@ -30,7 +30,7 @@ }, "v1": { "default": 10.5, - "opentrons/opentrons_flex_96_tiprack_1000ul/1": 9.65, + "opentrons/opentrons_flex_96_tiprack_1000ul/1": 10.5, "opentrons/opentrons_flex_96_tiprack_200ul/1": 9.76, "opentrons/opentrons_flex_96_tiprack_50ul/1": 10.09, "opentrons/opentrons_flex_96_filtertiprack_1000ul/1": 9.65, diff --git a/shared-data/pipette/definitions/2/geometry/single_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/geometry/single_channel/p1000/3_6.json index 8eb3dfb9e97..9c0d1b6b8e1 100644 --- a/shared-data/pipette/definitions/2/geometry/single_channel/p1000/3_6.json +++ b/shared-data/pipette/definitions/2/geometry/single_channel/p1000/3_6.json @@ -13,15 +13,15 @@ }, "lldSettings": { "t50": { - "minHeight": 1.0, + "minHeight": 0.0, "minVolume": 0 }, "t200": { - "minHeight": 1.0, + "minHeight": 0.0, "minVolume": 0 }, "t1000": { - "minHeight": 1.5, + "minHeight": 0.0, "minVolume": 0 } } From 8803b47e10c824d70df21677006bb544d03f5b59 Mon Sep 17 00:00:00 2001 From: caila-marashaj Date: Wed, 15 Jan 2025 17:05:11 -0500 Subject: [PATCH 10/10] aspirate while tracking hw testing stuff --- .../protocol_api/core/engine/instrument.py | 105 ++++++++++++------ .../opentrons/protocol_api/core/instrument.py | 12 +- .../core/legacy/legacy_instrument_core.py | 12 +- .../legacy_instrument_core.py | 4 - .../protocol_api/instrument_context.py | 66 +++++------ api/src/opentrons/protocol_api/labware.py | 12 +- .../protocol_engine/commands/__init__.py | 28 +++++ .../protocol_engine/commands/aspirate.py | 1 - .../commands/aspirate_while_tracking.py | 35 +++--- .../commands/command_unions.py | 26 +++++ .../protocol_engine/commands/dispense.py | 2 - .../commands/dispense_while_tracking.py | 7 +- .../commands/movement_common.py | 4 +- .../commands/pipetting_common.py | 9 -- .../protocol_engine/execution/movement.py | 2 - .../protocol_engine/execution/pipetting.py | 28 ++++- .../protocol_engine/state/geometry.py | 27 +++-- .../opentrons/protocol_engine/state/motion.py | 2 - api/src/opentrons/types.py | 27 ++++- .../core/engine/test_instrument_core.py | 4 - .../protocol_api/test_instrument_context.py | 15 +-- api/tests/opentrons/protocol_api/test_well.py | 1 - .../protocol_engine/commands/test_aspirate.py | 9 -- .../protocol_engine/commands/test_blow_out.py | 3 - .../protocol_engine/commands/test_dispense.py | 3 - .../protocol_engine/commands/test_drop_tip.py | 4 - .../commands/test_liquid_probe.py | 3 - .../commands/test_move_to_well.py | 2 - .../commands/test_pick_up_tip.py | 3 - .../commands/test_touch_tip.py | 2 - .../execution/test_movement_handler.py | 2 - .../state/test_geometry_view.py | 1 - .../protocol_engine/state/test_motion_view.py | 1 - api/tests/opentrons/test_types.py | 9 +- 34 files changed, 255 insertions(+), 216 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 89996dc75c1..407334bfc45 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -3,7 +3,13 @@ from __future__ import annotations from typing import Optional, TYPE_CHECKING, cast, Union, List, Tuple -from opentrons.types import Location, Mount, NozzleConfigurationType, NozzleMapInterface +from opentrons.types import ( + Location, + Mount, + NozzleConfigurationType, + NozzleMapInterface, + MeniscusTracking, +) from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.dev_types import PipetteDict from opentrons.protocols.api_support.util import FlowRates, find_value_for_api_version @@ -134,8 +140,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = False, + meniscus_tracking: Optional[MeniscusTracking] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -145,8 +150,7 @@ def aspirate( rate: Not used in this core. flow_rate: The flow rate in µL/s to aspirate at. in_place: whether this is a in-place command. - is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. - is_tracking: whether the z motor is to move with the liquid meniscus. + meniscus_tracking: Optional data about where to aspirate from. """ if well_core is None: if not in_place: @@ -176,10 +180,15 @@ def aspirate( labware_id=labware_id, well_name=well_name, absolute_point=location.point, - is_meniscus=is_meniscus, + meniscus_tracking=meniscus_tracking, ) - if well_location.origin == WellOrigin.MENISCUS: - well_location.volumeOffset = "operationVolume" + dynamic_liquid_tracking = False + # caila bookmark + if meniscus_tracking: + if meniscus_tracking.target == "end": + well_location.volumeOffset = "operationVolume" + elif meniscus_tracking.target == "dynamic_meniscus": + dynamic_liquid_tracking = True pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, @@ -187,17 +196,28 @@ def aspirate( well_name=well_name, well_location=well_location, ) - self._engine_client.execute_command( - cmd.AspirateParams( - pipetteId=self._pipette_id, - labwareId=labware_id, - wellName=well_name, - wellLocation=well_location, - volume=volume, - flowRate=flow_rate, - is_tracking=is_tracking if is_tracking else False, + if dynamic_liquid_tracking: + self._engine_client.execute_command( + cmd.AspirateWhileTrackingParams( + pipetteId=self._pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + volume=volume, + flowRate=flow_rate, + ) + ) + else: + self._engine_client.execute_command( + cmd.AspirateParams( + pipetteId=self._pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + volume=volume, + flowRate=flow_rate, + ) ) - ) self._protocol_core.set_last_location(location=location, mount=self.get_mount()) @@ -210,8 +230,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = False, + meniscus_tracking: Optional[MeniscusTracking] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -222,9 +241,9 @@ def dispense( flow_rate: The flow rate in µL/s to dispense at. in_place: whether this is a in-place command. push_out: The amount to push the plunger below bottom position. - is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. - is_tracking: whether the z motor is to move with the liquid meniscus. + meniscus_tracking: Optional data about where to dispense from. """ + # raise ValueError(f"well location = {location}") if self._protocol_core.api_version < _DISPENSE_VOLUME_VALIDATION_ADDED_IN: # In older API versions, when you try to dispense more than you can, # it gets clamped. @@ -273,8 +292,14 @@ def dispense( labware_id=labware_id, well_name=well_name, absolute_point=location.point, - is_meniscus=is_meniscus, + meniscus_tracking=meniscus_tracking, ) + dynamic_liquid_tracking = False + if meniscus_tracking: + if meniscus_tracking.target == "end": + well_location.volumeOffset = "operationVolume" + elif meniscus_tracking.target == "dynamic_meniscus": + dynamic_liquid_tracking = True pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, @@ -282,18 +307,30 @@ def dispense( well_name=well_name, well_location=well_location, ) - self._engine_client.execute_command( - cmd.DispenseParams( - pipetteId=self._pipette_id, - labwareId=labware_id, - wellName=well_name, - wellLocation=well_location, - volume=volume, - flowRate=flow_rate, - pushOut=push_out, - is_tracking=is_tracking if is_tracking else False, + if dynamic_liquid_tracking: + self._engine_client.execute_command( + cmd.DispenseWhileTrackingParams( + pipetteId=self._pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + volume=volume, + flowRate=flow_rate, + pushOut=push_out, + ) + ) + else: + self._engine_client.execute_command( + cmd.DispenseParams( + pipetteId=self._pipette_id, + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + volume=volume, + flowRate=flow_rate, + pushOut=push_out, + ) ) - ) if isinstance(location, (TrashBin, WasteChute)): self._protocol_core.set_last_location(location=None, mount=self.get_mount()) diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 941e3aa583d..f76ad2eadc8 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -41,8 +41,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = False, + meniscus_tracking: Optional[types.MeniscusTracking] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -52,8 +51,7 @@ def aspirate( rate: The rate for how quickly to aspirate. flow_rate: The flow rate in µL/s to aspirate at. in_place: Whether this is in-place. - is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. - is_tracking: whether the z motor is to move with the liquid meniscus. + meniscus_tracking: Optional data about where to aspirate from. """ ... @@ -67,8 +65,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = None, + meniscus_tracking: Optional[types.MeniscusTracking] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -79,8 +76,7 @@ def dispense( flow_rate: The flow rate in µL/s to dispense at. in_place: Whether this is in-place. push_out: The amount to push the plunger below bottom position. - is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. - is_tracking: whether the z motor is to move with the liquid meniscus. + meniscus_tracking: Optional data about where to dispense from. """ ... 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 44e88309385..abf0e352e10 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 @@ -84,8 +84,7 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = None, + meniscus_tracking: Optional[types.MeniscusTracking] = None, ) -> None: """Aspirate a given volume of liquid from the specified location. Args: @@ -95,8 +94,7 @@ def aspirate( rate: The rate in µL/s to aspirate at. flow_rate: Not used in this core. in_place: Whether we should move_to location. - is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. - is_tracking: whether the z motor is to move with the liquid meniscus. + meniscus_tracking: Optional data about where to aspirate from. """ if self.get_current_volume() == 0: # Make sure we're at the top of the labware and clear of any @@ -130,8 +128,7 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = None, + meniscus_tracking: Optional[types.MeniscusTracking] = None, ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -142,8 +139,7 @@ def dispense( flow_rate: Not used in this core. in_place: Whether we should move_to location. push_out: The amount to push the plunger below bottom position. - is_meniscus: whether the aspirate location specified is relative to a liquid meniscus. - is_tracking: whether the z motor is to move with the liquid meniscus. + meniscus_tracking: Optional data about where to dispense from. """ if isinstance(location, (TrashBin, WasteChute)): raise APIVersionError( 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 70bba0f5dd2..3e6d02c2ab9 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 @@ -95,8 +95,6 @@ def aspirate( rate: float, flow_rate: float, in_place: bool, - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = None, ) -> None: if self.get_current_volume() == 0: # Make sure we're at the top of the labware and clear of any @@ -138,8 +136,6 @@ def dispense( flow_rate: float, in_place: bool, push_out: Optional[float], - is_meniscus: Optional[bool] = None, - is_tracking: Optional[bool] = None, ) -> None: if isinstance(location, (TrashBin, WasteChute)): raise APIVersionError( diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 453ed1c8edc..4cca7d7655e 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging from contextlib import ExitStack -from typing import Any, List, Optional, Sequence, Union, cast, Dict, Tuple +from typing import Any, List, Optional, Sequence, Union, cast, Dict, Tuple, Literal from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -172,6 +172,7 @@ def aspirate( volume: Optional[float] = None, location: Optional[Union[types.Location, labware.Well]] = None, rate: float = 1.0, + # meniscus_tracking: Optional[Literal["start", "end", "dynamic_meniscus"]] = None ) -> InstrumentContext: """ Draw liquid into a pipette tip. @@ -207,6 +208,11 @@ def aspirate( as ``rate`` multiplied by :py:attr:`flow_rate.aspirate `. If not specified, defaults to 1.0. See :ref:`new-plunger-flow-rates`. + :param meniscus_tracking: Whether to aspirate relative to the liquid meniscus + "start" - aspirate from the current liquid meniscus at the start of the operation + "end" - aspirate from the projected liquid meniscus at the end of the operation + "dynamic_meniscus" - move the pipette down while aspirating to maintain a given depth relative + to the liquid meniscus. :type rate: float :returns: This instance. @@ -226,7 +232,6 @@ def aspirate( 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( @@ -244,7 +249,7 @@ def aspirate( 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( + move_to_location, well, meniscus_tracking = self._handle_aspirate_target( target=target ) if self.api_version >= APIVersion(2, 11): @@ -287,7 +292,7 @@ def aspirate( rate=rate, flow_rate=flow_rate, in_place=target.in_place, - is_meniscus=is_meniscus, + meniscus_tracking=meniscus_tracking, ) return self @@ -352,7 +357,6 @@ def aspirate_while_tracking( 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( @@ -370,7 +374,7 @@ def aspirate_while_tracking( 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( + move_to_location, well, meniscus_tracking = self._handle_aspirate_target( target=target ) if self.api_version >= APIVersion(2, 11): @@ -413,8 +417,7 @@ def aspirate_while_tracking( rate=rate, flow_rate=flow_rate, in_place=target.in_place, - is_meniscus=is_meniscus, - is_tracking=True, + meniscus_tracking=meniscus_tracking, ) return self @@ -480,7 +483,6 @@ def dispense_while_tracking( 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( @@ -498,7 +500,7 @@ def dispense_while_tracking( raise ValueError( "Trash Bin and Waste Chute are not acceptable location parameters for dispense commands." ) - move_to_location, well, is_meniscus = self._handle_dispense_target( + move_to_location, well, meniscus_tracking = self._handle_dispense_target( target=target ) if self.api_version >= APIVersion(2, 11): @@ -542,8 +544,7 @@ def dispense_while_tracking( flow_rate=flow_rate, in_place=target.in_place, push_out=push_out, - is_meniscus=is_meniscus, - is_tracking=True, + # meniscus_tracking=meniscus_tracking, ) return self @@ -645,8 +646,7 @@ def dispense( # noqa: C901 volume, location if location else "current position", rate ) ) - well: Optional[labware.Well] = None - is_meniscus: Optional[bool] = None + # well: Optional[labware.Well] = None last_location = self._get_last_location_by_api_version() try: @@ -661,19 +661,9 @@ def dispense( # noqa: C901 "knows where it is." ) from e - if isinstance(target, validation.WellTarget): - 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: - move_to_location = target.well.bottom( - z=self._well_bottom_clearances.dispense - ) - if isinstance(target, validation.PointTarget): - move_to_location = target.location + move_to_location, well, meniscus_tracking = self._handle_dispense_target( + target=target + ) if self.api_version >= APIVersion(2, 11) and not isinstance( target, (TrashBin, WasteChute) @@ -710,6 +700,7 @@ def dispense( # noqa: C901 flow_rate=flow_rate, in_place=False, push_out=push_out, + meniscus_tracking=None, ) return self @@ -731,7 +722,7 @@ def dispense( # noqa: C901 flow_rate=flow_rate, in_place=target.in_place, push_out=push_out, - is_meniscus=is_meniscus, + meniscus_tracking=meniscus_tracking, ) return self @@ -2588,15 +2579,15 @@ def _raise_if_pressure_not_supported_by_pipette(self) -> None: def _handle_aspirate_target( self, target: validation.ValidTarget - ) -> tuple[types.Location, Optional[labware.Well], Optional[bool]]: + ) -> tuple[types.Location, Optional[labware.Well], Optional[types.MeniscusTracking]]: move_to_location: types.Location well: Optional[labware.Well] = None - is_meniscus: Optional[bool] = None + meniscus_tracking: Optional[types.MeniscusTracking] = None if isinstance(target, validation.WellTarget): well = target.well if target.location: move_to_location = target.location - is_meniscus = target.location.is_meniscus + meniscus_tracking = target.location.meniscus_tracking else: move_to_location = target.well.bottom( @@ -2604,27 +2595,28 @@ def _handle_aspirate_target( ) if isinstance(target, validation.PointTarget): move_to_location = target.location - return (move_to_location, well, is_meniscus) + return move_to_location, well, meniscus_tracking def _handle_dispense_target( self, target: validation.ValidTarget - ) -> tuple[types.Location, Optional[labware.Well], Optional[bool]]: + ) -> tuple[types.Location, Optional[labware.Well], Optional[types.MeniscusTracking]]: move_to_location: types.Location well: Optional[labware.Well] = None - is_meniscus: Optional[bool] = None + meniscus_tracking: Optional[types.MeniscusTracking] = None if isinstance(target, validation.WellTarget): well = target.well if target.location: move_to_location = target.location - is_meniscus = target.location.is_meniscus - + meniscus_tracking = target.location.meniscus_tracking + elif well.parent._core.is_fixed_trash(): + move_to_location = target.well.top() else: move_to_location = target.well.bottom( z=self._well_bottom_clearances.dispense ) if isinstance(target, validation.PointTarget): move_to_location = target.location - return move_to_location, well, is_meniscus + return move_to_location, well, meniscus_tracking class AutoProbeDisable: diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index bb8a094e4c2..778cf8cf106 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -24,11 +24,12 @@ cast, Sequence, Mapping, + Literal, ) from opentrons_shared_data.labware.types import LabwareDefinition, LabwareParameters -from opentrons.types import Location, Point, NozzleMapInterface +from opentrons.types import Location, Point, NozzleMapInterface, MeniscusTracking from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.util import ( requires_version, @@ -233,16 +234,21 @@ def center(self) -> Location: return Location(self._core.get_center(), self) @requires_version(2, 21) - def meniscus(self, z: float = 0.0) -> Location: + def meniscus( + self, target: Literal["beginning", "end", "dynamic_meniscus"], z: float = 0.0 + ) -> Location: """ :param z: An offset on the z-axis, in mm. Positive offsets are higher and negative offsets are lower. + :param target: The relative position inside the well to target with respect to a liquid handling operation. :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( - point=Point(x=0, y=0, z=z), labware=self, _ot_internal_is_meniscus=True + point=Point(x=0, y=0, z=z), + labware=self, + _meniscus_tracking=MeniscusTracking(target=target), ) @requires_version(2, 8) diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index 4ad91012b11..acb7d74b69f 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -60,6 +60,14 @@ AspirateCommandType, ) +from .aspirate_while_tracking import ( + AspirateWhileTracking, + AspirateWhileTrackingParams, + AspirateWhileTrackingCreate, + AspirateWhileTrackingResult, + AspirateWhileTrackingCommandType, +) + from .aspirate_in_place import ( AspirateInPlace, AspirateInPlaceParams, @@ -92,6 +100,14 @@ DispenseCommandType, ) +from .dispense_while_tracking import ( + DispenseWhileTracking, + DispenseWhileTrackingParams, + DispenseWhileTrackingCreate, + DispenseWhileTrackingResult, + DispenseWhileTrackingCommandType, +) + from .dispense_in_place import ( DispenseInPlace, DispenseInPlaceParams, @@ -413,6 +429,12 @@ "AspirateParams", "AspirateResult", "AspirateCommandType", + # aspirate while tracking command models + "AspirateWhileTracking", + "AspirateWhileTrackingCreate", + "AspirateWhileTrackingParams", + "AspirateWhileTrackingResult", + "AspirateWhileTrackingCommandType", # aspirate in place command models "AspirateInPlace", "AspirateInPlaceCreate", @@ -437,6 +459,12 @@ "DispenseParams", "DispenseResult", "DispenseCommandType", + # dispense while tracking command models + "DispenseWhileTracking", + "DispenseWhileTrackingCreate", + "DispenseWhileTrackingParams", + "DispenseWhileTrackingResult", + "DispenseWhileTrackingCommandType", # dispense in place command models "DispenseInPlace", "DispenseInPlaceCreate", diff --git a/api/src/opentrons/protocol_engine/commands/aspirate.py b/api/src/opentrons/protocol_engine/commands/aspirate.py index 5b47b78efbb..b07cd522f93 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate.py @@ -161,7 +161,6 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn: well_location=well_location, current_well=current_well, operation_volume=-params.volume, - is_tracking=False, ) state_update.append(move_result.state_update) if isinstance(move_result, DefinedErrorData): diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py index 2d6f6a7e567..5ea29af8f6e 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_while_tracking.py @@ -43,7 +43,7 @@ from ..notes import CommandNoteAdder -AspirateCommandType = Literal["aspirate"] +AspirateWhileTrackingCommandType = Literal["aspirateWhileTracking"] class AspirateWhileTrackingParams( @@ -57,22 +57,22 @@ class AspirateWhileTrackingParams( pass -class AspirateResult(BaseLiquidHandlingResult, DestinationPositionResult): +class AspirateWhileTrackingResult(BaseLiquidHandlingResult, DestinationPositionResult): """Result data from execution of an Aspirate command.""" pass _ExecuteReturn = Union[ - SuccessData[AspirateResult], + SuccessData[AspirateWhileTrackingResult], DefinedErrorData[OverpressureError] | DefinedErrorData[StallOrCollisionError], ] -class AspirateImplementation( +class AspirateWhileTrackingImplementation( AbstractCommandImpl[AspirateWhileTrackingParams, _ExecuteReturn] ): - """Aspirate command implementation.""" + """AspirateWhileTracking command implementation.""" def __init__( self, @@ -153,7 +153,6 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: labware_id=labware_id, well_name=well_name, ) - move_result = await move_to_well( movement=self._movement, model_utils=self._model_utils, @@ -162,8 +161,6 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: well_name=well_name, well_location=well_location, current_well=current_well, - operation_volume=-params.volume, - is_tracking=True, ) state_update.append(move_result.state_update) if isinstance(move_result, DefinedErrorData): @@ -217,7 +214,7 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: ) return SuccessData( - public=AspirateResult( + public=AspirateWhileTrackingResult( volume=aspirate_result.public.volume, position=move_result.public.position, ), @@ -225,26 +222,28 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn: ) -class Aspirate( +class AspirateWhileTracking( BaseCommand[ AspirateWhileTrackingParams, - AspirateResult, + AspirateWhileTrackingResult, OverpressureError | StallOrCollisionError, ] ): - """Aspirate command model.""" + """AspirateWhileTracking command model.""" - commandType: AspirateCommandType = "aspirate" + commandType: AspirateWhileTrackingCommandType = "aspirateWhileTracking" params: AspirateWhileTrackingParams - result: Optional[AspirateResult] = None + result: Optional[AspirateWhileTrackingResult] = None - _ImplementationCls: Type[AspirateImplementation] = AspirateImplementation + _ImplementationCls: Type[ + AspirateWhileTrackingImplementation + ] = AspirateWhileTrackingImplementation -class AspirateCreate(BaseCommandCreate[AspirateWhileTrackingParams]): +class AspirateWhileTrackingCreate(BaseCommandCreate[AspirateWhileTrackingParams]): """Create aspirate command request model.""" - commandType: AspirateCommandType = "aspirate" + commandType: AspirateWhileTrackingCommandType = "aspirateWhileTracking" params: AspirateWhileTrackingParams - _CommandCls: Type[Aspirate] = Aspirate + _CommandCls: Type[AspirateWhileTracking] = AspirateWhileTracking diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index b04b381ae6b..72151002811 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -57,6 +57,14 @@ AspirateInPlaceCommandType, ) +from .aspirate_while_tracking import ( + AspirateWhileTracking, + AspirateWhileTrackingParams, + AspirateWhileTrackingCreate, + AspirateWhileTrackingResult, + AspirateWhileTrackingCommandType +) + from .comment import ( Comment, CommentParams, @@ -81,6 +89,14 @@ DispenseCommandType, ) +from .dispense_while_tracking import ( + DispenseWhileTracking, + DispenseWhileTrackingParams, + DispenseWhileTrackingCreate, + DispenseWhileTrackingResult, + DispenseWhileTrackingCommandType +) + from .dispense_in_place import ( DispenseInPlace, DispenseInPlaceParams, @@ -365,10 +381,12 @@ AirGapInPlace, Aspirate, AspirateInPlace, + AspirateWhileTracking, Comment, Custom, Dispense, DispenseInPlace, + DispenseWhileTracking, BlowOut, BlowOutInPlace, ConfigureForVolume, @@ -452,6 +470,7 @@ CommandParams = Union[ AirGapInPlaceParams, AspirateParams, + AspirateWhileTrackingParams, AspirateInPlaceParams, CommentParams, ConfigureForVolumeParams, @@ -459,6 +478,7 @@ CustomParams, DispenseParams, DispenseInPlaceParams, + DispenseWhileTrackingParams, BlowOutParams, BlowOutInPlaceParams, DropTipParams, @@ -538,6 +558,7 @@ CommandType = Union[ AirGapInPlaceCommandType, AspirateCommandType, + AspirateWhileTrackingCommandType, AspirateInPlaceCommandType, CommentCommandType, ConfigureForVolumeCommandType, @@ -545,6 +566,7 @@ CustomCommandType, DispenseCommandType, DispenseInPlaceCommandType, + DispenseWhileTrackingCommandType, BlowOutCommandType, BlowOutInPlaceCommandType, DropTipCommandType, @@ -625,6 +647,7 @@ Union[ AirGapInPlaceCreate, AspirateCreate, + AspirateWhileTrackingCreate, AspirateInPlaceCreate, CommentCreate, ConfigureForVolumeCreate, @@ -632,6 +655,7 @@ CustomCreate, DispenseCreate, DispenseInPlaceCreate, + DispenseWhileTrackingCreate, BlowOutCreate, BlowOutInPlaceCreate, DropTipCreate, @@ -720,6 +744,7 @@ CommandResult = Union[ AirGapInPlaceResult, AspirateResult, + AspirateWhileTrackingResult, AspirateInPlaceResult, CommentResult, ConfigureForVolumeResult, @@ -727,6 +752,7 @@ CustomResult, DispenseResult, DispenseInPlaceResult, + DispenseWhileTrackingResult, BlowOutResult, BlowOutInPlaceResult, DropTipResult, diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index 3acb789b7f4..3b4b84f6b75 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -16,7 +16,6 @@ BaseLiquidHandlingResult, OverpressureError, dispense_in_place, - IsTrackingMixin, ) from .movement_common import ( LiquidHandlingWellLocationMixin, @@ -103,7 +102,6 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn: labware_id=labware_id, well_name=well_name, well_location=well_location, - is_tracking=False, ) if isinstance(move_result, DefinedErrorData): return move_result diff --git a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py index 30357e936ba..de4904f5295 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_while_tracking.py @@ -37,7 +37,7 @@ from ..state.state import StateView -DispenseCommandType = Literal["dispense"] +DispenseWhileTrackingCommandType = Literal["dispenseWhileTracking"] def _remove_default(s: dict[str, Any]) -> None: @@ -104,7 +104,6 @@ async def execute(self, params: DispenseWhileTrackingParams) -> _ExecuteReturn: labware_id=labware_id, well_name=well_name, well_location=well_location, - is_tracking=True, ) if isinstance(move_result, DefinedErrorData): return move_result @@ -185,7 +184,7 @@ class DispenseWhileTracking( ): """Dispense command model.""" - commandType: DispenseCommandType = "dispense" + commandType: DispenseWhileTrackingCommandType = "dispenseWhileTracking" params: DispenseWhileTrackingParams result: Optional[DispenseWhileTrackingResult] = None @@ -197,7 +196,7 @@ class DispenseWhileTracking( class DispenseWhileTrackingCreate(BaseCommandCreate[DispenseWhileTrackingParams]): """Create dispense command request model.""" - commandType: DispenseCommandType = "dispense" + commandType: DispenseWhileTrackingCommandType = "dispenseWhileTracking" params: DispenseWhileTrackingParams _CommandCls: Type[DispenseWhileTracking] = DispenseWhileTracking diff --git a/api/src/opentrons/protocol_engine/commands/movement_common.py b/api/src/opentrons/protocol_engine/commands/movement_common.py index ebb55128c3b..5cff8d5f358 100644 --- a/api/src/opentrons/protocol_engine/commands/movement_common.py +++ b/api/src/opentrons/protocol_engine/commands/movement_common.py @@ -61,7 +61,7 @@ class LiquidHandlingWellLocationMixin(BaseModel): ) wellLocation: LiquidHandlingWellLocation = Field( default_factory=LiquidHandlingWellLocation, - description="Relative well location at which to perform the operation", + description="Relative wwellell location at which to perform the operation", ) @@ -152,7 +152,6 @@ async def move_to_well( minimum_z_height: Optional[float] = None, speed: Optional[float] = None, operation_volume: Optional[float] = None, - is_tracking: Optional[bool] = False, ) -> MoveToWellOperationReturn: """Execute a move to well microoperation.""" try: @@ -166,7 +165,6 @@ async def move_to_well( minimum_z_height=minimum_z_height, speed=speed, operation_volume=operation_volume, - is_tracking=is_tracking, ) except StallOrCollisionDetectedError as e: return DefinedErrorData( diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index cfaf67f6375..6740a4babb3 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -63,15 +63,6 @@ class FlowRateMixin(BaseModel): ) -class IsTrackingMixin(BaseModel): - """Mixin for the 'is_tracking' field of aspirate commands.""" - - is_tracking: bool = Field( - False, - description="Whether or not the pipette should move with the liquid while aspirating.", - ) - - class BaseLiquidHandlingResult(BaseModel): """Base properties of a liquid handling result.""" diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index b5e3c5aeeac..be8bbbb8de2 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -74,7 +74,6 @@ async def move_to_well( minimum_z_height: Optional[float] = None, speed: Optional[float] = None, operation_volume: Optional[float] = None, - is_tracking: Optional[bool] = False, ) -> Point: """Move to a specific well.""" self._state_store.labware.raise_if_labware_inaccessible_by_pipette( @@ -138,7 +137,6 @@ async def move_to_well( force_direct=force_direct, minimum_z_height=minimum_z_height, operation_volume=operation_volume, - is_tracking=is_tracking, ) 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 a64cd8b0141..4f850592b25 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -188,7 +188,7 @@ async def aspirate_while_tracking( flow_rate=flow_rate, volume=adjusted_volume, ) - + # raise ValueError(f"adjusted volume = {adjusted_volume}\n volume = {volume}") return adjusted_volume async def dispense_while_tracking( @@ -384,6 +384,7 @@ async def dispense_in_place( raise InvalidPushOutVolumeError( "push out value cannot have a negative value." ) + raise ValueError("why am i here") self._validate_tip_attached(pipette_id=pipette_id, command_name="dispense") return _validate_dispense_volume( state_view=self._state_view, pipette_id=pipette_id, dispense_volume=volume @@ -424,8 +425,15 @@ async def aspirate_while_tracking( flow_rate: float, command_note_adder: CommandNoteAdder, ) -> float: - """Aspirate while moving the z stage with the liquid meniscus.""" - return 0.0 + """Virtually aspirate (no-op).""" + self._validate_tip_attached(pipette_id=pipette_id, command_name="aspirate") + + return _validate_aspirate_volume( + state_view=self._state_view, + pipette_id=pipette_id, + aspirate_volume=volume, + command_note_adder=command_note_adder, + ) async def dispense_while_tracking( self, @@ -436,8 +444,18 @@ async def dispense_while_tracking( flow_rate: float, push_out: Optional[float], ) -> float: - """Dispense while moving the z stage with the liquid meniscus.""" - return 0.0 + """Virtually dispense (no-op).""" + # TODO (tz, 8-23-23): add a check for push_out not larger that the max volume allowed when working on this https://opentrons.atlassian.net/browse/RSS-329 + if push_out and push_out < 0: + raise InvalidPushOutVolumeError( + "push out value cannot have a negative value." + ) + # raise ValueError("here") + self._validate_tip_attached(pipette_id=pipette_id, command_name="dispense") + return _validate_dispense_volume( + state_view=self._state_view, pipette_id=pipette_id, dispense_volume=volume + ) + # return volume def create_pipetting_handler( diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index d3633044e31..19a34ee3fa4 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -9,7 +9,13 @@ from dataclasses import dataclass from functools import cached_property -from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType +from opentrons.types import ( + Point, + DeckSlotName, + StagingSlotName, + MountType, + MeniscusTracking, +) from opentrons_shared_data.errors.exceptions import InvalidStoredData from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN @@ -465,7 +471,8 @@ def validate_well_position( ) 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" + # 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" + f"well = {well_location}" ) def get_well_position( @@ -475,7 +482,6 @@ def get_well_position( well_location: Optional[WellLocations] = None, operation_volume: Optional[float] = None, pipette_id: Optional[str] = None, - is_tracking: Optional[bool] = False, ) -> Point: """Given relative well location in a labware, get absolute position.""" labware_pos = self.get_labware_position(labware_id) @@ -491,7 +497,6 @@ def get_well_position( well_location=well_location, well_depth=well_depth, operation_volume=operation_volume, - is_tracking=is_tracking, ) offset = offset.model_copy(update={"z": offset.z + offset_adjustment}) self.validate_well_position( @@ -536,13 +541,13 @@ def get_relative_liquid_handling_well_location( labware_id: str, well_name: str, absolute_point: Point, - is_meniscus: Optional[bool] = None, + meniscus_tracking: Optional[MeniscusTracking] = 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: + if meniscus_tracking: return LiquidHandlingWellLocation( origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=absolute_point.z), @@ -1433,7 +1438,6 @@ def get_well_offset_adjustment( well_location: WellLocations, well_depth: float, operation_volume: Optional[float] = None, - is_tracking: Optional[bool] = False, ) -> float: """Return a z-axis distance that accounts for well handling height and operation volume. @@ -1446,11 +1450,10 @@ def get_well_offset_adjustment( well_location=well_location, well_depth=well_depth, ) - # _log = logging.getLogger(__name__) - # raise ValueError( - # f"initial handling height {initial_handling_height} \n is_tracking {is_tracking}" - # ) - if is_tracking: + if ( + well_location.origin == WellOrigin.MENISCUS + and not well_location.volumeOffset + ): return initial_handling_height if isinstance(well_location, PickUpTipWellLocation): volume = 0.0 diff --git a/api/src/opentrons/protocol_engine/state/motion.py b/api/src/opentrons/protocol_engine/state/motion.py index 9cb566156ec..15a8d6a633c 100644 --- a/api/src/opentrons/protocol_engine/state/motion.py +++ b/api/src/opentrons/protocol_engine/state/motion.py @@ -98,7 +98,6 @@ def get_movement_waypoints_to_well( force_direct: bool = False, minimum_z_height: Optional[float] = None, operation_volume: Optional[float] = None, - is_tracking: Optional[bool] = False, ) -> List[motion_planning.Waypoint]: """Calculate waypoints to a destination that's specified as a well.""" location = current_well or self._pipettes.get_current_location() @@ -115,7 +114,6 @@ def get_movement_waypoints_to_well( well_location=well_location, operation_volume=operation_volume, pipette_id=pipette_id, - is_tracking=is_tracking, ) move_type = _move_types.get_move_type_to_well( diff --git a/api/src/opentrons/types.py b/api/src/opentrons/types.py index 09e138513c1..7551b8a67f2 100644 --- a/api/src/opentrons/types.py +++ b/api/src/opentrons/types.py @@ -11,6 +11,7 @@ Optional, Protocol, Dict, + Literal, ) from opentrons_shared_data.robot.types import RobotType @@ -88,6 +89,20 @@ def magnitude_to(self, other: Any) -> float: ] +class MeniscusTracking: + def __init__( + self, + target: Union[ + Literal["beginning"], Literal["end"], Literal["dynamic_meniscus"] + ] = "end", + ) -> None: + self._target = target + + @property + def target(self) -> str: + return str(self._target) + + class Location: """Location(point: Point, labware: Union["Labware", "Well", str, "ModuleGeometry", LabwareLike, None, "ModuleContext"]) @@ -129,12 +144,12 @@ def __init__( "ModuleContext", ], *, - _ot_internal_is_meniscus: Optional[bool] = None, + _meniscus_tracking: Optional[MeniscusTracking] = None, ): self._point = point self._given_labware = labware self._labware = LabwareLike(labware) - self._is_meniscus = _ot_internal_is_meniscus + self._meniscus_tracking = _meniscus_tracking # 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. @@ -148,8 +163,8 @@ def labware(self) -> LabwareLike: return self._labware @property - def is_meniscus(self) -> Optional[bool]: - return self._is_meniscus + def meniscus_tracking(self) -> Optional[MeniscusTracking]: + return self._meniscus_tracking def __iter__(self) -> Iterator[Union[Point, LabwareLike]]: """Iterable interface to support unpacking. Like a tuple. @@ -167,7 +182,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 + and other._meniscus_tracking == self._meniscus_tracking ) def move(self, point: Point) -> "Location": @@ -193,7 +208,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}, is_meniscus={self._is_meniscus if self._is_meniscus is not None else False})" + return f"Location(point={repr(self._point)}, labware={self._labware}, meniscus_tracking={self._meniscus_tracking})" # 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 73f39006299..23d2f4bcf58 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 @@ -508,7 +508,6 @@ def test_aspirate_from_well( labware_id="123abc", well_name="my cool well", absolute_point=Point(1, 2, 3), - is_meniscus=None, ) ).then_return( LiquidHandlingWellLocation( @@ -607,7 +606,6 @@ def test_aspirate_from_meniscus( labware_id="123abc", well_name="my cool well", absolute_point=Point(1, 2, 3), - is_meniscus=True, ) ).then_return( LiquidHandlingWellLocation( @@ -622,7 +620,6 @@ def test_aspirate_from_meniscus( rate=5.6, flow_rate=7.8, in_place=False, - is_meniscus=True, ) decoy.verify( @@ -805,7 +802,6 @@ def test_dispense_to_well( labware_id="123abc", well_name="my cool well", absolute_point=Point(1, 2, 3), - is_meniscus=None, ) ).then_return( LiquidHandlingWellLocation( diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 3f639aff922..6a72054a02f 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -342,7 +342,6 @@ def test_aspirate( volume=42.0, rate=1.23, flow_rate=5.67, - is_meniscus=None, ), times=1, ) @@ -380,7 +379,6 @@ def test_aspirate_well_location( volume=42.0, rate=1.23, flow_rate=5.67, - is_meniscus=None, ), times=1, ) @@ -394,9 +392,7 @@ def test_aspirate_meniscus_well_location( ) -> 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 - ) + input_location = Location(point=Point(2, 2, 2), labware=mock_well) last_location = Location(point=Point(9, 9, 9), labware=None) decoy.when(mock_instrument_core.get_mount()).then_return(Mount.RIGHT) @@ -420,7 +416,6 @@ def test_aspirate_meniscus_well_location( volume=42.0, rate=1.23, flow_rate=5.67, - is_meniscus=True, ), times=1, ) @@ -457,7 +452,6 @@ def test_aspirate_from_coordinates( volume=42.0, rate=1.23, flow_rate=5.67, - is_meniscus=None, ), times=1, ) @@ -971,7 +965,6 @@ def test_dispense_with_location( rate=1.23, flow_rate=5.67, push_out=None, - is_meniscus=None, ), times=1, ) @@ -1010,7 +1003,6 @@ def test_dispense_with_well_location( rate=1.23, flow_rate=3.0, push_out=7, - is_meniscus=None, ), times=1, ) @@ -1051,7 +1043,6 @@ def test_dispense_with_well( rate=1.23, flow_rate=5.67, push_out=None, - is_meniscus=None, ), times=1, ) @@ -1306,7 +1297,6 @@ def test_dispense_0_volume_means_dispense_everything( rate=1.23, flow_rate=5.67, push_out=None, - is_meniscus=None, ), times=1, ) @@ -1336,7 +1326,6 @@ def test_dispense_0_volume_means_dispense_nothing( rate=1.23, flow_rate=5.67, push_out=None, - is_meniscus=None, ), times=1, ) @@ -1376,7 +1365,6 @@ def test_aspirate_0_volume_means_aspirate_everything( volume=200, rate=1.23, flow_rate=5.67, - is_meniscus=None, ), times=1, ) @@ -1416,7 +1404,6 @@ 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 c0ef530289b..fd9555fb3b9 100644 --- a/api/tests/opentrons/protocol_api/test_well.py +++ b/api/tests/opentrons/protocol_api/test_well.py @@ -108,7 +108,6 @@ def test_well_meniscus(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> assert isinstance(result, Location) 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 6954a74cf57..4a8adbcdc76 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate.py @@ -117,7 +117,6 @@ async def test_aspirate_implementation_no_prep( minimum_z_height=None, speed=None, operation_volume=-50, - is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -210,7 +209,6 @@ async def test_aspirate_implementation_with_prep( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_return(Point()) @@ -229,7 +227,6 @@ async def test_aspirate_implementation_with_prep( minimum_z_height=None, speed=None, operation_volume=-volume, - is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -320,7 +317,6 @@ async def test_aspirate_raises_volume_error( minimum_z_height=None, speed=None, operation_volume=-50, - is_tracking=False, ), ).then_return(Point(1, 2, 3)) @@ -397,7 +393,6 @@ async def test_overpressure_error( minimum_z_height=None, speed=None, operation_volume=-50, - is_tracking=False, ), ).then_return(position) @@ -498,7 +493,6 @@ async def test_aspirate_implementation_meniscus( minimum_z_height=None, speed=None, operation_volume=-50, - is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -578,7 +572,6 @@ async def test_stall_during_final_movement( minimum_z_height=None, speed=None, operation_volume=-50, - is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) @@ -639,7 +632,6 @@ async def test_stall_during_preparation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) decoy.when(model_utils.generate_id()).then_return(error_id) @@ -712,7 +704,6 @@ async def test_overpressure_during_preparation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_return(prep_location) 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 b7731f1d93b..7549141be5b 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_blow_out.py +++ b/api/tests/opentrons/protocol_engine/commands/test_blow_out.py @@ -80,7 +80,6 @@ async def test_blow_out_implementation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -153,7 +152,6 @@ async def test_overpressure_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -229,7 +227,6 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index 2c48c6359de..5b60b61d4df 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -80,7 +80,6 @@ async def test_dispense_implementation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -189,7 +188,6 @@ async def test_overpressure_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_return(position) @@ -281,7 +279,6 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py index bf908dceb9e..430fa8dff32 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py @@ -132,7 +132,6 @@ async def test_drop_tip_implementation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) @@ -219,7 +218,6 @@ async def test_drop_tip_with_alternating_locations( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) @@ -291,7 +289,6 @@ async def test_tip_attached_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) decoy.when( @@ -388,7 +385,6 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py index eed15d26abb..c9661512aaa 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py +++ b/api/tests/opentrons/protocol_engine/commands/test_liquid_probe.py @@ -155,7 +155,6 @@ async def test_liquid_probe_implementation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_return(Point(x=1, y=2, z=3)) @@ -316,7 +315,6 @@ async def test_liquid_not_found_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_return(position) @@ -717,7 +715,6 @@ async def test_liquid_probe_stall( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ), ).then_raise(StallOrCollisionDetectedError()) 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 8e0f3b6b735..56a2691bbee 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 @@ -72,7 +72,6 @@ async def test_move_to_well_implementation( speed=7.89, current_well=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=9, y=8, z=7)) @@ -137,7 +136,6 @@ async def test_move_to_well_stall_defined_error( speed=7.89, current_well=None, operation_volume=None, - is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) decoy.when(mock_model_utils.generate_id()).then_return(error_id) 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 df99b42c927..d4c53ea5992 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 @@ -67,7 +67,6 @@ async def test_success( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) @@ -153,7 +152,6 @@ async def test_tip_physically_missing_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=111, y=222, z=333)) decoy.when( @@ -246,7 +244,6 @@ async def test_stall_error( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_raise(StallOrCollisionDetectedError()) diff --git a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py index 0d343c8179b..5756810c9ee 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_touch_tip.py @@ -88,7 +88,6 @@ async def test_touch_tip_implementation( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) @@ -179,7 +178,6 @@ async def test_touch_tip_implementation_with_mm_to_edge( minimum_z_height=None, speed=None, operation_volume=None, - is_tracking=False, ) ).then_return(Point(x=1, y=2, z=3)) 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 e775382ecf0..73b293fdbef 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py @@ -150,7 +150,6 @@ async def test_move_to_well( force_direct=True, minimum_z_height=12.3, operation_volume=None, - is_tracking=False, ) ).then_return( [Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER), Waypoint(Point(4, 5, 6))] @@ -260,7 +259,6 @@ async def test_move_to_well_from_starting_location( force_direct=False, minimum_z_height=None, operation_volume=None, - is_tracking=False, ) ).then_return([Waypoint(Point(1, 2, 3), CriticalPoint.XY_CENTER)]) 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 bf82c17c6bc..805c255fa60 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -2006,7 +2006,6 @@ def test_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( 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 65aa2192846..18e765cb438 100644 --- a/api/tests/opentrons/protocol_engine/state/test_motion_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_motion_view.py @@ -466,7 +466,6 @@ def test_get_movement_waypoints_to_well_raises( well_location=None, operation_volume=None, pipette_id="pipette-id", - is_tracking=False, ) ).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/test_types.py b/api/tests/opentrons/test_types.py index 77249fa0492..6cd93dce125 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, is_meniscus=False)" + == "Location(point=Point(x=1.1, y=2.1, z=3.5), labware=minimal labware on deck)" ) @@ -38,17 +38,14 @@ 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, is_meniscus=False)" + == "Location(point=Point(x=1, y=2, z=3), labware=A1 of minimal labware on deck)" ) 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, is_meniscus=False)" - ) + assert f"{loc}" == "Location(point=Point(x=-1, y=2, z=3), labware=1)" @pytest.mark.parametrize(