Skip to content

Commit

Permalink
chore(merge): 8.0.0 alpha.9 into edge (#16343)
Browse files Browse the repository at this point in the history
  • Loading branch information
mjhuff authored Sep 24, 2024
1 parent 0078382 commit 95159ed
Show file tree
Hide file tree
Showing 101 changed files with 4,152 additions and 480 deletions.
63 changes: 36 additions & 27 deletions api/docs/v2/pipettes/partial_tip_pickup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,10 @@ The ``start`` parameter sets the first and only nozzle used in the configuration
- | Back to front, left to right
| (A1 through H1, A2 through H2, …)
Since they follow the same pickup order as a single-channel pipette, Opentrons recommends using the following configurations:
.. warning::
In certain conditions, tips in adjacent columns may cling to empty nozzles during single-tip pickup. You can avoid this by overriding automatic tip tracking to pick up tips row by row, rather than column by column. The code sample below demonstrates how to pick up tips this way.

- For 8-channel pipettes, ``start="H1"``.
- For 96-channel pipettes, ``start="H12"``.
However, as with all partial tip layouts, be careful that you don't place the pipette in a position where it overlaps more tips than intended.

Here is the start of a protocol that imports the ``SINGLE`` and ``ALL`` layout constants, loads an 8-channel pipette, and sets it to pick up a single tip.

Expand All @@ -210,29 +210,30 @@ Here is the start of a protocol that imports the ``SINGLE`` and ``ALL`` layout c
)
pipette.configure_nozzle_layout(
style=SINGLE,
start="H12",
tip_racks=[partial_rack]
start="H1"
)
.. versionadded:: 2.20

Since this configuration uses ``start="H12"``, it will pick up tips in the usual order::
To pick up tips row by row, first construct a list of all wells in the tip rack ordered from A1, A2 … H11, H12. One way to do this is to use :py:func:`sum` to flatten the list of lists returned by :py:meth:`.Labware.rows`::

pipette.pick_up_tip() # picks up A1 from tip rack
pipette.drop_tip()
pipette.pick_up_tip() # picks up B1 from tip rack
tips_by_row = sum(partial_rack.rows(), [])

.. note::
Then ``pop`` items from the front of the list (index 0) and pass them as the ``location`` of :py:meth:`.pick_up_tip`::

You can pick up tips row by row, rather than column by column, by specifying a location for :py:meth:`.pick_up_tip` each time you use it in ``SINGLE`` configuration. However, as with all partial tip layouts, be careful that you don't place the pipette in a position where it overlaps more tips than intended.
# pick up A1 from tip rack
pipette.pick_up_tip(location=tips_by_row.pop(0))
pipette.drop_tip()
# pick up A2 from tip rack
pipette.pick_up_tip(location=tips_by_row.pop(0))


Partial Column Layout
---------------------

Partial column pickup is available on 8-channel pipettes only. Partial columns contain 2 to 7 consecutive tips in a single column. The pipette always picks up partial columns with its frontmost nozzles (``start="H1"``).

To specify the number of tips to pick up, add the ``end`` parameter when calling :py:meth:`.configure_nozzle_layout`. Use the chart below to determine the end row (G through B) for your desired number of tips. The end column should be the same as your start column (1 or 12).
To specify the number of tips to pick up, add the ``end`` parameter when calling :py:meth:`.configure_nozzle_layout`. Use the chart below to determine the ending nozzle (G1 through B1) for your desired number of tips.

.. list-table::
:stub-columns: 1
Expand All @@ -244,16 +245,21 @@ To specify the number of tips to pick up, add the ``end`` parameter when calling
- 5
- 6
- 7
* - ``end`` row
- G
- F
- E
- D
- C
- B
* - ``end`` nozzle
- G1
- F1
- E1
- D1
- C1
- B1

When picking up 3, 5, 6, or 7 tips, extra tips will be left at the front of each column. You can use these tips with a different nozzle configuration, or you can manually re-rack them at the end of your protocol for future use.

.. warning::
In certain conditions, tips in adjacent columns may cling to empty nozzles during partial-column pickup. You can avoid this by overriding automatic tip tracking to pick up tips row by row, rather than column by column. The code sample below demonstrates how to pick up tips this way.

However, as with all partial tip layouts, be careful that you don't place the pipette in a position where it overlaps more tips than intended.

Here is the start of a protocol that imports the ``PARTIAL_COLUMN`` and ``ALL`` layout constants, loads an 8-channel pipette, and sets it to pick up four tips:

.. code-block:: python
Expand All @@ -274,21 +280,24 @@ Here is the start of a protocol that imports the ``PARTIAL_COLUMN`` and ``ALL``
pipette.configure_nozzle_layout(
style=PARTIAL_COLUMN,
start="H1",
end="E1",
tip_racks=[partial_rack]
end="E1"
)
.. versionadded:: 2.20

This configuration will pick up tips from the back half of column 1, then the front half of column 1, then the back half of column 2, and so on::
When pipetting in partial column configuration, remember that *the frontmost channel of the pipette is its primary channel*. To pick up tips across the back half of the rack, then across the front half of the rack, construct a list of that includes all and only the wells in row D and row H::

pipette.pick_up_tip() # picks up A1-D1 from tip rack
pipette.drop_tip()
pipette.pick_up_tip() # picks up E1-H1 from tip rack
tips_by_row = partial_rack.rows_by_name()["D"] + partial_rack.rows_by_name()["H"]

Then ``pop`` items from the front of the list (index 0) and pass them as the ``location`` of :py:meth:`.pick_up_tip`::

# pick up A1-D1 from tip rack
pipette.pick_up_tip(location=tips_by_row.pop(0))
pipette.drop_tip()
pipette.pick_up_tip() # picks up A2-D2 from tip rack
# pick up A2-D2 from tip rack
pipette.pick_up_tip(location=tips_by_row.pop(0))

When handling liquids in partial column configuration, remember that *the frontmost channel of the pipette is its primary channel*. For example, to use the same configuration as above to transfer liquid from wells A1–D1 to wells A2–D2 on a plate, you must use the wells in row D as the source and destination targets::
To use the same configuration as above to transfer liquid from wells A1–D1 to wells A2–D2 on a plate, you must use the wells in row D as the source and destination targets::

# pipette in 4-nozzle partial column layout
pipette.transfer(
Expand Down
6 changes: 6 additions & 0 deletions api/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Welcome to the v8.0.0 release of the Opentrons robot software!
- Provides more partial tip pickup configurations. All multi-channel pipettes now support single and partial column pickup, and the Flex 96-channel pipette now supports row pickup.
- Improves homing behavior when a Flex protocol completes or is canceled with liquid-filled tips attached to the pipette.

### Known Issues

- During single-tip or partial-column pickup with a multi-channel pipette, tips in adjacent columns may cling to empty nozzles. Pick up tips row by row, rather than column by column, to avoid this.
- Protocol analysis and `opentrons_simulate` do not raise an error when a protocol tries to detect liquid with a pipette nozzle configuration that doesn't contain a pressure sensor (single-tip pickup with A12 or H1). Avoid using the A12 and H1 nozzles for single-tip pickup if you need to detect liquid presence within wells.
- `opentrons_simulate` describes motion to wells only with respect to the primary channel, regardless of the current pipette nozzle configuration.

---

## Opentrons Robot Software Changes in 7.5.0
Expand Down
19 changes: 15 additions & 4 deletions api/src/opentrons/hardware_control/ot3api.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def _adjust_high_throughput_z_current(func: Wrapped) -> Wrapped:
A decorator that temproarily and conditionally changes the active current (based on the axis input)
before a function is executed and the cleans up afterwards
"""

# only home and retract should be wrappeed by this decorator
@wraps(func)
async def wrapper(self: Any, axis: Axis, *args: Any, **kwargs: Any) -> Any:
Expand Down Expand Up @@ -933,7 +934,6 @@ async def home_gear_motors(self) -> None:
current_pos_float > self._config.safe_home_distance
and current_pos_float < max_distance
):

# move toward home until a safe distance
await self._backend.tip_action(
origin={Axis.Q: current_pos_float},
Expand Down Expand Up @@ -1811,7 +1811,8 @@ async def tip_pickup_moves(
increment: Optional[float] = None,
) -> None:
"""This is a slightly more barebones variation of pick_up_tip. This is only the motor routine
directly involved in tip pickup, and leaves any state updates and plunger moves to the caller."""
directly involved in tip pickup, and leaves any state updates and plunger moves to the caller.
"""
realmount = OT3Mount.from_mount(mount)
instrument = self._pipette_handler.get_pipette(realmount)

Expand Down Expand Up @@ -2654,7 +2655,7 @@ async def _liquid_probe_pass(
cp = self.critical_point_for(mount, None)
return deck_end_z + offset.z + cp.z

async def liquid_probe(
async def liquid_probe( # noqa: C901
self,
mount: Union[top_types.Mount, OT3Mount],
max_z_dist: float,
Expand Down Expand Up @@ -2683,6 +2684,16 @@ async def liquid_probe(
self._pipette_handler.ready_for_tip_action(
instrument, HardwareAction.LIQUID_PROBE, checked_mount
)
# default to using all available sensors
if probe:
checked_probe = probe
else:
checked_probe = (
InstrumentProbeType.BOTH
if instrument.channels > 1
else InstrumentProbeType.PRIMARY
)

if not probe_settings:
probe_settings = deepcopy(self.config.liquid_sense)

Expand Down Expand Up @@ -2774,7 +2785,7 @@ async def prep_plunger_for_probe_move(
height = await self._liquid_probe_pass(
checked_mount,
probe_settings,
probe if probe else InstrumentProbeType.PRIMARY,
checked_probe,
plunger_travel_mm + sensor_baseline_plunger_move_mm,
)
# if we made it here without an error we found the liquid
Expand Down
22 changes: 21 additions & 1 deletion api/src/opentrons/protocol_api/core/engine/deck_conflict.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError
from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE

from opentrons.hardware_control import CriticalPoint
from opentrons.hardware_control.modules.types import ModuleType
from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict
from opentrons.motion_planning import adjacent_slots_getters
Expand Down Expand Up @@ -228,9 +229,13 @@ def check_safe_for_pipette_movement(
)
primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id)

destination_cp = _get_critical_point_to_use(engine_state, labware_id)

pipette_bounds_at_well_location = (
engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position(
pipette_id=pipette_id, destination_position=well_location_point
pipette_id=pipette_id,
destination_position=well_location_point,
critical_point=destination_cp,
)
)
if not _is_within_pipette_extents(
Expand Down Expand Up @@ -284,6 +289,21 @@ def check_safe_for_pipette_movement(
)


def _get_critical_point_to_use(
engine_state: StateView, labware_id: str
) -> Optional[CriticalPoint]:
"""Return the critical point to use when accessing the given labware."""
# TODO (spp, 2024-09-17): looks like Y_CENTER of column is the same as its XY_CENTER.
# I'm using this if-else ladder to be consistent with what we do in
# `MotionPlanning.get_movement_waypoints_to_well()`.
# We should probably use only XY_CENTER in both places.
if engine_state.labware.get_should_center_column_on_target_well(labware_id):
return CriticalPoint.Y_CENTER
elif engine_state.labware.get_should_center_pipette_on_target_well(labware_id):
return CriticalPoint.XY_CENTER
return None


def _slot_has_potential_colliding_object(
engine_state: StateView,
pipette_bounds: Tuple[Point, Point, Point, Point],
Expand Down
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def aspirate(
and well is not None
and self.liquid_presence_detection
and self._96_tip_config_valid()
and self._core.get_current_volume() == 0
):
self.require_liquid_presence(well=well)

Expand Down
42 changes: 32 additions & 10 deletions api/src/opentrons/protocol_engine/commands/liquid_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@

from pydantic import Field

from opentrons.protocol_engine.errors.exceptions import MustHomeError, TipNotEmptyError
from opentrons.protocol_engine.state import update_types
from opentrons.protocol_engine.errors.exceptions import (
MustHomeError,
PipetteNotReadyToAspirateError,
TipNotEmptyError,
)
from opentrons.types import MountType
from opentrons_shared_data.errors.exceptions import (
PipetteLiquidNotFoundError,
Expand All @@ -33,6 +37,7 @@
if TYPE_CHECKING:
from ..execution import MovementHandler, PipettingHandler
from ..resources import ModelUtils
from ..state.state import StateView


LiquidProbeCommandType = Literal["liquidProbe"]
Expand Down Expand Up @@ -97,21 +102,32 @@ class _ExecuteCommonResult(NamedTuple):


async def _execute_common(
movement: MovementHandler, pipetting: PipettingHandler, params: _CommonParams
state_view: StateView,
movement: MovementHandler,
pipetting: PipettingHandler,
params: _CommonParams,
) -> _ExecuteCommonResult:
pipette_id = params.pipetteId
labware_id = params.labwareId
well_name = params.wellName

state_update = update_types.StateUpdate()

# _validate_tip_attached in pipetting.py is a private method so we're using
# get_is_ready_to_aspirate as an indirect way to throw a TipNotAttachedError if appropriate
pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id)

if pipetting.get_is_empty(pipette_id=pipette_id) is False:
# May raise TipNotAttachedError.
aspirated_volume = state_view.pipettes.get_aspirated_volume(pipette_id)

if aspirated_volume is None:
# Theoretically, we could avoid raising an error by automatically preparing
# to aspirate above the well like AspirateImplementation does. However, the
# only way for this to happen is if someone tries to do a liquid probe with
# a tip that's previously held liquid, which they should avoid anyway.
raise PipetteNotReadyToAspirateError(
"The pipette cannot probe liquid because of a previous blow out."
" The plunger must be reset while the tip is somewhere away from liquid."
)
elif aspirated_volume != 0:
raise TipNotEmptyError(
message="This operation requires a tip with no liquid in it."
message="The pipette cannot probe for liquid when the tip has liquid in it."
)

if await movement.check_for_valid_position(mount=MountType.LEFT) is False:
Expand Down Expand Up @@ -158,11 +174,13 @@ class LiquidProbeImplementation(

def __init__(
self,
state_view: StateView,
movement: MovementHandler,
pipetting: PipettingHandler,
model_utils: ModelUtils,
**kwargs: object,
) -> None:
self._state_view = state_view
self._movement = movement
self._pipetting = pipetting
self._model_utils = model_utils
Expand All @@ -178,11 +196,13 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn:
the pipette.
TipNotEmptyError: as an undefined error, if the tip starts with liquid
in it.
PipetteNotReadyToAspirateError: as an undefined error, if the plunger is not
in a safe position to do the liquid probe.
MustHomeError: as an undefined error, if the plunger is not in a valid
position.
"""
z_pos_or_error, state_update, deck_point = await _execute_common(
self._movement, self._pipetting, params
self._state_view, self._movement, self._pipetting, params
)
if isinstance(z_pos_or_error, PipetteLiquidNotFoundError):
return DefinedErrorData(
Expand Down Expand Up @@ -216,10 +236,12 @@ class TryLiquidProbeImplementation(

def __init__(
self,
state_view: StateView,
movement: MovementHandler,
pipetting: PipettingHandler,
**kwargs: object,
) -> None:
self._state_view = state_view
self._movement = movement
self._pipetting = pipetting

Expand All @@ -231,7 +253,7 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn:
of a defined error.
"""
z_pos_or_error, state_update, deck_point = await _execute_common(
self._movement, self._pipetting, params
self._state_view, self._movement, self._pipetting, params
)

z_pos = (
Expand Down
11 changes: 0 additions & 11 deletions api/src/opentrons/protocol_engine/execution/pipetting.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@
class PipettingHandler(TypingProtocol):
"""Liquid handling commands."""

def get_is_empty(self, pipette_id: str) -> bool:
"""Get whether a pipette has an aspirated volume equal to 0."""

def get_is_ready_to_aspirate(self, pipette_id: str) -> bool:
"""Get whether a pipette is ready to aspirate."""

Expand Down Expand Up @@ -82,10 +79,6 @@ def __init__(self, state_view: StateView, hardware_api: HardwareControlAPI) -> N
self._state_view = state_view
self._hardware_api = hardware_api

def get_is_empty(self, pipette_id: str) -> bool:
"""Get whether a pipette has an aspirated volume equal to 0."""
return self._state_view.pipettes.get_aspirated_volume(pipette_id) == 0

def get_is_ready_to_aspirate(self, pipette_id: str) -> bool:
"""Get whether a pipette is ready to aspirate."""
hw_pipette = self._state_view.pipettes.get_hardware_pipette(
Expand Down Expand Up @@ -239,10 +232,6 @@ def __init__(
"""Initialize a PipettingHandler instance."""
self._state_view = state_view

def get_is_empty(self, pipette_id: str) -> bool:
"""Get whether a pipette has an aspirated volume equal to 0."""
return self._state_view.pipettes.get_aspirated_volume(pipette_id) == 0

def get_is_ready_to_aspirate(self, pipette_id: str) -> bool:
"""Get whether a pipette is ready to aspirate."""
return self._state_view.pipettes.get_aspirated_volume(pipette_id) is not None
Expand Down
Loading

0 comments on commit 95159ed

Please sign in to comment.