Repository: Opentrons/opentrons
Pull request #18249: feat(api): add option to air_gap() in place
Author: login: ddcc4
Created: 2025-05-02T23:00:04Z | Merged: 2025-05-09T21:09:57Z

# Overview

The API `air_gap()` function allows you to specify a `height` above the well to perform the air gap. If no `height` is specified, then `air_gap()` defaults to 5 mm above the well. But that means there is no way to air gap in place without moving the pipette.

To match the capabilities of PD step generation, this PR adds a new `air_gap(in_place=True)` option to the API. This new parameter is gated on API version 2.24 and above. AUTH-1797

## Test Plan and Hands on Testing

Added unit tests for the new `in_place` option, and made sure you cannot specify both `in_place` and a `height`.

## Risk assessment

This is an API change, so once we release it publicly, we'll be stuck with it.


Diff:
diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py
index 90d04bb830e8..446c3799189a 100644
--- a/api/src/opentrons/protocol_api/instrument_context.py
+++ b/api/src/opentrons/protocol_api/instrument_context.py
@@ -859,6 +859,7 @@ def air_gap(  # noqa: C901
         self,
         volume: Optional[float] = None,
         height: Optional[float] = None,
+        in_place: Optional[bool] = None,
         rate: Optional[float] = None,
         flow_rate: Optional[float] = None,
     ) -> InstrumentContext:
@@ -875,6 +876,11 @@ def air_gap(  # noqa: C901
                        the air gap. The default is 5 mm above the current well.
         :type height: float
 
+        :param in_place: Air gap at the pipette's current position, without moving to
+                         some height above the well. If ``in_place`` is specified,
+                         ``height`` must be unset.
+        :type in_place: bool
+
         :param rate: A multiplier for the default flow rate of the pipette. Calculated
                      as ``rate`` multiplied by :py:attr:`flow_rate.aspirate
                      <flow_rate>`. If neither rate nor flow_rate is specified, the pipette
@@ -887,10 +893,10 @@ def air_gap(  # noqa: C901
 
         :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette.
 
-        :raises RuntimeError: If location cache is ``None``. This should happen if
-                              ``air_gap()`` is called without first calling a method
-                              that takes a location (e.g., :py:meth:`.aspirate`,
-                              :py:meth:`dispense`)
+        :raises RuntimeError: If location cache is ``None`` and the air gap is not
+                              ``in_place``. This would happen if ``air_gap()`` is called
+                              without first calling a method that takes a location (e.g.,
+                              :py:meth:`.aspirate`, :py:meth:`dispense`)
 
         :returns: This instance.
 
@@ -908,6 +914,8 @@ def air_gap(  # noqa: C901
 
         .. versionchanged:: 2.22
             No longer implemented as an aspirate.
+        .. versionchanged:: 2.24
+            Added the ``in_place`` option.
         .. versionchanged:: 2.24
             Adds the ``rate`` and ``flow_rate`` parameter. You can only define one or the other. If
             both are unspecified then ``rate`` is by default set to 1.0.
@@ -933,26 +941,37 @@ def air_gap(  # noqa: C901
         if flow_rate is not None and rate is not None:
             raise ValueError("Cannot define both flow_rate and rate.")
 
-        if height is None:
-            height = 5
-        last_location = self._protocol_core.get_last_location()
-        if self.api_version < APIVersion(2, 24) and isinstance(
-            last_location, (TrashBin, WasteChute)
-        ):
-            last_location = None
-        if last_location is None or (
-            isinstance(last_location, types.Location)
-            and not last_location.labware.is_well
-        ):
-            raise RuntimeError(
-                f"Cached location of {last_location} is not valid for air gap."
-            )
-        target: Union[types.Location, TrashBin, WasteChute]
-        if isinstance(last_location, types.Location):
-            target = last_location.labware.as_well().top(height)
+        if in_place:
+            if self.api_version < APIVersion(2, 24):
+                raise APIVersionError(
+                    api_element="in_place",
+                    until_version="2.24",
+                    current_version=f"{self._api_version}",
+                )
+            if height is not None:
+                raise ValueError("height must be unset if air gapping in_place")
         else:
-            target = last_location.top(height)
-        self.move_to(target, publish=False)
+            if height is None:
+                height = 5
+            last_location = self._protocol_core.get_last_location()
+            if self.api_version < APIVersion(2, 24) and isinstance(
+                last_location, (TrashBin, WasteChute)
+            ):
+                last_location = None
+            if last_location is None or (
+                isinstance(last_location, types.Location)
+                and not last_location.labware.is_well
+            ):
+                raise RuntimeError(
+                    f"Cached location of {last_location} is not valid for air gap."
+                )
+            target: Union[types.Location, TrashBin, WasteChute]
+            if isinstance(last_location, types.Location):
+                target = last_location.labware.as_well().top(height)
+            else:
+                target = last_location.top(height)
+            self.move_to(target, publish=False)
+
         if self.api_version >= _AIR_GAP_TRACKING_ADDED_IN:
             self._core.prepare_to_aspirate()
             c_vol = self._core.get_available_volume() if volume is None else volume
diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py
index 19f16b7d7cc2..77279aa86618 100644
--- a/api/tests/opentrons/protocol_api/test_instrument_context.py
+++ b/api/tests/opentrons/protocol_api/test_instrument_context.py
@@ -1966,6 +1966,29 @@ def test_air_gap_uses_air_gap(
     decoy.verify(mock_instrument_core.air_gap_in_place(10, 11))
 
 
+def test_air_gap_in_place(
+    decoy: Decoy,
+    mock_instrument_core: InstrumentCore,
+    subject: InstrumentContext,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """It should air gap in place when in_place=True."""
+    decoy.when(mock_instrument_core.has_tip()).then_return(True)
+    decoy.when(mock_instrument_core.get_aspirate_flow_rate()).then_return(11)
+    monkeypatch.setattr(subject, "move_to", None)  # pipette should not move
+
+    subject.air_gap(volume=10, in_place=True)
+
+    decoy.verify(mock_instrument_core.air_gap_in_place(10, 11))
+
+    # Should not allow height if in_place=True is specified.
+    with pytest.raises(ValueError):
+        subject.air_gap(volume=10, height=2, in_place=True)
+    # height=0 is also not allowed when in_place=True.
+    with pytest.raises(ValueError):
+        subject.air_gap(volume=10, height=0, in_place=True)
+
+
 @pytest.mark.parametrize("api_version", versions_at_or_above(APIVersion(2, 24)))
 def test_air_gap_has_rate(
     decoy: Decoy,


Reviews:
user: login: sanni-t
body: LGTM! 👍
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-04T22:58:07Z
user: login: ddcc4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T15:52:20Z

Review comments:
diff_hunk: @@ -859,6 +862,11 @@ def air_gap(
                        the air gap. The default is 5 mm above the current well.
         :type height: float
 
+        :param in_place: Air gap at the pipette's current position, without moving to
+                         some height above the well. If ``in_place`` is specified,
+                         ``height`` must be unset.
+        :type in_place: bool
+
         :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette.
 
         :raises RuntimeError: If location cache is ``None``. This should happen if
path: api/src/opentrons/protocol_api/instrument_context.py
user: login: sanni-t
body: We should update this line to mention that this applies only if `in_place` is `False`.
created_at: 2025-05-04T22:12:39Z
author_association: MEMBER
diff_hunk: @@ -859,6 +862,11 @@ def air_gap(
                        the air gap. The default is 5 mm above the current well.
         :type height: float
 
+        :param in_place: Air gap at the pipette's current position, without moving to
+                         some height above the well. If ``in_place`` is specified,
+                         ``height`` must be unset.
+        :type in_place: bool
+
         :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette.
 
         :raises RuntimeError: If location cache is ``None``. This should happen if
path: api/src/opentrons/protocol_api/instrument_context.py
user: login: ddcc4
body: Good point, thanks! I've updated the docstring.
created_at: 2025-05-05T15:52:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: ecormany
created_at: 2025-05-05T14:08:58Z
body: I think the behavior added in this PR doesn't match the expectations we've set in the rest of the API. This would be the only API command that I know of where you would explicitly command the pipette, "Don't move! But then do an action." 

I would expect that any API addition that allows an action to happen in more places than before would use something like a `location` argument, or expand an existing `location` argument's valid values. But `air_gap()` is strange: it doesn't have a `location` argument, yet it's keeping track of a location. The pipette _might_ move, or it might not. It might move _back_ to some `Well`-based location even though you've had the pipette `move_to()` somewhere else in the meantime. And the API doesn't have an `InstrumentContext.current_location()` that we can pass in. Whenever we want the pipette to stay in place for other methods, we use `location=None`. But that would break the behavior of older protocols migrating to 2.24 and not changing any of their `air_gap()` args.

I've argued around in a circle on this, so I'm not sure what the solution is. I think the conclusion may be that this is an acceptable band-aid for getting a PD feature into the Python API. But it might really be waiting on some sort of PAPIv3 unification yet to come.
author_association: CONTRIBUTOR
user: login: ddcc4
created_at: 2025-05-05T15:43:38Z
body: > I think the behavior added in this PR doesn't match the expectations we've set in the rest of the API. This would be the only API command that I know of where you would explicitly command the pipette, "Don't move! But then do an action."

Yeah. I've mentioned to the folks here that, ideally, `air_gap()` should behave like `aspirate()`, since `air_gap()` is really an aspiration behind the scenes. If you `aspirate()` and don't specify a location, it would aspirate in place. Ideally, `air_gap()` would do that too.

But we've already defined `air_gap()` with no argument to mean air-gapping 5 mm above the well, and we can't change that. So I don't see an elegant way to make `air_gap()` match the semantics of `aspirate()`. 
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-07T18:24:56Z
body: ## [Codecov](https://app.codecov.io/gh/Opentrons/opentrons/pull/18249?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 23.47%. Comparing base [(`c5c0329`)](https://app.codecov.io/gh/Opentrons/opentrons/commit/c5c032990145ea8df9b39154ea0ec8df40d0e80d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons) to head [(`5ee1637`)](https://app.codecov.io/gh/Opentrons/opentrons/commit/5ee16373678923a82a130551021fa05ccb0ee16f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons).
> Report is 2 commits behind head on edge.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/Opentrons/opentrons/pull/18249/graphs/tree.svg?width=650&height=150&src=pr&token=DIJPWI1JiU&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons)](https://app.codecov.io/gh/Opentrons/opentrons/pull/18249?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons)

```diff
@@            Coverage Diff             @@
##             edge   #18249      +/-   ##
==========================================
- Coverage   23.51%   23.47%   -0.04%     
==========================================
  Files        3059     3064       +5     
  Lines      256370   256745     +375     
  Branches    30114    30040      -74     
==========================================
+ Hits        60274    60283       +9     
- Misses     196082   196448     +366     
  Partials       14       14              
```

| [Flag](https://app.codecov.io/gh/Opentrons/opentrons/pull/18249/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons) | Coverage Δ | |
|---|---|---|
| [step-generation](https://app.codecov.io/gh/Opentrons/opentrons/pull/18249/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons) | `4.55% <ø> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons#carryforward-flags-in-the-pull-request-comment) to find out more.

[see 22 files with indirect coverage changes](https://app.codecov.io/gh/Opentrons/opentrons/pull/18249/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Opentrons)

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE