diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index a1504dfa95c3..d739154a3aa8 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import contextmanager from itertools import dropwhile +from copy import deepcopy from typing import ( Optional, TYPE_CHECKING, @@ -88,6 +89,7 @@ from opentrons.protocol_api._liquid_properties import ( TransferProperties, MultiDispenseProperties, + SingleDispenseProperties, ) _DISPENSE_VOLUME_VALIDATION_ADDED_IN = APIVersion(2, 17) @@ -1955,22 +1957,54 @@ def aspirate_liquid_class( max_volume=self.get_working_volume(), ) source_loc, source_well = source + last_liquid_and_airgap_in_tip = ( + deepcopy(tip_contents[-1]) # don't modify caller's object + if tip_contents + else tx_comps_executor.LiquidAndAirGapPair( + liquid=0, + air_gap=0, + ) + ) + if volume_for_pipette_mode_configuration is not None: + prep_location = Location( + point=source_well.get_top(LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP.z), + labware=source_loc.labware, + ) + self.move_to( + location=prep_location, + well_core=source_well, + force_direct=False, + minimum_z_height=None, + speed=None, + ) + self.remove_air_gap_during_transfer_with_liquid_class( + last_air_gap=last_liquid_and_airgap_in_tip.air_gap, + dispense_props=transfer_properties.dispense, + location=prep_location, + ) + last_liquid_and_airgap_in_tip.air_gap = 0 + if ( + transfer_type != tx_comps_executor.TransferType.MANY_TO_ONE + and self.get_liquid_presence_detection() + ): + self.liquid_probe_with_recovery( + well_core=source_well, loc=prep_location + ) + # TODO: do volume configuration + prepare for aspirate only if the mode needs to be changed + self.configure_for_volume(volume_for_pipette_mode_configuration) + self.prepare_to_aspirate() + aspirate_point = ( tx_comps_executor.absolute_point_from_position_reference_and_offset( well=source_well, + well_volume_difference=-volume, position_reference=aspirate_props.aspirate_position.position_reference, offset=aspirate_props.aspirate_position.offset, + mount=self.get_mount(), ) ) aspirate_location = Location(aspirate_point, labware=source_loc.labware) - last_liquid_and_airgap_in_tip = ( - tip_contents[-1] - if tip_contents - else tx_comps_executor.LiquidAndAirGapPair( - liquid=0, - air_gap=0, - ) - ) + components_executor = tx_comps_executor.TransferComponentsExecutor( instrument_core=self, transfer_properties=transfer_properties, @@ -1982,9 +2016,7 @@ def aspirate_liquid_class( ), ) components_executor.submerge( - submerge_properties=aspirate_props.submerge, - post_submerge_action="aspirate", - volume_for_pipette_mode_configuration=volume_for_pipette_mode_configuration, + submerge_properties=aspirate_props.submerge, post_submerge_action="aspirate" ) # Do not do a pre-aspirate mix or pre-wet if consolidating if transfer_type != tx_comps_executor.TransferType.MANY_TO_ONE: @@ -2023,6 +2055,38 @@ def aspirate_liquid_class( new_tip_contents = tip_contents[0:-1] + [last_contents] return new_tip_contents + def remove_air_gap_during_transfer_with_liquid_class( + self, + last_air_gap: float, + dispense_props: SingleDispenseProperties, + location: Location, + ) -> None: + """Remove an air gap that was previously added during a transfer.""" + if last_air_gap == 0: + return + + correction_volume = dispense_props.correction_by_volume.get_for_volume( + last_air_gap + ) + # The minimum flow rate should be air_gap_volume per second + flow_rate = max( + dispense_props.flow_rate_by_volume.get_for_volume(last_air_gap), + last_air_gap, + ) + self.dispense( + location=location, + well_core=None, + volume=last_air_gap, + rate=1, + flow_rate=flow_rate, + in_place=True, + push_out=0, + correction_volume=correction_volume, + ) + dispense_delay = dispense_props.delay + if dispense_delay.enabled and dispense_delay.duration: + self.delay(dispense_delay.duration) + def dispense_liquid_class( self, volume: float, @@ -2069,8 +2133,10 @@ def dispense_liquid_class( dispense_point = ( tx_comps_executor.absolute_point_from_position_reference_and_offset( well=dest_well, + well_volume_difference=volume, position_reference=dispense_props.dispense_position.position_reference, offset=dispense_props.dispense_position.offset, + mount=self.get_mount(), ) ) dispense_location = Location(dispense_point, labware=dest_loc.labware) @@ -2093,9 +2159,7 @@ def dispense_liquid_class( ), ) components_executor.submerge( - submerge_properties=dispense_props.submerge, - post_submerge_action="dispense", - volume_for_pipette_mode_configuration=None, + submerge_properties=dispense_props.submerge, post_submerge_action="dispense" ) push_out_vol = ( 0.0 @@ -2151,8 +2215,10 @@ def dispense_liquid_class_during_multi_dispense( dispense_point = ( tx_comps_executor.absolute_point_from_position_reference_and_offset( well=dest_well, + well_volume_difference=volume, position_reference=dispense_props.dispense_position.position_reference, offset=dispense_props.dispense_position.offset, + mount=self.get_mount(), ) ) dispense_location = Location(dispense_point, labware=dest_loc.labware) @@ -2175,9 +2241,7 @@ def dispense_liquid_class_during_multi_dispense( ), ) components_executor.submerge( - submerge_properties=dispense_props.submerge, - post_submerge_action="dispense", - volume_for_pipette_mode_configuration=None, + submerge_properties=dispense_props.submerge, post_submerge_action="dispense" ) tip_starting_volume = self.get_current_volume() is_last_dispense_without_disposal_vol = ( diff --git a/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py b/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py index 0acd0472fdb2..9af8b6a268a5 100644 --- a/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py +++ b/api/src/opentrons/protocol_api/core/engine/transfer_components_executor.py @@ -12,7 +12,6 @@ Coordinate, BlowoutLocation, ) -from opentrons_shared_data.pipette.types import LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP from opentrons.protocol_api._liquid_properties import ( Submerge, @@ -23,7 +22,7 @@ TouchTipProperties, ) from opentrons.protocol_engine.errors import TouchTipDisabledError -from opentrons.types import Location, Point +from opentrons.types import Location, Point, Mount from opentrons.protocols.advanced_control.transfers.transfer_liquid_utils import ( LocationCheckDescriptors, ) @@ -124,6 +123,26 @@ def __init__( tip_state: TipState, transfer_type: TransferType, ) -> None: + """Create a TransferComponentsExecutor instance. + + One instance should be created to execute all the steps inside each of the + liquid class' transfer components- aspirate, dispense and multi-dispense. + The state of the TransferComponentsExecutor instance is expected to be valid + only for the component it was created. + + For example, if we want to execute all the steps (submerge, dispense, retract, etc) + related to the 'dispense' component of a liquid-class based transfer, the class + will be used to initialize info about the dispense by assigning values + to class attributes as follows- + - target_location: the dispense location + - target_well: the well associated with dispense location + - tip_state: the state of the tip before dispense component steps are executed + - transfer_type: whether the dispense component is being called as a part of a + 1-to-1 transfer or a consolidation or a distribution + + These attributes will remain the same throughout the component's execution, + except `tip_state`, which will keep updating as fluids are handled. + """ self._instrument = instrument_core self._transfer_properties = transfer_properties self._target_location = target_location @@ -140,7 +159,6 @@ def submerge( self, submerge_properties: Submerge, post_submerge_action: Literal["aspirate", "dispense"], - volume_for_pipette_mode_configuration: Optional[float], ) -> None: """Execute submerge steps. @@ -153,41 +171,14 @@ def submerge( """ submerge_start_point = absolute_point_from_position_reference_and_offset( well=self._target_well, + well_volume_difference=0, position_reference=submerge_properties.start_position.position_reference, offset=submerge_properties.start_position.offset, + mount=self._instrument.get_mount(), ) submerge_start_location = Location( point=submerge_start_point, labware=self._target_location.labware ) - prep_before_moving_to_submerge = ( - post_submerge_action == "aspirate" - and volume_for_pipette_mode_configuration is not None - ) - if prep_before_moving_to_submerge: - # Move to the tip probe start position - self._instrument.move_to( - location=Location( - point=self._target_well.get_top( - LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP.z - ), - labware=self._target_location.labware, - ), - well_core=self._target_well, - force_direct=False, - minimum_z_height=None, - speed=None, - ) - self._remove_air_gap(location=submerge_start_location) - if ( - self._transfer_type != TransferType.MANY_TO_ONE - and self._instrument.get_liquid_presence_detection() - ): - self._instrument.liquid_probe_with_recovery( - well_core=self._target_well, loc=submerge_start_location - ) - # TODO: do volume configuration + prepare for aspirate only if the mode needs to be changed - self._instrument.configure_for_volume(volume_for_pipette_mode_configuration) # type: ignore[arg-type] - self._instrument.prepare_to_aspirate() tx_utils.raise_if_location_inside_liquid( location=submerge_start_location, well_location=self._target_location, @@ -205,8 +196,7 @@ def submerge( minimum_z_height=None, speed=None, ) - if not prep_before_moving_to_submerge: - self._remove_air_gap(location=submerge_start_location) + self._remove_air_gap(location=submerge_start_location) self._instrument.move_to( location=self._target_location, well_core=self._target_well, @@ -340,8 +330,10 @@ def retract_after_aspiration( retract_props = self._transfer_properties.aspirate.retract retract_point = absolute_point_from_position_reference_and_offset( well=self._target_well, + well_volume_difference=0, position_reference=retract_props.end_position.position_reference, offset=retract_props.end_position.offset, + mount=self._instrument.get_mount(), ) retract_location = Location( retract_point, labware=self._target_location.labware @@ -437,8 +429,10 @@ def retract_after_dispensing( retract_props = self._transfer_properties.dispense.retract retract_point = absolute_point_from_position_reference_and_offset( well=self._target_well, + well_volume_difference=0, position_reference=retract_props.end_position.position_reference, offset=retract_props.end_position.offset, + mount=self._instrument.get_mount(), ) retract_location = Location( retract_point, labware=self._target_location.labware @@ -579,8 +573,10 @@ def retract_during_multi_dispensing( retract_props = self._transfer_properties.multi_dispense.retract retract_point = absolute_point_from_position_reference_and_offset( well=self._target_well, + well_volume_difference=0, position_reference=retract_props.end_position.position_reference, offset=retract_props.end_position.offset, + mount=self._instrument.get_mount(), ) retract_location = Location( retract_point, labware=self._target_location.labware @@ -806,40 +802,30 @@ def _add_air_gap(self, air_gap_volume: float) -> None: def _remove_air_gap(self, location: Location) -> None: """Remove a previously added air gap.""" last_air_gap = self._tip_state.last_liquid_and_air_gap_in_tip.air_gap - if last_air_gap == 0: - return - dispense_props = self._transfer_properties.dispense - correction_volume = dispense_props.correction_by_volume.get_for_volume( - last_air_gap - ) - # The minimum flow rate should be air_gap_volume per second - flow_rate = max( - dispense_props.flow_rate_by_volume.get_for_volume(last_air_gap), - last_air_gap, - ) - self._instrument.dispense( + self._instrument.remove_air_gap_during_transfer_with_liquid_class( + last_air_gap=last_air_gap, + dispense_props=dispense_props, location=location, - well_core=None, - volume=last_air_gap, - rate=1, - flow_rate=flow_rate, - in_place=True, - push_out=0, - correction_volume=correction_volume, ) self._tip_state.delete_air_gap(last_air_gap) - dispense_delay = dispense_props.delay - if dispense_delay.enabled and dispense_delay.duration: - self._instrument.delay(dispense_delay.duration) def absolute_point_from_position_reference_and_offset( well: WellCore, + well_volume_difference: float, position_reference: PositionReference, offset: Coordinate, + mount: Mount, ) -> Point: - """Return the absolute point, given the well, the position reference and offset.""" + """Return the absolute point, given the well, the position reference and offset. + + If using meniscus as the position reference, well_volume_difference should be specified. + `well_volume_difference` is the expected *difference* in well volume we want to consider + when estimating the height of the liquid meniscus after an aspirate/ dispense. + So, for liquid height estimation after an aspirate, well_volume_difference is + expected to be a -ve value while for a dispense, it will be a +ve value. + """ match position_reference: case PositionReference.WELL_TOP: reference_point = well.get_top(0) @@ -848,11 +834,17 @@ def absolute_point_from_position_reference_and_offset( case PositionReference.WELL_CENTER: reference_point = well.get_center() case PositionReference.LIQUID_MENISCUS: - meniscus_point = well.get_meniscus() - if not isinstance(meniscus_point, Point): - reference_point = well.get_center() + estimated_liquid_height = well.estimate_liquid_height_after_pipetting( + mount=mount, + operation_volume=well_volume_difference, + ) + if isinstance(estimated_liquid_height, (float, int)): + reference_point = well.get_bottom(z_offset=estimated_liquid_height) else: - reference_point = meniscus_point + # If estimated liquid height gives a SimulatedProbeResult then + # assume meniscus is at well center. + # Will this cause more harm than good? Is there a better alternative to this? + reference_point = well.get_center() case _: raise ValueError(f"Unknown position reference {position_reference}") return reference_point + Point(offset.x, offset.y, offset.z) diff --git a/api/src/opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py b/api/src/opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py index 090061f45628..a724f77232d3 100644 --- a/api/src/opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +++ b/api/src/opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py @@ -48,7 +48,7 @@ def raise_if_location_inside_liquid( liquid_height_from_bottom = well_core.current_liquid_height() except LiquidHeightUnknownError: liquid_height_from_bottom = None - if liquid_height_from_bottom is not None: + if isinstance(liquid_height_from_bottom, (int, float)): if liquid_height_from_bottom + well_core.get_bottom(0).z > location.point.z: raise RuntimeError( f"{location_check_descriptors.location_type.capitalize()} location {location} is" 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 97b2e49b1544..63f2616bf3b5 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 @@ -7,7 +7,7 @@ CommandPreconditionViolated, ) import pytest -from decoy import Decoy +from decoy import Decoy, matchers from decoy import errors from opentrons_shared_data.liquid_classes.liquid_class_definition import ( LiquidClassSchemaV1, @@ -193,6 +193,41 @@ def subject( ) +@pytest.fixture +def instrument_core_with_lpd( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_sync_hardware: SyncHardwareAPI, + mock_protocol_core: ProtocolCore, +) -> InstrumentCore: + """Get an InstrumentCore test subject with liquid presence detection enabled.""" + decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return( + LoadedPipette.model_construct(mount=MountType.LEFT) # type: ignore[call-arg] + ) + + decoy.when(mock_engine_client.state.pipettes.get_flow_rates("abc123")).then_return( + FlowRates( + default_aspirate={"1.2": 2.3}, + default_dispense={"3.4": 4.5}, + default_blow_out={"5.6": 6.7}, + ), + ) + decoy.when( + mock_engine_client.state.pipettes.get_liquid_presence_detection("abc123") + ).then_return(True) + decoy.when( + mock_engine_client.state.pipettes.get_pipette_supports_pressure("abc123") + ).then_return(True) + return InstrumentCore( + pipette_id="abc123", + engine_client=mock_engine_client, + sync_hardware_api=mock_sync_hardware, + protocol_core=mock_protocol_core, + # When this baby hits 88 mph, you're going to see some serious shit. + default_movement_speed=39339.5, + ) + + def test_pipette_id(subject: InstrumentCore) -> None: """It should have a ProtocolEngine ID.""" assert subject.pipette_id == "abc123" @@ -1864,7 +1899,7 @@ def test_load_liquid_class( assert result == "liquid-class-id" -def test_aspirate_liquid_class_for_transfer( +def test_aspirate_liquid_class_for_transfer_without_volume_config( decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore, @@ -1881,8 +1916,10 @@ def test_aspirate_liquid_class_for_transfer( decoy.when( transfer_components_executor.absolute_point_from_position_reference_and_offset( well=source_well, + well_volume_difference=-123, position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), + mount=Mount.LEFT, ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -1891,8 +1928,8 @@ def test_aspirate_liquid_class_for_transfer( transfer_properties=test_transfer_properties, target_location=Location(Point(1, 2, 3), labware=None), target_well=source_well, - transfer_type=TransferType.ONE_TO_ONE, tip_state=TipState(), + transfer_type=TransferType.ONE_TO_ONE, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -1904,13 +1941,130 @@ def test_aspirate_liquid_class_for_transfer( transfer_properties=test_transfer_properties, transfer_type=TransferType.ONE_TO_ONE, tip_contents=[], + volume_for_pipette_mode_configuration=None, + ) + decoy.verify( + mock_transfer_components_executor.submerge( + submerge_properties=test_transfer_properties.aspirate.submerge, + post_submerge_action="aspirate", + ), + mock_transfer_components_executor.mix( + mix_properties=test_transfer_properties.aspirate.mix, + last_dispense_push_out=False, + ), + mock_transfer_components_executor.pre_wet(volume=123), + mock_transfer_components_executor.aspirate_and_wait(volume=123), + mock_transfer_components_executor.retract_after_aspiration( + volume=123, add_air_gap=True + ), + ) + assert result == [LiquidAndAirGapPair(air_gap=222, liquid=111)] + + +@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23))) +def test_aspirate_liquid_class_using_volume_config_without_lpd( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: InstrumentCore, + minimal_liquid_class_def2: LiquidClassSchemaV1, + mock_transfer_components_executor: TransferComponentsExecutor, + mock_protocol_core: ProtocolCore, + version: APIVersion, +) -> None: + """It should execute steps required for volume config only, without LPD steps.""" + source_well = decoy.mock(cls=WellCore) + source_location = Location(Point(1, 2, 3), labware=None) + liquid_probe_start_point = Point(3, 2, 1) + + test_liquid_class = LiquidClass.create(minimal_liquid_class_def2) + test_transfer_properties = test_liquid_class.get_for( + "flex_1channel_50", "opentrons_flex_96_tiprack_50ul" + ) + test_transfer_properties.dispense.flow_rate_by_volume.set_for_volume(100, 234) + test_transfer_properties.dispense.correction_by_volume.set_for_volume(100, 111) + test_transfer_properties.dispense.delay.duration = 22 + test_transfer_properties.dispense.delay.enabled = True + + last_liquid_and_airgap_in_tip = LiquidAndAirGapPair(liquid=0, air_gap=100) + decoy.when(mock_protocol_core.api_version).then_return(version) + decoy.when(source_well.labware_id).then_return("source-labware-id") + decoy.when(source_well.get_name()).then_return("source-well") + decoy.when(source_well.get_top(2)).then_return(liquid_probe_start_point) + decoy.when( + mock_engine_client.state.geometry.get_relative_well_location( + labware_id="source-labware-id", + well_name="source-well", + absolute_point=liquid_probe_start_point, + location_type=WellLocationFunction.LIQUID_HANDLING, + ) + ).then_return((LiquidHandlingWellLocation(origin=WellOrigin.BOTTOM), True)) + decoy.when( + transfer_components_executor.absolute_point_from_position_reference_and_offset( + well=source_well, + well_volume_difference=-123, + position_reference=PositionReference.WELL_BOTTOM, + offset=Coordinate(x=0, y=0, z=-5), + mount=Mount.LEFT, + ) + ).then_return(Point(1, 2, 3)) + decoy.when( + transfer_components_executor.TransferComponentsExecutor( + instrument_core=subject, + transfer_properties=test_transfer_properties, + target_location=Location(Point(1, 2, 3), labware=None), + target_well=source_well, + tip_state=TipState(), # air gap would have been removed during volume config + transfer_type=TransferType.ONE_TO_ONE, + ) + ).then_return(mock_transfer_components_executor) + decoy.when( + mock_transfer_components_executor.tip_state.last_liquid_and_air_gap_in_tip + ).then_return(LiquidAndAirGapPair(liquid=111, air_gap=222)) + result = subject.aspirate_liquid_class( + volume=123, + source=(source_location, source_well), + transfer_properties=test_transfer_properties, + transfer_type=TransferType.ONE_TO_ONE, + tip_contents=[last_liquid_and_airgap_in_tip], volume_for_pipette_mode_configuration=123, ) decoy.verify( + mock_engine_client.execute_command( + cmd.MoveToWellParams( + pipetteId="abc123", + labwareId="source-labware-id", + wellName="source-well", + wellLocation=LiquidHandlingWellLocation(origin=WellOrigin.BOTTOM), + minimumZHeight=None, + forceDirect=False, + speed=None, + ) + ), + mock_engine_client.execute_command( + cmd.DispenseInPlaceParams( + pipetteId="abc123", + volume=100, + flowRate=234, + pushOut=0, + correctionVolume=111, + ) + ), + mock_protocol_core.delay(seconds=22, msg=None), + mock_engine_client.execute_command( + cmd.ConfigureForVolumeParams( + pipetteId="abc123", + volume=123, + tipOverlapNotAfterVersion="v3", + ) + ), + mock_engine_client.execute_command( + cmd.PrepareToAspirateParams( + pipetteId="abc123", + ) + ), mock_transfer_components_executor.submerge( submerge_properties=test_transfer_properties.aspirate.submerge, post_submerge_action="aspirate", - volume_for_pipette_mode_configuration=123, ), mock_transfer_components_executor.mix( mix_properties=test_transfer_properties.aspirate.mix, @@ -1925,6 +2079,185 @@ def test_aspirate_liquid_class_for_transfer( assert result == [LiquidAndAirGapPair(air_gap=222, liquid=111)] +@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23))) +def test_aspirate_liquid_class_using_volume_config_and_lpd( + decoy: Decoy, + mock_engine_client: EngineClient, + instrument_core_with_lpd: InstrumentCore, + minimal_liquid_class_def2: LiquidClassSchemaV1, + mock_transfer_components_executor: TransferComponentsExecutor, + mock_protocol_core: ProtocolCore, + version: APIVersion, +) -> None: + """It should execute steps required for volume config and LPD.""" + source_well = decoy.mock(cls=WellCore) + source_location = Location(Point(1, 2, 3), labware=None) + liquid_probe_start_point = Point(3, 2, 1) + + test_liquid_class = LiquidClass.create(minimal_liquid_class_def2) + test_transfer_properties = test_liquid_class.get_for( + "flex_1channel_50", "opentrons_flex_96_tiprack_50ul" + ) + decoy.when(source_well.labware_id).then_return("source-labware-id") + decoy.when(source_well.get_name()).then_return("source-well") + decoy.when(source_well.get_top(2)).then_return(liquid_probe_start_point) + decoy.when( + mock_engine_client.state.geometry.get_relative_well_location( + labware_id="source-labware-id", + well_name="source-well", + absolute_point=liquid_probe_start_point, + location_type=WellLocationFunction.LIQUID_HANDLING, + ) + ).then_return((LiquidHandlingWellLocation(origin=WellOrigin.BOTTOM), True)) + decoy.when( + transfer_components_executor.absolute_point_from_position_reference_and_offset( + well=source_well, + well_volume_difference=-123, + position_reference=PositionReference.WELL_BOTTOM, + offset=Coordinate(x=0, y=0, z=-5), + mount=Mount.LEFT, + ) + ).then_return(Point(1, 2, 3)) + decoy.when( + transfer_components_executor.TransferComponentsExecutor( + instrument_core=instrument_core_with_lpd, + transfer_properties=test_transfer_properties, + target_location=Location(Point(1, 2, 3), labware=None), + target_well=source_well, + tip_state=TipState(), + transfer_type=TransferType.ONE_TO_ONE, + ) + ).then_return(mock_transfer_components_executor) + decoy.when( + mock_transfer_components_executor.tip_state.last_liquid_and_air_gap_in_tip + ).then_return(LiquidAndAirGapPair(liquid=111, air_gap=222)) + decoy.when(mock_protocol_core.api_version).then_return(version) + result = instrument_core_with_lpd.aspirate_liquid_class( + volume=123, + source=(source_location, source_well), + transfer_properties=test_transfer_properties, + transfer_type=TransferType.ONE_TO_ONE, + tip_contents=[], + volume_for_pipette_mode_configuration=123, + ) + decoy.verify( + mock_engine_client.execute_command( + cmd.MoveToWellParams( + pipetteId="abc123", + labwareId="source-labware-id", + wellName="source-well", + wellLocation=LiquidHandlingWellLocation(origin=WellOrigin.BOTTOM), + minimumZHeight=None, + forceDirect=False, + speed=None, + ) + ), + mock_engine_client.execute_command( + cmd.LiquidProbeParams( + pipetteId="abc123", + labwareId="source-labware-id", + wellName="source-well", + wellLocation=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=2) + ), + ) + ), + mock_engine_client.execute_command( + cmd.ConfigureForVolumeParams( + pipetteId="abc123", + volume=123, + tipOverlapNotAfterVersion="v3", + ) + ), + mock_engine_client.execute_command( + cmd.PrepareToAspirateParams( + pipetteId="abc123", + ) + ), + mock_transfer_components_executor.submerge( + submerge_properties=test_transfer_properties.aspirate.submerge, + post_submerge_action="aspirate", + ), + mock_transfer_components_executor.mix( + mix_properties=test_transfer_properties.aspirate.mix, + last_dispense_push_out=False, + ), + mock_transfer_components_executor.pre_wet(volume=123), + mock_transfer_components_executor.aspirate_and_wait(volume=123), + mock_transfer_components_executor.retract_after_aspiration( + volume=123, add_air_gap=True + ), + ) + assert result == [LiquidAndAirGapPair(air_gap=222, liquid=111)] + + +@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23))) +def test_aspirate_liquid_class_does_not_do_lpd_for_consolidate( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: InstrumentCore, + minimal_liquid_class_def2: LiquidClassSchemaV1, + mock_transfer_components_executor: TransferComponentsExecutor, + mock_protocol_core: ProtocolCore, + version: APIVersion, +) -> None: + """It should execute steps required for LPD.""" + source_well = decoy.mock(cls=WellCore) + source_location = Location(Point(1, 2, 3), labware=None) + liquid_probe_start_point = Point(3, 2, 1) + + test_liquid_class = LiquidClass.create(minimal_liquid_class_def2) + test_transfer_properties = test_liquid_class.get_for( + "flex_1channel_50", "opentrons_flex_96_tiprack_50ul" + ) + decoy.when(source_well.labware_id).then_return("source-labware-id") + decoy.when(source_well.get_name()).then_return("source-well") + decoy.when(source_well.get_top(2)).then_return(liquid_probe_start_point) + decoy.when( + mock_engine_client.state.geometry.get_relative_well_location( + labware_id="source-labware-id", + well_name="source-well", + absolute_point=liquid_probe_start_point, + location_type=WellLocationFunction.LIQUID_HANDLING, + ) + ).then_return((LiquidHandlingWellLocation(origin=WellOrigin.BOTTOM), True)) + decoy.when( + transfer_components_executor.absolute_point_from_position_reference_and_offset( + well=source_well, + well_volume_difference=-123, + position_reference=PositionReference.WELL_BOTTOM, + offset=Coordinate(x=0, y=0, z=-5), + mount=Mount.LEFT, + ) + ).then_return(Point(1, 2, 3)) + decoy.when( + transfer_components_executor.TransferComponentsExecutor( + instrument_core=subject, + transfer_properties=test_transfer_properties, + target_location=Location(Point(1, 2, 3), labware=None), + target_well=source_well, + tip_state=TipState(), + transfer_type=TransferType.ONE_TO_ONE, + ) + ).then_return(mock_transfer_components_executor) + decoy.when( + mock_transfer_components_executor.tip_state.last_liquid_and_air_gap_in_tip + ).then_return(LiquidAndAirGapPair(liquid=111, air_gap=222)) + decoy.when(mock_protocol_core.api_version).then_return(version) + subject.aspirate_liquid_class( + volume=123, + source=(source_location, source_well), + transfer_properties=test_transfer_properties, + transfer_type=TransferType.ONE_TO_ONE, + tip_contents=[], + volume_for_pipette_mode_configuration=123, + ) + decoy.verify( + mock_engine_client.execute_command(params=matchers.IsA(cmd.LiquidProbeParams)), + times=0, + ) + + def test_aspirate_liquid_class_for_consolidate( decoy: Decoy, mock_engine_client: EngineClient, @@ -1942,8 +2275,10 @@ def test_aspirate_liquid_class_for_consolidate( decoy.when( transfer_components_executor.absolute_point_from_position_reference_and_offset( well=source_well, + well_volume_difference=-123, position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), + mount=Mount.LEFT, ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -1952,8 +2287,8 @@ def test_aspirate_liquid_class_for_consolidate( transfer_properties=test_transfer_properties, target_location=Location(Point(1, 2, 3), labware=None), target_well=source_well, - transfer_type=TransferType.MANY_TO_ONE, tip_state=TipState(), + transfer_type=TransferType.MANY_TO_ONE, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -1965,13 +2300,12 @@ def test_aspirate_liquid_class_for_consolidate( transfer_properties=test_transfer_properties, transfer_type=TransferType.MANY_TO_ONE, tip_contents=[], - volume_for_pipette_mode_configuration=543, + volume_for_pipette_mode_configuration=None, ) decoy.verify( mock_transfer_components_executor.submerge( submerge_properties=test_transfer_properties.aspirate.submerge, post_submerge_action="aspirate", - volume_for_pipette_mode_configuration=543, ), mock_transfer_components_executor.aspirate_and_wait(volume=123), mock_transfer_components_executor.retract_after_aspiration( @@ -2023,10 +2357,110 @@ def test_aspirate_liquid_class_raises_for_more_than_max_volume( transfer_properties=test_transfer_properties, transfer_type=TransferType.ONE_TO_ONE, tip_contents=[], - volume_for_pipette_mode_configuration=543, + volume_for_pipette_mode_configuration=None, ) +@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23))) +@pytest.mark.parametrize( + argnames=[ + "air_gap_volume", + "air_gap_flow_rate_by_vol", + "expected_air_gap_flow_rate", + ], + argvalues=[(0.123, 123, 123), (1.23, 0.123, 1.23)], +) +def test_remove_air_gap_during_transfer_with_liquid_class( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_protocol_core: ProtocolCore, + subject: InstrumentCore, + air_gap_volume: float, + air_gap_flow_rate_by_vol: float, + expected_air_gap_flow_rate: float, + version: APIVersion, +) -> None: + """It should remove ait gap by calling dispense and delay with liquid class props.""" + test_transfer_props = decoy.mock(cls=TransferProperties) + air_gap_correction_by_vol = 0.321 + + test_transfer_props.dispense.delay.duration = 321 + test_transfer_props.dispense.delay.enabled = True + + decoy.when(mock_protocol_core.api_version).then_return(version) + decoy.when( + test_transfer_props.dispense.flow_rate_by_volume.get_for_volume(air_gap_volume) + ).then_return(air_gap_flow_rate_by_vol) + decoy.when( + test_transfer_props.dispense.correction_by_volume.get_for_volume(air_gap_volume) + ).then_return(air_gap_correction_by_vol) + subject.remove_air_gap_during_transfer_with_liquid_class( + last_air_gap=air_gap_volume, + dispense_props=test_transfer_props.dispense, + location=Location(Point(1, 2, 3), labware=None), + ) + decoy.verify( + mock_engine_client.execute_command( + cmd.DispenseInPlaceParams( + pipetteId="abc123", + volume=air_gap_volume, + flowRate=expected_air_gap_flow_rate, + pushOut=0, + correctionVolume=air_gap_correction_by_vol, + ) + ), + mock_protocol_core.delay(321, None), + ) + + +@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 23))) +def test_remove_air_gap_during_transfer_with_liquid_class_handles_delays( + decoy: Decoy, + mock_engine_client: EngineClient, + mock_protocol_core: ProtocolCore, + subject: InstrumentCore, + version: APIVersion, +) -> None: + """It should remove ait gap by calling dispense and delay with liquid class props.""" + test_transfer_props = decoy.mock(cls=TransferProperties) + air_gap_volume = 0.123 + air_gap_flow_rate_by_vol = 123 + air_gap_correction_by_vol = 0.321 + + test_transfer_props.dispense.delay.enabled = False + + decoy.when(mock_protocol_core.api_version).then_return(version) + decoy.when( + test_transfer_props.dispense.flow_rate_by_volume.get_for_volume(air_gap_volume) + ).then_return(air_gap_flow_rate_by_vol) + decoy.when( + test_transfer_props.dispense.correction_by_volume.get_for_volume(air_gap_volume) + ).then_return(air_gap_correction_by_vol) + + subject.remove_air_gap_during_transfer_with_liquid_class( + last_air_gap=air_gap_volume, + dispense_props=test_transfer_props.dispense, + location=Location(Point(1, 2, 3), labware=None), + ) + decoy.verify( + mock_protocol_core.delay(seconds=matchers.Anything(), msg=None), + times=0, + ) + + test_transfer_props.dispense.delay.enabled = True + test_transfer_props.dispense.delay.duration = 321 + + subject.remove_air_gap_during_transfer_with_liquid_class( + last_air_gap=air_gap_volume, + dispense_props=test_transfer_props.dispense, + location=Location(Point(1, 2, 3), labware=None), + ) + decoy.verify( + mock_protocol_core.delay(seconds=321, msg=None), + times=1, + ) + + def test_dispense_liquid_class( decoy: Decoy, mock_engine_client: EngineClient, @@ -2049,8 +2483,10 @@ def test_dispense_liquid_class( decoy.when( transfer_components_executor.absolute_point_from_position_reference_and_offset( well=dest_well, + well_volume_difference=123, position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=0, y=0, z=-5), + mount=Mount.LEFT, ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2059,8 +2495,8 @@ def test_dispense_liquid_class( transfer_properties=test_transfer_properties, target_location=Location(Point(1, 2, 3), labware=None), target_well=dest_well, - transfer_type=TransferType.ONE_TO_ONE, tip_state=TipState(), + transfer_type=TransferType.ONE_TO_ONE, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2080,7 +2516,6 @@ def test_dispense_liquid_class( mock_transfer_components_executor.submerge( submerge_properties=test_transfer_properties.dispense.submerge, post_submerge_action="dispense", - volume_for_pipette_mode_configuration=None, ), mock_transfer_components_executor.dispense_and_wait( dispense_properties=test_transfer_properties.dispense, @@ -2128,8 +2563,10 @@ def test_dispense_liquid_class_during_multi_dispense( decoy.when( transfer_components_executor.absolute_point_from_position_reference_and_offset( well=dest_well, + well_volume_difference=123, position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=1, y=3, z=2), + mount=Mount.LEFT, ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2138,8 +2575,8 @@ def test_dispense_liquid_class_during_multi_dispense( transfer_properties=test_transfer_properties, target_location=Location(Point(1, 2, 3), labware=None), target_well=dest_well, - transfer_type=TransferType.ONE_TO_MANY, tip_state=TipState(), + transfer_type=TransferType.ONE_TO_MANY, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2164,7 +2601,6 @@ def test_dispense_liquid_class_during_multi_dispense( mock_transfer_components_executor.submerge( submerge_properties=test_transfer_properties.multi_dispense.submerge, post_submerge_action="dispense", - volume_for_pipette_mode_configuration=None, ), mock_transfer_components_executor.dispense_and_wait( dispense_properties=test_transfer_properties.multi_dispense, @@ -2211,8 +2647,10 @@ def test_last_dispense_liquid_class_during_multi_dispense( decoy.when( transfer_components_executor.absolute_point_from_position_reference_and_offset( well=dest_well, + well_volume_difference=123, position_reference=PositionReference.WELL_BOTTOM, offset=Coordinate(x=1, y=3, z=2), + mount=Mount.LEFT, ) ).then_return(Point(1, 2, 3)) decoy.when( @@ -2221,8 +2659,8 @@ def test_last_dispense_liquid_class_during_multi_dispense( transfer_properties=test_transfer_properties, target_location=Location(Point(1, 2, 3), labware=None), target_well=dest_well, - transfer_type=TransferType.ONE_TO_MANY, tip_state=TipState(), + transfer_type=TransferType.ONE_TO_MANY, ) ).then_return(mock_transfer_components_executor) decoy.when( @@ -2247,7 +2685,6 @@ def test_last_dispense_liquid_class_during_multi_dispense( mock_transfer_components_executor.submerge( submerge_properties=test_transfer_properties.multi_dispense.submerge, post_submerge_action="dispense", - volume_for_pipette_mode_configuration=None, ), mock_transfer_components_executor.dispense_and_wait( dispense_properties=test_transfer_properties.multi_dispense, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py b/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py index 0e3d91cadca4..edb1ad0c7cc4 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_transfer_components_executor.py @@ -27,7 +27,7 @@ from opentrons.protocols.advanced_control.transfers.transfer_liquid_utils import ( LocationCheckDescriptors, ) -from opentrons.types import Location, Point +from opentrons.types import Location, Point, Mount @pytest.fixture @@ -83,7 +83,7 @@ def patch_mock_raise_if_location_inside_liquid( ], argvalues=[(0.123, 123, 123), (1.23, 0.123, 1.23)], ) -def test_submerge_without_lpd( +def test_submerge( decoy: Decoy, mock_instrument_core: InstrumentCore, sample_transfer_props: TransferProperties, @@ -119,34 +119,11 @@ def test_submerge_without_lpd( decoy.when(source_well.get_bottom(0)).then_return(well_bottom_point) decoy.when(source_well.get_top(0)).then_return(well_top_point) decoy.when(source_well.get_top(2)).then_return(Point(1, 2, 6)) - decoy.when(mock_instrument_core.get_liquid_presence_detection()).then_return(False) subject.submerge( submerge_properties=sample_transfer_props.aspirate.submerge, post_submerge_action="aspirate", - volume_for_pipette_mode_configuration=123, ) - decoy.verify( - mock_instrument_core.move_to( - location=Location(point=Point(1, 2, 6), labware=None), - well_core=source_well, - force_direct=False, - minimum_z_height=None, - speed=None, - ), - mock_instrument_core.dispense( - location=Location(Point(x=2, y=4, z=7), labware=None), - well_core=None, - volume=air_gap_volume, - rate=1, - flow_rate=expected_air_gap_flow_rate, - in_place=True, - push_out=0, - correction_volume=air_gap_correction_by_vol, - ), - mock_instrument_core.delay(0.5), - mock_instrument_core.configure_for_volume(123), - mock_instrument_core.prepare_to_aspirate(), tx_utils.raise_if_location_inside_liquid( location=Location(Point(x=2, y=4, z=7), labware=None), well_location=Location(Point(x=1, y=2, z=3), labware=None), @@ -164,6 +141,11 @@ def test_submerge_without_lpd( minimum_z_height=None, speed=None, ), + mock_instrument_core.remove_air_gap_during_transfer_with_liquid_class( + last_air_gap=air_gap_volume, + dispense_props=sample_transfer_props.dispense, + location=Location(Point(x=2, y=4, z=7), labware=None), + ), mock_instrument_core.move_to( location=Location(Point(1, 2, 3), labware=None), well_core=source_well, @@ -175,7 +157,7 @@ def test_submerge_without_lpd( ) -def test_submerge_with_lpd( +def test_submerge_without_starting_air_gap( decoy: Decoy, mock_instrument_core: InstrumentCore, sample_transfer_props: TransferProperties, @@ -184,14 +166,12 @@ def test_submerge_with_lpd( source_well = decoy.mock(cls=WellCore) well_top_point = Point(1, 2, 4) well_bottom_point = Point(4, 5, 6) - air_gap_flow_rate_by_vol = 1234 - air_gap_correction_by_vol = 0.321 - + air_gap_volume = 0 sample_transfer_props.dispense.flow_rate_by_volume.set_for_volume( - 123, air_gap_flow_rate_by_vol + air_gap_volume, 1234 ) sample_transfer_props.dispense.correction_by_volume.set_for_volume( - 123, air_gap_correction_by_vol + air_gap_volume, 1234 ) subject = TransferComponentsExecutor( @@ -201,44 +181,30 @@ def test_submerge_with_lpd( target_well=source_well, tip_state=TipState( ready_to_aspirate=True, - last_liquid_and_air_gap_in_tip=LiquidAndAirGapPair(liquid=0, air_gap=123), + last_liquid_and_air_gap_in_tip=LiquidAndAirGapPair( + liquid=0, air_gap=air_gap_volume + ), ), transfer_type=TransferType.ONE_TO_ONE, ) decoy.when(source_well.get_bottom(0)).then_return(well_bottom_point) decoy.when(source_well.get_top(0)).then_return(well_top_point) decoy.when(source_well.get_top(2)).then_return(Point(1, 2, 6)) - decoy.when(mock_instrument_core.get_liquid_presence_detection()).then_return(True) subject.submerge( submerge_properties=sample_transfer_props.aspirate.submerge, post_submerge_action="aspirate", - volume_for_pipette_mode_configuration=123, ) - decoy.verify( - mock_instrument_core.move_to( - location=Location(point=Point(1, 2, 6), labware=None), - well_core=source_well, - force_direct=False, - minimum_z_height=None, - speed=None, - ), - mock_instrument_core.dispense( + tx_utils.raise_if_location_inside_liquid( location=Location(Point(x=2, y=4, z=7), labware=None), - well_core=None, - volume=123, - rate=1, - flow_rate=air_gap_flow_rate_by_vol, - in_place=True, - push_out=0, - correction_volume=air_gap_correction_by_vol, - ), - mock_instrument_core.delay(0.5), - mock_instrument_core.liquid_probe_with_recovery( - source_well, Location(Point(x=2, y=4, z=7), labware=None) + well_location=Location(Point(x=1, y=2, z=3), labware=None), + well_core=source_well, + location_check_descriptors=LocationCheckDescriptors( + location_type="submerge start", + pipetting_action="aspirate", + ), + logger=matchers.Anything(), ), - mock_instrument_core.configure_for_volume(123), - mock_instrument_core.prepare_to_aspirate(), mock_instrument_core.move_to( location=Location(Point(x=2, y=4, z=7), labware=None), well_core=source_well, @@ -296,7 +262,6 @@ def test_submerge_raises_when_submerge_point_is_invalid( subject.submerge( submerge_properties=sample_transfer_props.aspirate.submerge, post_submerge_action="aspirate", - volume_for_pipette_mode_configuration=123, ) @@ -1699,7 +1664,7 @@ def test_multi_dispense_retract_raises_for_invalid_retract_point( ( PositionReference.LIQUID_MENISCUS, Coordinate(x=41, y=42, z=43), - Point(51, 53, 55), + Point(45, 47, 61), ), ], ) @@ -1715,15 +1680,24 @@ def test_absolute_point_from_position_reference_and_offset( well_top_point = Point(1, 2, 3) well_bottom_point = Point(4, 5, 6) well_center_point = Point(7, 8, 9) - liquid_meniscus_point = Point(10, 11, 12) + estimated_liquid_height = 12 decoy.when(well.get_bottom(0)).then_return(well_bottom_point) decoy.when(well.get_top(0)).then_return(well_top_point) decoy.when(well.get_center()).then_return(well_center_point) - decoy.when(well.get_meniscus()).then_return(liquid_meniscus_point) + decoy.when( + well.estimate_liquid_height_after_pipetting( + operation_volume=123, mount=Mount.RIGHT + ), + ).then_return(estimated_liquid_height) + decoy.when(well.get_bottom(12)).then_return(Point(4, 5, 18)) assert ( absolute_point_from_position_reference_and_offset( - well=well, position_reference=position_reference, offset=offset + well=well, + well_volume_difference=123, + position_reference=position_reference, + offset=offset, + mount=Mount.RIGHT, ) == expected_result ) @@ -1737,6 +1711,8 @@ def test_absolute_point_from_position_reference_and_offset_raises_errors( with pytest.raises(ValueError, match="Unknown position reference"): absolute_point_from_position_reference_and_offset( well=well, + well_volume_difference=123, position_reference="PositionReference", # type: ignore[arg-type] offset=Coordinate(x=0, y=0, z=0), + mount=Mount.RIGHT, ) diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 0f078cae9e8a..5b232e97d655 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -554,12 +554,6 @@ def test_blow_out_to_well_meniscus_location( decoy.when(mock_protocol_core.get_last_location(Mount.RIGHT)).then_return( last_location ) - decoy.when( - mock_validation.validate_location( - location=input_location, last_location=last_location - ) - ).then_return(WellTarget(well=mock_well, location=input_location, in_place=False)) - subject.blow_out(location=input_location) mock_instrument_core.blow_out(