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): add meniscus wellOrigin enum #16139

Merged
merged 12 commits into from
Sep 17, 2024
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:
height = self._wells.get_last_measured_liquid_height(
labware_id, well_name
) # in deck coordinates
pmoegenburg marked this conversation as resolved.
Show resolved Hide resolved
if height is not None:
offset = offset.copy(update={"z": offset.z + 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
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
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"],
pmoegenburg marked this conversation as resolved.
Show resolved Hide resolved
"type": "string"
},
"WellOffset": {
Expand Down
Loading