opentrons 8.4.1a2__py2.py3-none-any.whl → 8.5.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. opentrons/config/defaults_ot3.py +1 -1
  2. opentrons/hardware_control/backends/flex_protocol.py +25 -0
  3. opentrons/hardware_control/backends/ot3controller.py +76 -1
  4. opentrons/hardware_control/backends/ot3simulator.py +27 -0
  5. opentrons/hardware_control/instruments/ot3/pipette_handler.py +1 -0
  6. opentrons/hardware_control/ot3api.py +32 -0
  7. opentrons/legacy_commands/commands.py +16 -4
  8. opentrons/legacy_commands/robot_commands.py +51 -0
  9. opentrons/legacy_commands/types.py +91 -2
  10. opentrons/protocol_api/_liquid.py +60 -15
  11. opentrons/protocol_api/_liquid_properties.py +149 -90
  12. opentrons/protocol_api/_transfer_liquid_validation.py +43 -14
  13. opentrons/protocol_api/core/engine/instrument.py +367 -221
  14. opentrons/protocol_api/core/engine/protocol.py +14 -15
  15. opentrons/protocol_api/core/engine/robot.py +2 -2
  16. opentrons/protocol_api/core/engine/transfer_components_executor.py +275 -163
  17. opentrons/protocol_api/core/engine/well.py +16 -0
  18. opentrons/protocol_api/core/instrument.py +11 -5
  19. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +11 -5
  20. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +2 -2
  21. opentrons/protocol_api/core/legacy/legacy_well_core.py +8 -0
  22. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +11 -5
  23. opentrons/protocol_api/core/protocol.py +3 -3
  24. opentrons/protocol_api/core/well.py +8 -0
  25. opentrons/protocol_api/instrument_context.py +478 -111
  26. opentrons/protocol_api/labware.py +10 -0
  27. opentrons/protocol_api/module_contexts.py +5 -2
  28. opentrons/protocol_api/protocol_context.py +76 -11
  29. opentrons/protocol_api/robot_context.py +48 -6
  30. opentrons/protocol_api/validation.py +15 -8
  31. opentrons/protocol_engine/commands/command_unions.py +10 -10
  32. opentrons/protocol_engine/commands/generate_command_schema.py +1 -1
  33. opentrons/protocol_engine/commands/get_next_tip.py +2 -2
  34. opentrons/protocol_engine/commands/load_labware.py +0 -19
  35. opentrons/protocol_engine/commands/pick_up_tip.py +9 -3
  36. opentrons/protocol_engine/commands/robot/__init__.py +20 -20
  37. opentrons/protocol_engine/commands/robot/close_gripper_jaw.py +34 -24
  38. opentrons/protocol_engine/commands/robot/open_gripper_jaw.py +29 -20
  39. opentrons/protocol_engine/commands/seal_pipette_to_tip.py +1 -1
  40. opentrons/protocol_engine/commands/unsafe/__init__.py +17 -1
  41. opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +1 -2
  42. opentrons/protocol_engine/execution/labware_movement.py +9 -2
  43. opentrons/protocol_engine/execution/movement.py +12 -9
  44. opentrons/protocol_engine/execution/queue_worker.py +8 -1
  45. opentrons/protocol_engine/execution/thermocycler_movement_flagger.py +52 -19
  46. opentrons/protocol_engine/resources/labware_validation.py +7 -1
  47. opentrons/protocol_engine/state/_well_math.py +2 -2
  48. opentrons/protocol_engine/state/commands.py +14 -28
  49. opentrons/protocol_engine/state/frustum_helpers.py +11 -7
  50. opentrons/protocol_engine/state/labware.py +12 -0
  51. opentrons/protocol_engine/state/modules.py +1 -1
  52. opentrons/protocol_engine/state/pipettes.py +8 -0
  53. opentrons/protocol_engine/state/tips.py +46 -83
  54. opentrons/protocol_engine/state/update_types.py +8 -23
  55. opentrons/protocol_engine/types/liquid_level_detection.py +68 -8
  56. opentrons/protocol_runner/legacy_command_mapper.py +12 -6
  57. opentrons/protocol_runner/run_orchestrator.py +1 -1
  58. opentrons/protocols/advanced_control/transfers/common.py +54 -11
  59. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +55 -28
  60. opentrons/protocols/api_support/definitions.py +1 -1
  61. opentrons/types.py +6 -6
  62. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/METADATA +4 -4
  63. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/RECORD +67 -66
  64. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/LICENSE +0 -0
  65. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/WHEEL +0 -0
  66. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/entry_points.txt +0 -0
  67. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
  import logging
3
3
  from contextlib import ExitStack
4
- from typing import Any, List, Optional, Sequence, Union, cast, Dict
4
+ from typing import Any, List, Optional, Sequence, Union, cast, Tuple
5
5
  from opentrons_shared_data.errors.exceptions import (
6
6
  CommandPreconditionViolated,
7
7
  CommandParameterLimitViolated,
@@ -12,7 +12,10 @@ from opentrons_shared_data.errors.exceptions import (
12
12
  from opentrons.legacy_broker import LegacyBroker
13
13
  from opentrons.hardware_control.dev_types import PipetteDict
14
14
  from opentrons import types
15
- from opentrons.legacy_commands import commands as cmds
15
+ from opentrons.legacy_commands import (
16
+ commands as cmds,
17
+ protocol_commands as protocol_cmds,
18
+ )
16
19
 
17
20
  from opentrons.legacy_commands import publisher
18
21
  from opentrons.protocols.advanced_control.mix import mix_from_kwargs
@@ -29,14 +32,17 @@ from opentrons.protocols.api_support.util import (
29
32
  UnsupportedAPIError,
30
33
  )
31
34
 
32
- from .core.common import InstrumentCore, ProtocolCore
35
+ from .core.common import InstrumentCore, ProtocolCore, WellCore
33
36
  from .core.engine import ENGINE_CORE_API_VERSION
34
37
  from .core.legacy.legacy_instrument_core import LegacyInstrumentCore
35
38
  from .config import Clearances
36
39
  from .disposal_locations import TrashBin, WasteChute
37
40
  from ._nozzle_layout import NozzleLayout
38
41
  from ._liquid import LiquidClass
39
- from ._transfer_liquid_validation import verify_and_normalize_transfer_args
42
+ from ._transfer_liquid_validation import (
43
+ verify_and_normalize_transfer_args,
44
+ resolve_keep_last_tip,
45
+ )
40
46
  from . import labware, validation
41
47
  from ..protocols.advanced_control.transfers.common import (
42
48
  TransferTipPolicyV2,
@@ -70,6 +76,16 @@ _AIR_GAP_TRACKING_ADDED_IN = APIVersion(2, 22)
70
76
  AdvancedLiquidHandling = v1_transfer.AdvancedLiquidHandling
71
77
 
72
78
 
79
+ class _Unset:
80
+ """A sentinel value when no value has been supplied for an argument.
81
+ User code should never use this explicitly."""
82
+
83
+ def __repr__(self) -> str:
84
+ # Without this, the generated docs render the argument as
85
+ # "<opentrons.protocol_api.instrument_context._Unset object at 0x1234>"
86
+ return self.__class__.__name__
87
+
88
+
73
89
  class InstrumentContext(publisher.CommandPublisher):
74
90
  """
75
91
  A context for a specific pipette or instrument.
@@ -173,11 +189,12 @@ class InstrumentContext(publisher.CommandPublisher):
173
189
  return self._core.get_minimum_liquid_sense_height()
174
190
 
175
191
  @requires_version(2, 0)
176
- def aspirate(
192
+ def aspirate( # noqa: C901
177
193
  self,
178
194
  volume: Optional[float] = None,
179
195
  location: Optional[Union[types.Location, labware.Well]] = None,
180
196
  rate: float = 1.0,
197
+ flow_rate: Optional[float] = None,
181
198
  ) -> InstrumentContext:
182
199
  """
183
200
  Draw liquid into a pipette tip.
@@ -214,6 +231,9 @@ class InstrumentContext(publisher.CommandPublisher):
214
231
  <flow_rate>`. If not specified, defaults to 1.0. See
215
232
  :ref:`new-plunger-flow-rates`.
216
233
  :type rate: float
234
+ :param flow_rate: The absolute flow rate in µL/s. If ``flow_rate`` is specified,
235
+ ``rate`` must not be set.
236
+ :type flow_rate: float
217
237
  :returns: This instance.
218
238
 
219
239
  .. note::
@@ -223,15 +243,30 @@ class InstrumentContext(publisher.CommandPublisher):
223
243
  ``location``, specify it as a keyword argument:
224
244
  ``pipette.aspirate(location=plate['A1'])``
225
245
 
246
+ .. versionchanged:: 2.24
247
+ Added the ``flow_rate`` parameter.
226
248
  """
249
+ if flow_rate is not None:
250
+ if self.api_version < APIVersion(2, 24):
251
+ raise APIVersionError(
252
+ api_element="flow_rate",
253
+ until_version="2.24",
254
+ current_version=f"{self.api_version}",
255
+ )
256
+ if rate != 1.0:
257
+ raise ValueError("rate must not be set if flow_rate is specified")
258
+ rate = flow_rate / self._core.get_aspirate_flow_rate()
259
+ else:
260
+ flow_rate = self._core.get_aspirate_flow_rate(rate)
261
+
227
262
  _log.debug(
228
- "aspirate {} from {} at {}".format(
229
- volume, location if location else "current position", rate
263
+ "aspirate {} from {} at {} µL/s".format(
264
+ volume, location if location else "current position", flow_rate
230
265
  )
231
266
  )
232
267
 
233
268
  move_to_location: types.Location
234
- well: Optional[labware.Well] = None
269
+ well: Optional[labware.Well]
235
270
  last_location = self._get_last_location_by_api_version()
236
271
  try:
237
272
  target = validation.validate_location(
@@ -245,7 +280,7 @@ class InstrumentContext(publisher.CommandPublisher):
245
280
  "knows where it is."
246
281
  ) from e
247
282
 
248
- if isinstance(target, (TrashBin, WasteChute)):
283
+ if isinstance(target, validation.DisposalTarget):
249
284
  raise ValueError(
250
285
  "Trash Bin and Waste Chute are not acceptable location parameters for Aspirate commands."
251
286
  )
@@ -263,7 +298,6 @@ class InstrumentContext(publisher.CommandPublisher):
263
298
  c_vol = self._core.get_available_volume() if volume is None else volume
264
299
  else:
265
300
  c_vol = self._core.get_available_volume() if not volume else volume
266
- flow_rate = self._core.get_aspirate_flow_rate(rate)
267
301
 
268
302
  if (
269
303
  self.api_version >= APIVersion(2, 20)
@@ -299,7 +333,7 @@ class InstrumentContext(publisher.CommandPublisher):
299
333
  return self
300
334
 
301
335
  @requires_version(2, 0)
302
- def dispense(
336
+ def dispense( # noqa: C901
303
337
  self,
304
338
  volume: Optional[float] = None,
305
339
  location: Optional[
@@ -307,6 +341,7 @@ class InstrumentContext(publisher.CommandPublisher):
307
341
  ] = None,
308
342
  rate: float = 1.0,
309
343
  push_out: Optional[float] = None,
344
+ flow_rate: Optional[float] = None,
310
345
  ) -> InstrumentContext:
311
346
  """
312
347
  Dispense liquid from a pipette tip.
@@ -363,15 +398,19 @@ class InstrumentContext(publisher.CommandPublisher):
363
398
  <flow_rate>`. If not specified, defaults to 1.0. See
364
399
  :ref:`new-plunger-flow-rates`.
365
400
  :type rate: float
401
+
366
402
  :param push_out: Continue past the plunger bottom to help ensure all liquid
367
403
  leaves the tip. Measured in µL. The default value is ``None``.
368
404
 
369
405
  When not specified or set to ``None``, the plunger moves by a non-zero default amount.
370
406
 
371
-
372
407
  For a table of default values, see :ref:`push-out-dispense`.
373
408
  :type push_out: float
374
409
 
410
+ :param flow_rate: The absolute flow rate in µL/s. If ``flow_rate`` is specified,
411
+ ``rate`` must not be set.
412
+ :type flow_rate: float
413
+
375
414
  :returns: This instance.
376
415
 
377
416
  .. note::
@@ -386,6 +425,13 @@ class InstrumentContext(publisher.CommandPublisher):
386
425
 
387
426
  .. versionchanged:: 2.17
388
427
  Behavior of the ``volume`` parameter.
428
+
429
+ .. versionchanged:: 2.24
430
+ Added the ``flow_rate`` parameter.
431
+
432
+ .. versionchanged:: 2.24
433
+ ``location`` is no longer required if the pipette just moved to, dispensed, or blew out
434
+ into a trash bin or waste chute.
389
435
  """
390
436
  if self.api_version < APIVersion(2, 15) and push_out:
391
437
  raise APIVersionError(
@@ -393,13 +439,27 @@ class InstrumentContext(publisher.CommandPublisher):
393
439
  until_version="2.15",
394
440
  current_version=f"{self.api_version}",
395
441
  )
442
+
443
+ if flow_rate is not None:
444
+ if self.api_version < APIVersion(2, 24):
445
+ raise APIVersionError(
446
+ api_element="flow_rate",
447
+ until_version="2.24",
448
+ current_version=f"{self.api_version}",
449
+ )
450
+ if rate != 1.0:
451
+ raise ValueError("rate must not be set if flow_rate is specified")
452
+ rate = flow_rate / self._core.get_dispense_flow_rate()
453
+ else:
454
+ flow_rate = self._core.get_dispense_flow_rate(rate)
455
+
396
456
  _log.debug(
397
- "dispense {} from {} at {}".format(
398
- volume, location if location else "current position", rate
457
+ "dispense {} from {} at {} µL/s".format(
458
+ volume, location if location else "current position", flow_rate
399
459
  )
400
460
  )
401
- last_location = self._get_last_location_by_api_version()
402
461
 
462
+ last_location = self._get_last_location_by_api_version()
403
463
  try:
404
464
  target = validation.validate_location(
405
465
  location=location, last_location=last_location
@@ -417,15 +477,13 @@ class InstrumentContext(publisher.CommandPublisher):
417
477
  else:
418
478
  c_vol = self._core.get_current_volume() if not volume else volume
419
479
 
420
- flow_rate = self._core.get_dispense_flow_rate(rate)
421
-
422
- if isinstance(target, (TrashBin, WasteChute)):
480
+ if isinstance(target, validation.DisposalTarget):
423
481
  with publisher.publish_context(
424
482
  broker=self.broker,
425
483
  command=cmds.dispense_in_disposal_location(
426
484
  instrument=self,
427
485
  volume=c_vol,
428
- location=target,
486
+ location=target.location,
429
487
  rate=rate,
430
488
  flow_rate=flow_rate,
431
489
  ),
@@ -433,10 +491,10 @@ class InstrumentContext(publisher.CommandPublisher):
433
491
  self._core.dispense(
434
492
  volume=c_vol,
435
493
  rate=rate,
436
- location=target,
494
+ location=target.location,
437
495
  well_core=None,
438
496
  flow_rate=flow_rate,
439
- in_place=False,
497
+ in_place=target.in_place,
440
498
  push_out=push_out,
441
499
  meniscus_tracking=None,
442
500
  )
@@ -477,12 +535,17 @@ class InstrumentContext(publisher.CommandPublisher):
477
535
  return self
478
536
 
479
537
  @requires_version(2, 0)
480
- def mix(
538
+ def mix( # noqa: C901
481
539
  self,
482
540
  repetitions: int = 1,
483
541
  volume: Optional[float] = None,
484
542
  location: Optional[Union[types.Location, labware.Well]] = None,
485
543
  rate: float = 1.0,
544
+ aspirate_flow_rate: Optional[float] = None,
545
+ dispense_flow_rate: Optional[float] = None,
546
+ aspirate_delay: Optional[float] = None,
547
+ dispense_delay: Optional[float] = None,
548
+ final_push_out: Optional[float] = None,
486
549
  ) -> InstrumentContext:
487
550
  """
488
551
  Mix a volume of liquid by repeatedly aspirating and dispensing it in a single location.
@@ -507,6 +570,16 @@ class InstrumentContext(publisher.CommandPublisher):
507
570
  dispensing flow rate is calculated as ``rate`` multiplied by
508
571
  :py:attr:`flow_rate.dispense <flow_rate>`. See
509
572
  :ref:`new-plunger-flow-rates`.
573
+ :param aspirate_flow_rate: The absolute flow rate for each aspirate in the mix, in µL/s.
574
+ If this is specified, ``rate`` must not be set.
575
+ :param dispense_flow_rate: The absolute flow rate for each dispense in the mix, in µL/s.
576
+ If this is specified, ``rate`` must not be set.
577
+ :param aspirate_delay: How long to wait after each aspirate in the mix, in seconds.
578
+ :param dispense_delay: How long to wait after each dispense in the mix, in seconds.
579
+ :param final_push_out: How much volume to push out after the final mix repetition. The
580
+ pipette will not push out after earlier repetitions. If
581
+ not specified or ``None``, the pipette will push out the
582
+ default non-zero amount. See :ref:`push-out-dispense`.
510
583
  :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette.
511
584
  :returns: This instance.
512
585
 
@@ -519,6 +592,9 @@ class InstrumentContext(publisher.CommandPublisher):
519
592
 
520
593
  .. versionchanged:: 2.21
521
594
  Does not repeatedly check for liquid presence.
595
+ .. versionchanged:: 2.24
596
+ Adds the ``aspirate_flow_rate``, ``dispense_flow_rate``, ``aspirate_delay``,
597
+ ``dispense_delay``, and ``final_push_out`` parameters.
522
598
  """
523
599
  _log.debug(
524
600
  "mixing {}uL with {} repetitions in {} at rate={}".format(
@@ -533,9 +609,69 @@ class InstrumentContext(publisher.CommandPublisher):
533
609
  else:
534
610
  c_vol = self._core.get_available_volume() if not volume else volume
535
611
 
536
- dispense_kwargs: Dict[str, Any] = {}
537
- if self.api_version >= APIVersion(2, 16):
538
- dispense_kwargs["push_out"] = 0.0
612
+ if aspirate_flow_rate:
613
+ if self.api_version < APIVersion(2, 24):
614
+ raise APIVersionError(
615
+ api_element="aspirate_flow_rate",
616
+ until_version="2.24",
617
+ current_version=f"{self._api_version}",
618
+ )
619
+ if rate != 1.0:
620
+ raise ValueError(
621
+ "rate must not be set if aspirate_flow_rate is specified"
622
+ )
623
+ if dispense_flow_rate:
624
+ if self.api_version < APIVersion(2, 24):
625
+ raise APIVersionError(
626
+ api_element="dispense_flow_rate",
627
+ until_version="2.24",
628
+ current_version=f"{self._api_version}",
629
+ )
630
+ if rate != 1.0:
631
+ raise ValueError(
632
+ "rate must not be set if dispense_flow_rate is specified"
633
+ )
634
+ if aspirate_delay and self.api_version < APIVersion(2, 24):
635
+ raise APIVersionError(
636
+ api_element="aspirate_delay",
637
+ until_version="2.24",
638
+ current_version=f"{self._api_version}",
639
+ )
640
+ if dispense_delay and self.api_version < APIVersion(2, 24):
641
+ raise APIVersionError(
642
+ api_element="dispense_delay",
643
+ until_version="2.24",
644
+ current_version=f"{self._api_version}",
645
+ )
646
+ if final_push_out and self.api_version < APIVersion(2, 24):
647
+ raise APIVersionError(
648
+ api_element="final_push_out",
649
+ until_version="2.24",
650
+ current_version=f"{self._api_version}",
651
+ )
652
+
653
+ def delay_with_publish(seconds: float) -> None:
654
+ # We don't have access to ProtocolContext.delay() which would automatically
655
+ # publish a message to the broker, so we have to do it manually:
656
+ with publisher.publish_context(
657
+ broker=self.broker,
658
+ command=protocol_cmds.delay(seconds=seconds, minutes=0, msg=None),
659
+ ):
660
+ self._protocol_core.delay(seconds=seconds, msg=None)
661
+
662
+ def aspirate_with_delay(
663
+ location: Optional[types.Location | labware.Well],
664
+ ) -> None:
665
+ self.aspirate(volume, location, rate, flow_rate=aspirate_flow_rate)
666
+ if aspirate_delay:
667
+ delay_with_publish(aspirate_delay)
668
+
669
+ def dispense_with_delay(push_out: Optional[float]) -> None:
670
+ self.dispense(
671
+ volume, None, rate, flow_rate=dispense_flow_rate, push_out=push_out
672
+ )
673
+ if dispense_delay:
674
+ delay_with_publish(dispense_delay)
539
675
 
540
676
  with publisher.publish_context(
541
677
  broker=self.broker,
@@ -546,13 +682,22 @@ class InstrumentContext(publisher.CommandPublisher):
546
682
  location=location,
547
683
  ),
548
684
  ):
549
- self.aspirate(volume, location, rate)
685
+ aspirate_with_delay(location=location)
550
686
  with AutoProbeDisable(self):
551
687
  while repetitions - 1 > 0:
552
- self.dispense(volume, rate=rate, **dispense_kwargs)
553
- self.aspirate(volume, rate=rate)
688
+ # starting in 2.16, we disable push_out on all but the last
689
+ # dispense() to prevent the tip from jumping out of the liquid
690
+ # during the mix (PR #14004):
691
+ dispense_with_delay(
692
+ push_out=0 if self.api_version >= APIVersion(2, 16) else None
693
+ )
694
+ # aspirate location was set above, do subsequent aspirates in-place:
695
+ aspirate_with_delay(location=None)
554
696
  repetitions -= 1
555
- self.dispense(volume, rate=rate)
697
+ if final_push_out is not None:
698
+ dispense_with_delay(push_out=final_push_out)
699
+ else:
700
+ dispense_with_delay(push_out=None)
556
701
  return self
557
702
 
558
703
  @requires_version(2, 0)
@@ -583,6 +728,10 @@ class InstrumentContext(publisher.CommandPublisher):
583
728
  without first calling a method that takes a location, like
584
729
  :py:meth:`.aspirate` or :py:meth:`dispense`.
585
730
  :returns: This instance.
731
+
732
+ .. versionchanged:: 2.24
733
+ ``location`` is no longer required if the pipette just moved to, dispensed, or blew out
734
+ into a trash bin or waste chute.
586
735
  """
587
736
  well: Optional[labware.Well] = None
588
737
  move_to_location: types.Location
@@ -623,17 +772,17 @@ class InstrumentContext(publisher.CommandPublisher):
623
772
  well = target.well
624
773
  elif isinstance(target, validation.PointTarget):
625
774
  move_to_location = target.location
626
- elif isinstance(target, (TrashBin, WasteChute)):
775
+ elif isinstance(target, validation.DisposalTarget):
627
776
  with publisher.publish_context(
628
777
  broker=self.broker,
629
778
  command=cmds.blow_out_in_disposal_location(
630
- instrument=self, location=target
779
+ instrument=self, location=target.location
631
780
  ),
632
781
  ):
633
782
  self._core.blow_out(
634
- location=target,
783
+ location=target.location,
635
784
  well_core=None,
636
- in_place=False,
785
+ in_place=target.in_place,
637
786
  )
638
787
  return self
639
788
 
@@ -657,12 +806,13 @@ class InstrumentContext(publisher.CommandPublisher):
657
806
 
658
807
  @publisher.publish(command=cmds.touch_tip)
659
808
  @requires_version(2, 0)
660
- def touch_tip(
809
+ def touch_tip( # noqa: C901
661
810
  self,
662
811
  location: Optional[labware.Well] = None,
663
812
  radius: float = 1.0,
664
813
  v_offset: float = -1.0,
665
814
  speed: float = 60.0,
815
+ mm_from_edge: Union[float, _Unset] = _Unset(),
666
816
  ) -> InstrumentContext:
667
817
  """
668
818
  Touch the pipette tip to the sides of a well, with the intent of removing leftover droplets.
@@ -688,12 +838,27 @@ class InstrumentContext(publisher.CommandPublisher):
688
838
  - Maximum: 80.0 mm/s
689
839
  - Minimum: 1.0 mm/s
690
840
  :type speed: float
841
+ :param mm_from_edge: How far to move inside the well, as a distance from the
842
+ well's edge.
843
+ When ``mm_from_edge=0``, the pipette will move to the target well's edge to touch the tip. When ``mm_from_edge=1``,
844
+ the pipette will move to 1 mm from the target well's edge to touch the tip.
845
+ Values lower than 0 will press the tip harder into the target well's
846
+ walls; higher values will touch the well more lightly, or
847
+ not at all.
848
+ ``mm_from_edge`` and ``radius`` are mutually exclusive: to
849
+ use ``mm_from_edge``, ``radius`` must be unspecified (left
850
+ to its default value of 1.0).
851
+ :type mm_from_edge: float
691
852
  :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette.
692
853
  :raises RuntimeError: If no location is specified and the location cache is
693
854
  ``None``. This should happen if ``touch_tip`` is called
694
855
  without first calling a method that takes a location, like
695
856
  :py:meth:`.aspirate` or :py:meth:`dispense`.
857
+ :raises: ValueError: If both ``mm_from_edge`` and ``radius`` are specified.
696
858
  :returns: This instance.
859
+
860
+ .. versionchanged:: 2.24
861
+ Added the ``mm_from_edge`` parameter.
697
862
  """
698
863
  if not self._core.has_tip():
699
864
  raise UnexpectedTipRemovalError("touch_tip", self.name, self.mount)
@@ -703,8 +868,12 @@ class InstrumentContext(publisher.CommandPublisher):
703
868
  # If location is a valid well, move to the well first
704
869
  if location is None:
705
870
  last_location = self._protocol_core.get_last_location()
706
- if not last_location:
707
- raise RuntimeError("No valid current location cache present")
871
+ if last_location is None or isinstance(
872
+ last_location, (TrashBin, WasteChute)
873
+ ):
874
+ raise RuntimeError(
875
+ f"Cached location of {last_location} is not valid for touch tip."
876
+ )
708
877
  parent_labware, well = last_location.labware.get_parent_labware_and_well()
709
878
  if not well or not parent_labware:
710
879
  raise RuntimeError(
@@ -716,6 +885,18 @@ class InstrumentContext(publisher.CommandPublisher):
716
885
  else:
717
886
  raise TypeError(f"location should be a Well, but it is {location}")
718
887
 
888
+ if not isinstance(mm_from_edge, _Unset):
889
+ if self.api_version < APIVersion(2, 24):
890
+ raise APIVersionError(
891
+ api_element="mm_from_edge",
892
+ until_version="2.24",
893
+ current_version=f"{self.api_version}",
894
+ )
895
+ if radius != 1.0:
896
+ raise ValueError(
897
+ "radius must be set to 1.0 if mm_from_edge is specified"
898
+ )
899
+
719
900
  if "touchTipDisabled" in parent_labware.quirks:
720
901
  _log.info(f"Ignoring touch tip on labware {well}")
721
902
  return self
@@ -735,13 +916,19 @@ class InstrumentContext(publisher.CommandPublisher):
735
916
  radius=radius,
736
917
  z_offset=v_offset,
737
918
  speed=checked_speed,
919
+ mm_from_edge=mm_from_edge if not isinstance(mm_from_edge, _Unset) else None,
738
920
  )
739
921
  return self
740
922
 
741
923
  @publisher.publish(command=cmds.air_gap)
742
924
  @requires_version(2, 0)
743
- def air_gap(
744
- self, volume: Optional[float] = None, height: Optional[float] = None
925
+ def air_gap( # noqa: C901
926
+ self,
927
+ volume: Optional[float] = None,
928
+ height: Optional[float] = None,
929
+ in_place: Optional[bool] = None,
930
+ rate: Optional[float] = None,
931
+ flow_rate: Optional[float] = None,
745
932
  ) -> InstrumentContext:
746
933
  """
747
934
  Draw air into the pipette's tip at the current well.
@@ -756,12 +943,27 @@ class InstrumentContext(publisher.CommandPublisher):
756
943
  the air gap. The default is 5 mm above the current well.
757
944
  :type height: float
758
945
 
946
+ :param in_place: Air gap at the pipette's current position, without moving to
947
+ some height above the well. If ``in_place`` is specified,
948
+ ``height`` must be unset.
949
+ :type in_place: bool
950
+
951
+ :param rate: A multiplier for the default flow rate of the pipette. Calculated
952
+ as ``rate`` multiplied by :py:attr:`flow_rate.aspirate
953
+ <flow_rate>`. If neither rate nor flow_rate is specified, the pipette
954
+ will aspirate at a rate of 1.0 * InstrumentContext.flow_rate.aspirate. See
955
+ :ref:`new-plunger-flow-rates`.
956
+ :type rate: float
957
+
958
+ :param flow_rate: The rate, in µL/s, at which the pipette will draw in air.
959
+ :type flow_rate: float
960
+
759
961
  :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette.
760
962
 
761
- :raises RuntimeError: If location cache is ``None``. This should happen if
762
- ``air_gap()`` is called without first calling a method
763
- that takes a location (e.g., :py:meth:`.aspirate`,
764
- :py:meth:`dispense`)
963
+ :raises RuntimeError: If location cache is ``None`` and the air gap is not
964
+ ``in_place``. This would happen if ``air_gap()`` is called
965
+ without first calling a method that takes a location (e.g.,
966
+ :py:meth:`.aspirate`, :py:meth:`dispense`)
765
967
 
766
968
  :returns: This instance.
767
969
 
@@ -779,22 +981,75 @@ class InstrumentContext(publisher.CommandPublisher):
779
981
 
780
982
  .. versionchanged:: 2.22
781
983
  No longer implemented as an aspirate.
984
+ .. versionchanged:: 2.24
985
+ Added the ``in_place`` option.
986
+ .. versionchanged:: 2.24
987
+ Adds the ``rate`` and ``flow_rate`` parameter. You can only define one or the other. If
988
+ both are unspecified then ``rate`` is by default set to 1.0.
989
+ Can air gap over a trash bin or waste chute.
782
990
  """
783
991
  if not self._core.has_tip():
784
992
  raise UnexpectedTipRemovalError("air_gap", self.name, self.mount)
785
993
 
786
- if height is None:
787
- height = 5
788
- loc = self._protocol_core.get_last_location()
789
- if not loc or not loc.labware.is_well:
790
- raise RuntimeError("No previous Well cached to perform air gap")
791
- target = loc.labware.as_well().top(height)
792
- self.move_to(target, publish=False)
994
+ if rate is not None and self.api_version < APIVersion(2, 24):
995
+ raise APIVersionError(
996
+ api_element="rate",
997
+ until_version="2.24",
998
+ current_version=f"{self._api_version}",
999
+ )
1000
+
1001
+ if flow_rate is not None and self.api_version < APIVersion(2, 24):
1002
+ raise APIVersionError(
1003
+ api_element="flow_rate",
1004
+ until_version="2.24",
1005
+ current_version=f"{self._api_version}",
1006
+ )
1007
+
1008
+ if flow_rate is not None and rate is not None:
1009
+ raise ValueError("Cannot define both flow_rate and rate.")
1010
+
1011
+ if in_place:
1012
+ if self.api_version < APIVersion(2, 24):
1013
+ raise APIVersionError(
1014
+ api_element="in_place",
1015
+ until_version="2.24",
1016
+ current_version=f"{self._api_version}",
1017
+ )
1018
+ if height is not None:
1019
+ raise ValueError("height must be unset if air gapping in_place")
1020
+ else:
1021
+ if height is None:
1022
+ height = 5
1023
+ last_location = self._protocol_core.get_last_location()
1024
+ if self.api_version < APIVersion(2, 24) and isinstance(
1025
+ last_location, (TrashBin, WasteChute)
1026
+ ):
1027
+ last_location = None
1028
+ if last_location is None or (
1029
+ isinstance(last_location, types.Location)
1030
+ and not last_location.labware.is_well
1031
+ ):
1032
+ raise RuntimeError(
1033
+ f"Cached location of {last_location} is not valid for air gap."
1034
+ )
1035
+ target: Union[types.Location, TrashBin, WasteChute]
1036
+ if isinstance(last_location, types.Location):
1037
+ target = last_location.labware.as_well().top(height)
1038
+ else:
1039
+ target = last_location.top(height)
1040
+ self.move_to(target, publish=False)
1041
+
793
1042
  if self.api_version >= _AIR_GAP_TRACKING_ADDED_IN:
794
1043
  self._core.prepare_to_aspirate()
795
1044
  c_vol = self._core.get_available_volume() if volume is None else volume
796
- flow_rate = self._core.get_aspirate_flow_rate()
797
- self._core.air_gap_in_place(c_vol, flow_rate)
1045
+ if flow_rate is not None:
1046
+ calculated_rate = flow_rate
1047
+ elif rate is not None:
1048
+ calculated_rate = rate * self._core.get_aspirate_flow_rate()
1049
+ else:
1050
+ calculated_rate = self._core.get_aspirate_flow_rate()
1051
+
1052
+ self._core.air_gap_in_place(c_vol, calculated_rate)
798
1053
  else:
799
1054
  self.aspirate(volume)
800
1055
  return self
@@ -1521,7 +1776,7 @@ class InstrumentContext(publisher.CommandPublisher):
1521
1776
  for cmd in plan:
1522
1777
  getattr(self, cmd["method"])(*cmd["args"], **cmd["kwargs"])
1523
1778
 
1524
- @requires_version(2, 23)
1779
+ @requires_version(2, 24)
1525
1780
  def transfer_with_liquid_class(
1526
1781
  self,
1527
1782
  liquid_class: LiquidClass,
@@ -1530,14 +1785,19 @@ class InstrumentContext(publisher.CommandPublisher):
1530
1785
  labware.Well, Sequence[labware.Well], Sequence[Sequence[labware.Well]]
1531
1786
  ],
1532
1787
  dest: Union[
1533
- labware.Well, Sequence[labware.Well], Sequence[Sequence[labware.Well]]
1788
+ labware.Well,
1789
+ Sequence[labware.Well],
1790
+ Sequence[Sequence[labware.Well]],
1791
+ TrashBin,
1792
+ WasteChute,
1534
1793
  ],
1535
1794
  new_tip: TransferTipPolicyV2Type = "once",
1536
1795
  trash_location: Optional[
1537
1796
  Union[types.Location, labware.Well, TrashBin, WasteChute]
1538
1797
  ] = None,
1539
1798
  return_tip: bool = False,
1540
- visit_every_well: bool = False,
1799
+ group_wells: bool = True,
1800
+ keep_last_tip: Optional[bool] = None,
1541
1801
  ) -> InstrumentContext:
1542
1802
  """Move a particular type of liquid from one well or group of wells to another.
1543
1803
 
@@ -1552,7 +1812,7 @@ class InstrumentContext(publisher.CommandPublisher):
1552
1812
  :param volume: The amount, in µL, to aspirate from each source and dispense to
1553
1813
  each destination.
1554
1814
  :param source: A single well or a list of wells to aspirate liquid from.
1555
- :param dest: A single well or a list of wells to dispense liquid into.
1815
+ :param dest: A single well, list of wells, trash bin, or waste chute to dispense liquid into.
1556
1816
  :param new_tip: When to pick up and drop tips during the command.
1557
1817
  Defaults to ``"once"``.
1558
1818
 
@@ -1560,16 +1820,24 @@ class InstrumentContext(publisher.CommandPublisher):
1560
1820
  - ``"always"``: Use a new tip for each set of aspirate and dispense steps.
1561
1821
  - ``"per source"``: Use one tip for each source well, even if
1562
1822
  :ref:`tip refilling <complex-tip-refilling>` is required.
1823
+ - ``"per destination"``: Use one tip for each destination well, even if
1824
+ :ref:`tip refilling <complex-tip-refilling>` is required.
1563
1825
  - ``"never"``: Do not pick up or drop tips at all.
1564
1826
 
1565
1827
  See :ref:`param-tip-handling` for details.
1566
1828
 
1567
1829
  :param trash_location: A trash container, well, or other location to dispose of
1568
1830
  tips. Depending on the liquid class, the pipette may also blow out liquid here.
1831
+ If not specified, the pipette will dispose of tips in its :py:obj:`~.InstrumentContext.trash_container`.
1569
1832
  :param return_tip: Whether to drop used tips in their original locations
1570
1833
  in the tip rack, instead of the trash.
1834
+ :param group_wells: For multi-channel transfers only. If set to ``True``, group together contiguous wells
1835
+ given into a single transfer step, taking into account the tip configuration. If ``False``, target
1836
+ each well given with the primary nozzle. Defaults to ``True``.
1837
+ :param keep_last_tip: When ``True``, the pipette keeps the last tip used in the transfer attached. When
1838
+ ``False``, the last tip will be dropped or returned. If not set, behavior depends on the value of
1839
+ ``new_tip``. ``new_tip="never"`` keeps the tip, and all other values of ``new_tip`` drop or return the tip.
1571
1840
 
1572
- :meta private:
1573
1841
  """
1574
1842
  if volume == 0.0:
1575
1843
  _log.info(
@@ -1582,21 +1850,35 @@ class InstrumentContext(publisher.CommandPublisher):
1582
1850
  source=source,
1583
1851
  dest=dest,
1584
1852
  tip_policy=new_tip,
1585
- last_tip_picked_up_from=self._last_tip_picked_up_from,
1853
+ last_tip_well=self._last_tip_picked_up_from,
1586
1854
  tip_racks=self._tip_racks,
1587
1855
  nozzle_map=self._core.get_nozzle_map(),
1588
- target_all_wells=visit_every_well,
1856
+ group_wells_for_multi_channel=group_wells,
1589
1857
  current_volume=self.current_volume,
1590
1858
  trash_location=(
1591
1859
  trash_location if trash_location is not None else self.trash_container
1592
1860
  ),
1593
1861
  )
1594
- if len(transfer_args.sources_list) != len(transfer_args.destinations_list):
1595
- raise ValueError(
1596
- "Sources and destinations should be of the same length in order to perform a transfer."
1597
- " To transfer liquid from one source to many destinations, use 'distribute_liquid',"
1598
- " to transfer liquid onto one destinations from many sources, use 'consolidate_liquid'."
1599
- )
1862
+ verified_keep_last_tip = resolve_keep_last_tip(
1863
+ keep_last_tip, transfer_args.tip_policy
1864
+ )
1865
+
1866
+ verified_dest: Union[
1867
+ List[Tuple[types.Location, WellCore]], TrashBin, WasteChute
1868
+ ]
1869
+ if isinstance(transfer_args.dest, (TrashBin, WasteChute)):
1870
+ verified_dest = transfer_args.dest
1871
+ else:
1872
+ if len(transfer_args.source) != len(transfer_args.dest):
1873
+ raise ValueError(
1874
+ "Sources and destinations should be of the same length in order to perform a transfer."
1875
+ " To transfer liquid from one source to many destinations, use 'distribute_liquid',"
1876
+ " to transfer liquid to one destination from many sources, use 'consolidate_liquid'."
1877
+ )
1878
+ verified_dest = [
1879
+ (types.Location(types.Point(), labware=well), well._core)
1880
+ for well in transfer_args.dest
1881
+ ]
1600
1882
 
1601
1883
  with publisher.publish_context(
1602
1884
  broker=self.broker,
@@ -1608,17 +1890,14 @@ class InstrumentContext(publisher.CommandPublisher):
1608
1890
  destination=dest,
1609
1891
  ),
1610
1892
  ):
1611
- self._core.transfer_with_liquid_class(
1893
+ last_tip_location = self._core.transfer_with_liquid_class(
1612
1894
  liquid_class=liquid_class,
1613
1895
  volume=volume,
1614
1896
  source=[
1615
1897
  (types.Location(types.Point(), labware=well), well._core)
1616
- for well in transfer_args.sources_list
1617
- ],
1618
- dest=[
1619
- (types.Location(types.Point(), labware=well), well._core)
1620
- for well in transfer_args.destinations_list
1898
+ for well in transfer_args.source
1621
1899
  ],
1900
+ dest=verified_dest,
1622
1901
  new_tip=transfer_args.tip_policy,
1623
1902
  tip_racks=[
1624
1903
  (types.Location(types.Point(), labware=rack), rack._core)
@@ -1629,10 +1908,23 @@ class InstrumentContext(publisher.CommandPublisher):
1629
1908
  ),
1630
1909
  trash_location=transfer_args.trash_location,
1631
1910
  return_tip=return_tip,
1911
+ keep_last_tip=verified_keep_last_tip,
1912
+ last_tip_location=transfer_args.last_tip_location,
1632
1913
  )
1914
+
1915
+ # TODO(jbl 2025-06-23) last_tip_picked_up_from should be removed from the public context and
1916
+ # moved to the engine core or engine as a simpler and more holistic solution
1917
+ if last_tip_location is not None:
1918
+ tip_rack_loc, tip_well_core = last_tip_location
1919
+ self._last_tip_picked_up_from = tip_rack_loc.labware.as_labware()[
1920
+ tip_well_core.get_name()
1921
+ ]
1922
+ else:
1923
+ self._last_tip_picked_up_from = None
1924
+
1633
1925
  return self
1634
1926
 
1635
- @requires_version(2, 23)
1927
+ @requires_version(2, 24)
1636
1928
  def distribute_with_liquid_class(
1637
1929
  self,
1638
1930
  liquid_class: LiquidClass,
@@ -1646,7 +1938,8 @@ class InstrumentContext(publisher.CommandPublisher):
1646
1938
  Union[types.Location, labware.Well, TrashBin, WasteChute]
1647
1939
  ] = None,
1648
1940
  return_tip: bool = False,
1649
- visit_every_well: bool = False,
1941
+ group_wells: bool = True,
1942
+ keep_last_tip: Optional[bool] = None,
1650
1943
  ) -> InstrumentContext:
1651
1944
  """
1652
1945
  Distribute a particular type of liquid from one well to a group of wells.
@@ -1661,22 +1954,30 @@ class InstrumentContext(publisher.CommandPublisher):
1661
1954
 
1662
1955
  :param volume: The amount, in µL, to aspirate from the source and dispense to
1663
1956
  each destination.
1664
- :param source: A single well to aspirate liquid from.
1957
+ :param source: A single well for the pipette to target, or a group of wells to
1958
+ target in a single aspirate for a multi-channel pipette.
1665
1959
  :param dest: A list of wells to dispense liquid into.
1666
1960
  :param new_tip: When to pick up and drop tips during the command.
1667
1961
  Defaults to ``"once"``.
1668
1962
 
1669
1963
  - ``"once"``: Use one tip for the entire command.
1964
+ - ``"always"``: Use a new tip before each aspirate.
1670
1965
  - ``"never"``: Do not pick up or drop tips at all.
1671
1966
 
1672
1967
  See :ref:`param-tip-handling` for details.
1673
1968
 
1674
1969
  :param trash_location: A trash container, well, or other location to dispose of
1675
1970
  tips. Depending on the liquid class, the pipette may also blow out liquid here.
1971
+ If not specified, the pipette will dispose of tips in its :py:obj:`~.InstrumentContext.trash_container`.
1676
1972
  :param return_tip: Whether to drop used tips in their original locations
1677
1973
  in the tip rack, instead of the trash.
1974
+ :param group_wells: For multi-channel transfers only. If set to ``True``, group together contiguous wells
1975
+ given into a single transfer step, taking into account the tip configuration. If ``False``, target
1976
+ each well given with the primary nozzle. Defaults to ``True``.
1977
+ :param keep_last_tip: When ``True``, the pipette keeps the last tip used in the distribute attached. When
1978
+ ``False``, the last tip will be dropped or returned. If not set, behavior depends on the value of
1979
+ ``new_tip``. ``new_tip="never"`` keeps the tip, and all other values of ``new_tip`` drop or return the tip.
1678
1980
 
1679
- :meta private:
1680
1981
  """
1681
1982
  if volume == 0.0:
1682
1983
  _log.info(
@@ -1689,31 +1990,41 @@ class InstrumentContext(publisher.CommandPublisher):
1689
1990
  source=source,
1690
1991
  dest=dest,
1691
1992
  tip_policy=new_tip,
1692
- last_tip_picked_up_from=self._last_tip_picked_up_from,
1993
+ last_tip_well=self._last_tip_picked_up_from,
1693
1994
  tip_racks=self._tip_racks,
1694
1995
  nozzle_map=self._core.get_nozzle_map(),
1695
- target_all_wells=visit_every_well,
1996
+ group_wells_for_multi_channel=group_wells,
1696
1997
  current_volume=self.current_volume,
1697
1998
  trash_location=(
1698
1999
  trash_location if trash_location is not None else self.trash_container
1699
2000
  ),
1700
2001
  )
1701
- if len(transfer_args.sources_list) != 1:
2002
+ verified_keep_last_tip = resolve_keep_last_tip(
2003
+ keep_last_tip, transfer_args.tip_policy
2004
+ )
2005
+
2006
+ if isinstance(transfer_args.dest, (TrashBin, WasteChute)):
2007
+ raise ValueError(
2008
+ "distribute_with_liquid_class() does not support trash bin or waste chute"
2009
+ " as a destination."
2010
+ )
2011
+ if len(transfer_args.source) != 1:
1702
2012
  raise ValueError(
1703
2013
  f"Source should be a single well (or resolve to a single transfer for multi-channel) "
1704
- f"but received {transfer_args.sources_list}."
2014
+ f"but received {transfer_args.source}."
1705
2015
  )
1706
2016
  if transfer_args.tip_policy not in [
1707
2017
  TransferTipPolicyV2.ONCE,
1708
2018
  TransferTipPolicyV2.NEVER,
2019
+ TransferTipPolicyV2.ALWAYS,
1709
2020
  ]:
1710
2021
  raise ValueError(
1711
2022
  f"Incompatible `new_tip` value of {new_tip}."
1712
2023
  f" `distribute_with_liquid_class()` only supports `new_tip` values of"
1713
- f" 'once' and 'never'."
2024
+ f" 'once', 'never' and 'always'."
1714
2025
  )
1715
2026
 
1716
- verified_source = transfer_args.sources_list[0]
2027
+ verified_source = transfer_args.source[0]
1717
2028
  with publisher.publish_context(
1718
2029
  broker=self.broker,
1719
2030
  command=cmds.distribute_with_liquid_class(
@@ -1724,7 +2035,7 @@ class InstrumentContext(publisher.CommandPublisher):
1724
2035
  destination=dest,
1725
2036
  ),
1726
2037
  ):
1727
- self._core.distribute_with_liquid_class(
2038
+ last_tip_location = self._core.distribute_with_liquid_class(
1728
2039
  liquid_class=liquid_class,
1729
2040
  volume=volume,
1730
2041
  source=(
@@ -1733,7 +2044,7 @@ class InstrumentContext(publisher.CommandPublisher):
1733
2044
  ),
1734
2045
  dest=[
1735
2046
  (types.Location(types.Point(), labware=well), well._core)
1736
- for well in transfer_args.destinations_list
2047
+ for well in transfer_args.dest
1737
2048
  ],
1738
2049
  new_tip=transfer_args.tip_policy, # type: ignore[arg-type]
1739
2050
  tip_racks=[
@@ -1745,10 +2056,23 @@ class InstrumentContext(publisher.CommandPublisher):
1745
2056
  ),
1746
2057
  trash_location=transfer_args.trash_location,
1747
2058
  return_tip=return_tip,
2059
+ keep_last_tip=verified_keep_last_tip,
2060
+ last_tip_location=transfer_args.last_tip_location,
1748
2061
  )
2062
+
2063
+ # TODO(jbl 2025-06-23) last_tip_picked_up_from should be removed from the public context and
2064
+ # moved to the engine core or engine as a simpler and more holistic solution
2065
+ if last_tip_location is not None:
2066
+ tip_rack_loc, tip_well_core = last_tip_location
2067
+ self._last_tip_picked_up_from = tip_rack_loc.labware.as_labware()[
2068
+ tip_well_core.get_name()
2069
+ ]
2070
+ else:
2071
+ self._last_tip_picked_up_from = None
2072
+
1749
2073
  return self
1750
2074
 
1751
- @requires_version(2, 23)
2075
+ @requires_version(2, 24)
1752
2076
  def consolidate_with_liquid_class(
1753
2077
  self,
1754
2078
  liquid_class: LiquidClass,
@@ -1756,13 +2080,14 @@ class InstrumentContext(publisher.CommandPublisher):
1756
2080
  source: Union[
1757
2081
  labware.Well, Sequence[labware.Well], Sequence[Sequence[labware.Well]]
1758
2082
  ],
1759
- dest: Union[labware.Well, Sequence[labware.Well]],
2083
+ dest: Union[labware.Well, Sequence[labware.Well], TrashBin, WasteChute],
1760
2084
  new_tip: TransferTipPolicyV2Type = "once",
1761
2085
  trash_location: Optional[
1762
2086
  Union[types.Location, labware.Well, TrashBin, WasteChute]
1763
2087
  ] = None,
1764
2088
  return_tip: bool = False,
1765
- visit_every_well: bool = False,
2089
+ group_wells: bool = True,
2090
+ keep_last_tip: Optional[bool] = None,
1766
2091
  ) -> InstrumentContext:
1767
2092
  """
1768
2093
  Consolidate a particular type of liquid from a group of wells to one well.
@@ -1778,21 +2103,30 @@ class InstrumentContext(publisher.CommandPublisher):
1778
2103
  :param volume: The amount, in µL, to aspirate from the source and dispense to
1779
2104
  each destination.
1780
2105
  :param source: A list of wells to aspirate liquid from.
1781
- :param dest: A single well to dispense liquid into.
2106
+ :param dest: A single well, list of wells, trash bin, or waste chute to dispense liquid into.
2107
+ Multiple wells can only be given for multi-channel pipette configurations, and
2108
+ must be able to be dispensed to in a single dispense.
1782
2109
  :param new_tip: When to pick up and drop tips during the command.
1783
2110
  Defaults to ``"once"``.
1784
2111
 
1785
2112
  - ``"once"``: Use one tip for the entire command.
2113
+ - ``"always"``: Use a new tip after each aspirate and dispense, even when visiting the same source again.
1786
2114
  - ``"never"``: Do not pick up or drop tips at all.
1787
2115
 
1788
2116
  See :ref:`param-tip-handling` for details.
1789
2117
 
1790
2118
  :param trash_location: A trash container, well, or other location to dispose of
1791
2119
  tips. Depending on the liquid class, the pipette may also blow out liquid here.
2120
+ If not specified, the pipette will dispose of tips in its :py:obj:`~.InstrumentContext.trash_container`.
1792
2121
  :param return_tip: Whether to drop used tips in their original locations
1793
2122
  in the tip rack, instead of the trash.
2123
+ :param group_wells: For multi-channel transfers only. If set to ``True``, group together contiguous wells
2124
+ given into a single transfer step, taking into account the tip configuration. If ``False``, target
2125
+ each well given with the primary nozzle. Defaults to ``True``.
2126
+ :param keep_last_tip: When ``True``, the pipette keeps the last tip used in the consolidate attached. When
2127
+ ``False``, the last tip will be dropped or returned. If not set, behavior depends on the value of
2128
+ ``new_tip``. ``new_tip="never"`` keeps the tip, and all other values of ``new_tip`` drop or return the tip.
1794
2129
 
1795
- :meta private:
1796
2130
  """
1797
2131
  if volume == 0.0:
1798
2132
  _log.info(
@@ -1805,31 +2139,43 @@ class InstrumentContext(publisher.CommandPublisher):
1805
2139
  source=source,
1806
2140
  dest=dest,
1807
2141
  tip_policy=new_tip,
1808
- last_tip_picked_up_from=self._last_tip_picked_up_from,
2142
+ last_tip_well=self._last_tip_picked_up_from,
1809
2143
  tip_racks=self._tip_racks,
1810
2144
  nozzle_map=self._core.get_nozzle_map(),
1811
- target_all_wells=visit_every_well,
2145
+ group_wells_for_multi_channel=group_wells,
1812
2146
  current_volume=self.current_volume,
1813
2147
  trash_location=(
1814
2148
  trash_location if trash_location is not None else self.trash_container
1815
2149
  ),
1816
2150
  )
1817
- if len(transfer_args.destinations_list) != 1:
1818
- raise ValueError(
1819
- f"Destination should be a single well (or resolve to a single transfer for multi-channel) "
1820
- f"but received {transfer_args.destinations_list}."
2151
+ verified_keep_last_tip = resolve_keep_last_tip(
2152
+ keep_last_tip, transfer_args.tip_policy
2153
+ )
2154
+
2155
+ verified_dest: Union[Tuple[types.Location, WellCore], TrashBin, WasteChute]
2156
+ if isinstance(transfer_args.dest, (TrashBin, WasteChute)):
2157
+ verified_dest = transfer_args.dest
2158
+ else:
2159
+ if len(transfer_args.dest) != 1:
2160
+ raise ValueError(
2161
+ f"Destination should be a single well (or resolve to a single transfer for multi-channel) "
2162
+ f"but received {transfer_args.dest}."
2163
+ )
2164
+ verified_dest = (
2165
+ types.Location(types.Point(), labware=transfer_args.dest[0]),
2166
+ transfer_args.dest[0]._core,
1821
2167
  )
1822
2168
  if transfer_args.tip_policy not in [
1823
2169
  TransferTipPolicyV2.ONCE,
1824
2170
  TransferTipPolicyV2.NEVER,
2171
+ TransferTipPolicyV2.ALWAYS,
1825
2172
  ]:
1826
2173
  raise ValueError(
1827
2174
  f"Incompatible `new_tip` value of {new_tip}."
1828
2175
  f" `consolidate_with_liquid_class()` only supports `new_tip` values of"
1829
- f" 'once' and 'never'."
2176
+ f" 'once', 'never' and 'always'."
1830
2177
  )
1831
2178
 
1832
- verified_dest = transfer_args.destinations_list[0]
1833
2179
  with publisher.publish_context(
1834
2180
  broker=self.broker,
1835
2181
  command=cmds.consolidate_with_liquid_class(
@@ -1840,17 +2186,14 @@ class InstrumentContext(publisher.CommandPublisher):
1840
2186
  destination=dest,
1841
2187
  ),
1842
2188
  ):
1843
- self._core.consolidate_with_liquid_class(
2189
+ last_tip_location = self._core.consolidate_with_liquid_class(
1844
2190
  liquid_class=liquid_class,
1845
2191
  volume=volume,
1846
2192
  source=[
1847
2193
  (types.Location(types.Point(), labware=well), well._core)
1848
- for well in transfer_args.sources_list
2194
+ for well in transfer_args.source
1849
2195
  ],
1850
- dest=(
1851
- types.Location(types.Point(), labware=verified_dest),
1852
- verified_dest._core,
1853
- ),
2196
+ dest=verified_dest,
1854
2197
  new_tip=transfer_args.tip_policy, # type: ignore[arg-type]
1855
2198
  tip_racks=[
1856
2199
  (types.Location(types.Point(), labware=rack), rack._core)
@@ -1861,7 +2204,20 @@ class InstrumentContext(publisher.CommandPublisher):
1861
2204
  ),
1862
2205
  trash_location=transfer_args.trash_location,
1863
2206
  return_tip=return_tip,
2207
+ keep_last_tip=verified_keep_last_tip,
2208
+ last_tip_location=transfer_args.last_tip_location,
1864
2209
  )
2210
+
2211
+ # TODO(jbl 2025-06-23) last_tip_picked_up_from should be removed from the public context and
2212
+ # moved to the engine core or engine as a simpler and more holistic solution
2213
+ if last_tip_location is not None:
2214
+ tip_rack_loc, tip_well_core = last_tip_location
2215
+ self._last_tip_picked_up_from = tip_rack_loc.labware.as_labware()[
2216
+ tip_well_core.get_name()
2217
+ ]
2218
+ else:
2219
+ self._last_tip_picked_up_from = None
2220
+
1865
2221
  return self
1866
2222
 
1867
2223
  @requires_version(2, 0)
@@ -2286,10 +2642,9 @@ class InstrumentContext(publisher.CommandPublisher):
2286
2642
  From API version 2.15 to 2.22, this property returned an internal name for Flex
2287
2643
  pipettes. (e.g., ``"p1000_single_flex"``).
2288
2644
 
2289
- .. TODO uncomment when 2.23 is ready
2290
- In API version 2.23 and later, this property returns the Python Protocol API
2291
- :ref:`load name <new-pipette-models>` of Flex pipettes (e.g.,
2292
- ``"flex_1channel_1000"``).
2645
+ In API version 2.23 and later, this property returns the Python Protocol API
2646
+ :ref:`load name <new-pipette-models>` of Flex pipettes (e.g.,
2647
+ ``"flex_1channel_1000"``).
2293
2648
  """
2294
2649
  return self._core.get_pipette_name()
2295
2650
 
@@ -2413,14 +2768,22 @@ class InstrumentContext(publisher.CommandPublisher):
2413
2768
  """
2414
2769
  return self._well_bottom_clearances
2415
2770
 
2416
- def _get_last_location_by_api_version(self) -> Optional[types.Location]:
2771
+ def _get_last_location_by_api_version(
2772
+ self,
2773
+ ) -> Optional[Union[types.Location, TrashBin, WasteChute]]:
2417
2774
  """Get the last location accessed by this pipette, if any.
2418
2775
 
2419
2776
  In pre-engine Protocol API versions, this call omits the pipette mount.
2777
+ Between 2.14 (first engine PAPI version) and 2.23 this only returns None or a Location object.
2420
2778
  This is to preserve pre-existing, potentially buggy behavior.
2421
2779
  """
2422
- if self._api_version >= ENGINE_CORE_API_VERSION:
2780
+ if self._api_version >= APIVersion(2, 24):
2423
2781
  return self._protocol_core.get_last_location(mount=self._core.get_mount())
2782
+ elif self._api_version >= ENGINE_CORE_API_VERSION:
2783
+ last_location = self._protocol_core.get_last_location(
2784
+ mount=self._core.get_mount()
2785
+ )
2786
+ return last_location if isinstance(last_location, types.Location) else None
2424
2787
  else:
2425
2788
  return self._protocol_core.get_last_location()
2426
2789
 
@@ -2476,7 +2839,11 @@ class InstrumentContext(publisher.CommandPublisher):
2476
2839
  actual_value=str(volume),
2477
2840
  )
2478
2841
  last_location = self._get_last_location_by_api_version()
2479
- if last_location and isinstance(last_location.labware, labware.Well):
2842
+ if (
2843
+ last_location
2844
+ and isinstance(last_location, types.Location)
2845
+ and isinstance(last_location.labware, labware.Well)
2846
+ ):
2480
2847
  self.move_to(last_location.labware.top())
2481
2848
  self._core.configure_for_volume(volume)
2482
2849
 
@@ -2498,19 +2865,19 @@ class InstrumentContext(publisher.CommandPublisher):
2498
2865
  If the pipette is in a well, it will move out of the well, move the plunger,
2499
2866
  and then move back.
2500
2867
 
2501
- Use ``prepare_to_aspirate`` when you need to control exactly when the plunger
2868
+ Use ``prepare_to_aspirate()`` when you need to control exactly when the plunger
2502
2869
  motion will happen. A common use case is a pre-wetting routine, which requires
2503
2870
  preparing for aspiration, moving into a well, and then aspirating *without
2504
2871
  leaving the well*::
2505
2872
 
2506
2873
  pipette.move_to(well.bottom(z=2))
2507
- pipette.delay(5)
2874
+ protocol.delay(5)
2508
2875
  pipette.mix(10, 10)
2509
2876
  pipette.move_to(well.top(z=5))
2510
2877
  pipette.blow_out()
2511
2878
  pipette.prepare_to_aspirate()
2512
2879
  pipette.move_to(well.bottom(z=2))
2513
- pipette.delay(5)
2880
+ protocol.delay(5)
2514
2881
  pipette.aspirate(10, well.bottom(z=2))
2515
2882
 
2516
2883
  The call to ``prepare_to_aspirate()`` means that the plunger will be in the