diff --git a/api/src/opentrons/protocol_api/core/common.py b/api/src/opentrons/protocol_api/core/common.py index 3aff2523a1f..8baab2e9095 100644 --- a/api/src/opentrons/protocol_api/core/common.py +++ b/api/src/opentrons/protocol_api/core/common.py @@ -19,7 +19,7 @@ WellCore = AbstractWellCore LabwareCore = AbstractLabware[WellCore] -InstrumentCore = AbstractInstrument[WellCore] +InstrumentCore = AbstractInstrument[WellCore, LabwareCore] ModuleCore = AbstractModuleCore TemperatureModuleCore = AbstractTemperatureModuleCore MagneticModuleCore = AbstractMagneticModuleCore diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index b67ecf17bac..2f9d6a9d9e0 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -2,17 +2,22 @@ from __future__ import annotations -from typing import Optional, TYPE_CHECKING, cast, Union, List, Tuple +from typing import ( + Optional, + TYPE_CHECKING, + cast, + Union, + List, + Tuple, + NamedTuple, +) from opentrons.types import Location, Mount, NozzleConfigurationType, NozzleMapInterface 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 from opentrons.protocols.api_support.types import APIVersion -from opentrons.protocols.advanced_control.transfers.common import ( - TransferTipPolicyV2, - check_valid_volume_parameters, - expand_for_volume_constraints, -) +from opentrons.protocols.advanced_control.transfers.common import TransferTipPolicyV2 +from opentrons.protocols.advanced_control.transfers import common as tx_commons from opentrons.protocol_engine import commands as cmd from opentrons.protocol_engine import ( DeckPoint, @@ -32,11 +37,12 @@ NozzleLayoutConfigurationType, AddressableOffsetVector, LiquidClassRecord, + NextTipInfo, ) from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION -from opentrons_shared_data.pipette.types import PipetteNameType +from opentrons_shared_data.pipette.types import PipetteNameType, PIPETTE_API_NAMES_MAP from opentrons_shared_data.errors.exceptions import ( UnsupportedHardwareCommand, ) @@ -45,6 +51,7 @@ from . import transfer_components_executor as tx_comps_executor from .well import WellCore +from .labware import LabwareCore from ..instrument import AbstractInstrument from ...disposal_locations import TrashBin, WasteChute @@ -56,7 +63,7 @@ _DISPENSE_VOLUME_VALIDATION_ADDED_IN = APIVersion(2, 17) -class InstrumentCore(AbstractInstrument[WellCore]): +class InstrumentCore(AbstractInstrument[WellCore, LabwareCore]): """Instrument API core using a ProtocolEngine. Args: @@ -705,6 +712,20 @@ def get_pipette_name(self) -> str: else pipette.pipetteName ) + def get_pipette_load_name(self) -> str: + """Get the pipette's requested API load name. + + This is the load name that is specified in the `ProtocolContext.load_instrument()` + method. This name might differ from the engine-specific pipette name. + """ + pipette = self._engine_client.state.pipettes.get(self._pipette_id) + assert pipette.pipetteName in PIPETTE_API_NAMES_MAP.values() + return [ + pip_api_name + for pip_api_name, pip_name in PIPETTE_API_NAMES_MAP.items() + if pip_name == pipette.pipetteName + ][0] + def get_model(self) -> str: return self._engine_client.state.pipettes.get_model_name(self._pipette_id) @@ -883,7 +904,7 @@ def load_liquid_class( liquid_class_record = LiquidClassRecord( liquidClassName=liquid_class.name, - pipetteModel=self.get_model(), # TODO: verify this is the correct 'model' to use + pipetteModel=pipette_load_name, # TODO: verify this is the correct 'model' to use tiprack=tiprack_uri, aspirate=transfer_props.aspirate.as_shared_data_model(), singleDispense=transfer_props.dispense.as_shared_data_model(), @@ -898,18 +919,29 @@ def load_liquid_class( ) return result.liquidClassId - # TODO: update with getNextTip implementation - def get_next_tip(self) -> None: + def get_next_tip( + self, tip_racks: List[LabwareCore], starting_well: Optional[str] + ) -> Optional[NextTipInfo]: """Get the next tip to pick up.""" + result = self._engine_client.execute_command_without_recovery( + cmd.GetNextTipParams( + pipetteId=self._pipette_id, + labwareIds=[tip_rack.labware_id for tip_rack in tip_racks], + startingTipWell=starting_well, + ) + ) + return ( + result.nextTipInfo if isinstance(result.nextTipInfo, NextTipInfo) else None + ) - def transfer_liquid( + def transfer_liquid( # noqa: C901 self, liquid_class: LiquidClass, volume: float, source: List[Tuple[Location, WellCore]], dest: List[Tuple[Location, WellCore]], new_tip: TransferTipPolicyV2, - tiprack_uri: str, + tip_racks: List[Tuple[Location, LabwareCore]], trash_location: Union[Location, TrashBin, WasteChute], ) -> None: """Execute transfer using liquid class properties. @@ -928,54 +960,135 @@ def transfer_liquid( tiprack_uri: The URI of the tiprack that the transfer settings are for. tip_drop_location: Location where the tip will be dropped (if appropriate). """ - # This function is WIP + if not tip_racks: + raise RuntimeError( + "No tipracks found for pipette in order to perform transfer" + ) + tiprack_uri_for_transfer_props = tip_racks[0][1].get_uri() + # TODO: use the ID returned by load_liquid_class in command annotations self.load_liquid_class( liquid_class=liquid_class, - pipette_load_name=self.get_pipette_name(), # TODO: update this to use load name instead - tiprack_uri=tiprack_uri, + pipette_load_name=self.get_pipette_load_name(), + tiprack_uri=tiprack_uri_for_transfer_props, ) transfer_props = liquid_class.get_for( - # update this to fetch load name instead - pipette=self.get_pipette_name(), - tiprack=tiprack_uri, + pipette=self.get_pipette_load_name(), + tiprack=tiprack_uri_for_transfer_props, ) - aspirate_props = transfer_props.aspirate - - check_valid_volume_parameters( - disposal_volume=0, # No disposal volume for 1-to-1 transfer - air_gap=aspirate_props.retract.air_gap_by_volume.get_for_volume(volume), - max_volume=self.get_max_volume(), - ) - source_dest_per_volume_step = expand_for_volume_constraints( + # TODO: add multi-channel pipette handling here + source_dest_per_volume_step = tx_commons.expand_for_volume_constraints( volumes=[volume for _ in range(len(source))], targets=zip(source, dest), max_volume=self.get_max_volume(), ) + + def _drop_tip() -> None: + if isinstance(trash_location, (TrashBin, WasteChute)): + self.drop_tip_in_disposal_location( + disposal_location=trash_location, + home_after=False, + alternate_tip_drop=True, + ) + elif isinstance(trash_location, Location): + self.drop_tip( + location=trash_location, + well_core=trash_location.labware.as_well()._core, # type: ignore[arg-type] + home_after=False, + alternate_drop_location=True, + ) + + def _pick_up_tip() -> None: + next_tip = self.get_next_tip( + tip_racks=[core for loc, core in tip_racks], + starting_well=None, + ) + if next_tip is None: + raise RuntimeError( + f"No tip available among {tip_racks} for this transfer." + ) + ( + tiprack_loc, + tiprack_uri, + tip_well, + ) = self._get_location_and_well_core_from_next_tip_info(next_tip, tip_racks) + if tiprack_uri != tiprack_uri_for_transfer_props: + raise RuntimeError( + f"Tiprack {tiprack_uri} does not match the tiprack designated " + f"for this transfer- {tiprack_uri_for_transfer_props}." + ) + self.pick_up_tip( + location=tiprack_loc, + well_core=tip_well, + presses=None, + increment=None, + ) + if new_tip == TransferTipPolicyV2.ONCE: - # TODO: update this once getNextTip is implemented - self.get_next_tip() - for step_volume, (src, dest) in source_dest_per_volume_step: # type: ignore[assignment] - if new_tip == TransferTipPolicyV2.ALWAYS: - # TODO: update this once getNextTip is implemented - self.get_next_tip() - - # TODO: add aspirate and dispense - - if new_tip == TransferTipPolicyV2.ALWAYS: - if isinstance(trash_location, (TrashBin, WasteChute)): - self.drop_tip_in_disposal_location( - disposal_location=trash_location, - home_after=False, - alternate_tip_drop=True, - ) - elif isinstance(trash_location, Location): - self.drop_tip( - location=trash_location, - well_core=trash_location.labware.as_well()._core, # type: ignore[arg-type] - home_after=False, - alternate_drop_location=True, + _pick_up_tip() + + prev_src: Optional[Tuple[Location, WellCore]] = None + post_disp_tip_contents = [ + tx_comps_executor.LiquidAndAirGapPair( + liquid=0, + air_gap=0, + ) + ] + for step_volume, src_dest_combo in source_dest_per_volume_step: + step_source, step_destination = src_dest_combo + if new_tip == TransferTipPolicyV2.ALWAYS or ( + new_tip == TransferTipPolicyV2.PER_SOURCE and step_source != prev_src + ): + if prev_src is not None: + _drop_tip() + _pick_up_tip() + post_disp_tip_contents = [ + tx_comps_executor.LiquidAndAirGapPair( + liquid=0, + air_gap=0, ) + ] + + post_asp_tip_contents = self.aspirate_liquid_class( + volume=step_volume, + source=step_source, + transfer_properties=transfer_props, + transfer_type=tx_comps_executor.TransferType.ONE_TO_ONE, + tip_contents=post_disp_tip_contents, + ) + post_disp_tip_contents = self.dispense_liquid_class( + volume=step_volume, + dest=step_destination, + source=step_source, + transfer_properties=transfer_props, + transfer_type=tx_comps_executor.TransferType.ONE_TO_ONE, + tip_contents=post_asp_tip_contents, + trash_location=trash_location, + ) + prev_src = step_source + if new_tip != TransferTipPolicyV2.NEVER: + # TODO: make sure that the tip has air gap when moving to the trash + _drop_tip() + + def _get_location_and_well_core_from_next_tip_info( + self, + tip_info: NextTipInfo, + tip_racks: List[Tuple[Location, LabwareCore]], + ) -> _TipInfo: + tiprack_labware_core = self._protocol_core._labware_cores_by_id[ + tip_info.labwareId + ] + tip_well = tiprack_labware_core.get_well_core(tip_info.tipStartingWell) + + tiprack_loc = [ + loc for loc, lw_core in tip_racks if lw_core == tiprack_labware_core + ] + + return _TipInfo( + Location(tip_well.get_top(0), tiprack_loc[0].labware), + tiprack_labware_core.get_uri(), + tip_well, + ) def aspirate_liquid_class( self, @@ -984,7 +1097,7 @@ def aspirate_liquid_class( transfer_properties: TransferProperties, transfer_type: tx_comps_executor.TransferType, tip_contents: List[tx_comps_executor.LiquidAndAirGapPair], - ) -> tx_comps_executor.LiquidAndAirGapPair: + ) -> List[tx_comps_executor.LiquidAndAirGapPair]: """Execute aspiration steps. 1. Submerge @@ -994,9 +1107,14 @@ def aspirate_liquid_class( 5. Delay- wait inside the liquid 6. Aspirate retract - Return: The last liquid and air gap pair in tip. + Return: List of liquid and air gap pairs in tip. """ aspirate_props = transfer_properties.aspirate + tx_commons.check_valid_volume_parameters( + disposal_volume=0, # No disposal volume for 1-to-1 transfer + air_gap=aspirate_props.retract.air_gap_by_volume.get_for_volume(volume), + max_volume=self.get_max_volume(), + ) source_loc, source_well = source aspirate_point = ( tx_comps_executor.absolute_point_from_position_reference_and_offset( @@ -1013,6 +1131,7 @@ def aspirate_liquid_class( liquid=0, air_gap=0, ) + tip_contents = [last_liquid_and_airgap_in_tip] components_executor = tx_comps_executor.TransferComponentsExecutor( instrument_core=self, transfer_properties=transfer_properties, @@ -1034,7 +1153,9 @@ def aspirate_liquid_class( ) components_executor.aspirate_and_wait(volume=volume) components_executor.retract_after_aspiration(volume=volume) - return components_executor.tip_state.last_liquid_and_air_gap_in_tip + last_contents = components_executor.tip_state.last_liquid_and_air_gap_in_tip + tip_contents[-1] = last_contents + return tip_contents def dispense_liquid_class( self, @@ -1045,7 +1166,7 @@ def dispense_liquid_class( transfer_type: tx_comps_executor.TransferType, tip_contents: List[tx_comps_executor.LiquidAndAirGapPair], trash_location: Union[Location, TrashBin, WasteChute], - ) -> tx_comps_executor.LiquidAndAirGapPair: + ) -> List[tx_comps_executor.LiquidAndAirGapPair]: """Execute single-dispense steps. 1. Move pipette to the ‘submerge’ position with normal speed. - The pipette will move in an arc- move to max z height of labware @@ -1073,7 +1194,7 @@ def dispense_liquid_class( 9. Retract Return: - The last liquid and air gap pair in tip. + List of liquid and air gap pairs in tip. """ dispense_props = transfer_properties.dispense dest_loc, dest_well = dest @@ -1092,6 +1213,7 @@ def dispense_liquid_class( liquid=0, air_gap=0, ) + tip_contents = [last_liquid_and_airgap_in_tip] components_executor = tx_comps_executor.TransferComponentsExecutor( instrument_core=self, transfer_properties=transfer_properties, @@ -1121,7 +1243,9 @@ def dispense_liquid_class( source_location=source[0] if source else None, source_well=source[1] if source else None, ) - return components_executor.tip_state.last_liquid_and_air_gap_in_tip + last_contents = components_executor.tip_state.last_liquid_and_air_gap_in_tip + tip_contents[-1] = last_contents + return tip_contents def retract(self) -> None: """Retract this instrument to the top of the gantry.""" @@ -1218,3 +1342,9 @@ def nozzle_configuration_valid_for_lld(self) -> bool: def delay(self, seconds: float) -> None: """Call a protocol delay.""" self._protocol_core.delay(seconds=seconds, msg=None) + + +class _TipInfo(NamedTuple): + tiprack_location: Location + tiprack_uri: str + tip_well: WellCore 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 209599510fc..d39c796dee1 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 @@ -3,7 +3,7 @@ from enum import Enum from typing import TYPE_CHECKING, Optional, Union -from dataclasses import dataclass +from dataclasses import dataclass, field from opentrons_shared_data.liquid_classes.liquid_class_definition import ( PositionReference, @@ -28,8 +28,8 @@ class LiquidAndAirGapPair: """Pairing of a liquid and air gap in a tip, in that order.""" - liquid: float - air_gap: float + liquid: float = 0 + air_gap: float = 0 @dataclass @@ -50,9 +50,8 @@ class TipState: ready_to_aspirate: bool = True # TODO: maybe use the tip contents from engine state instead. - last_liquid_and_air_gap_in_tip: LiquidAndAirGapPair = LiquidAndAirGapPair( - liquid=0, - air_gap=0, + last_liquid_and_air_gap_in_tip: LiquidAndAirGapPair = field( + default_factory=LiquidAndAirGapPair ) def add_liquid(self, volume: float) -> None: @@ -479,6 +478,8 @@ def _do_touch_tip_and_air_gap( def _add_air_gap(self, air_gap_volume: float) -> None: """Add an air gap.""" + if air_gap_volume == 0: + return aspirate_props = self._transfer_properties.aspirate # The maximum flow rate should be air_gap_volume per second flow_rate = min( diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index cd32045bdea..de918405fdc 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -13,9 +13,10 @@ from opentrons.protocol_api._liquid import LiquidClass from ..disposal_locations import TrashBin, WasteChute from .well import WellCoreType +from .labware import LabwareCoreType -class AbstractInstrument(ABC, Generic[WellCoreType]): +class AbstractInstrument(ABC, Generic[WellCoreType, LabwareCoreType]): @abstractmethod def get_default_speed(self) -> float: ... @@ -318,7 +319,7 @@ def transfer_liquid( source: List[Tuple[types.Location, WellCoreType]], dest: List[Tuple[types.Location, WellCoreType]], new_tip: TransferTipPolicyV2, - tiprack_uri: str, + tip_racks: List[Tuple[types.Location, LabwareCoreType]], trash_location: Union[types.Location, TrashBin, WasteChute], ) -> None: """Transfer a liquid from source to dest according to liquid class properties.""" @@ -358,4 +359,4 @@ def nozzle_configuration_valid_for_lld(self) -> bool: """Check if the nozzle configuration currently supports LLD.""" -InstrumentCoreType = TypeVar("InstrumentCoreType", bound=AbstractInstrument[Any]) +InstrumentCoreType = TypeVar("InstrumentCoreType", bound=AbstractInstrument[Any, Any]) 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 b3efd1cceaf..bbc02702f93 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 @@ -25,6 +25,7 @@ from ...disposal_locations import TrashBin, WasteChute from ..instrument import AbstractInstrument from .legacy_well_core import LegacyWellCore +from .legacy_labware_core import LegacyLabwareCore from .legacy_module_core import LegacyThermocyclerCore, LegacyHeaterShakerCore if TYPE_CHECKING: @@ -37,7 +38,7 @@ """In PAPIv2.1 and below, tips are always dropped 10 mm from the bottom of the well.""" -class LegacyInstrumentCore(AbstractInstrument[LegacyWellCore]): +class LegacyInstrumentCore(AbstractInstrument[LegacyWellCore, LegacyLabwareCore]): """Implementation of the InstrumentContext interface.""" def __init__( @@ -563,7 +564,7 @@ def transfer_liquid( source: List[Tuple[types.Location, LegacyWellCore]], dest: List[Tuple[types.Location, LegacyWellCore]], new_tip: TransferTipPolicyV2, - tiprack_uri: str, + tip_racks: List[Tuple[types.Location, LegacyLabwareCore]], trash_location: Union[types.Location, TrashBin, WasteChute], ) -> None: """This will never be called because it was added in ..""" 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 fcca77ca055..3a898c27a42 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 @@ -23,6 +23,7 @@ UnexpectedTipAttachError, ) +from ..legacy.legacy_labware_core import LegacyLabwareCore from ...disposal_locations import TrashBin, WasteChute from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.protocol_api._liquid import LiquidClass @@ -42,7 +43,9 @@ """In PAPIv2.1 and below, tips are always dropped 10 mm from the bottom of the well.""" -class LegacyInstrumentCoreSimulator(AbstractInstrument[LegacyWellCore]): +class LegacyInstrumentCoreSimulator( + AbstractInstrument[LegacyWellCore, LegacyLabwareCore] +): """A simulation of an instrument context.""" def __init__( @@ -481,7 +484,7 @@ def transfer_liquid( source: List[Tuple[types.Location, LegacyWellCore]], dest: List[Tuple[types.Location, LegacyWellCore]], new_tip: TransferTipPolicyV2, - tiprack_uri: str, + tip_racks: List[Tuple[types.Location, LegacyLabwareCore]], trash_location: Union[types.Location, TrashBin, WasteChute], ) -> None: """Transfer a liquid from source to dest according to liquid class properties.""" diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 7481db736ce..b0f0f666e74 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -1561,15 +1561,9 @@ def transfer_liquid( " of 'once' or 'always'." ) else: - tiprack = self._last_tip_picked_up_from.parent + tip_racks = [self._last_tip_picked_up_from.parent] else: - # TODO: update this with getNextTip result from engine - tiprack, well = labware.next_available_tip( - starting_tip=self.starting_tip, - tip_racks=self.tip_racks, - channels=self.active_channels, - nozzle_map=self._core.get_nozzle_map(), - ) + tip_racks = self._tip_racks if self.current_volume != 0: raise RuntimeError( "A transfer on a liquid class cannot start with liquid already in the tip." @@ -1602,7 +1596,10 @@ def transfer_liquid( for well in flat_dests_list ], new_tip=valid_new_tip, - tiprack_uri=tiprack.uri, + tip_racks=[ + (types.Location(types.Point(), labware=rack), rack._core) + for rack in tip_racks + ], trash_location=checked_trash_location, ) return self diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 51029871d75..a6cb2fc9eaa 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -15,7 +15,7 @@ from typing_extensions import TypeGuard from opentrons_shared_data.labware.labware_definition import LabwareRole -from opentrons_shared_data.pipette.types import PipetteNameType +from opentrons_shared_data.pipette.types import PipetteNameType, PIPETTE_API_NAMES_MAP from opentrons_shared_data.robot.types import RobotType from opentrons.protocols.api_support.types import APIVersion, ThermocyclerStep @@ -54,29 +54,6 @@ # The first APIVersion where Python protocols can specify staging deck slots (e.g. "D4") _STAGING_DECK_SLOT_VERSION_GATE = APIVersion(2, 16) -# Mapping of public Python Protocol API pipette load names -# to names used by the internal Opentrons system -_PIPETTE_NAMES_MAP = { - "p10_single": PipetteNameType.P10_SINGLE, - "p10_multi": PipetteNameType.P10_MULTI, - "p20_single_gen2": PipetteNameType.P20_SINGLE_GEN2, - "p20_multi_gen2": PipetteNameType.P20_MULTI_GEN2, - "p50_single": PipetteNameType.P50_SINGLE, - "p50_multi": PipetteNameType.P50_MULTI, - "p300_single": PipetteNameType.P300_SINGLE, - "p300_multi": PipetteNameType.P300_MULTI, - "p300_single_gen2": PipetteNameType.P300_SINGLE_GEN2, - "p300_multi_gen2": PipetteNameType.P300_MULTI_GEN2, - "p1000_single": PipetteNameType.P1000_SINGLE, - "p1000_single_gen2": PipetteNameType.P1000_SINGLE_GEN2, - "flex_1channel_50": PipetteNameType.P50_SINGLE_FLEX, - "flex_8channel_50": PipetteNameType.P50_MULTI_FLEX, - "flex_1channel_1000": PipetteNameType.P1000_SINGLE_FLEX, - "flex_8channel_1000": PipetteNameType.P1000_MULTI_FLEX, - "flex_96channel_1000": PipetteNameType.P1000_96, - "flex_96channel_200": PipetteNameType.P200_96, -} - class InvalidPipetteMountError(ValueError): """An error raised when attempting to load pipettes on an invalid mount.""" @@ -189,7 +166,7 @@ def ensure_pipette_name(pipette_name: str) -> PipetteNameType: pipette_name = ensure_lowercase_name(pipette_name) try: - return _PIPETTE_NAMES_MAP[pipette_name] + return PIPETTE_API_NAMES_MAP[pipette_name] except KeyError: raise ValueError( f"Cannot resolve {pipette_name} to pipette, must be given valid pipette name." diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index 71837a7a2ca..d0e9812f235 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -95,6 +95,12 @@ def execute_command_without_recovery( ) -> commands.LoadLiquidClassResult: pass + @overload + def execute_command_without_recovery( + self, params: commands.GetNextTipParams + ) -> commands.GetNextTipResult: + pass + def execute_command_without_recovery( self, params: commands.CommandParams ) -> commands.CommandResult: 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 632b07d3288..34dad58d84b 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -16,7 +16,7 @@ from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.dev_types import PipetteDict from opentrons.protocol_api._liquid_properties import TransferProperties -from opentrons.protocol_api.core.engine import transfer_components_executor +from opentrons.protocol_api.core.engine import transfer_components_executor, LabwareCore from opentrons.protocol_api.core.engine.transfer_components_executor import ( TransferComponentsExecutor, TransferType, @@ -38,6 +38,7 @@ ) from opentrons.protocol_engine import commands as cmd from opentrons.protocol_engine.clients.sync_client import SyncClient +from opentrons.protocol_engine.commands import GetNextTipResult from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocol_engine.types import ( @@ -49,6 +50,9 @@ ColumnNozzleLayoutConfiguration, AddressableOffsetVector, LiquidClassRecord, + NextTipInfo, + NoTipAvailable, + NoTipReason, ) from opentrons.protocol_api.disposal_locations import ( TrashBin, @@ -65,6 +69,7 @@ ) from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.advanced_control.transfers import common as tx_commons from opentrons.types import Location, Mount, MountType, Point, NozzleConfigurationType from ... import versions_below, versions_at_or_above @@ -99,6 +104,24 @@ def patch_mock_pipette_movement_safety_check( ) +@pytest.fixture(autouse=True) +def patch_mock_check_valid_volume_parameters( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + """Replace tx_commons.check_valid_volume_parameters() with a mock.""" + mock = decoy.mock(func=tx_commons.check_valid_volume_parameters) + monkeypatch.setattr(tx_commons, "check_valid_volume_parameters", mock) + + +@pytest.fixture(autouse=True) +def patch_mock_expand_for_volume_constraints( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + """Replace tx_commons.expand_for_volume_constraints() with a mock.""" + mock = decoy.mock(func=tx_commons.expand_for_volume_constraints) + monkeypatch.setattr(tx_commons, "expand_for_volume_constraints", mock) + + @pytest.fixture def mock_transfer_components_executor( decoy: Decoy, @@ -176,6 +199,21 @@ def test_get_pipette_name( assert result == "p300_single" +def test_get_pipette_load_name( + decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore +) -> None: + """It should get the pipette's API-specific load name.""" + decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return( + LoadedPipette.construct(pipetteName=PipetteNameType.P300_SINGLE) # type: ignore[call-arg] + ) + assert subject.get_pipette_load_name() == "p300_single" + + decoy.when(mock_engine_client.state.pipettes.get("abc123")).then_return( + LoadedPipette.construct(pipetteName=PipetteNameType.P1000_96) # type: ignore[call-arg] + ) + assert subject.get_pipette_load_name() == "flex_96channel_1000" + + def test_get_mount( decoy: Decoy, mock_engine_client: EngineClient, subject: InstrumentCore ) -> None: @@ -1649,7 +1687,7 @@ def test_aspirate_liquid_class( mock_transfer_components_executor.aspirate_and_wait(volume=123), mock_transfer_components_executor.retract_after_aspiration(volume=123), ) - assert result == LiquidAndAirGapPair(air_gap=222, liquid=111) + assert result == [LiquidAndAirGapPair(air_gap=222, liquid=111)] def test_dispense_liquid_class( @@ -1718,4 +1756,53 @@ def test_dispense_liquid_class( source_well=source_well, ), ) - assert result == LiquidAndAirGapPair(air_gap=444, liquid=333) + assert result == [LiquidAndAirGapPair(air_gap=444, liquid=333)] + + +def test_get_next_tip( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: InstrumentCore, +) -> None: + """It should return the next tip result.""" + tip_racks = [decoy.mock(cls=LabwareCore)] + expected_next_tip = NextTipInfo(labwareId="1234", tipStartingWell="BAR") + decoy.when(tip_racks[0].labware_id).then_return("tiprack-id") + decoy.when( + mock_engine_client.execute_command_without_recovery( + cmd.GetNextTipParams( + pipetteId="abc123", labwareIds=["tiprack-id"], startingTipWell="F00" + ) + ) + ).then_return(GetNextTipResult(nextTipInfo=expected_next_tip)) + result = subject.get_next_tip( + tip_racks=tip_racks, + starting_well="F00", + ) + assert result == expected_next_tip + + +def test_get_next_tip_when_no_tip_available( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: InstrumentCore, +) -> None: + """It should return None when there's no next tip available.""" + tip_racks = [decoy.mock(cls=LabwareCore)] + decoy.when(tip_racks[0].labware_id).then_return("tiprack-id") + decoy.when( + mock_engine_client.execute_command_without_recovery( + cmd.GetNextTipParams( + pipetteId="abc123", labwareIds=["tiprack-id"], startingTipWell="F00" + ) + ) + ).then_return( + GetNextTipResult( + nextTipInfo=NoTipAvailable(noTipReason=NoTipReason.NO_AVAILABLE_TIPS) + ) + ) + result = subject.get_next_tip( + tip_racks=tip_racks, + starting_well="F00", + ) + assert result is None diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index e18842717bc..c73a154c937 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -1950,7 +1950,7 @@ def test_transfer_liquid_delegates_to_engine_core( trash_location = Location(point=Point(1, 2, 3), labware=mock_well) next_tiprack = decoy.mock(cls=Labware) subject.starting_tip = None - subject.tip_racks = tip_racks + subject._tip_racks = tip_racks decoy.when(mock_protocol_core.robot_type).then_return(robot_type) decoy.when( @@ -1964,14 +1964,6 @@ def test_transfer_liquid_delegates_to_engine_core( ) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(MOCK_MAP) decoy.when(mock_instrument_core.get_active_channels()).then_return(2) - decoy.when( - labware.next_available_tip( - starting_tip=None, - tip_racks=tip_racks, - channels=2, - nozzle_map=MOCK_MAP, - ) - ).then_return((next_tiprack, decoy.mock(cls=Well))) decoy.when(mock_instrument_core.get_current_volume()).then_return(0) decoy.when( mock_validation.ensure_valid_trash_location_for_transfer_v2(trash_location) @@ -1993,7 +1985,7 @@ def test_transfer_liquid_delegates_to_engine_core( source=[(Location(Point(), labware=mock_well), mock_well._core)], dest=[(Location(Point(), labware=mock_well), mock_well._core)], new_tip=TransferTipPolicyV2.ONCE, - tiprack_uri="tiprack-uri", + tip_racks=[(Location(Point(), labware=tip_racks[0]), tip_racks[0]._core)], trash_location=trash_location.move(Point(1, 2, 3)), ) ) diff --git a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py index 20bbd2b646c..54112941c4c 100644 --- a/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py +++ b/api/tests/opentrons/protocol_api_integration/test_liquid_classes.py @@ -31,20 +31,17 @@ def test_liquid_class_creation_and_property_fetching( # TODO (spp, 2024-10-17): update this to fetch pipette load name from instrument context assert ( water.get_for( - pipette_load_name, tiprack.load_name + pipette_load_name, tiprack.uri ).dispense.flow_rate_by_volume.get_for_volume(1) == 50 ) - assert ( - water.get_for(pipette_load_name, tiprack.load_name).aspirate.submerge.speed - == 100 - ) + assert water.get_for(pipette_load_name, tiprack.uri).aspirate.submerge.speed == 100 with pytest.raises( ValueError, match="No properties found for non-existent-pipette in water liquid class", ): - water.get_for("non-existent-pipette", tiprack.load_name) + water.get_for("non-existent-pipette", tiprack.uri) with pytest.raises(AttributeError): water.name = "foo" # type: ignore diff --git a/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py b/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py new file mode 100644 index 00000000000..10b4616c0aa --- /dev/null +++ b/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py @@ -0,0 +1,64 @@ +"""Tests for the transfer APIs using liquid classes.""" +import pytest +from decoy import Decoy +from opentrons_shared_data.robot.types import RobotTypeEnum + +from opentrons.protocol_api import ProtocolContext +from opentrons.config import feature_flags as ff + + +@pytest.mark.ot3_only +@pytest.mark.parametrize( + "simulated_protocol_context", [("2.20", "Flex")], indirect=True +) +def test_water_transfer( + decoy: Decoy, mock_feature_flags: None, simulated_protocol_context: ProtocolContext +) -> None: + """It should run the transfer steps without any errors. + + This test only checks that various supported configurations for a transfer + analyze successfully. It doesn't check whether the steps are as expected. + That will be covered in analysis snapshot tests. + """ + decoy.when(ff.allow_liquid_classes(RobotTypeEnum.FLEX)).then_return(True) + trash = simulated_protocol_context.load_trash_bin("A3") + tiprack = simulated_protocol_context.load_labware( + "opentrons_flex_96_tiprack_50ul", "D1" + ) + pipette_50 = simulated_protocol_context.load_instrument( + "flex_1channel_50", mount="left", tip_racks=[tiprack] + ) + nest_plate = simulated_protocol_context.load_labware( + "nest_96_wellplate_200ul_flat", "C3" + ) + arma_plate = simulated_protocol_context.load_labware( + "armadillo_96_wellplate_200ul_pcr_full_skirt", "C2" + ) + + water = simulated_protocol_context.define_liquid_class("water") + pipette_50.transfer_liquid( + liquid_class=water, + volume=60, + source=nest_plate.rows()[0], + dest=arma_plate.rows()[0], + new_tip="always", + trash_location=trash, + ) + pipette_50.transfer_liquid( + liquid_class=water, + volume=60, + source=nest_plate.rows()[0], + dest=arma_plate.rows()[0], + new_tip="per source", + trash_location=trash, + ) + pipette_50.pick_up_tip() + pipette_50.transfer_liquid( + liquid_class=water, + volume=50, + source=nest_plate.rows()[0], + dest=arma_plate.rows()[0], + new_tip="never", + trash_location=trash, + ) + pipette_50.drop_tip() diff --git a/shared-data/liquid-class/definitions/1/water.json b/shared-data/liquid-class/definitions/1/water.json index b9447aa9c52..d7bcd1afdc3 100644 --- a/shared-data/liquid-class/definitions/1/water.json +++ b/shared-data/liquid-class/definitions/1/water.json @@ -8,7 +8,7 @@ "pipetteModel": "flex_1channel_50", "byTipType": [ { - "tiprack": "opentrons_flex_96_tiprack_50ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_50ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -235,7 +235,7 @@ "pipetteModel": "flex_8channel_50", "byTipType": [ { - "tiprack": "opentrons_flex_96_tiprack_50ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_50ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -462,7 +462,7 @@ "pipetteModel": "flex_1channel_1000", "byTipType": [ { - "tiprack": "opentrons_flex_96_tiprack_50ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_50ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -686,7 +686,7 @@ } }, { - "tiprack": "opentrons_flex_96_tiprack_200ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_200ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -898,7 +898,7 @@ } }, { - "tiprack": "opentrons_flex_96_tiprack_1000ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_1000ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -1115,7 +1115,7 @@ "pipetteModel": "flex_8channel_1000", "byTipType": [ { - "tiprack": "opentrons_flex_96_tiprack_50ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_50ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -1339,7 +1339,7 @@ } }, { - "tiprack": "opentrons_flex_96_tiprack_200ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_200ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -1551,7 +1551,7 @@ } }, { - "tiprack": "opentrons_flex_96_tiprack_1000ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_1000ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -1768,7 +1768,7 @@ "pipetteModel": "flex_96channel_1000", "byTipType": [ { - "tiprack": "opentrons_flex_96_tiprack_50ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_50ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -1980,7 +1980,7 @@ } }, { - "tiprack": "opentrons_flex_96_tiprack_200ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_200ul/1", "aspirate": { "submerge": { "positionReference": "well-top", @@ -2192,7 +2192,7 @@ } }, { - "tiprack": "opentrons_flex_96_tiprack_1000ul", + "tiprack": "opentrons/opentrons_flex_96_tiprack_1000ul/1", "aspirate": { "submerge": { "positionReference": "well-top", diff --git a/shared-data/python/opentrons_shared_data/pipette/types.py b/shared-data/python/opentrons_shared_data/pipette/types.py index 685dae89957..0706805b687 100644 --- a/shared-data/python/opentrons_shared_data/pipette/types.py +++ b/shared-data/python/opentrons_shared_data/pipette/types.py @@ -202,7 +202,6 @@ def dict_for_encode(self) -> bool: OverrideType = Dict[str, Union[Dict[str, QuirkConfig], MutableConfig, str]] - PipetteName = Literal[ "p10_single", "p10_multi", @@ -252,6 +251,31 @@ class PipetteNameType(str, enum.Enum): P200_96 = "p200_96" +# Mapping of public Python Protocol API pipette load names +# to names used by the internal Opentrons system +# TODO (spp, 2025-01-02): make the API load name a part of the pipette's definition +PIPETTE_API_NAMES_MAP = { + "p10_single": PipetteNameType.P10_SINGLE, + "p10_multi": PipetteNameType.P10_MULTI, + "p20_single_gen2": PipetteNameType.P20_SINGLE_GEN2, + "p20_multi_gen2": PipetteNameType.P20_MULTI_GEN2, + "p50_single": PipetteNameType.P50_SINGLE, + "p50_multi": PipetteNameType.P50_MULTI, + "p300_single": PipetteNameType.P300_SINGLE, + "p300_multi": PipetteNameType.P300_MULTI, + "p300_single_gen2": PipetteNameType.P300_SINGLE_GEN2, + "p300_multi_gen2": PipetteNameType.P300_MULTI_GEN2, + "p1000_single": PipetteNameType.P1000_SINGLE, + "p1000_single_gen2": PipetteNameType.P1000_SINGLE_GEN2, + "flex_1channel_50": PipetteNameType.P50_SINGLE_FLEX, + "flex_8channel_50": PipetteNameType.P50_MULTI_FLEX, + "flex_1channel_1000": PipetteNameType.P1000_SINGLE_FLEX, + "flex_8channel_1000": PipetteNameType.P1000_MULTI_FLEX, + "flex_96channel_1000": PipetteNameType.P1000_96, + "flex_96channel_200": PipetteNameType.P200_96, +} + + # Generic NewType for models because we get new ones frequently and theres # a huge number of them PipetteModel = NewType("PipetteModel", str)