Skip to content

Commit

Permalink
feat(api): add liquid presence detection to load_instrument() (#15482)
Browse files Browse the repository at this point in the history
<!--
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.
-->

This PR adds a boolean argument to the load_instrument ProtocolContext
method that enables liquid presence detection for the loaded pipette via
an additional pipette state variable.

Closes EXEC-541.

# 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 tests to ensure this bool arg can only be populated in API
versions 2.20 onward and when using a Flex 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.
-->

# 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: pmoegenburg <[email protected]>
  • Loading branch information
3 people authored Jun 26, 2024
1 parent 3efff55 commit 6888ffd
Show file tree
Hide file tree
Showing 58 changed files with 196 additions and 19 deletions.
6 changes: 5 additions & 1 deletion api/src/opentrons/protocol_api/core/engine/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,10 @@ def _get_module_core(
)

def load_instrument(
self, instrument_name: PipetteNameType, mount: Mount
self,
instrument_name: PipetteNameType,
mount: Mount,
liquid_presence_detection: bool = False,
) -> InstrumentCore:
"""Load an instrument into the protocol.
Expand All @@ -515,6 +518,7 @@ def load_instrument(
tipOverlapNotAfterVersion=overlap_versions.overlap_for_api_version(
self._api_version
),
liquidPresenceDetection=liquid_presence_detection,
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,10 @@ def load_module(
return module_core

def load_instrument(
self, instrument_name: PipetteNameType, mount: Mount
self,
instrument_name: PipetteNameType,
mount: Mount,
liquid_presence_detection: bool = False,
) -> LegacyInstrumentCore:
"""Load an instrument."""
attached = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ class LegacyProtocolCoreSimulator(
_instruments: Dict[Mount, Optional[LegacyInstrumentCoreSimulator]] # type: ignore[assignment]

def load_instrument( # type: ignore[override]
self, instrument_name: PipetteNameType, mount: Mount
self,
instrument_name: PipetteNameType,
mount: Mount,
liquid_presence_detection: bool = False,
) -> LegacyInstrumentCoreSimulator:
"""Create a simulating instrument context."""
pipette_generation = convert_to_pipette_name_type(
Expand Down
5 changes: 4 additions & 1 deletion api/src/opentrons/protocol_api/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ def load_module(

@abstractmethod
def load_instrument(
self, instrument_name: PipetteNameType, mount: Mount
self,
instrument_name: PipetteNameType,
mount: Mount,
liquid_presence_detection: bool = False,
) -> InstrumentCoreType:
...

Expand Down
18 changes: 18 additions & 0 deletions api/src/opentrons/protocol_api/protocol_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
AxisMaxSpeeds,
requires_version,
APIVersionError,
RobotTypeError,
)

from ._types import OffDeckType
Expand Down Expand Up @@ -870,6 +871,7 @@ def load_instrument(
mount: Union[Mount, str, None] = None,
tip_racks: Optional[List[Labware]] = None,
replace: bool = False,
liquid_presence_detection: Optional[bool] = None,
) -> InstrumentContext:
"""Load a specific instrument for use in the protocol.
Expand Down Expand Up @@ -897,6 +899,7 @@ def load_instrument(
control <advanced-control>` applications. You cannot
replace an instrument in the middle of a protocol being run
from the Opentrons App or touchscreen.
:param bool liquid_presence_detection: If ``True``, enable liquid presence detection for instrument. Only available on Flex robots in API Version 2.20 and above.
"""
instrument_name = validation.ensure_lowercase_name(instrument_name)
checked_instrument_name = validation.ensure_pipette_name(instrument_name)
Expand Down Expand Up @@ -928,9 +931,24 @@ def load_instrument(
f"Loading {checked_instrument_name} on {checked_mount.name.lower()} mount"
)

if (
self._api_version < APIVersion(2, 20)
and liquid_presence_detection is not None
):
raise APIVersionError(
"Liquid Presence Detection is only supported in API Version 2.20 and above."
)
if (
self._core.robot_type != "OT-3 Standard"
and liquid_presence_detection is not None
):
raise RobotTypeError(
"Liquid presence detection only available on Flex robot."
)
instrument_core = self._core.load_instrument(
instrument_name=checked_instrument_name,
mount=checked_mount,
liquid_presence_detection=liquid_presence_detection or False,
)

for tip_rack in tip_racks:
Expand Down
4 changes: 4 additions & 0 deletions api/src/opentrons/protocol_engine/commands/load_pipette.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ class LoadPipetteParams(BaseModel):
"expressed as vN where N is an integer, counting up from v0. If None, the current "
"highest version will be used.",
)
liquidPresenceDetection: Optional[bool] = Field(
None,
description="Enable liquid presence detection for this pipette. Defaults to False.",
)


class LoadPipetteResult(BaseModel):
Expand Down
14 changes: 14 additions & 0 deletions api/src/opentrons/protocol_engine/state/pipettes.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ class PipetteState:
static_config_by_id: Dict[str, StaticPipetteConfig]
flow_rates_by_id: Dict[str, FlowRates]
nozzle_configuration_by_id: Dict[str, Optional[NozzleMap]]
liquid_presence_detection_by_id: Dict[str, bool]


class PipetteStore(HasState[PipetteState], HandlesActions):
Expand All @@ -152,6 +153,7 @@ def __init__(self) -> None:
static_config_by_id={},
flow_rates_by_id={},
nozzle_configuration_by_id={},
liquid_presence_detection_by_id={},
)

def handle_action(self, action: Action) -> None:
Expand Down Expand Up @@ -215,6 +217,9 @@ def _handle_command( # noqa: C901
pipetteName=command.params.pipetteName,
mount=command.params.mount,
)
self._state.liquid_presence_detection_by_id[pipette_id] = (
command.params.liquidPresenceDetection or False
)
self._state.aspirated_volume_by_id[pipette_id] = None
self._state.movement_speed_by_id[pipette_id] = None
self._state.attached_tip_by_id[pipette_id] = None
Expand Down Expand Up @@ -801,3 +806,12 @@ def get_pipette_bounds_at_specified_move_to_position(
pip_back_right_bound,
pip_front_left_bound,
)

def get_liquid_presence_detection(self, pipette_id: str) -> bool:
"""Determine if liquid presence detection is enabled for this pipette."""
try:
return self._state.liquid_presence_detection_by_id[pipette_id]
except KeyError as e:
raise errors.PipetteNotLoadedError(
f"Pipette {pipette_id} not found; unable to determine if pipette liquid presence detection enabled."
) from e
15 changes: 12 additions & 3 deletions api/src/opentrons/protocols/api_support/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
from opentrons.hardware_control.types import Axis
from opentrons.hardware_control.util import ot2_axis_to_string
from opentrons_shared_data.robot.dev_types import RobotType
from opentrons_shared_data.errors.exceptions import (
UnsupportedHardwareCommand,
)

if TYPE_CHECKING:
from opentrons.protocol_api.labware import Well, Labware
Expand All @@ -37,17 +40,23 @@
MODULE_LOG = logging.getLogger(__name__)


class RobotTypeError(UnsupportedHardwareCommand):
"""Error raised when a protocol attempts to access behavior not available to the robot type in use."""

pass


class APIVersionError(Exception):
"""
Error raised when a protocol attempts to access behavior not implemented
"""
"""Error raised when a protocol attempts to access behavior not implemented in the API in use."""

pass


class UnsupportedAPIError(Exception):
"""Error raised when a protocol attempts to use unsupported API."""

pass


def _assert_gzero(val: Any, message: str) -> float:
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def test_load_instrument_pre_219(
pipetteName=PipetteNameType.P300_SINGLE,
mount=MountType.LEFT,
tipOverlapNotAfterVersion="v0",
liquidPresenceDetection=False,
)
)
).then_return(commands.LoadPipetteResult(pipetteId="cool-pipette"))
Expand Down Expand Up @@ -290,6 +291,7 @@ def test_load_instrument_post_219(
pipetteName=PipetteNameType.P300_SINGLE,
mount=MountType.LEFT,
tipOverlapNotAfterVersion="v1",
liquidPresenceDetection=False,
)
)
).then_return(commands.LoadPipetteResult(pipetteId="cool-pipette"))
Expand Down
63 changes: 62 additions & 1 deletion api/tests/opentrons/protocol_api/test_protocol_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from opentrons.hardware_control.modules.types import ModuleType, TemperatureModuleModel
from opentrons.protocols.api_support import instrument as mock_instrument_support
from opentrons.protocols.api_support.types import APIVersion
from opentrons.protocols.api_support.util import APIVersionError
from opentrons.protocols.api_support.util import APIVersionError, RobotTypeError
from opentrons.protocol_api import (
MAX_SUPPORTED_VERSION,
ProtocolContext,
Expand Down Expand Up @@ -178,6 +178,64 @@ def test_deck(subject: ProtocolContext) -> None:
assert isinstance(result, Deck)


@pytest.mark.parametrize("api_version", [APIVersion(2, 20)])
def test_load_instrument_robot_type(
decoy: Decoy,
mock_core: ProtocolCore,
subject: ProtocolContext,
) -> None:
"""Non-Flex robot type should raise a ValueError."""
mock_tip_racks = [decoy.mock(cls=Labware), decoy.mock(cls=Labware)]

decoy.when(mock_validation.ensure_lowercase_name("Gandalf")).then_return("gandalf")
decoy.when(mock_validation.ensure_pipette_name("gandalf")).then_return(
PipetteNameType.P300_SINGLE
)
decoy.when(
mock_validation.ensure_mount_for_pipette(
"shadowfax", PipetteNameType.P300_SINGLE
)
).then_return(Mount.LEFT)
decoy.when(mock_core.robot_type).then_return("OT-2 Standard")

with pytest.raises(RobotTypeError):
subject.load_instrument(
instrument_name="Gandalf",
mount="shadowfax",
tip_racks=mock_tip_racks,
liquid_presence_detection=False,
)


@pytest.mark.parametrize("api_version", [APIVersion(2, 14)])
def test_load_instrument_api_version(
decoy: Decoy,
mock_core: ProtocolCore,
subject: ProtocolContext,
) -> None:
"""Using an API Version prior to 2.20 should raise a APIVersionError."""
mock_tip_racks = [decoy.mock(cls=Labware), decoy.mock(cls=Labware)]

decoy.when(mock_validation.ensure_lowercase_name("Gandalf")).then_return("gandalf")
decoy.when(mock_validation.ensure_pipette_name("gandalf")).then_return(
PipetteNameType.P300_SINGLE
)
decoy.when(
mock_validation.ensure_mount_for_pipette(
"shadowfax", PipetteNameType.P300_SINGLE
)
).then_return(Mount.LEFT)
decoy.when(mock_core.robot_type).then_return("OT-3 Standard")

with pytest.raises(APIVersionError):
subject.load_instrument(
instrument_name="Gandalf",
mount="shadowfax",
tip_racks=mock_tip_racks,
liquid_presence_detection=False,
)


def test_load_instrument(
decoy: Decoy,
mock_core: ProtocolCore,
Expand All @@ -201,6 +259,7 @@ def test_load_instrument(
mock_core.load_instrument(
instrument_name=PipetteNameType.P300_SINGLE,
mount=Mount.LEFT,
liquid_presence_detection=False,
)
).then_return(mock_instrument_core)

Expand Down Expand Up @@ -253,6 +312,7 @@ def test_load_instrument_replace(
mock_core.load_instrument(
instrument_name=matchers.IsA(PipetteNameType),
mount=matchers.IsA(Mount),
liquid_presence_detection=False,
)
).then_return(mock_instrument_core)
decoy.when(mock_instrument_core.get_pipette_name()).then_return("Ada Lovelace")
Expand Down Expand Up @@ -296,6 +356,7 @@ def test_96_channel_pipette_raises_if_another_pipette_attached(
mock_core.load_instrument(
instrument_name=PipetteNameType.P300_SINGLE,
mount=Mount.RIGHT,
liquid_presence_detection=False,
)
).then_return(mock_instrument_core)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_sets_initial_state(subject: PipetteStore) -> None:
static_config_by_id={},
flow_rates_by_id={},
nozzle_configuration_by_id={},
liquid_presence_detection_by_id={},
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def get_pipette_view(
static_config_by_id: Optional[Dict[str, StaticPipetteConfig]] = None,
flow_rates_by_id: Optional[Dict[str, FlowRates]] = None,
nozzle_layout_by_id: Optional[Dict[str, Optional[NozzleMap]]] = None,
liquid_presence_detection_by_id: Optional[Dict[str, bool]] = None,
) -> PipetteView:
"""Get a pipette view test subject with the specified state."""
state = PipetteState(
Expand All @@ -74,6 +75,7 @@ def get_pipette_view(
static_config_by_id=static_config_by_id or {},
flow_rates_by_id=flow_rates_by_id or {},
nozzle_configuration_by_id=nozzle_layout_by_id or {},
liquid_presence_detection_by_id=liquid_presence_detection_by_id or {},
)

return PipetteView(state=state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8846,6 +8846,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "left",
"pipetteName": "p1000_96",
"tipOverlapNotAfterVersion": "v0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2314,6 +2314,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "left",
"pipetteName": "p300_multi_gen2",
"tipOverlapNotAfterVersion": "v0"
Expand All @@ -2325,6 +2326,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "right",
"pipetteName": "p20_single_gen2",
"tipOverlapNotAfterVersion": "v0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2350,6 +2350,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "left",
"pipetteName": "p300_multi_gen2",
"tipOverlapNotAfterVersion": "v0"
Expand All @@ -2361,6 +2362,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "right",
"pipetteName": "p20_single_gen2",
"tipOverlapNotAfterVersion": "v0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2350,6 +2350,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "left",
"pipetteName": "p300_multi_gen2",
"tipOverlapNotAfterVersion": "v0"
Expand All @@ -2361,6 +2362,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "right",
"pipetteName": "p20_single_gen2",
"tipOverlapNotAfterVersion": "v0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13208,6 +13208,7 @@
"commandType": "loadPipette",
"notes": [],
"params": {
"liquidPresenceDetection": false,
"mount": "left",
"pipetteName": "p1000_96",
"tipOverlapNotAfterVersion": "v0"
Expand Down
Loading

0 comments on commit 6888ffd

Please sign in to comment.