opentrons 8.4.1a1__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.1a1.dist-info → opentrons-8.5.0.dist-info}/METADATA +4 -4
  63. {opentrons-8.4.1a1.dist-info → opentrons-8.5.0.dist-info}/RECORD +67 -66
  64. {opentrons-8.4.1a1.dist-info → opentrons-8.5.0.dist-info}/LICENSE +0 -0
  65. {opentrons-8.4.1a1.dist-info → opentrons-8.5.0.dist-info}/WHEEL +0 -0
  66. {opentrons-8.4.1a1.dist-info → opentrons-8.5.0.dist-info}/entry_points.txt +0 -0
  67. {opentrons-8.4.1a1.dist-info → opentrons-8.5.0.dist-info}/top_level.txt +0 -0
@@ -12,7 +12,6 @@ from opentrons_shared_data.liquid_classes.liquid_class_definition import (
12
12
  Coordinate,
13
13
  BlowoutLocation,
14
14
  )
15
- from opentrons_shared_data.pipette.types import LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP
16
15
 
17
16
  from opentrons.protocol_api._liquid_properties import (
18
17
  Submerge,
@@ -23,9 +22,10 @@ from opentrons.protocol_api._liquid_properties import (
23
22
  TouchTipProperties,
24
23
  )
25
24
  from opentrons.protocol_engine.errors import TouchTipDisabledError
26
- from opentrons.types import Location, Point
25
+ from opentrons.types import Location, Point, Mount
27
26
  from opentrons.protocols.advanced_control.transfers.transfer_liquid_utils import (
28
27
  LocationCheckDescriptors,
28
+ check_current_volume_before_dispensing,
29
29
  )
30
30
  from opentrons.protocols.advanced_control.transfers import (
31
31
  transfer_liquid_utils as tx_utils,
@@ -39,6 +39,9 @@ if TYPE_CHECKING:
39
39
  log = logging.getLogger(__name__)
40
40
 
41
41
 
42
+ AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP = 2
43
+
44
+
42
45
  @dataclass
43
46
  class LiquidAndAirGapPair:
44
47
  """Pairing of a liquid and air gap in a tip, with air gap below the liquid in a tip."""
@@ -119,11 +122,32 @@ class TransferComponentsExecutor:
119
122
  self,
120
123
  instrument_core: InstrumentCore,
121
124
  transfer_properties: TransferProperties,
122
- target_location: Location,
123
- target_well: WellCore,
125
+ target_location: Union[Location, TrashBin, WasteChute],
126
+ target_well: Optional[WellCore],
124
127
  tip_state: TipState,
125
128
  transfer_type: TransferType,
126
129
  ) -> None:
130
+ """Create a TransferComponentsExecutor instance.
131
+
132
+ One instance should be created to execute all the steps inside each of the
133
+ liquid class' transfer components- aspirate, dispense and multi-dispense.
134
+ The state of the TransferComponentsExecutor instance is expected to be valid
135
+ only for the component it was created.
136
+
137
+ For example, if we want to execute all the steps (submerge, dispense, retract, etc)
138
+ related to the 'dispense' component of a liquid-class based transfer, the class
139
+ will be used to initialize info about the dispense by assigning values
140
+ to class attributes as follows-
141
+ - target_location: the dispense location
142
+ - target_well: the well associated with dispense location, will be None when the
143
+ target_location argument is a TrashBin or WasteChute
144
+ - tip_state: the state of the tip before dispense component steps are executed
145
+ - transfer_type: whether the dispense component is being called as a part of a
146
+ 1-to-1 transfer or a consolidation or a distribution
147
+
148
+ These attributes will remain the same throughout the component's execution,
149
+ except `tip_state`, which will keep updating as fluids are handled.
150
+ """
127
151
  self._instrument = instrument_core
128
152
  self._transfer_properties = transfer_properties
129
153
  self._target_location = target_location
@@ -140,7 +164,6 @@ class TransferComponentsExecutor:
140
164
  self,
141
165
  submerge_properties: Submerge,
142
166
  post_submerge_action: Literal["aspirate", "dispense"],
143
- volume_for_pipette_mode_configuration: Optional[float],
144
167
  ) -> None:
145
168
  """Execute submerge steps.
146
169
 
@@ -148,56 +171,37 @@ class TransferComponentsExecutor:
148
171
  Should raise an error if this point is inside the liquid?
149
172
  For liquid meniscus this is easy to tell. Can’t be below meniscus
150
173
  For reference pos of anything else, do not allow submerge position to be below aspirate position
151
- 2. move to aspirate position at desired speed
174
+ 2. move to aspirate/dispense position at desired speed
152
175
  3. delay
176
+
177
+ If target location is a trash bin or waste chute, the pipette will move to the disposal location given,
178
+ remove air gap and delay
153
179
  """
154
- submerge_start_point = absolute_point_from_position_reference_and_offset(
155
- well=self._target_well,
156
- position_reference=submerge_properties.position_reference,
157
- offset=submerge_properties.offset,
158
- )
159
- submerge_start_location = Location(
160
- point=submerge_start_point, labware=self._target_location.labware
161
- )
162
- prep_before_moving_to_submerge = (
163
- post_submerge_action == "aspirate"
164
- and volume_for_pipette_mode_configuration is not None
165
- )
166
- if prep_before_moving_to_submerge:
167
- # Move to the tip probe start position
168
- self._instrument.move_to(
169
- location=Location(
170
- point=self._target_well.get_top(
171
- LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP.z
172
- ),
173
- labware=self._target_location.labware,
174
- ),
180
+ submerge_start_location: Union[Location, TrashBin, WasteChute]
181
+ if isinstance(self._target_location, Location):
182
+ assert self._target_well is not None
183
+ submerge_start_point = absolute_point_from_position_reference_and_offset(
184
+ well=self._target_well,
185
+ well_volume_difference=0,
186
+ position_reference=submerge_properties.start_position.position_reference,
187
+ offset=submerge_properties.start_position.offset,
188
+ mount=self._instrument.get_mount(),
189
+ )
190
+ submerge_start_location = Location(
191
+ point=submerge_start_point, labware=self._target_location.labware
192
+ )
193
+ tx_utils.raise_if_location_inside_liquid(
194
+ location=submerge_start_location,
175
195
  well_core=self._target_well,
176
- force_direct=False,
177
- minimum_z_height=None,
178
- speed=None,
196
+ location_check_descriptors=LocationCheckDescriptors(
197
+ location_type="submerge start",
198
+ pipetting_action=post_submerge_action,
199
+ ),
200
+ logger=log,
179
201
  )
180
- self._remove_air_gap(location=submerge_start_location)
181
- if (
182
- self._transfer_type != TransferType.MANY_TO_ONE
183
- and self._instrument.get_liquid_presence_detection()
184
- ):
185
- self._instrument.liquid_probe_with_recovery(
186
- well_core=self._target_well, loc=submerge_start_location
187
- )
188
- # TODO: do volume configuration + prepare for aspirate only if the mode needs to be changed
189
- self._instrument.configure_for_volume(volume_for_pipette_mode_configuration) # type: ignore[arg-type]
190
- self._instrument.prepare_to_aspirate()
191
- tx_utils.raise_if_location_inside_liquid(
192
- location=submerge_start_location,
193
- well_location=self._target_location,
194
- well_core=self._target_well,
195
- location_check_descriptors=LocationCheckDescriptors(
196
- location_type="submerge start",
197
- pipetting_action=post_submerge_action,
198
- ),
199
- logger=log,
200
- )
202
+ else:
203
+ submerge_start_location = self._target_location
204
+
201
205
  self._instrument.move_to(
202
206
  location=submerge_start_location,
203
207
  well_core=self._target_well,
@@ -205,23 +209,30 @@ class TransferComponentsExecutor:
205
209
  minimum_z_height=None,
206
210
  speed=None,
207
211
  )
208
- if not prep_before_moving_to_submerge:
209
- self._remove_air_gap(location=submerge_start_location)
210
- self._instrument.move_to(
211
- location=self._target_location,
212
- well_core=self._target_well,
213
- force_direct=True,
214
- minimum_z_height=None,
215
- speed=submerge_properties.speed,
216
- )
212
+ self._remove_air_gap(location=submerge_start_location)
213
+ if isinstance(self._target_location, Location):
214
+ self._instrument.move_to(
215
+ location=self._target_location,
216
+ well_core=self._target_well,
217
+ force_direct=True,
218
+ minimum_z_height=None,
219
+ speed=submerge_properties.speed,
220
+ )
221
+
217
222
  if submerge_properties.delay.enabled and submerge_properties.delay.duration:
218
223
  self._instrument.delay(submerge_properties.delay.duration)
219
224
 
220
225
  def aspirate_and_wait(self, volume: float) -> None:
221
226
  """Aspirate according to aspirate properties and wait if enabled."""
222
227
  # TODO: handle volume correction
228
+ assert (
229
+ isinstance(self._target_location, Location)
230
+ and self._target_well is not None
231
+ )
223
232
  aspirate_props = self._transfer_properties.aspirate
224
- correction_volume = aspirate_props.correction_by_volume.get_for_volume(volume)
233
+ correction_volume = aspirate_props.correction_by_volume.get_for_volume(
234
+ self._instrument.get_current_volume() + volume
235
+ )
225
236
  self._instrument.aspirate(
226
237
  location=self._target_location,
227
238
  well_core=None,
@@ -243,8 +254,12 @@ class TransferComponentsExecutor:
243
254
  push_out_override: Optional[float],
244
255
  ) -> None:
245
256
  """Dispense according to dispense properties and wait if enabled."""
257
+ current_vol = self._instrument.get_current_volume()
258
+ check_current_volume_before_dispensing(
259
+ current_volume=current_vol, dispense_volume=volume
260
+ )
246
261
  correction_volume = dispense_properties.correction_by_volume.get_for_volume(
247
- volume
262
+ current_vol - volume
248
263
  )
249
264
  self._instrument.dispense(
250
265
  location=self._target_location,
@@ -275,11 +290,15 @@ class TransferComponentsExecutor:
275
290
  NOTE: For most of our built-in definitions, we will keep _mix_ off because it is a very application specific thing.
276
291
  We should mention in our docs that users should adjust this property according to their application.
277
292
  """
278
- if not mix_properties.enabled:
293
+ if not mix_properties.enabled or not isinstance(
294
+ self._target_location, Location
295
+ ):
279
296
  return
280
297
  # Assertion only for mypy purposes
281
298
  assert (
282
- mix_properties.repetitions is not None and mix_properties.volume is not None
299
+ mix_properties.repetitions is not None
300
+ and mix_properties.volume is not None
301
+ and self._target_well is not None
283
302
  )
284
303
  push_out_vol = (
285
304
  self._transfer_properties.dispense.push_out_by_volume.get_for_volume(
@@ -325,30 +344,40 @@ class TransferComponentsExecutor:
325
344
  - Touch tip to the sides at the specified speed (tip moves back to the center as part of touch tip)
326
345
  - Return back to the retract position
327
346
  4. Air gap
328
- - Air gap volume depends on the amount of liquid in the pipette
329
- So if total aspirated volume is 20, use the value for airGapByVolume[20]
330
- Flow rate = min(aspirateFlowRate, (airGapByVolume)/sec)
347
+ - If the retract location is at or above the safe location of
348
+ AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP, then add the air gap at the
349
+ retract location (where the pipette is already assumed to be).
350
+ - If the retract location is below the safe location, then move to
351
+ the safe location and then add the air gap.
352
+ - Air gap volume depends on the amount of liquid in the pipette.
353
+ So, if the total aspirated volume is 20, use the value for airGapByVolume[20]
354
+ Flow rate = max(aspirateFlowRate, (airGapByVolume)/sec)
331
355
  - Use post-aspirate delay
332
356
 
333
357
  Args:
334
358
  volume: dispense volume
335
359
  add_air_gap: whether to add an air gap before moving away from the current well.
336
360
  This value is True for all retractions, except when retracting
337
- during a multi-dispense.
361
+ during a multi-dispense. Value of add_air_gap during multi-dispense
362
+ will depend on whether a conditioning volume is used.
338
363
  """
339
- # TODO: Raise error if retract is below the meniscus
364
+ assert (
365
+ isinstance(self._target_location, Location)
366
+ and self._target_well is not None
367
+ )
340
368
  retract_props = self._transfer_properties.aspirate.retract
341
369
  retract_point = absolute_point_from_position_reference_and_offset(
342
370
  well=self._target_well,
343
- position_reference=retract_props.position_reference,
344
- offset=retract_props.offset,
371
+ well_volume_difference=0,
372
+ position_reference=retract_props.end_position.position_reference,
373
+ offset=retract_props.end_position.offset,
374
+ mount=self._instrument.get_mount(),
345
375
  )
346
376
  retract_location = Location(
347
377
  retract_point, labware=self._target_location.labware
348
378
  )
349
379
  tx_utils.raise_if_location_inside_liquid(
350
380
  location=retract_location,
351
- well_location=self._target_location,
352
381
  well_core=self._target_well,
353
382
  location_check_descriptors=LocationCheckDescriptors(
354
383
  location_type="retract end",
@@ -371,7 +400,7 @@ class TransferComponentsExecutor:
371
400
  assert (
372
401
  touch_tip_props.speed is not None
373
402
  and touch_tip_props.z_offset is not None
374
- and touch_tip_props.mm_to_edge is not None
403
+ and touch_tip_props.mm_from_edge is not None
375
404
  )
376
405
  self._instrument.touch_tip(
377
406
  location=retract_location,
@@ -379,7 +408,7 @@ class TransferComponentsExecutor:
379
408
  radius=1,
380
409
  z_offset=touch_tip_props.z_offset,
381
410
  speed=touch_tip_props.speed,
382
- mm_from_edge=touch_tip_props.mm_to_edge,
411
+ mm_from_edge=touch_tip_props.mm_from_edge,
383
412
  )
384
413
  self._instrument.move_to(
385
414
  location=retract_location,
@@ -396,6 +425,29 @@ class TransferComponentsExecutor:
396
425
  else:
397
426
  volume_for_air_gap = volume
398
427
  if add_air_gap:
428
+ # If we need to add air gap, move to a safe location above the well if
429
+ # the retract location is not already at or above this safe location
430
+ if (
431
+ retract_location.point.z
432
+ < self._target_well.get_top(AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP).z
433
+ ):
434
+ self._instrument.move_to(
435
+ location=Location(
436
+ point=Point(
437
+ retract_location.point.x,
438
+ retract_location.point.y,
439
+ self._target_well.get_top(
440
+ AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP
441
+ ).z,
442
+ ),
443
+ labware=retract_location.labware,
444
+ ),
445
+ well_core=self._target_well,
446
+ force_direct=True,
447
+ minimum_z_height=None,
448
+ # Full speed because the tip will already be out of the liquid
449
+ speed=None,
450
+ )
399
451
  self._add_air_gap(
400
452
  air_gap_volume=self._transfer_properties.aspirate.retract.air_gap_by_volume.get_for_volume(
401
453
  volume_for_air_gap
@@ -431,35 +483,45 @@ class TransferComponentsExecutor:
431
483
  - Prepare-to-aspirate (top of well)
432
484
  - Do air-gap (top of well)
433
485
  7. If drop tip, move to drop tip location, drop tip
434
- """
435
- # TODO: Raise error if retract is below the meniscus
436
486
 
487
+ If target location is a trash bin or waste chute, the retract movement step is skipped along with touch tip,
488
+ even if it is enabled.
489
+ """
437
490
  retract_props = self._transfer_properties.dispense.retract
438
- retract_point = absolute_point_from_position_reference_and_offset(
439
- well=self._target_well,
440
- position_reference=retract_props.position_reference,
441
- offset=retract_props.offset,
442
- )
443
- retract_location = Location(
444
- retract_point, labware=self._target_location.labware
445
- )
446
- tx_utils.raise_if_location_inside_liquid(
447
- location=retract_location,
448
- well_location=self._target_location,
449
- well_core=self._target_well,
450
- location_check_descriptors=LocationCheckDescriptors(
451
- location_type="retract end",
452
- pipetting_action="dispense",
453
- ),
454
- logger=log,
455
- )
456
- self._instrument.move_to(
457
- location=retract_location,
458
- well_core=self._target_well,
459
- force_direct=True,
460
- minimum_z_height=None,
461
- speed=retract_props.speed,
462
- )
491
+
492
+ retract_location: Union[Location, TrashBin, WasteChute]
493
+ if isinstance(self._target_location, Location):
494
+ assert self._target_well is not None
495
+ retract_point = absolute_point_from_position_reference_and_offset(
496
+ well=self._target_well,
497
+ well_volume_difference=0,
498
+ position_reference=retract_props.end_position.position_reference,
499
+ offset=retract_props.end_position.offset,
500
+ mount=self._instrument.get_mount(),
501
+ )
502
+ retract_location = Location(
503
+ retract_point, labware=self._target_location.labware
504
+ )
505
+ tx_utils.raise_if_location_inside_liquid(
506
+ location=retract_location,
507
+ well_core=self._target_well,
508
+ location_check_descriptors=LocationCheckDescriptors(
509
+ location_type="retract end",
510
+ pipetting_action="dispense",
511
+ ),
512
+ logger=log,
513
+ )
514
+ self._instrument.move_to(
515
+ location=retract_location,
516
+ well_core=self._target_well,
517
+ force_direct=True,
518
+ minimum_z_height=None,
519
+ speed=retract_props.speed,
520
+ )
521
+ else:
522
+ retract_location = self._target_location
523
+
524
+ # TODO should we delay here for a trash despite not having a "retract"?
463
525
  retract_delay = retract_props.delay
464
526
  if retract_delay.enabled and retract_delay.duration:
465
527
  self._instrument.delay(retract_delay.duration)
@@ -485,7 +547,7 @@ class TransferComponentsExecutor:
485
547
  # when leaving the dispense well. If this will be the final air gap, i.e,
486
548
  # we won't be moving to a Trash or a Source for Blowout after this air gap,
487
549
  # then skip the final air gap if we have been told to do so.
488
- self._do_touch_tip_and_air_gap(
550
+ self._do_touch_tip_and_air_gap_after_dispense(
489
551
  touch_tip_properties=retract_props.touch_tip,
490
552
  location=retract_location,
491
553
  well=self._target_well,
@@ -499,7 +561,7 @@ class TransferComponentsExecutor:
499
561
  # TODO: no-op touch tip if touch tip is enabled and blowout is in trash/ reservoir/ any labware with touch-tip disabled
500
562
  assert blowout_props.flow_rate is not None
501
563
  self._instrument.set_flow_rate(blow_out=blowout_props.flow_rate)
502
- touch_tip_and_air_gap_location: Optional[Location]
564
+ touch_tip_and_air_gap_location: Union[Location, TrashBin, WasteChute]
503
565
  if blowout_props.location == BlowoutLocation.SOURCE:
504
566
  if source_location is None or source_well is None:
505
567
  raise RuntimeError(
@@ -523,9 +585,7 @@ class TransferComponentsExecutor:
523
585
  well_core=None,
524
586
  in_place=False,
525
587
  )
526
- touch_tip_and_air_gap_location = (
527
- trash_location if isinstance(trash_location, Location) else None
528
- )
588
+ touch_tip_and_air_gap_location = trash_location
529
589
  touch_tip_and_air_gap_well = (
530
590
  # We have already established that trash location of `Location` type
531
591
  # has its `labware` as `Well` type.
@@ -540,7 +600,7 @@ class TransferComponentsExecutor:
540
600
  self._tip_state.delete_air_gap(last_air_gap)
541
601
  self._tip_state.ready_to_aspirate = False
542
602
  # Do touch tip and air gap again after blowing out into source well or trash
543
- self._do_touch_tip_and_air_gap(
603
+ self._do_touch_tip_and_air_gap_after_dispense(
544
604
  touch_tip_properties=retract_props.touch_tip,
545
605
  location=touch_tip_and_air_gap_location,
546
606
  well=touch_tip_and_air_gap_well,
@@ -572,22 +632,25 @@ class TransferComponentsExecutor:
572
632
  that it handles air gaps differently based on the disposal volume, conditioning volume
573
633
  and whether we are moving to another dispense or going back to the source.
574
634
  """
575
- # TODO: Raise error if retract is below the meniscus
576
-
635
+ assert (
636
+ isinstance(self._target_location, Location)
637
+ and self._target_well is not None
638
+ )
577
639
  assert self._transfer_properties.multi_dispense is not None
578
640
 
579
641
  retract_props = self._transfer_properties.multi_dispense.retract
580
642
  retract_point = absolute_point_from_position_reference_and_offset(
581
643
  well=self._target_well,
582
- position_reference=retract_props.position_reference,
583
- offset=retract_props.offset,
644
+ well_volume_difference=0,
645
+ position_reference=retract_props.end_position.position_reference,
646
+ offset=retract_props.end_position.offset,
647
+ mount=self._instrument.get_mount(),
584
648
  )
585
649
  retract_location = Location(
586
650
  retract_point, labware=self._target_location.labware
587
651
  )
588
652
  tx_utils.raise_if_location_inside_liquid(
589
653
  location=retract_location,
590
- well_location=self._target_location,
591
654
  well_core=self._target_well,
592
655
  location_check_descriptors=LocationCheckDescriptors(
593
656
  location_type="retract end",
@@ -662,7 +725,7 @@ class TransferComponentsExecutor:
662
725
  # Add an air gap depending on conditioning volume + whether this is
663
726
  # the last step of a multi-dispense sequence + whether this is the last step
664
727
  # of the entire liquid distribution.
665
- self._do_touch_tip_and_air_gap(
728
+ self._do_touch_tip_and_air_gap_after_dispense(
666
729
  touch_tip_properties=retract_props.touch_tip,
667
730
  location=retract_location,
668
731
  well=self._target_well,
@@ -676,7 +739,7 @@ class TransferComponentsExecutor:
676
739
  ):
677
740
  assert blowout_props.flow_rate is not None
678
741
  self._instrument.set_flow_rate(blow_out=blowout_props.flow_rate)
679
- touch_tip_and_air_gap_location: Optional[Location]
742
+ touch_tip_and_air_gap_location: Union[Location, TrashBin, WasteChute]
680
743
  if blowout_props.location == BlowoutLocation.SOURCE:
681
744
  if source_location is None or source_well is None:
682
745
  raise RuntimeError(
@@ -700,9 +763,7 @@ class TransferComponentsExecutor:
700
763
  well_core=None,
701
764
  in_place=False,
702
765
  )
703
- touch_tip_and_air_gap_location = (
704
- trash_location if isinstance(trash_location, Location) else None
705
- )
766
+ touch_tip_and_air_gap_location = trash_location
706
767
  touch_tip_and_air_gap_well = (
707
768
  # We have already established that trash location of `Location` type
708
769
  # has its `labware` as `Well` type.
@@ -716,7 +777,7 @@ class TransferComponentsExecutor:
716
777
  self._tip_state.ready_to_aspirate = False
717
778
 
718
779
  # Do touch tip and air gap again after blowing out into source well or trash
719
- self._do_touch_tip_and_air_gap(
780
+ self._do_touch_tip_and_air_gap_after_dispense(
720
781
  touch_tip_properties=retract_props.touch_tip,
721
782
  location=touch_tip_and_air_gap_location,
722
783
  well=touch_tip_and_air_gap_well,
@@ -728,24 +789,35 @@ class TransferComponentsExecutor:
728
789
  ),
729
790
  )
730
791
 
731
- def _do_touch_tip_and_air_gap(
792
+ def _do_touch_tip_and_air_gap_after_dispense( # noqa: C901
732
793
  self,
733
794
  touch_tip_properties: TouchTipProperties,
734
- location: Optional[Location],
795
+ location: Union[Location, TrashBin, WasteChute],
735
796
  well: Optional[WellCore],
736
797
  add_air_gap: bool,
737
798
  ) -> None:
738
- """Perform touch tip and air gap as part of post-dispense retract."""
799
+ """Perform touch tip and air gap as part of post-dispense retract.
800
+
801
+ If the retract location is at or above the safe location of
802
+ AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP, then add the air gap at the retract location
803
+ (where the pipette is already assumed to be at).
804
+
805
+ If the retract location is below the safe location, then move to the safe location
806
+ and then add the air gap.
807
+
808
+ Note: if the plunger needs to be adjusted to prepare for aspirate, it will be done
809
+ at the same location where the air gap will be added.
810
+ """
739
811
  if touch_tip_properties.enabled:
740
812
  assert (
741
813
  touch_tip_properties.speed is not None
742
814
  and touch_tip_properties.z_offset is not None
743
- and touch_tip_properties.mm_to_edge is not None
815
+ and touch_tip_properties.mm_from_edge is not None
744
816
  )
745
817
  # TODO:, check that when blow out is a non-dest-well,
746
818
  # whether the touch tip params from transfer props should be used for
747
819
  # both dest-well touch tip and non-dest-well touch tip.
748
- if well is not None and location is not None:
820
+ if isinstance(location, Location) and well is not None:
749
821
  try:
750
822
  self._instrument.touch_tip(
751
823
  location=location,
@@ -753,7 +825,7 @@ class TransferComponentsExecutor:
753
825
  radius=1,
754
826
  z_offset=touch_tip_properties.z_offset,
755
827
  speed=touch_tip_properties.speed,
756
- mm_from_edge=touch_tip_properties.mm_to_edge,
828
+ mm_from_edge=touch_tip_properties.mm_from_edge,
757
829
  )
758
830
  except TouchTipDisabledError:
759
831
  # TODO: log a warning
@@ -768,25 +840,69 @@ class TransferComponentsExecutor:
768
840
  # Full speed because the tip will already be out of the liquid
769
841
  speed=None,
770
842
  )
843
+ if add_air_gap or not self._tip_state.ready_to_aspirate:
844
+ # If we need to move the plunger up either to prepare for aspirate or to add air gap,
845
+ # move to a safe location above the well if the retract location is not already
846
+ # at or above this safe location
847
+ if isinstance(location, Location):
848
+ assert well is not None # For mypy purposes only
849
+ if (
850
+ location.point.z
851
+ < well.get_top(AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP).z
852
+ ):
853
+ self._instrument.move_to(
854
+ location=Location(
855
+ point=Point(
856
+ location.point.x,
857
+ location.point.y,
858
+ well.get_top(AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP).z,
859
+ ),
860
+ labware=location.labware,
861
+ ),
862
+ well_core=well,
863
+ force_direct=True,
864
+ minimum_z_height=None,
865
+ speed=None,
866
+ )
867
+ else:
868
+ if (
869
+ location.offset.z
870
+ < location.top(
871
+ x=0, y=0, z=AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP
872
+ ).offset.z
873
+ ):
874
+ self._instrument.move_to(
875
+ location=location.top(
876
+ x=location.offset.x,
877
+ y=location.offset.y,
878
+ z=AIR_GAP_LOC_Z_OFFSET_FROM_WELL_TOP,
879
+ ),
880
+ well_core=None,
881
+ force_direct=True,
882
+ minimum_z_height=None,
883
+ speed=None,
884
+ )
771
885
 
772
- # TODO: check if it is okay to just do `prepare_to_aspirate` unconditionally
773
- if not self._tip_state.ready_to_aspirate:
774
- self._instrument.prepare_to_aspirate()
775
- self._tip_state.ready_to_aspirate = True
776
- if add_air_gap:
777
- self._add_air_gap(
778
- air_gap_volume=self._transfer_properties.aspirate.retract.air_gap_by_volume.get_for_volume(
779
- 0
886
+ if not self._tip_state.ready_to_aspirate:
887
+ self._instrument.prepare_to_aspirate()
888
+ self._tip_state.ready_to_aspirate = True
889
+ if add_air_gap:
890
+ self._add_air_gap(
891
+ air_gap_volume=self._transfer_properties.aspirate.retract.air_gap_by_volume.get_for_volume(
892
+ 0
893
+ )
780
894
  )
781
- )
782
895
 
783
- def _add_air_gap(self, air_gap_volume: float) -> None:
896
+ def _add_air_gap(
897
+ self,
898
+ air_gap_volume: float,
899
+ ) -> None:
784
900
  """Add an air gap."""
785
901
  if air_gap_volume == 0:
786
902
  return
787
903
  aspirate_props = self._transfer_properties.aspirate
788
904
  correction_volume = aspirate_props.correction_by_volume.get_for_volume(
789
- air_gap_volume
905
+ self._instrument.get_current_volume() + air_gap_volume
790
906
  )
791
907
  # The minimum flow rate should be air_gap_volume per second
792
908
  flow_rate = max(
@@ -803,43 +919,33 @@ class TransferComponentsExecutor:
803
919
  self._instrument.delay(delay_props.duration)
804
920
  self._tip_state.append_air_gap(air_gap_volume)
805
921
 
806
- def _remove_air_gap(self, location: Location) -> None:
922
+ def _remove_air_gap(self, location: Union[Location, TrashBin, WasteChute]) -> None:
807
923
  """Remove a previously added air gap."""
808
924
  last_air_gap = self._tip_state.last_liquid_and_air_gap_in_tip.air_gap
809
- if last_air_gap == 0:
810
- return
811
-
812
925
  dispense_props = self._transfer_properties.dispense
813
- correction_volume = dispense_props.correction_by_volume.get_for_volume(
814
- last_air_gap
815
- )
816
- # The minimum flow rate should be air_gap_volume per second
817
- flow_rate = max(
818
- dispense_props.flow_rate_by_volume.get_for_volume(last_air_gap),
819
- last_air_gap,
820
- )
821
- self._instrument.dispense(
926
+ self._instrument.remove_air_gap_during_transfer_with_liquid_class(
927
+ last_air_gap=last_air_gap,
928
+ dispense_props=dispense_props,
822
929
  location=location,
823
- well_core=None,
824
- volume=last_air_gap,
825
- rate=1,
826
- flow_rate=flow_rate,
827
- in_place=True,
828
- push_out=0,
829
- correction_volume=correction_volume,
830
930
  )
831
931
  self._tip_state.delete_air_gap(last_air_gap)
832
- dispense_delay = dispense_props.delay
833
- if dispense_delay.enabled and dispense_delay.duration:
834
- self._instrument.delay(dispense_delay.duration)
835
932
 
836
933
 
837
934
  def absolute_point_from_position_reference_and_offset(
838
935
  well: WellCore,
936
+ well_volume_difference: float,
839
937
  position_reference: PositionReference,
840
938
  offset: Coordinate,
939
+ mount: Mount,
841
940
  ) -> Point:
842
- """Return the absolute point, given the well, the position reference and offset."""
941
+ """Return the absolute point, given the well, the position reference and offset.
942
+
943
+ If using meniscus as the position reference, well_volume_difference should be specified.
944
+ `well_volume_difference` is the expected *difference* in well volume we want to consider
945
+ when estimating the height of the liquid meniscus after an aspirate/ dispense.
946
+ So, for liquid height estimation after an aspirate, well_volume_difference is
947
+ expected to be a -ve value while for a dispense, it will be a +ve value.
948
+ """
843
949
  match position_reference:
844
950
  case PositionReference.WELL_TOP:
845
951
  reference_point = well.get_top(0)
@@ -848,11 +954,17 @@ def absolute_point_from_position_reference_and_offset(
848
954
  case PositionReference.WELL_CENTER:
849
955
  reference_point = well.get_center()
850
956
  case PositionReference.LIQUID_MENISCUS:
851
- meniscus_point = well.get_meniscus()
852
- if not isinstance(meniscus_point, Point):
853
- reference_point = well.get_center()
957
+ estimated_liquid_height = well.estimate_liquid_height_after_pipetting(
958
+ mount=mount,
959
+ operation_volume=well_volume_difference,
960
+ )
961
+ if isinstance(estimated_liquid_height, (float, int)):
962
+ reference_point = well.get_bottom(z_offset=estimated_liquid_height)
854
963
  else:
855
- reference_point = meniscus_point
964
+ # If estimated liquid height gives a SimulatedProbeResult then
965
+ # assume meniscus is at well center.
966
+ # Will this cause more harm than good? Is there a better alternative to this?
967
+ reference_point = well.get_center()
856
968
  case _:
857
969
  raise ValueError(f"Unknown position reference {position_reference}")
858
970
  return reference_point + Point(offset.x, offset.y, offset.z)