opentrons 8.7.0a7__py3-none-any.whl → 8.8.0a7__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.

Potentially problematic release.


This version of opentrons might be problematic. Click here for more details.

Files changed (109) hide show
  1. opentrons/_version.py +2 -2
  2. opentrons/cli/analyze.py +4 -1
  3. opentrons/config/__init__.py +7 -0
  4. opentrons/drivers/asyncio/communication/serial_connection.py +8 -5
  5. opentrons/drivers/flex_stacker/driver.py +6 -1
  6. opentrons/drivers/vacuum_module/__init__.py +5 -0
  7. opentrons/drivers/vacuum_module/abstract.py +93 -0
  8. opentrons/drivers/vacuum_module/driver.py +208 -0
  9. opentrons/drivers/vacuum_module/errors.py +39 -0
  10. opentrons/drivers/vacuum_module/simulator.py +85 -0
  11. opentrons/drivers/vacuum_module/types.py +79 -0
  12. opentrons/execute.py +3 -0
  13. opentrons/hardware_control/backends/flex_protocol.py +2 -0
  14. opentrons/hardware_control/backends/ot3controller.py +35 -2
  15. opentrons/hardware_control/backends/ot3simulator.py +2 -0
  16. opentrons/hardware_control/backends/ot3utils.py +37 -0
  17. opentrons/hardware_control/module_control.py +23 -2
  18. opentrons/hardware_control/modules/mod_abc.py +1 -1
  19. opentrons/hardware_control/modules/types.py +1 -1
  20. opentrons/hardware_control/motion_utilities.py +6 -6
  21. opentrons/hardware_control/ot3api.py +62 -13
  22. opentrons/hardware_control/protocols/gripper_controller.py +1 -0
  23. opentrons/hardware_control/protocols/liquid_handler.py +6 -2
  24. opentrons/hardware_control/types.py +12 -0
  25. opentrons/legacy_commands/commands.py +58 -5
  26. opentrons/legacy_commands/module_commands.py +29 -0
  27. opentrons/legacy_commands/protocol_commands.py +33 -1
  28. opentrons/legacy_commands/types.py +75 -1
  29. opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
  30. opentrons/protocol_api/_types.py +2 -0
  31. opentrons/protocol_api/core/engine/_default_labware_versions.py +1 -0
  32. opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
  33. opentrons/protocol_api/core/engine/instrument.py +109 -26
  34. opentrons/protocol_api/core/engine/module_core.py +27 -3
  35. opentrons/protocol_api/core/engine/protocol.py +33 -1
  36. opentrons/protocol_api/core/engine/stringify.py +2 -0
  37. opentrons/protocol_api/core/instrument.py +19 -2
  38. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
  39. opentrons/protocol_api/core/legacy/legacy_module_core.py +15 -4
  40. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +12 -0
  41. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
  42. opentrons/protocol_api/core/module.py +25 -2
  43. opentrons/protocol_api/core/protocol.py +12 -0
  44. opentrons/protocol_api/instrument_context.py +388 -2
  45. opentrons/protocol_api/labware.py +5 -2
  46. opentrons/protocol_api/module_contexts.py +133 -30
  47. opentrons/protocol_api/protocol_context.py +61 -17
  48. opentrons/protocol_api/robot_context.py +3 -4
  49. opentrons/protocol_api/validation.py +43 -2
  50. opentrons/protocol_engine/__init__.py +4 -0
  51. opentrons/protocol_engine/actions/__init__.py +2 -0
  52. opentrons/protocol_engine/actions/actions.py +9 -0
  53. opentrons/protocol_engine/commands/__init__.py +14 -0
  54. opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
  55. opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
  56. opentrons/protocol_engine/commands/capture_image.py +302 -0
  57. opentrons/protocol_engine/commands/command.py +1 -0
  58. opentrons/protocol_engine/commands/command_unions.py +13 -0
  59. opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
  60. opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
  61. opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
  62. opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +1 -1
  63. opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +1 -1
  64. opentrons/protocol_engine/commands/move_labware.py +3 -4
  65. opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
  66. opentrons/protocol_engine/commands/movement_common.py +29 -2
  67. opentrons/protocol_engine/commands/pipetting_common.py +48 -3
  68. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +12 -9
  69. opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +17 -12
  70. opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +1 -1
  71. opentrons/protocol_engine/create_protocol_engine.py +12 -0
  72. opentrons/protocol_engine/engine_support.py +3 -0
  73. opentrons/protocol_engine/errors/__init__.py +8 -0
  74. opentrons/protocol_engine/errors/exceptions.py +64 -0
  75. opentrons/protocol_engine/execution/__init__.py +2 -0
  76. opentrons/protocol_engine/execution/command_executor.py +54 -1
  77. opentrons/protocol_engine/execution/create_queue_worker.py +4 -1
  78. opentrons/protocol_engine/execution/labware_movement.py +13 -4
  79. opentrons/protocol_engine/execution/pipetting.py +19 -25
  80. opentrons/protocol_engine/protocol_engine.py +62 -2
  81. opentrons/protocol_engine/resources/__init__.py +2 -0
  82. opentrons/protocol_engine/resources/camera_provider.py +110 -0
  83. opentrons/protocol_engine/resources/file_provider.py +133 -58
  84. opentrons/protocol_engine/slot_standardization.py +2 -0
  85. opentrons/protocol_engine/state/camera.py +54 -0
  86. opentrons/protocol_engine/state/commands.py +24 -4
  87. opentrons/protocol_engine/state/geometry.py +68 -10
  88. opentrons/protocol_engine/state/labware.py +10 -6
  89. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +6 -1
  90. opentrons/protocol_engine/state/modules.py +9 -0
  91. opentrons/protocol_engine/state/preconditions.py +59 -0
  92. opentrons/protocol_engine/state/state.py +30 -0
  93. opentrons/protocol_engine/state/state_summary.py +2 -0
  94. opentrons/protocol_engine/state/update_types.py +10 -0
  95. opentrons/protocol_engine/types/__init__.py +14 -1
  96. opentrons/protocol_engine/types/command_preconditions.py +18 -0
  97. opentrons/protocol_engine/types/location.py +26 -2
  98. opentrons/protocol_engine/types/module.py +1 -1
  99. opentrons/protocol_runner/protocol_runner.py +14 -1
  100. opentrons/protocol_runner/run_orchestrator.py +31 -0
  101. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
  102. opentrons/simulate.py +3 -0
  103. opentrons/system/camera.py +333 -3
  104. opentrons/system/ffmpeg.py +110 -0
  105. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
  106. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +109 -97
  107. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
  108. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
  109. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
@@ -49,6 +49,7 @@ from .ot3utils import (
49
49
  gripper_jaw_state_from_fw,
50
50
  get_system_constraints,
51
51
  get_system_constraints_for_plunger_acceleration,
52
+ add_delay_to_move_group,
52
53
  )
53
54
  from .tip_presence_manager import TipPresenceManager
54
55
 
@@ -657,11 +658,17 @@ class OT3Controller(FlexBackend):
657
658
  speed: float,
658
659
  stop_condition: HWStopCondition,
659
660
  nodes_in_moves_only: bool,
661
+ delay: Optional[Tuple[List[Axis], float]] = None,
660
662
  ) -> Tuple[Optional[MoveGroupRunner], bool]:
661
663
  if not target:
662
664
  return None, False
663
- move_target = MoveTarget.build(position=target, max_speed=speed)
665
+ # Create a target that doesn't incorporate the plunger into a joint axis with the gantry
666
+ plunger_axes = [Axis.P_L, Axis.P_R]
667
+
664
668
  try:
669
+ move_target = self._move_manager.devectorize_axes(
670
+ origin, target, speed, plunger_axes
671
+ )
665
672
  _, movelist = self._move_manager.plan_motion(
666
673
  origin=origin, target_list=[move_target]
667
674
  )
@@ -683,6 +690,28 @@ class OT3Controller(FlexBackend):
683
690
  move_group, _ = create_move_group(
684
691
  origin, moves, ordered_nodes, MoveStopCondition[stop_condition.name]
685
692
  )
693
+
694
+ if delay is not None:
695
+ delay_axes, delay_time = delay
696
+ delay_nodes = [axis_to_node(ax) for ax in delay_axes]
697
+ move_group = add_delay_to_move_group(
698
+ move_group, ordered_nodes, (delay_nodes, delay_time)
699
+ )
700
+
701
+ (
702
+ plunger_slowed,
703
+ error_str,
704
+ ) = self._move_manager.ensure_pipette_flow_rate_unchanged(
705
+ [node_to_axis(node) for node in ordered_nodes],
706
+ origin,
707
+ target,
708
+ speed,
709
+ move_group,
710
+ [(ax, axis_to_node(ax)) for ax in plunger_axes],
711
+ )
712
+ if plunger_slowed:
713
+ log.error(error_str)
714
+
686
715
  return (
687
716
  MoveGroupRunner(
688
717
  move_groups=[move_group],
@@ -728,6 +757,7 @@ class OT3Controller(FlexBackend):
728
757
  speed: float,
729
758
  stop_condition: HWStopCondition = HWStopCondition.none,
730
759
  nodes_in_moves_only: bool = True,
760
+ delay: Optional[Tuple[List[Axis], float]] = None,
731
761
  ) -> None:
732
762
  """Move to a position.
733
763
 
@@ -750,7 +780,7 @@ class OT3Controller(FlexBackend):
750
780
 
751
781
  maybe_runners = (
752
782
  self._build_move_node_axis_runner(
753
- origin, target, speed, stop_condition, nodes_in_moves_only
783
+ origin, target, speed, stop_condition, nodes_in_moves_only, delay
754
784
  ),
755
785
  self._build_move_gear_axis_runner(
756
786
  possible_q_axis_origin,
@@ -1766,6 +1796,7 @@ class OT3Controller(FlexBackend):
1766
1796
  max_allowed_grip_error: float,
1767
1797
  hard_limit_lower: float,
1768
1798
  hard_limit_upper: float,
1799
+ disable_geometry_grip_check: bool = False,
1769
1800
  ) -> None:
1770
1801
  """
1771
1802
  Check if the gripper is at the expected location.
@@ -1808,6 +1839,7 @@ class OT3Controller(FlexBackend):
1808
1839
  if (
1809
1840
  current_gripper_position - expected_gripper_position_min
1810
1841
  < -max_allowed_grip_error
1842
+ and not disable_geometry_grip_check
1811
1843
  ):
1812
1844
  raise FailedGripperPickupError(
1813
1845
  message="Failed to grip: jaws closed too far",
@@ -1821,6 +1853,7 @@ class OT3Controller(FlexBackend):
1821
1853
  if (
1822
1854
  current_gripper_position - expected_gripper_position_max
1823
1855
  > max_allowed_grip_error
1856
+ and not disable_geometry_grip_check
1824
1857
  ):
1825
1858
  raise FailedGripperPickupError(
1826
1859
  message="Failed to grip: jaws could not close far enough",
@@ -367,6 +367,7 @@ class OT3Simulator(FlexBackend):
367
367
  speed: Optional[float] = None,
368
368
  stop_condition: HWStopCondition = HWStopCondition.none,
369
369
  nodes_in_moves_only: bool = True,
370
+ delay: Optional[Tuple[List[Axis], float]] = None,
370
371
  ) -> None:
371
372
  """Move to a position.
372
373
 
@@ -849,6 +850,7 @@ class OT3Simulator(FlexBackend):
849
850
  max_allowed_grip_error: float,
850
851
  hard_limit_lower: float,
851
852
  hard_limit_upper: float,
853
+ disable_geometry_grip_check: bool = False,
852
854
  ) -> None:
853
855
  # This is a (pretty bad) simulation of the gripper actually gripping something,
854
856
  # but it should work.
@@ -1,4 +1,5 @@
1
1
  """Shared utilities for ot3 hardware control."""
2
+ import copy
2
3
  from typing import Dict, Iterable, List, Set, Tuple, TypeVar, cast, Sequence, Optional
3
4
  from typing_extensions import Literal
4
5
  from logging import getLogger
@@ -57,6 +58,8 @@ from opentrons_hardware.hardware_control.motion import (
57
58
  MoveStopCondition,
58
59
  create_gripper_jaw_step,
59
60
  create_tip_action_step,
61
+ SingleMoveStep,
62
+ MoveGroupSingleAxisStep,
60
63
  )
61
64
  from opentrons_hardware.hardware_control.constants import interrupts_per_sec
62
65
 
@@ -376,6 +379,40 @@ def motor_nodes(devices: Set[FirmwareTarget]) -> Set[NodeId]:
376
379
  return {NodeId(target) for target in motor_nodes if target in NodeId}
377
380
 
378
381
 
382
+ def add_delay_to_move_group(
383
+ group: MoveGroup,
384
+ present_nodes: Iterable[NodeId],
385
+ delay: Tuple[List[NodeId], float],
386
+ ) -> MoveGroup:
387
+ delay_nodes, delay_time = delay
388
+ if delay_time == 0.0:
389
+ return group
390
+
391
+ as_single_moves: Dict[NodeId, List[SingleMoveStep]] = {}
392
+ for node in present_nodes:
393
+ as_single_moves[node] = [step[node] for step in group]
394
+
395
+ delay_step = MoveGroupSingleAxisStep(
396
+ distance_mm=np.float64(0),
397
+ velocity_mm_sec=np.float64(0),
398
+ duration_sec=np.float64(delay_time),
399
+ )
400
+ for node in present_nodes:
401
+ if node in delay_nodes:
402
+ # Add the delay at the beginning
403
+ as_single_moves[node] = [copy.deepcopy(delay_step)] + as_single_moves[node]
404
+ else:
405
+ # Add the delay at the end.
406
+ as_single_moves[node] = as_single_moves[node] + [copy.deepcopy(delay_step)]
407
+
408
+ new_move_group: MoveGroup = []
409
+ for i in range(len(group) + 1):
410
+ new_move_group.append(
411
+ {node: as_single_moves[node][i] for node in present_nodes}
412
+ )
413
+ return new_move_group
414
+
415
+
379
416
  def create_move_group(
380
417
  origin: Coordinates[Axis, CoordinateValue],
381
418
  moves: List[Move[Axis]],
@@ -30,6 +30,7 @@ from .types import (
30
30
  StatusBarUpdateEvent,
31
31
  HardwareEvent,
32
32
  AsynchronousModuleErrorNotification,
33
+ ModuleDisconnectedNotification,
33
34
  )
34
35
  from . import modules
35
36
 
@@ -164,13 +165,28 @@ class AttachedModulesControl:
164
165
  self.subscribe_to_api_event(mod)
165
166
  return mod
166
167
 
167
- def _disconnected_callback(self, port: str, serial: Optional[str]) -> None:
168
+ def _disconnected_callback(
169
+ self, model: str, port: str, serial: Optional[str]
170
+ ) -> None:
168
171
  """Used by the module to indicate that it was disconnected and should be deleted."""
169
172
  mod = ModuleAtPort(port=port, serial=serial, name="")
170
173
  asyncio.run_coroutine_threadsafe(
171
174
  self.unregister_modules([mod]),
172
175
  self._api.loop,
173
176
  )
177
+ try:
178
+ self._api.loop.call_soon(
179
+ self._event_callback,
180
+ ModuleDisconnectedNotification(
181
+ module_serial=serial,
182
+ module_model=modules.module_model_from_string(model),
183
+ port=port,
184
+ ),
185
+ )
186
+ except Exception:
187
+ log.exception(
188
+ f"Module disconnect callback for module {model} {serial} at {port} failed"
189
+ )
174
190
 
175
191
  def _async_error_callback(
176
192
  self,
@@ -218,10 +234,15 @@ class AttachedModulesControl:
218
234
  for removed_mod in removed_modules:
219
235
  try:
220
236
  self._available_modules.remove(removed_mod)
237
+ # Important: this wants to be after the remove because this may trigger
238
+ # recursion back to here; we therefore want the module to already be
239
+ # removed so that the recursion terminates next loop
240
+ removed_mod.disconnected_callback()
221
241
  except ValueError:
222
- log.exception(
242
+ log.warning(
223
243
  f"Removed Module {removed_mod} not found in attached modules"
224
244
  )
245
+
225
246
  for removed_mod in removed_modules:
226
247
  log.info(
227
248
  f"Module {removed_mod.name()} detached from port {removed_mod.port}"
@@ -105,7 +105,7 @@ class AbstractModule(abc.ABC):
105
105
  def disconnected_callback(self) -> None:
106
106
  """Called from within the module object to signify the object is no longer connected"""
107
107
  if self._disconnected_callback is not None:
108
- self._disconnected_callback(self.port, self.serial_number)
108
+ self._disconnected_callback(self.model(), self.port, self.serial_number)
109
109
 
110
110
  def error_callback(self, exc: Exception) -> None:
111
111
  """Called from within the module object when an asynchronous hardware error occurrs."""
@@ -59,7 +59,7 @@ UploadFunction = Callable[[str, str, Dict[str, Any]], Awaitable[Tuple[bool, str]
59
59
  class ModuleDisconnectedCallback(Protocol):
60
60
  """Protocol for the callback when the module should be disconnected."""
61
61
 
62
- def __call__(self, port: str, serial: str | None) -> None:
62
+ def __call__(self, model: str, port: str, serial: str | None) -> None:
63
63
  ...
64
64
 
65
65
 
@@ -195,8 +195,7 @@ def target_position_from_plunger(
195
195
  def target_positions_from_plunger_tracking(
196
196
  mount: Union[Mount, OT3Mount],
197
197
  plunger_delta: float,
198
- z_delta: float,
199
- current_position: Dict[Axis, float],
198
+ end_position: OrderedDict[Axis, float],
200
199
  ) -> "OrderedDict[Axis, float]":
201
200
  """Create a target position for machine axes including plungers for dynamic liquid tracking.
202
201
 
@@ -206,10 +205,11 @@ def target_positions_from_plunger_tracking(
206
205
  volume to aspirate/dispense.
207
206
  z_delta: the distance to move the z axis- should be determined based on volume and well geometry.
208
207
  """
209
- all_axes_pos = target_position_from_plunger(mount, plunger_delta, current_position)
210
- z_ax = Axis.by_mount(mount)
211
- all_axes_pos[z_ax] = current_position[z_ax] + z_delta
212
- return all_axes_pos
208
+ plunger_pos = OrderedDict()
209
+ plunger = Axis.of_main_tool_actuator(mount)
210
+ plunger_pos[plunger] = plunger_delta
211
+ end_position.update(plunger_pos)
212
+ return end_position
213
213
 
214
214
 
215
215
  def deck_point_from_machine_point(
@@ -52,6 +52,7 @@ from opentrons_shared_data.errors.exceptions import (
52
52
  InvalidActuator,
53
53
  FirmwareUpdateFailedError,
54
54
  PipetteLiquidNotFoundError,
55
+ PipetteOverpressureError,
55
56
  )
56
57
 
57
58
  from .util import use_or_initialize_loop, check_motion_bounds
@@ -75,6 +76,7 @@ from .types import (
75
76
  ErrorMessageNotification,
76
77
  HardwareEvent,
77
78
  AsynchronousModuleErrorNotification,
79
+ ModuleDisconnectedNotification,
78
80
  HardwareEventHandler,
79
81
  HardwareAction,
80
82
  HepaFanState,
@@ -371,7 +373,10 @@ class OT3API(
371
373
  def _send_module_notification(self, event: HardwareEvent) -> None:
372
374
  if not isinstance(
373
375
  event,
374
- AsynchronousModuleErrorNotification,
376
+ (
377
+ AsynchronousModuleErrorNotification,
378
+ ModuleDisconnectedNotification,
379
+ ),
375
380
  ):
376
381
  return
377
382
  mod_log.info(
@@ -1483,6 +1488,7 @@ class OT3API(
1483
1488
  expected_grip_width: float,
1484
1489
  grip_width_uncertainty_wider: float,
1485
1490
  grip_width_uncertainty_narrower: float,
1491
+ disable_geometry_grip_check: bool = False,
1486
1492
  ) -> None:
1487
1493
  """Ensure that a gripper pickup succeeded.
1488
1494
 
@@ -1503,6 +1509,7 @@ class OT3API(
1503
1509
  gripper.max_allowed_grip_error,
1504
1510
  gripper.min_jaw_width,
1505
1511
  gripper.max_jaw_width,
1512
+ disable_geometry_grip_check,
1506
1513
  )
1507
1514
 
1508
1515
  def gripper_jaw_can_home(self) -> bool:
@@ -1518,6 +1525,7 @@ class OT3API(
1518
1525
  acquire_lock: bool = True,
1519
1526
  check_bounds: MotionChecks = MotionChecks.NONE,
1520
1527
  expect_stalls: bool = False,
1528
+ delay: Optional[Tuple[List[Axis], float]] = None,
1521
1529
  ) -> None:
1522
1530
  """Worker function to apply robot motion."""
1523
1531
  machine_pos = machine_from_deck(
@@ -1551,6 +1559,7 @@ class OT3API(
1551
1559
  machine_pos,
1552
1560
  speed or 400.0,
1553
1561
  HWStopCondition.stall if expect_stalls else HWStopCondition.none,
1562
+ delay=delay,
1554
1563
  )
1555
1564
  except Exception:
1556
1565
  self._log.exception("Move failed")
@@ -3076,9 +3085,10 @@ class OT3API(
3076
3085
  async def aspirate_while_tracking(
3077
3086
  self,
3078
3087
  mount: Union[top_types.Mount, OT3Mount],
3079
- z_distance: float,
3088
+ end_point: top_types.Point,
3080
3089
  volume: float,
3081
- flow_rate: float = 1.0,
3090
+ rate: float = 1.0,
3091
+ movement_delay: Optional[float] = None,
3082
3092
  ) -> None:
3083
3093
  """
3084
3094
  Aspirate a volume of liquid (in microliters/uL) while moving the z axis synchronously.
@@ -3086,20 +3096,33 @@ class OT3API(
3086
3096
  :param mount: A robot mount that the instrument is on.
3087
3097
  :param z_distance: The distance the z axis will move during apsiration.
3088
3098
  :param volume: The volume of liquid to be aspirated.
3089
- :param flow_rate: The flow rate to aspirate with.
3099
+ :param rate: The rate multiplier to aspirate with.
3100
+ :param movement_delay: Time to wait after the pipette starts aspirating before x/y/z movement.
3090
3101
  """
3091
3102
  realmount = OT3Mount.from_mount(mount)
3092
3103
  aspirate_spec = self._pipette_handler.plan_check_aspirate(
3093
- realmount, volume, flow_rate
3104
+ realmount, volume, rate
3094
3105
  )
3095
3106
  if not aspirate_spec:
3096
3107
  return
3108
+ end_position = target_position_from_absolute(
3109
+ realmount,
3110
+ end_point,
3111
+ self.critical_point_for,
3112
+ top_types.Point(*self._config.left_mount_offset),
3113
+ top_types.Point(*self._config.right_mount_offset),
3114
+ top_types.Point(*self._config.gripper_mount_offset),
3115
+ )
3116
+
3097
3117
  target_pos = target_positions_from_plunger_tracking(
3098
3118
  realmount,
3099
3119
  aspirate_spec.plunger_distance,
3100
- z_distance,
3101
- self._current_position,
3120
+ end_position,
3102
3121
  )
3122
+
3123
+ delay: Optional[Tuple[List[Axis], float]] = None
3124
+ if movement_delay is not None:
3125
+ delay = ([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R], movement_delay)
3103
3126
  try:
3104
3127
  await self._backend.set_active_current(
3105
3128
  {aspirate_spec.axis: aspirate_spec.current}
@@ -3112,7 +3135,13 @@ class OT3API(
3112
3135
  target_pos,
3113
3136
  speed=aspirate_spec.speed,
3114
3137
  home_flagged_axes=False,
3138
+ delay=delay,
3115
3139
  )
3140
+ except PipetteOverpressureError:
3141
+ self._log.exception("Aspirate failed with overpressure")
3142
+ # refresh positions during an over pressure here so we know where the gantry stopped.
3143
+ await self.refresh_positions()
3144
+ raise
3116
3145
  except Exception:
3117
3146
  self._log.exception("Aspirate failed")
3118
3147
  aspirate_spec.instr.set_current_volume(0)
@@ -3123,11 +3152,12 @@ class OT3API(
3123
3152
  async def dispense_while_tracking(
3124
3153
  self,
3125
3154
  mount: Union[top_types.Mount, OT3Mount],
3126
- z_distance: float,
3155
+ end_point: top_types.Point,
3127
3156
  volume: float,
3128
3157
  push_out: Optional[float],
3129
- flow_rate: float = 1.0,
3158
+ rate: float = 1.0,
3130
3159
  is_full_dispense: bool = False,
3160
+ movement_delay: Optional[float] = None,
3131
3161
  ) -> None:
3132
3162
  """
3133
3163
  Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
@@ -3135,21 +3165,34 @@ class OT3API(
3135
3165
  :param mount: A robot mount that the instrument is on.
3136
3166
  :param z_distance: The distance the z axis will move during dispensing.
3137
3167
  :param volume: The volume of liquid to be dispensed.
3138
- :param flow_rate: The flow rate to dispense with.
3168
+ :param rate: The rate multiplier to dispense with.
3169
+ :param movement_delay: Time to wait after the pipette starts dispensing before x/y/z movement.
3139
3170
  """
3140
3171
  realmount = OT3Mount.from_mount(mount)
3141
3172
  dispense_spec = self._pipette_handler.plan_check_dispense(
3142
- realmount, volume, flow_rate, push_out, is_full_dispense
3173
+ realmount, volume, rate, push_out, is_full_dispense
3143
3174
  )
3144
3175
  if not dispense_spec:
3145
3176
  return
3177
+ end_position = target_position_from_absolute(
3178
+ realmount,
3179
+ end_point,
3180
+ self.critical_point_for,
3181
+ top_types.Point(*self._config.left_mount_offset),
3182
+ top_types.Point(*self._config.right_mount_offset),
3183
+ top_types.Point(*self._config.gripper_mount_offset),
3184
+ )
3185
+
3146
3186
  target_pos = target_positions_from_plunger_tracking(
3147
3187
  realmount,
3148
3188
  dispense_spec.plunger_distance,
3149
- z_distance,
3150
- self._current_position,
3189
+ end_position,
3151
3190
  )
3152
3191
 
3192
+ delay: Optional[Tuple[List[Axis], float]] = None
3193
+ if movement_delay is not None:
3194
+ delay = ([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R], movement_delay)
3195
+
3153
3196
  try:
3154
3197
  await self._backend.set_active_current(
3155
3198
  {dispense_spec.axis: dispense_spec.current}
@@ -3162,7 +3205,13 @@ class OT3API(
3162
3205
  target_pos,
3163
3206
  speed=dispense_spec.speed,
3164
3207
  home_flagged_axes=False,
3208
+ delay=delay,
3165
3209
  )
3210
+ except PipetteOverpressureError:
3211
+ self._log.exception("Aspirate failed with overpressure")
3212
+ # refresh positions during an over pressure here so we know where the gantry stopped.
3213
+ await self.refresh_positions()
3214
+ raise
3166
3215
  except Exception:
3167
3216
  self._log.exception("dispense failed")
3168
3217
  dispense_spec.instr.set_current_volume(0)
@@ -41,6 +41,7 @@ class GripperController(Protocol):
41
41
  expected_grip_width: float,
42
42
  grip_width_uncertainty_wider: float,
43
43
  grip_width_uncertainty_narrower: float,
44
+ disable_geometry_grip_check: bool = False,
44
45
  ) -> None:
45
46
  """Ensure that a gripper pickup succeeded."""
46
47
 
@@ -125,9 +125,10 @@ class LiquidHandler(
125
125
  async def aspirate_while_tracking(
126
126
  self,
127
127
  mount: MountArgType,
128
- z_distance: float,
128
+ end_point: Point,
129
129
  volume: float,
130
130
  flow_rate: float = 1.0,
131
+ movement_delay: Optional[float] = None,
131
132
  ) -> None:
132
133
  """
133
134
  Aspirate a volume of liquid (in microliters/uL) while moving the z axis synchronously.
@@ -136,6 +137,7 @@ class LiquidHandler(
136
137
  :param z_distance: The distance the z axis will move during apsiration.
137
138
  :param volume: The volume of liquid to be aspirated.
138
139
  :param flow_rate: The flow rate to aspirate with.
140
+ :param movement_delay: Time to wait after the pipette starts aspirating before x/y/z movement.
139
141
  """
140
142
  ...
141
143
 
@@ -164,11 +166,12 @@ class LiquidHandler(
164
166
  async def dispense_while_tracking(
165
167
  self,
166
168
  mount: MountArgType,
167
- z_distance: float,
169
+ end_point: Point,
168
170
  volume: float,
169
171
  push_out: Optional[float],
170
172
  flow_rate: float = 1.0,
171
173
  is_full_dispense: bool = False,
174
+ movement_delay: Optional[float] = None,
172
175
  ) -> None:
173
176
  """
174
177
  Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
@@ -177,6 +180,7 @@ class LiquidHandler(
177
180
  :param z_distance: The distance the z axis will move during dispensing.
178
181
  :param volume: The volume of liquid to be dispensed.
179
182
  :param flow_rate: The flow rate to dispense with.
183
+ :param movement_delay: Time to wait after the pipette starts dispensing before x/y/z movement.
180
184
  """
181
185
  ...
182
186
 
@@ -399,6 +399,7 @@ class HardwareEventType(enum.Enum):
399
399
  ERROR_MESSAGE = enum.auto()
400
400
  ESTOP_CHANGE = enum.auto()
401
401
  ASYNCHRONOUS_MODULE_ERROR = enum.auto()
402
+ MODULE_DISCONNECTED = enum.auto()
402
403
 
403
404
 
404
405
  @dataclass
@@ -454,6 +455,16 @@ class AsynchronousModuleErrorNotification:
454
455
  ] = HardwareEventType.ASYNCHRONOUS_MODULE_ERROR
455
456
 
456
457
 
458
+ @dataclass(frozen=True)
459
+ class ModuleDisconnectedNotification:
460
+ module_serial: str | None
461
+ module_model: "ModuleModel"
462
+ port: str
463
+ event: Literal[
464
+ HardwareEventType.MODULE_DISCONNECTED
465
+ ] = HardwareEventType.MODULE_DISCONNECTED
466
+
467
+
457
468
  # new event types get new dataclasses
458
469
  # when we add more event types we add them here
459
470
  HardwareEvent = Union[
@@ -461,6 +472,7 @@ HardwareEvent = Union[
461
472
  ErrorMessageNotification,
462
473
  EstopStateNotification,
463
474
  AsynchronousModuleErrorNotification,
475
+ ModuleDisconnectedNotification,
464
476
  ]
465
477
 
466
478
  HardwareEventHandler = Callable[[HardwareEvent], None]
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING, List, Sequence, Union, overload
2
+ from typing import TYPE_CHECKING, List, Sequence, Union, overload, Optional
3
3
 
4
4
 
5
5
  from .helpers import (
@@ -31,10 +31,21 @@ def aspirate(
31
31
  location: Location,
32
32
  flow_rate: float,
33
33
  rate: float,
34
+ end_location: Optional[Location],
34
35
  ) -> command_types.AspirateCommand:
35
36
  location_text = stringify_location(location)
36
- template = "Aspirating {volume} uL from {location} at {flow} uL/sec"
37
- text = template.format(volume=float(volume), location=location_text, flow=flow_rate)
37
+ end_location_text = (
38
+ f" while moving to {stringify_location(end_location)}"
39
+ if end_location is not None
40
+ else ""
41
+ )
42
+ template = "Aspirating {volume} uL from {location} at {flow} uL/sec{end}"
43
+ text = template.format(
44
+ volume=float(volume),
45
+ location=location_text,
46
+ flow=flow_rate,
47
+ end=end_location_text,
48
+ )
38
49
 
39
50
  return {
40
51
  "name": command_types.ASPIRATE,
@@ -44,6 +55,7 @@ def aspirate(
44
55
  "location": location,
45
56
  "rate": rate,
46
57
  "text": text,
58
+ "end_location": end_location,
47
59
  },
48
60
  }
49
61
 
@@ -54,10 +66,21 @@ def dispense(
54
66
  location: Location,
55
67
  flow_rate: float,
56
68
  rate: float,
69
+ end_location: Optional[Location],
57
70
  ) -> command_types.DispenseCommand:
58
71
  location_text = stringify_location(location)
59
- template = "Dispensing {volume} uL into {location} at {flow} uL/sec"
60
- text = template.format(volume=float(volume), location=location_text, flow=flow_rate)
72
+ end_location_text = (
73
+ f" while moving to {stringify_location(end_location)}"
74
+ if end_location is not None
75
+ else ""
76
+ )
77
+ template = "Dispensing {volume} uL into {location} at {flow} uL/sec{end}"
78
+ text = template.format(
79
+ volume=float(volume),
80
+ location=location_text,
81
+ flow=flow_rate,
82
+ end=end_location_text,
83
+ )
61
84
 
62
85
  return {
63
86
  "name": command_types.DISPENSE,
@@ -67,6 +90,7 @@ def dispense(
67
90
  "location": location,
68
91
  "rate": rate,
69
92
  "text": text,
93
+ "end_location": end_location,
70
94
  },
71
95
  }
72
96
 
@@ -208,6 +232,35 @@ def mix(
208
232
  }
209
233
 
210
234
 
235
+ def dynamic_mix(
236
+ instrument: InstrumentContext,
237
+ repetitions: int,
238
+ volume: float,
239
+ aspirate_start_location: Location,
240
+ aspirate_end_location: Union[Location, None],
241
+ dispense_start_location: Location,
242
+ dispense_end_location: Union[Location, None],
243
+ movement_delay: float,
244
+ ) -> command_types.DynamicMixCommand:
245
+ text = "Dynamically mixing {repetitions} times with a volume of {volume} ul".format(
246
+ repetitions=repetitions, volume=float(volume)
247
+ )
248
+ return {
249
+ "name": command_types.MIX,
250
+ "payload": {
251
+ "instrument": instrument,
252
+ "aspirate_start_location": aspirate_start_location,
253
+ "aspirate_end_location": aspirate_end_location,
254
+ "dispense_start_location": dispense_start_location,
255
+ "dispense_end_location": dispense_end_location,
256
+ "volume": volume,
257
+ "repetitions": repetitions,
258
+ "text": text,
259
+ "movement_delay": movement_delay,
260
+ },
261
+ }
262
+
263
+
211
264
  def blow_out(
212
265
  instrument: InstrumentContext, location: Location
213
266
  ) -> command_types.BlowOutCommand: