Skip to content

Commit bd4f9a2

Browse files
refactor(api): better names for liquid probe commands (#15578)
<!-- 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 Edit detect_liquid_presence, require_liquid_presence, and measure_liquid_height as per the standup today. Change instrument_context tests to check for the right error(LiquidNotFoundError) Split find_liquid_level into two functions: liquid_probe_with_recovery and liquid_probe_without_recovery <!-- 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 --> Added unit tests for liquid_probe_with_recovery and liquid_probe_without recovery to test_instrument_core.py Added unit tests for detect_liquid_presence, require_liquid_presence, and measure_liquid_height to test_instrument_context.py After the PR is merged, these API calls will be tested on the robot. # 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. --> Accidentally merged #15555 too soon, this is a continuation of that PR. # 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. --> Low: Since all the work was done in newly created functions, the risk that it breaks existing code is minimal. However, the new API calls still do need to be tested on a robot before they can be used in other parts of the system.
1 parent 12f9095 commit bd4f9a2

File tree

8 files changed

+130
-84
lines changed

8 files changed

+130
-84
lines changed

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

+24-23
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@
3131
from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError
3232
from opentrons.protocol_engine.clients import SyncClient as EngineClient
3333
from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION
34-
35-
from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError
3634
from opentrons_shared_data.pipette.dev_types import PipetteNameType
3735
from opentrons.protocol_api._nozzle_layout import NozzleLayout
3836
from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType
@@ -846,34 +844,37 @@ def retract(self) -> None:
846844
z_axis = self._engine_client.state.pipettes.get_z_axis(self._pipette_id)
847845
self._engine_client.execute_command(cmd.HomeParams(axes=[z_axis]))
848846

849-
def find_liquid_level(self, well_core: WellCore, error_recovery: bool) -> float:
847+
def liquid_probe_with_recovery(self, well_core: WellCore) -> None:
850848
labware_id = well_core.labware_id
851849
well_name = well_core.get_name()
852850
well_location = WellLocation(
853851
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0)
854852
)
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-
)
853+
854+
self._engine_client.execute_command(
855+
cmd.LiquidProbeParams(
856+
labwareId=labware_id,
857+
wellName=well_name,
858+
wellLocation=well_location,
859+
pipetteId=self.pipette_id,
863860
)
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-
)
861+
)
862+
863+
def liquid_probe_without_recovery(self, well_core: WellCore) -> float:
864+
labware_id = well_core.labware_id
865+
well_name = well_core.get_name()
866+
well_location = WellLocation(
867+
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0)
868+
)
869+
870+
result = self._engine_client.execute_command_without_recovery(
871+
cmd.LiquidProbeParams(
872+
labwareId=labware_id,
873+
wellName=well_name,
874+
wellLocation=well_location,
875+
pipetteId=self.pipette_id,
872876
)
877+
)
873878

874879
if result is not None and isinstance(result, LiquidProbeResult):
875880
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-1
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,12 @@ def retract(self) -> None:
304304
...
305305

306306
@abstractmethod
307-
def find_liquid_level(self, well_core: WellCoreType, error_recovery: bool) -> float:
307+
def liquid_probe_with_recovery(self, well_core: WellCoreType) -> None:
308+
"""Do a liquid probe to detect the presence of liquid in the well."""
309+
...
310+
311+
@abstractmethod
312+
def liquid_probe_without_recovery(self, well_core: WellCoreType) -> float:
308313
"""Do a liquid probe to find the level of the liquid in the well."""
309314
...
310315

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

+6-2
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,10 @@ def retract(self) -> None:
571571
"""Retract this instrument to the top of the gantry."""
572572
self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined]
573573

574-
def find_liquid_level(self, well_core: WellCore, error_recovery: bool) -> float:
574+
def liquid_probe_with_recovery(self, well_core: WellCore) -> None:
575575
"""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"
576+
assert False, "liquid_probe_with_recovery only supported in API 2.20 & later"
577+
578+
def liquid_probe_without_recovery(self, well_core: WellCore) -> float:
579+
"""This will never be called because it was added in API 2.20."""
580+
assert False, "liquid_probe_without_recovery only supported in API 2.20 & later"

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

+6-2
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,10 @@ def retract(self) -> None:
489489
"""Retract this instrument to the top of the gantry."""
490490
self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined]
491491

492-
def find_liquid_level(self, well_core: WellCore, error_recovery: bool) -> float:
492+
def liquid_probe_with_recovery(self, well_core: WellCore) -> None:
493493
"""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"
494+
assert False, "liquid_probe_with_recovery only supported in API 2.20 & later"
495+
496+
def liquid_probe_without_recovery(self, well_core: WellCore) -> float:
497+
"""This will never be called because it was added in API 2.20."""
498+
assert False, "liquid_probe_without_recovery only supported in API 2.20 & later"

api/src/opentrons/protocol_api/instrument_context.py

+15-15
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
import logging
33
from contextlib import ExitStack
44
from typing import Any, List, Optional, Sequence, Union, cast, Dict
5+
from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError
6+
from opentrons.protocol_engine.errors.error_occurrence import ProtocolCommandFailedError
57
from opentrons_shared_data.errors.exceptions import (
68
CommandPreconditionViolated,
79
CommandParameterLimitViolated,
8-
PipetteLiquidNotFoundError,
910
UnexpectedTipRemovalError,
1011
)
1112
from opentrons.protocol_engine.errors.exceptions import WellDoesNotExistError
@@ -2057,16 +2058,13 @@ def detect_liquid_presence(self, well: labware.Well) -> bool:
20572058
if not isinstance(well, labware.Well):
20582059
raise WellDoesNotExistError("You must provide a valid well to check.")
20592060
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:
2061+
self._core.liquid_probe_without_recovery(well._core)
2062+
except ProtocolCommandFailedError as e:
2063+
if isinstance(e.original_error, LiquidNotFoundError):
2064+
return False
20692065
raise e
2066+
else:
2067+
return True
20702068

20712069
@requires_version(2, 20)
20722070
def require_liquid_presence(self, well: labware.Well) -> None:
@@ -2077,18 +2075,20 @@ def require_liquid_presence(self, well: labware.Well) -> None:
20772075
if not isinstance(well, labware.Well):
20782076
raise WellDoesNotExistError("You must provide a valid well to check.")
20792077

2080-
self._core.find_liquid_level(well_core=well._core, error_recovery=True)
2078+
self._core.liquid_probe_with_recovery(well._core)
20812079

20822080
@requires_version(2, 20)
20832081
def measure_liquid_height(self, well: labware.Well) -> float:
20842082
"""Check the height of the liquid within a well.
20852083
20862084
:returns: The height, in mm, of the liquid from the deck.
2085+
2086+
:meta private:
2087+
2088+
This is intended for Opentrons internal use only and is not a guaranteed API.
20872089
"""
20882090
if not isinstance(well, labware.Well):
20892091
raise WellDoesNotExistError("You must provide a valid well to check.")
20902092

2091-
height = self._core.find_liquid_level(
2092-
well_core=well._core, error_recovery=False
2093-
)
2094-
return float(height)
2093+
height = self._core.liquid_probe_without_recovery(well._core)
2094+
return height

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

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

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

5-
from opentrons.protocol_engine.errors.error_occurrence import ProtocolCommandFailedError
65
from opentrons_shared_data.labware.dev_types import LabwareUri
76
from opentrons_shared_data.labware.labware_definition import LabwareDefinition
87

@@ -96,26 +95,6 @@ def execute_command_without_recovery(
9695
create_request = CreateType(params=cast(Any, params))
9796
return self._transport.execute_command(create_request)
9897

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-
11998
@property
12099
def state(self) -> StateView:
121100
"""Get a view of the engine's state."""

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

+41-10
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.protocol_engine.commands.liquid_probe import LiquidProbeResult
45
from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError
56
import pytest
67
from decoy import Decoy
@@ -1292,23 +1293,41 @@ def test_configure_for_volume_post_219(
12921293

12931294

12941295
@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 20)))
1295-
def test_find_liquid_level(
1296+
def test_liquid_probe_without_recovery(
12961297
decoy: Decoy,
12971298
mock_engine_client: EngineClient,
12981299
mock_protocol_core: ProtocolCore,
12991300
subject: InstrumentCore,
13001301
version: APIVersion,
13011302
) -> None:
1302-
"""It should raise an exception on an empty well."""
1303+
"""It should raise an exception on an empty well and return a float on a valid well."""
13031304
well_core = WellCore(
13041305
name="my cool well", labware_id="123abc", engine_client=mock_engine_client
13051306
)
1307+
decoy.when(
1308+
mock_engine_client.execute_command_without_recovery(
1309+
cmd.LiquidProbeParams(
1310+
pipetteId=subject.pipette_id,
1311+
wellLocation=WellLocation(
1312+
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0)
1313+
),
1314+
wellName=well_core.get_name(),
1315+
labwareId=well_core.labware_id,
1316+
)
1317+
)
1318+
).then_raise(PipetteLiquidNotFoundError())
13061319
try:
1307-
subject.find_liquid_level(well_core=well_core, error_recovery=True)
1320+
subject.liquid_probe_without_recovery(well_core=well_core)
13081321
except PipetteLiquidNotFoundError:
13091322
assert True
1310-
decoy.verify(
1311-
mock_engine_client.execute_command_with_result(
1323+
else:
1324+
assert False
1325+
1326+
decoy.reset()
1327+
1328+
lpr = LiquidProbeResult(z_position=5.0)
1329+
decoy.when(
1330+
mock_engine_client.execute_command_without_recovery(
13121331
cmd.LiquidProbeParams(
13131332
pipetteId=subject.pipette_id,
13141333
wellLocation=WellLocation(
@@ -1318,13 +1337,25 @@ def test_find_liquid_level(
13181337
labwareId=well_core.labware_id,
13191338
)
13201339
)
1340+
).then_return(lpr)
1341+
assert subject.liquid_probe_without_recovery(well_core=well_core) == 5.0
1342+
1343+
1344+
@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 20)))
1345+
def test_liquid_probe_with_recovery(
1346+
decoy: Decoy,
1347+
mock_engine_client: EngineClient,
1348+
mock_protocol_core: ProtocolCore,
1349+
subject: InstrumentCore,
1350+
version: APIVersion,
1351+
) -> None:
1352+
"""It should not raise an exception on an empty well."""
1353+
well_core = WellCore(
1354+
name="my cool well", labware_id="123abc", engine_client=mock_engine_client
13211355
)
1322-
try:
1323-
subject.find_liquid_level(well_core=well_core, error_recovery=False)
1324-
except PipetteLiquidNotFoundError:
1325-
assert True
1356+
subject.liquid_probe_with_recovery(well_core=well_core)
13261357
decoy.verify(
1327-
mock_engine_client.execute_command_without_recovery(
1358+
mock_engine_client.execute_command(
13281359
cmd.LiquidProbeParams(
13291360
pipetteId=subject.pipette_id,
13301361
wellLocation=WellLocation(

api/tests/opentrons/protocol_api/test_instrument_context.py

+32-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"""Tests for the InstrumentContext public interface."""
22
from collections import OrderedDict
3+
from datetime import datetime
34
import inspect
5+
from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError
6+
from opentrons.protocol_engine.errors.error_occurrence import (
7+
ProtocolCommandFailedError,
8+
)
49
import pytest
510
from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped]
611
from decoy import Decoy
@@ -38,7 +43,6 @@
3843

3944
from opentrons_shared_data.errors.exceptions import (
4045
CommandPreconditionViolated,
41-
PipetteLiquidNotFoundError,
4246
)
4347

4448

@@ -1279,11 +1283,17 @@ def test_detect_liquid_presence(
12791283
) -> None:
12801284
"""It should only return booleans. Not raise an exception."""
12811285
mock_well = decoy.mock(cls=Well)
1286+
lnfe = LiquidNotFoundError(id="1234", createdAt=datetime.now())
1287+
errorToRaise = ProtocolCommandFailedError(
1288+
original_error=lnfe,
1289+
message=f"{lnfe.errorType}: {lnfe.detail}",
1290+
)
12821291
decoy.when(
1283-
mock_instrument_core.find_liquid_level(mock_well._core, False)
1284-
).then_raise(PipetteLiquidNotFoundError())
1292+
mock_instrument_core.liquid_probe_without_recovery(mock_well._core)
1293+
).then_raise(errorToRaise)
12851294
result = subject.detect_liquid_presence(mock_well)
12861295
assert isinstance(result, bool)
1296+
assert not result
12871297

12881298

12891299
@pytest.mark.parametrize("api_version", [APIVersion(2, 20)])
@@ -1295,13 +1305,19 @@ def test_require_liquid_presence(
12951305
) -> None:
12961306
"""It should raise an exception when called."""
12971307
mock_well = decoy.mock(cls=Well)
1298-
decoy.when(mock_instrument_core.find_liquid_level(mock_well._core, True))
1308+
lnfe = LiquidNotFoundError(id="1234", createdAt=datetime.now())
1309+
errorToRaise = ProtocolCommandFailedError(
1310+
original_error=lnfe,
1311+
message=f"{lnfe.errorType}: {lnfe.detail}",
1312+
)
1313+
decoy.when(mock_instrument_core.liquid_probe_with_recovery(mock_well._core))
12991314
subject.require_liquid_presence(mock_well)
13001315
decoy.when(
1301-
mock_instrument_core.find_liquid_level(mock_well._core, True)
1302-
).then_raise(PipetteLiquidNotFoundError())
1303-
with pytest.raises(PipetteLiquidNotFoundError):
1316+
mock_instrument_core.liquid_probe_with_recovery(mock_well._core)
1317+
).then_raise(errorToRaise)
1318+
with pytest.raises(ProtocolCommandFailedError) as pcfe:
13041319
subject.require_liquid_presence(mock_well)
1320+
assert pcfe.value is errorToRaise
13051321

13061322

13071323
@pytest.mark.parametrize("api_version", [APIVersion(2, 20)])
@@ -1313,8 +1329,14 @@ def test_measure_liquid_height(
13131329
) -> None:
13141330
"""It should raise an exception when called."""
13151331
mock_well = decoy.mock(cls=Well)
1332+
lnfe = LiquidNotFoundError(id="1234", createdAt=datetime.now())
1333+
errorToRaise = ProtocolCommandFailedError(
1334+
original_error=lnfe,
1335+
message=f"{lnfe.errorType}: {lnfe.detail}",
1336+
)
13161337
decoy.when(
1317-
mock_instrument_core.find_liquid_level(mock_well._core, False)
1318-
).then_raise(PipetteLiquidNotFoundError())
1319-
with pytest.raises(PipetteLiquidNotFoundError):
1338+
mock_instrument_core.liquid_probe_without_recovery(mock_well._core)
1339+
).then_raise(errorToRaise)
1340+
with pytest.raises(ProtocolCommandFailedError) as pcfe:
13201341
subject.measure_liquid_height(mock_well)
1342+
assert pcfe.value is errorToRaise

0 commit comments

Comments
 (0)