Skip to content

Commit

Permalink
feat(api): add meniscus wellOrigin enum (#16139)
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 adds MENISCUS to the WellOrigin enums. This also adds methods that
support using WellOrigin.MENISCUS. With this PR, Protocol Engine State
GeometryView `get_well_position()` now accepts calls specifying
WellOrigin.MENISCUS and pulls a well's liquid height (relative to well
bottom) from Protocol Engine State WellView. Currently, a well's liquid
height is populated only once a successful liquid probe has been done
for that well.

## 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.
-->

## 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 Sep 17, 2024
1 parent 3c37650 commit 4064bdf
Show file tree
Hide file tree
Showing 16 changed files with 169 additions and 4 deletions.
11 changes: 11 additions & 0 deletions api/src/opentrons/protocol_api/core/engine/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ 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
4 changes: 4 additions & 0 deletions api/src/opentrons/protocol_api/core/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ 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
11 changes: 11 additions & 0 deletions api/src/opentrons/protocol_api/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ def center(self) -> Location:
"""
return Location(self._core.get_center(), self)

@requires_version(2, 21)
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 Location(self._core.get_meniscus(z_offset=z), self)

@requires_version(2, 8)
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
"""
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/errors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
NotSupportedOnRobotType,
CommandNotAllowedError,
InvalidLiquidHeightFound,
LiquidHeightUnknownError,
InvalidWellDefinitionError,
)

Expand Down Expand Up @@ -149,5 +150,6 @@
"ErrorOccurrence",
"CommandNotAllowedError",
"InvalidLiquidHeightFound",
"LiquidHeightUnknownError",
"InvalidWellDefinitionError",
]
13 changes: 13 additions & 0 deletions api/src/opentrons/protocol_engine/errors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,19 @@ def __init__(
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)


class LiquidHeightUnknownError(ProtocolEngineError):
"""Raised when attempting to specify WellOrigin.MENISCUS before liquid probing has been done."""

def __init__(
self,
message: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
wrapping: Optional[Sequence[EnumeratedError]] = None,
) -> None:
"""Build a LiquidHeightUnknownError."""
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)


class EStopActivatedError(ProtocolEngineError):
"""Represents an E-stop event."""

Expand Down
4 changes: 3 additions & 1 deletion api/src/opentrons/protocol_engine/execution/pipetting.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ async def liquid_probe_in_place(
mount=hw_pipette.mount,
max_z_dist=well_depth - lld_min_height + well_location.offset.z,
)
return float(z_pos)
labware_pos = self._state_view.geometry.get_labware_position(labware_id)
relative_height = z_pos - labware_pos.z - well_def.z
return float(relative_height)

@contextmanager
def _set_flow_rate(
Expand Down
13 changes: 13 additions & 0 deletions api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
)
from .config import Config
from .labware import LabwareView
from .wells import WellView
from .modules import ModuleView
from .pipettes import PipetteView
from .addressable_areas import AddressableAreaView
Expand Down Expand Up @@ -98,13 +99,15 @@ def __init__(
self,
config: Config,
labware_view: LabwareView,
well_view: WellView,
module_view: ModuleView,
pipette_view: PipetteView,
addressable_area_view: AddressableAreaView,
) -> None:
"""Initialize a GeometryView instance."""
self._config = config
self._labware = labware_view
self._wells = well_view
self._modules = module_view
self._pipettes = pipette_view
self._addressable_areas = addressable_area_view
Expand Down Expand Up @@ -430,6 +433,16 @@ def get_well_position(
offset = offset.copy(update={"z": offset.z + well_depth})
elif well_location.origin == WellOrigin.CENTER:
offset = offset.copy(update={"z": offset.z + well_depth / 2.0})
elif well_location.origin == WellOrigin.MENISCUS:
liquid_height = self._wells.get_last_measured_liquid_height(
labware_id, well_name
)
if liquid_height is not None:
offset = offset.copy(update={"z": offset.z + liquid_height})
else:
raise errors.LiquidHeightUnknownError(
"Must liquid probe before specifying WellOrigin.MENISCUS."
)

return Point(
x=labware_pos.x + offset.x + well_def.x,
Expand Down
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_engine/state/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ def _initialize_state(self) -> None:
self._geometry = GeometryView(
config=self._config,
labware_view=self._labware,
well_view=self._wells,
module_view=self._modules,
pipette_view=self._pipettes,
addressable_area_view=self._addressable_areas,
Expand Down
2 changes: 1 addition & 1 deletion api/src/opentrons/protocol_engine/state/wells.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _handle_failed_command(self, action: FailCommandAction) -> None:
self._set_liquid_height(
labware_id=action.error.private.labware_id,
well_name=action.error.private.well_name,
height=0,
height=None,
time=action.failed_at,
)

Expand Down
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ class WellOrigin(str, Enum):
TOP = "top"
BOTTOM = "bottom"
CENTER = "center"
MENISCUS = "meniscus"


class DropTipWellOrigin(str, Enum):
Expand Down
2 changes: 1 addition & 1 deletion api/src/opentrons/protocols/api_support/definitions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .types import APIVersion

MAX_SUPPORTED_VERSION = APIVersion(2, 20)
MAX_SUPPORTED_VERSION = APIVersion(2, 21)
"""The maximum supported protocol API version in this release."""

MIN_SUPPORTED_VERSION = APIVersion(2, 0)
Expand Down
17 changes: 17 additions & 0 deletions api/tests/opentrons/protocol_api/core/engine/test_well_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ def test_get_center(
assert subject.get_center() == Point(1, 2, 3)


def test_get_meniscus(
decoy: Decoy, mock_engine_client: EngineClient, subject: WellCore
) -> None:
"""It should get a well bottom."""
decoy.when(
mock_engine_client.state.geometry.get_well_position(
labware_id="labware-id",
well_name="well-name",
well_location=WellLocation(
origin=WellOrigin.MENISCUS, offset=WellOffset(x=0, y=0, z=2.5)
),
)
).then_return(Point(1, 2, 3))

assert subject.get_meniscus(z_offset=2.5) == Point(1, 2, 3)


def test_has_tip(
decoy: Decoy, mock_engine_client: EngineClient, subject: WellCore
) -> None:
Expand Down
11 changes: 11 additions & 0 deletions api/tests/opentrons/protocol_api/test_well.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ def test_well_center(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> N
assert result.labware.as_well() is subject


def test_well_meniscus(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None:
"""It should get a Location representing the meniscus of the well."""
decoy.when(mock_well_core.get_meniscus(z_offset=4.2)).then_return(Point(1, 2, 3))

result = subject.meniscus(4.2)

assert isinstance(result, Location)
assert result.point == Point(1, 2, 3)
assert result.labware.as_well() is subject


def test_has_tip(decoy: Decoy, mock_well_core: WellCore, subject: Well) -> None:
"""It should get tip state from the core."""
decoy.when(mock_well_core.has_tip()).then_return(True)
Expand Down
75 changes: 75 additions & 0 deletions api/tests/opentrons/protocol_engine/state/test_geometry_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from opentrons.protocol_engine.state import _move_types
from opentrons.protocol_engine.state.config import Config
from opentrons.protocol_engine.state.labware import LabwareView, LabwareStore
from opentrons.protocol_engine.state.wells import WellView, WellStore
from opentrons.protocol_engine.state.modules import ModuleView, ModuleStore
from opentrons.protocol_engine.state.pipettes import (
PipetteView,
Expand Down Expand Up @@ -94,6 +95,12 @@ def mock_labware_view(decoy: Decoy) -> LabwareView:
return decoy.mock(cls=LabwareView)


@pytest.fixture
def mock_well_view(decoy: Decoy) -> WellView:
"""Get a mock in the shape of a WellView."""
return decoy.mock(cls=WellView)


@pytest.fixture
def mock_module_view(decoy: Decoy) -> ModuleView:
"""Get a mock in the shape of a ModuleView."""
Expand Down Expand Up @@ -152,6 +159,18 @@ def labware_view(labware_store: LabwareStore) -> LabwareView:
return LabwareView(labware_store._state)


@pytest.fixture
def well_store() -> WellStore:
"""Get a well store that can accept actions."""
return WellStore()


@pytest.fixture
def well_view(well_store: WellStore) -> WellView:
"""Get a well view of a real well store."""
return WellView(well_store._state)


@pytest.fixture
def module_store(state_config: Config) -> ModuleStore:
"""Get a module store that can accept actions."""
Expand Down Expand Up @@ -242,11 +261,13 @@ def nice_adapter_definition() -> LabwareDefinition:
@pytest.fixture
def subject(
mock_labware_view: LabwareView,
mock_well_view: WellView,
mock_module_view: ModuleView,
mock_pipette_view: PipetteView,
mock_addressable_area_view: AddressableAreaView,
state_config: Config,
labware_view: LabwareView,
well_view: WellView,
module_view: ModuleView,
pipette_view: PipetteView,
addressable_area_view: AddressableAreaView,
Expand All @@ -267,6 +288,7 @@ def my_cool_test(subject: GeometryView) -> None:
return GeometryView(
config=state_config,
labware_view=mock_labware_view if use_mocks else labware_view,
well_view=mock_well_view if use_mocks else well_view,
module_view=mock_module_view if use_mocks else module_view,
pipette_view=mock_pipette_view if use_mocks else pipette_view,
addressable_area_view=mock_addressable_area_view
Expand Down Expand Up @@ -1477,6 +1499,59 @@ def test_get_well_position_with_center_offset(
)


def test_get_well_position_with_meniscus_offset(
decoy: Decoy,
well_plate_def: LabwareDefinition,
mock_labware_view: LabwareView,
mock_well_view: WellView,
mock_addressable_area_view: AddressableAreaView,
subject: GeometryView,
) -> None:
"""It should be able to get the position of a well center in a labware."""
labware_data = LoadedLabware(
id="labware-id",
loadName="load-name",
definitionUri="definition-uri",
location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4),
offsetId="offset-id",
)
calibration_offset = LabwareOffsetVector(x=1, y=-2, z=3)
slot_pos = Point(4, 5, 6)
well_def = well_plate_def.wells["B2"]

decoy.when(mock_labware_view.get("labware-id")).then_return(labware_data)
decoy.when(mock_labware_view.get_definition("labware-id")).then_return(
well_plate_def
)
decoy.when(mock_labware_view.get_labware_offset_vector("labware-id")).then_return(
calibration_offset
)
decoy.when(
mock_addressable_area_view.get_addressable_area_position(DeckSlotName.SLOT_4.id)
).then_return(slot_pos)
decoy.when(mock_labware_view.get_well_definition("labware-id", "B2")).then_return(
well_def
)
decoy.when(
mock_well_view.get_last_measured_liquid_height("labware-id", "B2")
).then_return(70.5)

result = subject.get_well_position(
labware_id="labware-id",
well_name="B2",
well_location=WellLocation(
origin=WellOrigin.MENISCUS,
offset=WellOffset(x=2, y=3, z=4),
),
)

assert result == Point(
x=slot_pos[0] + 1 + well_def.x + 2,
y=slot_pos[1] - 2 + well_def.y + 3,
z=slot_pos[2] + 3 + well_def.z + 4 + 70.5,
)


def test_get_relative_well_location(
decoy: Decoy,
well_plate_def: LabwareDefinition,
Expand Down
2 changes: 1 addition & 1 deletion shared-data/command/schemas/9.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@
"WellOrigin": {
"title": "WellOrigin",
"description": "Origin of WellLocation offset.\n\nProps:\n TOP: the top-center of the well\n BOTTOM: the bottom-center of the well\n CENTER: the middle-center of the well",
"enum": ["top", "bottom", "center"],
"enum": ["top", "bottom", "center", "meniscus"],
"type": "string"
},
"WellOffset": {
Expand Down

0 comments on commit 4064bdf

Please sign in to comment.