Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): implement the liquid transfer function #17179

Draft
wants to merge 7 commits into
base: AUTH-866-add-transfer-flow-builder-2
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/src/opentrons/protocol_api/core/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

WellCore = AbstractWellCore
LabwareCore = AbstractLabware[WellCore]
InstrumentCore = AbstractInstrument[WellCore]
InstrumentCore = AbstractInstrument[WellCore, LabwareCore]
ModuleCore = AbstractModuleCore
TemperatureModuleCore = AbstractTemperatureModuleCore
MagneticModuleCore = AbstractMagneticModuleCore
Expand Down
240 changes: 185 additions & 55 deletions api/src/opentrons/protocol_api/core/engine/instrument.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions api/src/opentrons/protocol_api/core/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
...
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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])
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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__(
Expand Down Expand Up @@ -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 .."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__(
Expand Down Expand Up @@ -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."""
Expand Down
15 changes: 6 additions & 9 deletions api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down
27 changes: 2 additions & 25 deletions api/src/opentrons/protocol_api/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."
Expand Down
6 changes: 6 additions & 0 deletions api/src/opentrons/protocol_engine/clients/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand All @@ -49,6 +50,9 @@
ColumnNozzleLayoutConfiguration,
AddressableOffsetVector,
LiquidClassRecord,
NextTipInfo,
NoTipAvailable,
NoTipReason,
)
from opentrons.protocol_api.disposal_locations import (
TrashBin,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
12 changes: 2 additions & 10 deletions api/tests/opentrons/protocol_api/test_instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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)),
)
)
Loading
Loading