Skip to content

Commit

Permalink
feat(api): add WellVolumeOffset to WellLocation (#16302)
Browse files Browse the repository at this point in the history
<!--
Thanks for taking the time to open a Pull Request (PR)! Please make sure
you've read the "Opening Pull Requests" section of our Contributing
Guide:


https://github.com/Opentrons/opentrons/blob/edge/CONTRIBUTING.md#opening-pull-requests

GitHub provides robust markdown to format your PR. Links, diagrams,
pictures, and videos along with text formatting make it possible to
create a rich and informative PR. For more information on GitHub
markdown, see:


https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax

To ensure your code is reviewed quickly and thoroughly, please fill out
the sections below to the best of your ability!
-->

# Overview

<!--
Describe your PR at a high level. State acceptance criteria and how this
PR fits into other work. Link issues, PRs, and other relevant resources.
-->

This PR enables static meniscus-relative aspirate and dispense via the
Protocol API. To enable this, WellVolumeOffset has been added to
WellLocation. This is a volume of liquid to account for when executing
commands with an origin of WellOrigin.MENISCUS. Specifying
`operationVolume` results in this class acting as a sentinel and should
be used when volume can be determined from the command parameters, for
example commanding Aspirate. A volume should be specified when it cannot
be determined from the command parameters, for example commanding
MoveToWell prior to AspirateInPlace.

## Test Plan and Hands on Testing

<!--
Describe your testing of the PR. Emphasize testing not reflected in the
code. Attach protocols, logs, screenshots and any other assets that
support your testing.
-->

Successfully tested the following protocol on a robot multiple times in
a row. Aspiration from meniscus was not tested due to not-yet-available
InnerWellGeometry:
```
from opentrons.protocol_api import ProtocolContext
metadata = {"protocolName": "Test LLD"}
requirements = {"robotType": "Flex", "apiLevel":"2.21"}


def run(ctx: ProtocolContext) -> None:
    """Run."""
    tiprack = ctx.load_labware(f"opentrons_flex_96_tiprack_1000uL", "A3")
    source = ctx.load_labware("nest_12_reservoir_15ml", "C2")
    sink = ctx.load_labware("nest_96_wellplate_100ul_pcr_full_skirt", "D2")
    pipette = ctx.load_instrument("flex_1channel_1000", "left", liquid_presence_detection = True)

    pipette.pick_up_tip(tiprack)
    pipette.measure_liquid_height(sink["A1"])
    pipette.aspirate(10, source["A1"])
    pipette.dispense(10, sink["A1"].meniscus(-2))
    pipette.return_tip()
```

## Changelog

<!--
List changes introduced by this PR considering future developers and the
end user. Give careful thought and clear documentation to breaking
changes.
-->

## Review requests

<!--
- What do you need from reviewers to feel confident this PR is ready to
merge?
- Ask questions.
-->

## Risk assessment

<!--
- Indicate the level of attention this PR needs.
- Provide context to guide reviewers.
- Discuss trade-offs, coupling, and side effects.
- Look for the possibility, even if you think it's small, that your
change may affect some other part of the system.
- For instance, changing return tip behavior may also change the
behavior of labware calibration.
- How do your unit tests and on hands on testing mitigate this PR's
risks and the risk of future regressions?
- Especially in high risk PRs, explain how you know your testing is
enough.
-->
  • Loading branch information
pmoegenburg authored Oct 17, 2024
1 parent a8027f9 commit 9797d74
Show file tree
Hide file tree
Showing 50 changed files with 1,170 additions and 226 deletions.
34 changes: 18 additions & 16 deletions api/src/opentrons/protocol_api/core/engine/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def aspirate(
rate: float,
flow_rate: float,
in_place: bool,
is_meniscus: Optional[bool] = None,
) -> None:
"""Aspirate a given volume of liquid from the specified location.
Args:
Expand Down Expand Up @@ -146,12 +147,11 @@ def aspirate(
well_name = well_core.get_name()
labware_id = well_core.labware_id

well_location = (
self._engine_client.state.geometry.get_relative_well_location(
labware_id=labware_id,
well_name=well_name,
absolute_point=location.point,
)
well_location = self._engine_client.state.geometry.get_relative_liquid_handling_well_location(
labware_id=labware_id,
well_name=well_name,
absolute_point=location.point,
is_meniscus=is_meniscus,
)
pipette_movement_conflict.check_safe_for_pipette_movement(
engine_state=self._engine_client.state,
Expand Down Expand Up @@ -182,6 +182,7 @@ def dispense(
flow_rate: float,
in_place: bool,
push_out: Optional[float],
is_meniscus: Optional[bool] = None,
) -> None:
"""Dispense a given volume of liquid into the specified location.
Args:
Expand Down Expand Up @@ -237,12 +238,11 @@ def dispense(
well_name = well_core.get_name()
labware_id = well_core.labware_id

well_location = (
self._engine_client.state.geometry.get_relative_well_location(
labware_id=labware_id,
well_name=well_name,
absolute_point=location.point,
)
well_location = self._engine_client.state.geometry.get_relative_liquid_handling_well_location(
labware_id=labware_id,
well_name=well_name,
absolute_point=location.point,
is_meniscus=is_meniscus,
)
pipette_movement_conflict.check_safe_for_pipette_movement(
engine_state=self._engine_client.state,
Expand Down Expand Up @@ -416,10 +416,12 @@ def pick_up_tip(
well_name = well_core.get_name()
labware_id = well_core.labware_id

well_location = self._engine_client.state.geometry.get_relative_well_location(
labware_id=labware_id,
well_name=well_name,
absolute_point=location.point,
well_location = (
self._engine_client.state.geometry.get_relative_pick_up_tip_well_location(
labware_id=labware_id,
well_name=well_name,
absolute_point=location.point,
)
)
pipette_movement_conflict.check_safe_for_tip_pickup_and_return(
engine_state=self._engine_client.state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
DeckSlotLocation,
OnLabwareLocation,
WellLocation,
LiquidHandlingWellLocation,
PickUpTipWellLocation,
DropTipWellLocation,
)
from opentrons.protocol_engine.types import (
Expand Down Expand Up @@ -66,7 +68,12 @@ def check_safe_for_pipette_movement(
pipette_id: str,
labware_id: str,
well_name: str,
well_location: Union[WellLocation, DropTipWellLocation],
well_location: Union[
WellLocation,
LiquidHandlingWellLocation,
PickUpTipWellLocation,
DropTipWellLocation,
],
) -> None:
"""Check if the labware is safe to move to with a pipette in partial tip configuration.
Expand Down
11 changes: 0 additions & 11 deletions api/src/opentrons/protocol_api/core/engine/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,6 @@ def get_center(self) -> Point:
well_location=WellLocation(origin=WellOrigin.CENTER),
)

def get_meniscus(self, z_offset: float) -> Point:
"""Get the coordinate of the well's meniscus, with a z-offset."""
return self._engine_client.state.geometry.get_well_position(
well_name=self._name,
labware_id=self._labware_id,
well_location=WellLocation(
origin=WellOrigin.MENISCUS,
offset=WellOffset(x=0, y=0, z=z_offset),
),
)

def load_liquid(
self,
liquid: Liquid,
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_api/core/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def aspirate(
rate: float,
flow_rate: float,
in_place: bool,
is_meniscus: Optional[bool] = None,
) -> None:
"""Aspirate a given volume of liquid from the specified location.
Args:
Expand All @@ -55,6 +56,7 @@ def dispense(
flow_rate: float,
in_place: bool,
push_out: Optional[float],
is_meniscus: Optional[bool] = None,
) -> None:
"""Dispense a given volume of liquid into the specified location.
Args:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def aspirate(
rate: float,
flow_rate: float,
in_place: bool,
is_meniscus: Optional[bool] = None,
) -> None:
"""Aspirate a given volume of liquid from the specified location.
Args:
Expand Down Expand Up @@ -122,6 +123,7 @@ def dispense(
flow_rate: float,
in_place: bool,
push_out: Optional[float],
is_meniscus: Optional[bool] = None,
) -> None:
"""Dispense a given volume of liquid into the specified location.
Args:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,6 @@ def get_center(self) -> Point:
"""Get the coordinate of the well's center."""
return self._geometry.center()

def get_meniscus(self, z_offset: float) -> Point:
"""This will never be called because it was added in API 2.21."""
assert False, "get_meniscus only supported in API 2.21 & later"

def load_liquid(
self,
liquid: Liquid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def aspirate(
rate: float,
flow_rate: float,
in_place: bool,
is_meniscus: Optional[bool] = None,
) -> None:
if self.get_current_volume() == 0:
# Make sure we're at the top of the labware and clear of any
Expand Down Expand Up @@ -132,6 +133,7 @@ def dispense(
flow_rate: float,
in_place: bool,
push_out: Optional[float],
is_meniscus: Optional[bool] = None,
) -> None:
if isinstance(location, (TrashBin, WasteChute)):
raise APIVersionError(
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 @@ -71,10 +71,6 @@ def get_bottom(self, z_offset: float) -> Point:
def get_center(self) -> Point:
"""Get the coordinate of the well's center."""

@abstractmethod
def get_meniscus(self, z_offset: float) -> Point:
"""Get the coordinate of the well's meniscus, with an z-offset."""

@abstractmethod
def load_liquid(
self,
Expand Down
37 changes: 29 additions & 8 deletions api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ def aspirate(
)
)

well: Optional[labware.Well] = None
move_to_location: types.Location
well: Optional[labware.Well] = None
is_meniscus: Optional[bool] = None
last_location = self._get_last_location_by_api_version()
try:
target = validation.validate_location(
Expand All @@ -232,17 +233,13 @@ def aspirate(
"knows where it is."
) from e

if isinstance(target, validation.WellTarget):
move_to_location = target.location or target.well.bottom(
z=self._well_bottom_clearances.aspirate
)
well = target.well
if isinstance(target, validation.PointTarget):
move_to_location = target.location
if isinstance(target, (TrashBin, WasteChute)):
raise ValueError(
"Trash Bin and Waste Chute are not acceptable location parameters for Aspirate commands."
)
move_to_location, well, is_meniscus = self._handle_aspirate_target(
target=target
)
if self.api_version >= APIVersion(2, 11):
instrument.validate_takes_liquid(
location=move_to_location,
Expand Down Expand Up @@ -282,6 +279,7 @@ def aspirate(
rate=rate,
flow_rate=flow_rate,
in_place=target.in_place,
is_meniscus=is_meniscus,
)

return self
Expand Down Expand Up @@ -384,6 +382,7 @@ def dispense( # noqa: C901
)
)
well: Optional[labware.Well] = None
is_meniscus: Optional[bool] = None
last_location = self._get_last_location_by_api_version()

try:
Expand All @@ -402,6 +401,7 @@ def dispense( # noqa: C901
well = target.well
if target.location:
move_to_location = target.location
is_meniscus = target.location.is_meniscus
elif well.parent._core.is_fixed_trash():
move_to_location = target.well.top()
else:
Expand Down Expand Up @@ -467,6 +467,7 @@ def dispense( # noqa: C901
flow_rate=flow_rate,
in_place=target.in_place,
push_out=push_out,
is_meniscus=is_meniscus,
)

return self
Expand Down Expand Up @@ -2191,6 +2192,26 @@ def _raise_if_configuration_not_supported_by_pipette(
)
# SINGLE, QUADRANT and ALL are supported by all pipettes

def _handle_aspirate_target(
self, target: validation.ValidTarget
) -> tuple[types.Location, Optional[labware.Well], Optional[bool]]:
move_to_location: types.Location
well: Optional[labware.Well] = None
is_meniscus: Optional[bool] = None
if isinstance(target, validation.WellTarget):
well = target.well
if target.location:
move_to_location = target.location
is_meniscus = target.location.is_meniscus

else:
move_to_location = target.well.bottom(
z=self._well_bottom_clearances.aspirate
)
if isinstance(target, validation.PointTarget):
move_to_location = target.location
return (move_to_location, well, is_meniscus)


class AutoProbeDisable:
"""Use this class to temporarily disable automatic liquid presence detection."""
Expand Down
8 changes: 4 additions & 4 deletions api/src/opentrons/protocol_api/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,13 @@ def meniscus(self, z: float = 0.0) -> Location:
"""
:param z: An offset on the z-axis, in mm. Positive offsets are higher and
negative offsets are lower.
:return: A :py:class:`~opentrons.types.Location` corresponding to the
absolute position of the meniscus-center of the well, plus the ``z`` offset
(if specified).
:return: A :py:class:`~opentrons.types.Location` that indicates location is meniscus and that holds the ``z`` offset in its point.z field.
:meta private:
"""
return Location(self._core.get_meniscus(z_offset=z), self)
return Location(
point=Point(x=0, y=0, z=z), labware=self, _ot_internal_is_meniscus=True
)

@requires_version(2, 8)
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
Expand Down
5 changes: 4 additions & 1 deletion api/src/opentrons/protocol_api/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,10 +435,13 @@ class LocationTypeError(TypeError):
"""Error representing that the location supplied is of different expected type."""


ValidTarget = Union[WellTarget, PointTarget, TrashBin, WasteChute]


def validate_location(
location: Union[Location, Well, TrashBin, WasteChute, None],
last_location: Optional[Location],
) -> Union[WellTarget, PointTarget, TrashBin, WasteChute]:
) -> ValidTarget:
"""Validate a given location for a liquid handling command.
Args:
Expand Down
6 changes: 6 additions & 0 deletions api/src/opentrons/protocol_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@
LoadedPipette,
MotorAxis,
WellLocation,
LiquidHandlingWellLocation,
PickUpTipWellLocation,
DropTipWellLocation,
WellOrigin,
DropTipWellOrigin,
PickUpTipWellOrigin,
WellOffset,
ModuleModel,
ModuleDefinition,
Expand Down Expand Up @@ -109,9 +112,12 @@
"LoadedPipette",
"MotorAxis",
"WellLocation",
"LiquidHandlingWellLocation",
"PickUpTipWellLocation",
"DropTipWellLocation",
"WellOrigin",
"DropTipWellOrigin",
"PickUpTipWellOrigin",
"WellOffset",
"ModuleModel",
"ModuleDefinition",
Expand Down
11 changes: 8 additions & 3 deletions api/src/opentrons/protocol_engine/commands/aspirate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
PipetteIdMixin,
AspirateVolumeMixin,
FlowRateMixin,
WellLocationMixin,
LiquidHandlingWellLocationMixin,
BaseLiquidHandlingResult,
DestinationPositionResult,
)
Expand Down Expand Up @@ -38,7 +38,7 @@


class AspirateParams(
PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin, WellLocationMixin
PipetteIdMixin, AspirateVolumeMixin, FlowRateMixin, LiquidHandlingWellLocationMixin
):
"""Parameters required to aspirate from a specific well."""

Expand Down Expand Up @@ -112,12 +112,17 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
well_name=well_name,
)

well_location = params.wellLocation
if well_location.origin == WellOrigin.MENISCUS:
well_location.volumeOffset = "operationVolume"

position = await self._movement.move_to_well(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
well_location=params.wellLocation,
well_location=well_location,
current_well=current_well,
operation_volume=-params.volume,
)
deck_point = DeckPoint.construct(x=position.x, y=position.y, z=position.z)
state_update.set_pipette_location(
Expand Down
Loading

0 comments on commit 9797d74

Please sign in to comment.