Skip to content

Commit

Permalink
feat(api): add labware-scope liquid loading api
Browse files Browse the repository at this point in the history
We had the ability to load liquids on individual Wells, but this creates
an engine command per well - which can be very onerous for large labware
- and requires loops and such to load liquids. The common case for
loading liquids is that everything is the same, and the second common
case is that you care about a labware as a whole at a time (e.g. because
you're reading from a platemap).

We can support these uses better by having Labware.load_liquid (for many
wells with the same amount of liquid), Labware.load_liquid_by_well (more
complex, for more complex use cases) and Labware.load_empty, all taking
arguments of the type that you can derive from other PAPI methods or
domain-specific reasoning.

Also, remove well.load_empty because it wasn't shipped yet and is now
duplicative.

Closes EXEC-825
  • Loading branch information
sfoster1 committed Nov 8, 2024
1 parent cfa0bd2 commit 2a3384a
Show file tree
Hide file tree
Showing 10 changed files with 544 additions and 47 deletions.
23 changes: 22 additions & 1 deletion api/src/opentrons/protocol_api/core/engine/labware.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""ProtocolEngine-based Labware core implementations."""
from typing import List, Optional, cast

from typing import List, Optional, cast, Dict

from opentrons_shared_data.labware.types import (
LabwareParameters as LabwareParametersDict,
Expand All @@ -22,7 +23,9 @@
from opentrons.types import DeckSlotName, Point, NozzleMapInterface


from ..._liquid import Liquid
from ..labware import AbstractLabware, LabwareLoadParams

from .well import WellCore


Expand Down Expand Up @@ -194,3 +197,21 @@ def get_deck_slot(self) -> Optional[DeckSlotName]:
LocationIsStagingSlotError,
):
return None

def load_liquid(self, volumes: Dict[str, float], liquid: Liquid) -> None:
"""Load liquid into wells of the labware."""
self._engine_client.execute_command(
cmd.LoadLiquidParams(
labwareId=self._labware_id, liquidId=liquid._id, volumeByWell=volumes
)
)

def load_empty(self, wells: List[str]) -> None:
"""Mark wells of the labware as empty."""
self._engine_client.execute_command(
cmd.LoadLiquidParams(
labwareId=self._labware_id,
liquidId="EMPTY",
volumeByWell={well: 0.0 for well in wells},
)
)
16 changes: 0 additions & 16 deletions api/src/opentrons/protocol_api/core/engine/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,22 +142,6 @@ def load_liquid(
)
)

def load_empty(
self,
) -> None:
"""Inform the system that a well is known to be empty.
This should be done early in the protocol, at the same time as a load_liquid command might
be used.
"""
self._engine_client.execute_command(
cmd.LoadLiquidParams(
labwareId=self._labware_id,
liquidId="EMPTY",
volumeByWell={self._name: 0.0},
)
)

def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
well_size = self._engine_client.state.labware.get_well_size(
Expand Down
12 changes: 11 additions & 1 deletion api/src/opentrons/protocol_api/core/labware.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""The interface that implements InstrumentContext."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any, Generic, List, NamedTuple, Optional, TypeVar
from typing import Any, Generic, List, NamedTuple, Optional, TypeVar, Dict

from opentrons_shared_data.labware.types import (
LabwareUri,
Expand All @@ -11,6 +12,7 @@
)

from opentrons.types import DeckSlotName, Point, NozzleMapInterface
from .._liquid import Liquid

from .well import WellCoreType

Expand Down Expand Up @@ -129,5 +131,13 @@ def get_well_core(self, well_name: str) -> WellCoreType:
def get_deck_slot(self) -> Optional[DeckSlotName]:
"""Get the deck slot the labware or its parent is in, if any."""

@abstractmethod
def load_liquid(self, volumes: Dict[str, float], liquid: Liquid) -> None:
"""Load liquid into wells of the labware."""

@abstractmethod
def load_empty(self, wells: List[str]) -> None:
"""Mark wells of the labware as empty."""


LabwareCoreType = TypeVar("LabwareCoreType", bound=AbstractLabware[Any])
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import List, Optional, Dict

from opentrons.calibration_storage import helpers
from opentrons.protocols.geometry.labware_geometry import LabwareGeometry
Expand All @@ -8,6 +8,7 @@

from opentrons_shared_data.labware.types import LabwareParameters, LabwareDefinition

from ..._liquid import Liquid
from ..labware import AbstractLabware, LabwareLoadParams
from .legacy_well_core import LegacyWellCore
from .well_geometry import WellGeometry
Expand Down Expand Up @@ -215,3 +216,11 @@ def get_deck_slot(self) -> Optional[DeckSlotName]:
"""Get the deck slot the labware is in, if in a deck slot."""
slot = self._geometry.parent.labware.first_parent()
return DeckSlotName.from_primitive(slot) if slot is not None else None

def load_liquid(self, volumes: Dict[str, float], liquid: Liquid) -> None:
"""Load liquid into wells of the labware."""
assert False, "load_liquid only supported in API version 2.22 & later"

def load_empty(self, wells: List[str]) -> None:
"""Mark wells of the labware as empty."""
assert False, "load_empty only supported in API version 2.22 & later"
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,6 @@ def load_liquid(
"""Load liquid into a well."""
raise APIVersionError(api_element="Loading a liquid")

def load_empty(self) -> None:
"""Mark a well as empty."""
assert False, "load_empty only supported on engine core"

def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
return self._geometry.from_center_cartesian(x, y, z)
Expand Down
4 changes: 0 additions & 4 deletions api/src/opentrons/protocol_api/core/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ def load_liquid(
) -> None:
"""Load liquid into a well."""

@abstractmethod
def load_empty(self) -> None:
"""Mark a well as containing no liquid."""

@abstractmethod
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
Expand Down
155 changes: 146 additions & 9 deletions api/src/opentrons/protocol_api/labware.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" opentrons.protocol_api.labware: classes and functions for labware handling
"""opentrons.protocol_api.labware: classes and functions for labware handling
This module provides things like :py:class:`Labware`, and :py:class:`Well`
to encapsulate labware instances used in protocols
Expand All @@ -13,7 +13,18 @@
import logging

from itertools import dropwhile
from typing import TYPE_CHECKING, Any, List, Dict, Optional, Union, Tuple, cast
from typing import (
TYPE_CHECKING,
Any,
List,
Dict,
Optional,
Union,
Tuple,
cast,
Sequence,
Mapping,
)

from opentrons_shared_data.labware.types import LabwareDefinition, LabwareParameters

Expand Down Expand Up @@ -281,19 +292,15 @@ def load_liquid(self, liquid: Liquid, volume: float) -> None:
:param Liquid liquid: The liquid to load into the well.
:param float volume: The volume of liquid to load, in µL.
.. note::
In API version 2.22 and later, use :py:meth:`~.Well.load_empty()` to mark a well as empty at the beginning of a protocol, rather than using this method with ``volume=0``.
.. deprecated:: 2.22
In API version 2.22 and later, use :py:meth:`~Labware.load_liquid`, :py:meth:`~Labware.load_liquid_by_well`,
or :py:meth:`~Labware.load_empty` to load liquid into a well.
"""
self._core.load_liquid(
liquid=liquid,
volume=volume,
)

@requires_version(2, 22)
def load_empty(self) -> None:
"""Mark a well as empty."""
self._core.load_empty()

def _from_center_cartesian(self, x: float, y: float, z: float) -> Point:
"""
Private version of from_center_cartesian. Present only for backward
Expand Down Expand Up @@ -399,6 +406,9 @@ def api_version(self) -> APIVersion:
def __getitem__(self, key: str) -> Well:
return self.wells_by_name()[key]

def __contains__(self, key: str) -> bool:
return key in self.wells_by_name()

@property
@requires_version(2, 0)
def uri(self) -> str:
Expand Down Expand Up @@ -1113,6 +1123,133 @@ def reset(self) -> None:
"""
self._core.reset_tips()

@requires_version(2, 22)
def load_liquid(
self, wells: Sequence[Union[str, Well]], volume: float, liquid: Liquid
) -> None:
"""Mark several wells as containing the same amount of liquid.
This method should be called at the beginning of a protocol, soon after loading the labware and before
liquid handling operations begin. It is a base of information for liquid tracking functionality. If a well in a labware
has not been named in a call to :py:meth:`~Labware.load_empty`, :py:meth:`~Labware.load_liquid`, or
:py:meth:`~Labware.load_liquid_by_well`, the volume it contains is unknown and the well's liquid will not be tracked.
For example, to load 10µL of a liquid named ``water`` (defined with :py:meth:`~ProtocolContext.load_liquid`)
into all the wells of a labware, you could call ``labware.load_liquid(labware.wells(), 10, water)``.
If you want to load different volumes of liquid into different wells, use :py:meth:`~Labware.load_liquid_by_well`.
If you want to mark the well as containing no liquid, use :py:meth:`~Labware.load_empty`.
:param wells: The wells to load the liquid into.
:type wells: List of well names or list of Well objects, for instance from :py:meth:`~Labware.wells`.
:param volume: The volume of liquid to load into each well, in 10µL.
:type volume: float
:param liquid: The liquid to load into each well, previously defined by :py:meth:`~ProtocolContext.load_liquid`
:type liquid: Liquid
"""
well_names: List[str] = []
for well in wells:
if isinstance(well, str):
if well not in self:
raise KeyError(
"The elements of wells should name wells in this labware."
)
well_names.append(well)
elif isinstance(well, Well):
if well.parent is not self:
raise KeyError(
"The elements of wells should be wells of this labware."
)
well_names.append(well.well_name)
else:
raise TypeError(
"The elements of wells should be Well instances or well names."
)
self._core.load_liquid({well_name: volume for well_name in well_names}, liquid)

@requires_version(2, 22)
def load_liquid_by_well(
self, volumes: Mapping[Union[str, Well], float], liquid: Liquid
) -> None:
"""Mark several wells as containing unique volumes of liquid.
This method should be called at the beginning of a protocol, soon after loading the labware and before
liquid handling operations begin. It is a base of information for liquid tracking functionality. If a well in a labware
has not been named in a call to :py:meth:`~Labware.load_empty`, :py:meth:`~Labware.load_liquid`, or
:py:meth:`~Labware.load_liquid_by_well`, the volume it contains is unknown and the well's liquid will not be tracked.
For example, to load a decreasing amount of of a liquid named ``water`` (defined with :py:meth:`~ProtocolContext.load_liquid`)
into each successive well of a row, you could call
``labware.load_liquid_by_well({'A1': 1000, 'A2': 950, 'A3': 900, ..., 'A12': 600}, water)``
If you want to load the same volume of a liquid into multiple wells, it is often easier to use :py:meth:`~Labware.load_liquid`.
If you want to mark the well as containing no liquid, use :py:meth:`~Labware.load_empty`.
:param volumes: A dictionary of well names (or :py:class:`Well` objects, for instance from ``labware['A1']``)
:type wells: Dict[Union[str, Well], float]
:param liquid: The liquid to load into each well, previously defined by :py:meth:`~ProtocolContext.load_liquid`
:type liquid: Liquid
"""
verified_volumes: Dict[str, float] = {}
for well, volume in volumes.items():
if isinstance(well, str):
if well not in self:
raise KeyError(
"The keys of volumes should name wells in this labware"
)
verified_volumes[well] = volume
elif isinstance(well, Well):
if well.parent is not self:
raise KeyError(
"The keys of volumes should be wells of this labware"
)
verified_volumes[well.well_name] = volume
else:
raise TypeError(
"The elements of wells should be Well instances or well names."
)
self._core.load_liquid(verified_volumes, liquid)

@requires_version(2, 22)
def load_empty(self, wells: Sequence[Union[Well, str]]) -> None:
"""Mark several wells as empty.
This method should be called at the beginning of a protocol, soon after loading the labware and before liquid handling
operations begin. It is a base of information for liquid tracking functionality. If a well in a labware has not been named
in a call to :py:meth:`Labware.load_empty`, :py:meth:`Labware.load_liquid`, or :py:meth:`Labware.load_liquid_by_well`, the
volume it contains is unknown and the well's liquid will not be tracked.
For instance, to mark all wells in the labware as empty, you can call ``labware.load_empty(labware.wells())``.
:param wells: The list of wells to mark empty. To mark all wells as empty, pass ``labware.wells()``. You can also specify
wells by their names (for instance, ``labware.load_empty(['A1', 'A2'])``).
:type wells: Union[List[Well], List[str]]
"""
well_names: List[str] = []
for well in wells:
if isinstance(well, str):
if well not in self:
raise KeyError(
"The elements of wells should name wells in this labware."
)
well_names.append(well)
elif isinstance(well, Well):
if well.parent is not self:
raise KeyError(
"The elements of wells should be wells of this labware."
)
well_names.append(well.well_name)
else:
raise TypeError(
"The elements of wells should be Well instances or well names."
)
self._core.load_empty(well_names)


# TODO(mc, 2022-11-09): implementation detail, move to core
def split_tipracks(tip_racks: List[Labware]) -> Tuple[Labware, List[Labware]]:
Expand Down
Loading

0 comments on commit 2a3384a

Please sign in to comment.