diff --git a/api/src/opentrons/protocol_api/core/engine/well.py b/api/src/opentrons/protocol_api/core/engine/well.py index 6743a8a39c5..ec7307a6a90 100644 --- a/api/src/opentrons/protocol_api/core/engine/well.py +++ b/api/src/opentrons/protocol_api/core/engine/well.py @@ -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, diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py index a88dd2eee80..f37aefbd4be 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py @@ -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, diff --git a/api/src/opentrons/protocol_api/core/well.py b/api/src/opentrons/protocol_api/core/well.py index bd58963a59c..81dddede2f1 100644 --- a/api/src/opentrons/protocol_api/core/well.py +++ b/api/src/opentrons/protocol_api/core/well.py @@ -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, diff --git a/api/src/opentrons/protocol_api/labware.py b/api/src/opentrons/protocol_api/labware.py index 480af5fd009..43c2c0ce5a8 100644 --- a/api/src/opentrons/protocol_api/labware.py +++ b/api/src/opentrons/protocol_api/labware.py @@ -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: """ diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index f5cd1728fb4..e0f60a5cd45 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -70,6 +70,7 @@ NotSupportedOnRobotType, CommandNotAllowedError, InvalidLiquidHeightFound, + LiquidHeightUnknownError, InvalidWellDefinitionError, ) @@ -149,5 +150,6 @@ "ErrorOccurrence", "CommandNotAllowedError", "InvalidLiquidHeightFound", + "LiquidHeightUnknownError", "InvalidWellDefinitionError", ] diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 0b888372efc..57d420124a7 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -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.""" diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index 96f8256ef25..fed6fc52ee6 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -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( diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 7a0871abddb..a0fef65e7ee 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -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 @@ -98,6 +99,7 @@ def __init__( self, config: Config, labware_view: LabwareView, + well_view: WellView, module_view: ModuleView, pipette_view: PipetteView, addressable_area_view: AddressableAreaView, @@ -105,6 +107,7 @@ def __init__( """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 @@ -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, diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index 3bd7d087c2e..7fc23a8ee2f 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -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, diff --git a/api/src/opentrons/protocol_engine/state/wells.py b/api/src/opentrons/protocol_engine/state/wells.py index e6e19446c6f..d74d94a1be0 100644 --- a/api/src/opentrons/protocol_engine/state/wells.py +++ b/api/src/opentrons/protocol_engine/state/wells.py @@ -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, ) diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 68b51d7c1b7..519d39b6ec7 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -210,6 +210,7 @@ class WellOrigin(str, Enum): TOP = "top" BOTTOM = "bottom" CENTER = "center" + MENISCUS = "meniscus" class DropTipWellOrigin(str, Enum): diff --git a/api/src/opentrons/protocols/api_support/definitions.py b/api/src/opentrons/protocols/api_support/definitions.py index 799af1993f3..ad692e03828 100644 --- a/api/src/opentrons/protocols/api_support/definitions.py +++ b/api/src/opentrons/protocols/api_support/definitions.py @@ -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) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_well_core.py b/api/tests/opentrons/protocol_api/core/engine/test_well_core.py index 31b562f7e81..96efbbdde8d 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_well_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_well_core.py @@ -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: diff --git a/api/tests/opentrons/protocol_api/test_well.py b/api/tests/opentrons/protocol_api/test_well.py index 00cbbac8fa7..3a2ba81b9fa 100644 --- a/api/tests/opentrons/protocol_api/test_well.py +++ b/api/tests/opentrons/protocol_api/test_well.py @@ -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) diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 6e8b5632b06..1854d08523a 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -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, @@ -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.""" @@ -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.""" @@ -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, @@ -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 @@ -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, diff --git a/shared-data/command/schemas/9.json b/shared-data/command/schemas/9.json index 4d63aee9fd5..263330eb6de 100644 --- a/shared-data/command/schemas/9.json +++ b/shared-data/command/schemas/9.json @@ -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": {