Skip to content

Commit 389c12d

Browse files
feat(api): create instr ctx methods for checking liquid presence (#15555)
Added detect_liquid and require_liquid to instrument_context. The two methods both check for liquid in a specific well, but one returns true/false and the other raises exceptions. Also adds measure_liquid_level to instrument context. This method probes the well to find the height of the liquid. close EXEC-544, EXEC-545, EXEC-546 <!-- Thanks for taking the time to open a pull request! 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 To ensure your code is reviewed quickly and thoroughly, please fill out the sections below to the best of your ability! --> # Overview <!-- Use this section to describe your pull-request at a high level. If the PR addresses any open issues, please tag the issues here. --> # Test Plan <!-- Use this section to describe the steps that you took to test your Pull Request. If you did not perform any testing provide justification why. OT-3 Developers: You should default to testing on actual physical hardware. Once again, if you did not perform testing against hardware, justify why. Note: It can be helpful to write a test plan before doing development Example Test Plan (HTTP API Change) - Verified that new optional argument `dance-party` causes the robot to flash its lights, move the pipettes, then home. - Verified that when you omit the `dance-party` option the robot homes normally - Added protocol that uses `dance-party` argument to G-Code Testing Suite - Ran protocol that did not use `dance-party` argument and everything was successful - Added unit tests to validate that changes to pydantic model are correct --> # Changelog <!-- List out the changes to the code in this PR. Please try your best to categorize your changes and describe what has changed and why. Example changelog: - Fixed app crash when trying to calibrate an illegal pipette - Added state to API to track pipette usage - Updated API docs to mention only two pipettes are supported IMPORTANT: MAKE SURE ANY BREAKING CHANGES ARE PROPERLY COMMUNICATED --> # Review requests <!-- Describe any requests for your reviewers here. --> In find_liquid_level in the instrument core, I couldn't find a method that would execute the command with recovery and also return the result of the command. ~~So I settled for execute_command_without_recovery but I'm not sure if that's the right way to go about it.~~ I created a new method called execute_command_with_result to get around this. # Risk assessment <!-- Carefully go over your pull request and look at the other parts of the codebase it may affect. 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 in protocol may also change the behavior of labware calibration. Identify the other parts of the system your codebase may affect, so that in addition to your own review and testing, other people who may not have the system internalized as much as you can focus their attention and testing there. --> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: aaron-kulkarni <[email protected]>
1 parent bdf3d37 commit 389c12d

File tree

8 files changed

+222
-2
lines changed

8 files changed

+222
-2
lines changed

api/src/opentrons/protocol_api/core/engine/instrument.py

+34
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
from typing import Optional, TYPE_CHECKING, cast, Union
5+
from opentrons.protocol_engine.commands.liquid_probe import LiquidProbeResult
56
from opentrons.protocols.api_support.types import APIVersion
67

78
from opentrons.types import Location, Mount
@@ -31,6 +32,7 @@
3132
from opentrons.protocol_engine.clients import SyncClient as EngineClient
3233
from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION
3334

35+
from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError
3436
from opentrons_shared_data.pipette.dev_types import PipetteNameType
3537
from opentrons.protocol_api._nozzle_layout import NozzleLayout
3638
from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType
@@ -843,3 +845,35 @@ def retract(self) -> None:
843845
"""Retract this instrument to the top of the gantry."""
844846
z_axis = self._engine_client.state.pipettes.get_z_axis(self._pipette_id)
845847
self._engine_client.execute_command(cmd.HomeParams(axes=[z_axis]))
848+
849+
def find_liquid_level(self, well_core: WellCore, error_recovery: bool) -> float:
850+
labware_id = well_core.labware_id
851+
well_name = well_core.get_name()
852+
well_location = WellLocation(
853+
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0)
854+
)
855+
if error_recovery:
856+
result = self._engine_client.execute_command_with_result(
857+
cmd.LiquidProbeParams(
858+
labwareId=labware_id,
859+
wellName=well_name,
860+
wellLocation=well_location,
861+
pipetteId=self.pipette_id,
862+
)
863+
)
864+
else:
865+
result = self._engine_client.execute_command_without_recovery(
866+
cmd.LiquidProbeParams(
867+
labwareId=labware_id,
868+
wellName=well_name,
869+
wellLocation=well_location,
870+
pipetteId=self.pipette_id,
871+
)
872+
)
873+
874+
if result is not None and isinstance(result, LiquidProbeResult):
875+
return result.z_position
876+
# should never get here
877+
raise PipetteLiquidNotFoundError(
878+
"Error while trying to find liquid level.",
879+
)

api/src/opentrons/protocol_api/core/instrument.py

+6
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,15 @@ def configure_nozzle_layout(
298298
def is_tip_tracking_available(self) -> bool:
299299
"""Return whether auto tip tracking is available for the pipette's current nozzle configuration."""
300300

301+
@abstractmethod
301302
def retract(self) -> None:
302303
"""Retract this instrument to the top of the gantry."""
303304
...
304305

306+
@abstractmethod
307+
def find_liquid_level(self, well_core: WellCoreType, error_recovery: bool) -> float:
308+
"""Do a liquid probe to find the level of the liquid in the well."""
309+
...
310+
305311

306312
InstrumentCoreType = TypeVar("InstrumentCoreType", bound=AbstractInstrument[Any])

api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from opentrons import types
77
from opentrons.hardware_control import CriticalPoint
88
from opentrons.hardware_control.dev_types import PipetteDict
9+
from opentrons.protocol_api.core.common import WellCore
910
from opentrons.protocols.api_support import instrument as instrument_support
1011
from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION
1112
from opentrons.protocols.api_support.labware_like import LabwareLike
@@ -569,3 +570,7 @@ def is_tip_tracking_available(self) -> bool:
569570
def retract(self) -> None:
570571
"""Retract this instrument to the top of the gantry."""
571572
self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined]
573+
574+
def find_liquid_level(self, well_core: WellCore, error_recovery: bool) -> float:
575+
"""This will never be called because it was added in API 2.20."""
576+
assert False, "find_liquid_level only supported in API 2.20 & later"

api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from opentrons import types
77
from opentrons.hardware_control.dev_types import PipetteDict
88
from opentrons.hardware_control.types import HardwareAction
9+
from opentrons.protocol_api.core.common import WellCore
910
from opentrons.protocols.api_support import instrument as instrument_support
1011
from opentrons.protocols.api_support.labware_like import LabwareLike
1112
from opentrons.protocols.api_support.types import APIVersion
@@ -487,3 +488,7 @@ def is_tip_tracking_available(self) -> bool:
487488
def retract(self) -> None:
488489
"""Retract this instrument to the top of the gantry."""
489490
self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined]
491+
492+
def find_liquid_level(self, well_core: WellCore, error_recovery: bool) -> float:
493+
"""This will never be called because it was added in API 2.20."""
494+
assert False, "find_liquid_level only supported in API 2.20 & later"

api/src/opentrons/protocol_api/instrument_context.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
2-
32
import logging
43
from contextlib import ExitStack
54
from typing import Any, List, Optional, Sequence, Union, cast, Dict
65
from opentrons_shared_data.errors.exceptions import (
76
CommandPreconditionViolated,
87
CommandParameterLimitViolated,
8+
PipetteLiquidNotFoundError,
99
UnexpectedTipRemovalError,
1010
)
11+
from opentrons.protocol_engine.errors.exceptions import WellDoesNotExistError
1112
from opentrons.legacy_broker import LegacyBroker
1213
from opentrons.hardware_control.dev_types import PipetteDict
1314
from opentrons import types
@@ -2046,3 +2047,48 @@ def configure_nozzle_layout(
20462047
)
20472048
# TODO (spp, 2023-12-05): verify that tipracks are on adapters for only full 96 channel config
20482049
self._tip_racks = tip_racks or []
2050+
2051+
@requires_version(2, 20)
2052+
def detect_liquid_presence(self, well: labware.Well) -> bool:
2053+
"""Check if there is liquid in a well.
2054+
2055+
:returns: A boolean.
2056+
"""
2057+
if not isinstance(well, labware.Well):
2058+
raise WellDoesNotExistError("You must provide a valid well to check.")
2059+
try:
2060+
height = self._core.find_liquid_level(
2061+
well_core=well._core, error_recovery=False
2062+
)
2063+
if height > 0:
2064+
return True
2065+
return False # it should never get here
2066+
except PipetteLiquidNotFoundError:
2067+
return False
2068+
except Exception as e:
2069+
raise e
2070+
2071+
@requires_version(2, 20)
2072+
def require_liquid_presence(self, well: labware.Well) -> None:
2073+
"""If there is no liquid in a well, raise an error.
2074+
2075+
:returns: None.
2076+
"""
2077+
if not isinstance(well, labware.Well):
2078+
raise WellDoesNotExistError("You must provide a valid well to check.")
2079+
2080+
self._core.find_liquid_level(well_core=well._core, error_recovery=True)
2081+
2082+
@requires_version(2, 20)
2083+
def measure_liquid_height(self, well: labware.Well) -> float:
2084+
"""Check the height of the liquid within a well.
2085+
2086+
:returns: The height, in mm, of the liquid from the deck.
2087+
"""
2088+
if not isinstance(well, labware.Well):
2089+
raise WellDoesNotExistError("You must provide a valid well to check.")
2090+
2091+
height = self._core.find_liquid_level(
2092+
well_core=well._core, error_recovery=False
2093+
)
2094+
return float(height)

api/src/opentrons/protocol_engine/clients/sync_client.py

+27
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import cast, Any, Optional, overload
44

5+
from opentrons.protocol_engine.errors.error_occurrence import ProtocolCommandFailedError
56
from opentrons_shared_data.labware.dev_types import LabwareUri
67
from opentrons_shared_data.labware.labware_definition import LabwareDefinition
78

@@ -77,6 +78,12 @@ def execute_command_without_recovery(
7778
) -> commands.LoadPipetteResult:
7879
pass
7980

81+
@overload
82+
def execute_command_without_recovery(
83+
self, params: commands.LiquidProbeParams
84+
) -> commands.LiquidProbeResult:
85+
pass
86+
8087
def execute_command_without_recovery(
8188
self, params: commands.CommandParams
8289
) -> commands.CommandResult:
@@ -89,6 +96,26 @@ def execute_command_without_recovery(
8996
create_request = CreateType(params=cast(Any, params))
9097
return self._transport.execute_command(create_request)
9198

99+
def execute_command_with_result(
100+
self, params: commands.CommandParams
101+
) -> Optional[commands.CommandResult]:
102+
"""Execute a ProtocolEngine command, including error recovery, and return a result.
103+
104+
See `ChildThreadTransport.execute_command_wait_for_recovery()` for exact
105+
behavior.
106+
"""
107+
CreateType = CREATE_TYPES_BY_PARAMS_TYPE[type(params)]
108+
create_request = CreateType(params=cast(Any, params))
109+
result = self._transport.execute_command_wait_for_recovery(create_request)
110+
if result.error is None:
111+
return result.result
112+
if isinstance(result.error, BaseException): # necessary to pass lint
113+
raise result.error
114+
raise ProtocolCommandFailedError(
115+
original_error=result.error,
116+
message=f"{result.error.errorType}: {result.error.detail}",
117+
)
118+
92119
@property
93120
def state(self) -> StateView:
94121
"""Get a view of the engine's state."""

api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py

+47
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test for the ProtocolEngine-based instrument API core."""
22
from typing import cast, Optional, Union
33

4+
from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError
45
import pytest
56
from decoy import Decoy
67

@@ -1288,3 +1289,49 @@ def test_configure_for_volume_post_219(
12881289
)
12891290
)
12901291
)
1292+
1293+
1294+
@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 20)))
1295+
def test_find_liquid_level(
1296+
decoy: Decoy,
1297+
mock_engine_client: EngineClient,
1298+
mock_protocol_core: ProtocolCore,
1299+
subject: InstrumentCore,
1300+
version: APIVersion,
1301+
) -> None:
1302+
"""It should raise an exception on an empty well."""
1303+
well_core = WellCore(
1304+
name="my cool well", labware_id="123abc", engine_client=mock_engine_client
1305+
)
1306+
try:
1307+
subject.find_liquid_level(well_core=well_core, error_recovery=True)
1308+
except PipetteLiquidNotFoundError:
1309+
assert True
1310+
decoy.verify(
1311+
mock_engine_client.execute_command_with_result(
1312+
cmd.LiquidProbeParams(
1313+
pipetteId=subject.pipette_id,
1314+
wellLocation=WellLocation(
1315+
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0)
1316+
),
1317+
wellName=well_core.get_name(),
1318+
labwareId=well_core.labware_id,
1319+
)
1320+
)
1321+
)
1322+
try:
1323+
subject.find_liquid_level(well_core=well_core, error_recovery=False)
1324+
except PipetteLiquidNotFoundError:
1325+
assert True
1326+
decoy.verify(
1327+
mock_engine_client.execute_command_without_recovery(
1328+
cmd.LiquidProbeParams(
1329+
pipetteId=subject.pipette_id,
1330+
wellLocation=WellLocation(
1331+
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0)
1332+
),
1333+
wellName=well_core.get_name(),
1334+
labwareId=well_core.labware_id,
1335+
)
1336+
)
1337+
)

api/tests/opentrons/protocol_api/test_instrument_context.py

+51-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Tests for the InstrumentContext public interface."""
22
from collections import OrderedDict
33
import inspect
4-
54
import pytest
65
from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped]
76
from decoy import Decoy
@@ -39,6 +38,7 @@
3938

4039
from opentrons_shared_data.errors.exceptions import (
4140
CommandPreconditionViolated,
41+
PipetteLiquidNotFoundError,
4242
)
4343

4444

@@ -1268,3 +1268,53 @@ def test_aspirate_0_volume_means_aspirate_nothing(
12681268
),
12691269
times=1,
12701270
)
1271+
1272+
1273+
@pytest.mark.parametrize("api_version", [APIVersion(2, 20)])
1274+
def test_detect_liquid_presence(
1275+
decoy: Decoy,
1276+
mock_instrument_core: InstrumentCore,
1277+
subject: InstrumentContext,
1278+
mock_protocol_core: ProtocolCore,
1279+
) -> None:
1280+
"""It should only return booleans. Not raise an exception."""
1281+
mock_well = decoy.mock(cls=Well)
1282+
decoy.when(
1283+
mock_instrument_core.find_liquid_level(mock_well._core, False)
1284+
).then_raise(PipetteLiquidNotFoundError())
1285+
result = subject.detect_liquid_presence(mock_well)
1286+
assert isinstance(result, bool)
1287+
1288+
1289+
@pytest.mark.parametrize("api_version", [APIVersion(2, 20)])
1290+
def test_require_liquid_presence(
1291+
decoy: Decoy,
1292+
mock_instrument_core: InstrumentCore,
1293+
subject: InstrumentContext,
1294+
mock_protocol_core: ProtocolCore,
1295+
) -> None:
1296+
"""It should raise an exception when called."""
1297+
mock_well = decoy.mock(cls=Well)
1298+
decoy.when(mock_instrument_core.find_liquid_level(mock_well._core, True))
1299+
subject.require_liquid_presence(mock_well)
1300+
decoy.when(
1301+
mock_instrument_core.find_liquid_level(mock_well._core, True)
1302+
).then_raise(PipetteLiquidNotFoundError())
1303+
with pytest.raises(PipetteLiquidNotFoundError):
1304+
subject.require_liquid_presence(mock_well)
1305+
1306+
1307+
@pytest.mark.parametrize("api_version", [APIVersion(2, 20)])
1308+
def test_measure_liquid_height(
1309+
decoy: Decoy,
1310+
mock_instrument_core: InstrumentCore,
1311+
subject: InstrumentContext,
1312+
mock_protocol_core: ProtocolCore,
1313+
) -> None:
1314+
"""It should raise an exception when called."""
1315+
mock_well = decoy.mock(cls=Well)
1316+
decoy.when(
1317+
mock_instrument_core.find_liquid_level(mock_well._core, False)
1318+
).then_raise(PipetteLiquidNotFoundError())
1319+
with pytest.raises(PipetteLiquidNotFoundError):
1320+
subject.measure_liquid_height(mock_well)

0 commit comments

Comments
 (0)