opentrons 8.7.0a1__py3-none-any.whl → 8.7.0a2__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 (119) hide show
  1. opentrons/_version.py +2 -2
  2. opentrons/drivers/thermocycler/abstract.py +1 -0
  3. opentrons/drivers/thermocycler/driver.py +33 -4
  4. opentrons/drivers/thermocycler/simulator.py +2 -0
  5. opentrons/hardware_control/api.py +24 -5
  6. opentrons/hardware_control/backends/controller.py +8 -2
  7. opentrons/hardware_control/backends/ot3controller.py +3 -0
  8. opentrons/hardware_control/backends/ot3simulator.py +2 -1
  9. opentrons/hardware_control/backends/simulator.py +2 -1
  10. opentrons/hardware_control/backends/subsystem_manager.py +5 -2
  11. opentrons/hardware_control/module_control.py +82 -8
  12. opentrons/hardware_control/modules/__init__.py +3 -0
  13. opentrons/hardware_control/modules/absorbance_reader.py +11 -4
  14. opentrons/hardware_control/modules/flex_stacker.py +38 -9
  15. opentrons/hardware_control/modules/heater_shaker.py +30 -5
  16. opentrons/hardware_control/modules/magdeck.py +8 -4
  17. opentrons/hardware_control/modules/mod_abc.py +13 -5
  18. opentrons/hardware_control/modules/tempdeck.py +25 -5
  19. opentrons/hardware_control/modules/thermocycler.py +56 -10
  20. opentrons/hardware_control/modules/types.py +20 -1
  21. opentrons/hardware_control/modules/utils.py +11 -4
  22. opentrons/hardware_control/nozzle_manager.py +3 -0
  23. opentrons/hardware_control/ot3api.py +26 -5
  24. opentrons/hardware_control/scripts/update_module_fw.py +5 -0
  25. opentrons/hardware_control/types.py +31 -2
  26. opentrons/legacy_commands/protocol_commands.py +20 -0
  27. opentrons/legacy_commands/types.py +42 -0
  28. opentrons/motion_planning/waypoints.py +15 -29
  29. opentrons/protocol_api/__init__.py +5 -0
  30. opentrons/protocol_api/_types.py +6 -1
  31. opentrons/protocol_api/core/common.py +3 -1
  32. opentrons/protocol_api/core/engine/_default_labware_versions.py +32 -11
  33. opentrons/protocol_api/core/engine/labware.py +8 -1
  34. opentrons/protocol_api/core/engine/module_core.py +4 -0
  35. opentrons/protocol_api/core/engine/protocol.py +18 -1
  36. opentrons/protocol_api/core/engine/tasks.py +35 -0
  37. opentrons/protocol_api/core/legacy/legacy_module_core.py +2 -0
  38. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +11 -1
  39. opentrons/protocol_api/core/legacy/tasks.py +19 -0
  40. opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
  41. opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
  42. opentrons/protocol_api/core/module.py +1 -0
  43. opentrons/protocol_api/core/protocol.py +11 -2
  44. opentrons/protocol_api/core/tasks.py +31 -0
  45. opentrons/protocol_api/module_contexts.py +1 -0
  46. opentrons/protocol_api/protocol_context.py +26 -4
  47. opentrons/protocol_api/robot_context.py +38 -21
  48. opentrons/protocol_api/tasks.py +48 -0
  49. opentrons/protocol_api/validation.py +6 -1
  50. opentrons/protocol_engine/actions/__init__.py +4 -2
  51. opentrons/protocol_engine/actions/actions.py +22 -9
  52. opentrons/protocol_engine/clients/sync_client.py +6 -7
  53. opentrons/protocol_engine/commands/__init__.py +42 -0
  54. opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
  55. opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
  56. opentrons/protocol_engine/commands/aspirate.py +1 -0
  57. opentrons/protocol_engine/commands/command.py +1 -0
  58. opentrons/protocol_engine/commands/command_unions.py +39 -0
  59. opentrons/protocol_engine/commands/create_timer.py +83 -0
  60. opentrons/protocol_engine/commands/dispense.py +1 -0
  61. opentrons/protocol_engine/commands/drop_tip.py +32 -8
  62. opentrons/protocol_engine/commands/movement_common.py +2 -0
  63. opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
  64. opentrons/protocol_engine/commands/set_tip_state.py +97 -0
  65. opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
  66. opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
  67. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +17 -1
  68. opentrons/protocol_engine/commands/touch_tip.py +1 -1
  69. opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
  70. opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
  71. opentrons/protocol_engine/errors/__init__.py +4 -0
  72. opentrons/protocol_engine/errors/exceptions.py +55 -0
  73. opentrons/protocol_engine/execution/__init__.py +2 -0
  74. opentrons/protocol_engine/execution/command_executor.py +8 -0
  75. opentrons/protocol_engine/execution/create_queue_worker.py +5 -1
  76. opentrons/protocol_engine/execution/labware_movement.py +9 -12
  77. opentrons/protocol_engine/execution/movement.py +2 -0
  78. opentrons/protocol_engine/execution/queue_worker.py +4 -0
  79. opentrons/protocol_engine/execution/run_control.py +8 -0
  80. opentrons/protocol_engine/execution/task_handler.py +157 -0
  81. opentrons/protocol_engine/protocol_engine.py +67 -33
  82. opentrons/protocol_engine/resources/__init__.py +2 -0
  83. opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
  84. opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
  85. opentrons/protocol_engine/resources/labware_validation.py +10 -6
  86. opentrons/protocol_engine/state/_well_math.py +60 -18
  87. opentrons/protocol_engine/state/addressable_areas.py +2 -0
  88. opentrons/protocol_engine/state/commands.py +7 -7
  89. opentrons/protocol_engine/state/geometry.py +204 -374
  90. opentrons/protocol_engine/state/labware.py +52 -102
  91. opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
  92. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1331 -0
  93. opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
  94. opentrons/protocol_engine/state/modules.py +21 -8
  95. opentrons/protocol_engine/state/motion.py +44 -0
  96. opentrons/protocol_engine/state/state.py +14 -0
  97. opentrons/protocol_engine/state/state_summary.py +2 -0
  98. opentrons/protocol_engine/state/tasks.py +139 -0
  99. opentrons/protocol_engine/state/tips.py +177 -258
  100. opentrons/protocol_engine/state/update_types.py +16 -9
  101. opentrons/protocol_engine/types/__init__.py +9 -3
  102. opentrons/protocol_engine/types/deck_configuration.py +5 -1
  103. opentrons/protocol_engine/types/instrument.py +8 -1
  104. opentrons/protocol_engine/types/labware.py +1 -13
  105. opentrons/protocol_engine/types/module.py +10 -0
  106. opentrons/protocol_engine/types/tasks.py +38 -0
  107. opentrons/protocol_engine/types/tip.py +9 -0
  108. opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
  109. opentrons/protocol_runner/run_orchestrator.py +18 -2
  110. opentrons/protocols/api_support/definitions.py +1 -1
  111. opentrons/protocols/api_support/types.py +2 -1
  112. opentrons/simulate.py +48 -15
  113. opentrons/system/camera.py +1 -1
  114. {opentrons-8.7.0a1.dist-info → opentrons-8.7.0a2.dist-info}/METADATA +4 -4
  115. {opentrons-8.7.0a1.dist-info → opentrons-8.7.0a2.dist-info}/RECORD +118 -105
  116. opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
  117. {opentrons-8.7.0a1.dist-info → opentrons-8.7.0a2.dist-info}/WHEEL +0 -0
  118. {opentrons-8.7.0a1.dist-info → opentrons-8.7.0a2.dist-info}/entry_points.txt +0 -0
  119. {opentrons-8.7.0a1.dist-info → opentrons-8.7.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,98 @@
1
+ """WaitForTasks command request, result, and implementation models."""
2
+ from __future__ import annotations
3
+ from pydantic import BaseModel, Field
4
+ from typing import Optional, Type, TYPE_CHECKING
5
+ from typing_extensions import Literal
6
+
7
+ from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
8
+ from ..errors.error_occurrence import ErrorOccurrence, ProtocolCommandFailedError
9
+ from ..errors.exceptions import TaskFailedError
10
+
11
+ if TYPE_CHECKING:
12
+ from ..execution import TaskHandler, RunControlHandler
13
+ from ..state.state import StateView
14
+
15
+
16
+ WaitForTasksCommandType = Literal["waitForTasks"]
17
+
18
+
19
+ class WaitForTasksParams(BaseModel):
20
+ """Payload required to annotate execution with a WaitForTasks."""
21
+
22
+ task_ids: list[str] = Field(
23
+ ...,
24
+ description="The list of task ids to wait for.",
25
+ )
26
+
27
+
28
+ class WaitForTasksResult(BaseModel):
29
+ """Result data from the execution of a WaitForTasks command."""
30
+
31
+ task_ids: list[str] = Field(
32
+ ...,
33
+ description="The list of completed task ids.",
34
+ )
35
+
36
+
37
+ class WaitForTasksImplementation(
38
+ AbstractCommandImpl[WaitForTasksParams, SuccessData[WaitForTasksResult]]
39
+ ):
40
+ """WaitForTasks command implementation."""
41
+
42
+ def __init__(
43
+ self,
44
+ task_handler: TaskHandler,
45
+ run_control: RunControlHandler,
46
+ state_view: StateView,
47
+ **kwargs: object,
48
+ ) -> None:
49
+ self._task_handler = task_handler
50
+ self._run_control = run_control
51
+ self._state_view = state_view
52
+
53
+ async def execute(
54
+ self, params: WaitForTasksParams
55
+ ) -> SuccessData[WaitForTasksResult]:
56
+ """Checks for existance of task id and then asynchronously waits for the valid, specified tasks to finish."""
57
+ # Raises the exception if we don't have a valid task id.
58
+ for task_id in params.task_ids:
59
+ _ = self._state_view.tasks.get(task_id)
60
+
61
+ await self._run_control.wait_for_tasks(params.task_ids)
62
+
63
+ failed_tasks = self._state_view.tasks.get_failed_tasks(params.task_ids)
64
+ if failed_tasks:
65
+ raise TaskFailedError(
66
+ message=f"{len(failed_tasks)} tasks failed.",
67
+ details={"failed_task_ids": failed_tasks},
68
+ wrapping=[
69
+ ProtocolCommandFailedError(
70
+ original_error=self._state_view.tasks.get_finished(
71
+ task_id
72
+ ).error
73
+ )
74
+ for task_id in failed_tasks
75
+ ],
76
+ )
77
+ return SuccessData(public=WaitForTasksResult(task_ids=params.task_ids))
78
+
79
+
80
+ class WaitForTasks(
81
+ BaseCommand[WaitForTasksParams, WaitForTasksResult, ErrorOccurrence]
82
+ ):
83
+ """WaitForTasks command model."""
84
+
85
+ commandType: WaitForTasksCommandType = "waitForTasks"
86
+ params: WaitForTasksParams
87
+ result: Optional[WaitForTasksResult] = None
88
+
89
+ _ImplementationCls: Type[WaitForTasksImplementation] = WaitForTasksImplementation
90
+
91
+
92
+ class WaitForTasksCreate(BaseCommandCreate[WaitForTasksParams]):
93
+ """WaitForTasks command request model."""
94
+
95
+ commandType: WaitForTasksCommandType = "waitForTasks"
96
+ params: WaitForTasksParams
97
+
98
+ _CommandCls: Type[WaitForTasks] = WaitForTasks
@@ -57,6 +57,7 @@ from .exceptions import (
57
57
  InvalidTargetSpeedError,
58
58
  InvalidTargetTemperatureError,
59
59
  InvalidBlockVolumeError,
60
+ InvalidRampRateError,
60
61
  InvalidHoldTimeError,
61
62
  InvalidWavelengthError,
62
63
  CannotPerformModuleAction,
@@ -90,6 +91,7 @@ from .exceptions import (
90
91
  FlexStackerLabwarePoolNotYetDefinedError,
91
92
  FlexStackerNotLogicallyEmptyError,
92
93
  InvalidLabwarePositionError,
94
+ InvalidModuleOrientation,
93
95
  )
94
96
 
95
97
  from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError
@@ -151,6 +153,7 @@ __all__ = [
151
153
  "NoTargetTemperatureSetError",
152
154
  "InvalidTargetTemperatureError",
153
155
  "InvalidTargetSpeedError",
156
+ "InvalidRampRateError",
154
157
  "InvalidBlockVolumeError",
155
158
  "InvalidHoldTimeError",
156
159
  "InvalidLiquidError",
@@ -174,6 +177,7 @@ __all__ = [
174
177
  "FlexStackerLabwarePoolNotYetDefinedError",
175
178
  "FlexStackerNotLogicallyEmptyError",
176
179
  "InvalidLabwarePositionError",
180
+ "InvalidModuleOrientation",
177
181
  # error occurrence models
178
182
  "ErrorOccurrence",
179
183
  "CommandNotAllowedError",
@@ -413,6 +413,36 @@ class WellDoesNotExistError(ProtocolEngineError):
413
413
  super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
414
414
 
415
415
 
416
+ class NoTaskFoundError(ProtocolEngineError):
417
+ """Raised when referencing a task that does not exist.
418
+
419
+ This error could be raised if a protocol references a task before it
420
+ has been created.
421
+ """
422
+
423
+ def __init__(
424
+ self,
425
+ message: Optional[str] = None,
426
+ details: Optional[Dict[str, Any]] = None,
427
+ wrapping: Optional[Sequence[EnumeratedError]] = None,
428
+ ) -> None:
429
+ """Build a NoTaskFoundError."""
430
+ super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
431
+
432
+
433
+ class TaskFailedError(ProtocolEngineError):
434
+ """Raised when waiting on a task that failed."""
435
+
436
+ def __init__(
437
+ self,
438
+ message: Optional[str] = None,
439
+ details: Optional[Dict[str, Any]] = None,
440
+ wrapping: Optional[Sequence[EnumeratedError]] = None,
441
+ ) -> None:
442
+ """Build a TaskFailedError."""
443
+ super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
444
+
445
+
416
446
  class PipetteNotLoadedError(ProtocolEngineError):
417
447
  """Raised when referencing a pipette that has not been loaded."""
418
448
 
@@ -825,6 +855,19 @@ class InvalidTargetTemperatureError(ProtocolEngineError):
825
855
  super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
826
856
 
827
857
 
858
+ class InvalidRampRateError(ProtocolEngineError):
859
+ """Raised when attempting to set an invalid ramp rate."""
860
+
861
+ def __init__(
862
+ self,
863
+ message: Optional[str] = None,
864
+ details: Optional[Dict[str, Any]] = None,
865
+ wrapping: Optional[Sequence[EnumeratedError]] = None,
866
+ ) -> None:
867
+ """Build a InvalidRampRateError."""
868
+ super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
869
+
870
+
828
871
  class InvalidBlockVolumeError(ProtocolEngineError):
829
872
  """Raised when attempting to set an invalid block max volume."""
830
873
 
@@ -1306,3 +1349,15 @@ class InvalidLabwarePositionError(ProtocolEngineError):
1306
1349
  wrapping: Optional[Sequence[EnumeratedError]] = None,
1307
1350
  ) -> None:
1308
1351
  super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
1352
+
1353
+
1354
+ class InvalidModuleOrientation(ProtocolEngineError):
1355
+ """Raised when a module orientation is invalid for a slot id."""
1356
+
1357
+ def __init__(
1358
+ self,
1359
+ message: Optional[str] = None,
1360
+ details: Optional[dict[str, Any]] = None,
1361
+ wrapping: Optional[Sequence[EnumeratedError]] = None,
1362
+ ) -> None:
1363
+ super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
@@ -21,6 +21,7 @@ from .run_control import RunControlHandler
21
21
  from .hardware_stopper import HardwareStopper
22
22
  from .door_watcher import DoorWatcher
23
23
  from .status_bar import StatusBarHandler
24
+ from .task_handler import TaskHandler
24
25
  from ..resources.file_provider import FileProvider
25
26
 
26
27
  # .thermocycler_movement_flagger omitted from package's public interface.
@@ -46,5 +47,6 @@ __all__ = [
46
47
  "DoorWatcher",
47
48
  "RailLightsHandler",
48
49
  "StatusBarHandler",
50
+ "TaskHandler",
49
51
  "FileProvider",
50
52
  ]
@@ -35,6 +35,7 @@ from .tip_handler import TipHandler
35
35
  from .run_control import RunControlHandler
36
36
  from .rail_lights import RailLightsHandler
37
37
  from .status_bar import StatusBarHandler
38
+ from .task_handler import TaskHandler
38
39
 
39
40
 
40
41
  log = getLogger(__name__)
@@ -85,6 +86,7 @@ class CommandExecutor:
85
86
  run_control: RunControlHandler,
86
87
  rail_lights: RailLightsHandler,
87
88
  status_bar: StatusBarHandler,
89
+ task_handler: TaskHandler,
88
90
  model_utils: Optional[ModelUtils] = None,
89
91
  command_note_tracker_provider: Optional[CommandNoteTrackerProvider] = None,
90
92
  ) -> None:
@@ -106,6 +108,7 @@ class CommandExecutor:
106
108
  self._command_note_tracker_provider = (
107
109
  command_note_tracker_provider or _NoteTracker
108
110
  )
111
+ self._task_handler = task_handler
109
112
 
110
113
  async def execute(self, command_id: str) -> None:
111
114
  """Run a given command's execution procedure.
@@ -131,6 +134,7 @@ class CommandExecutor:
131
134
  model_utils=self._model_utils,
132
135
  status_bar=self._status_bar,
133
136
  command_note_adder=note_tracker,
137
+ task_handler=self._task_handler,
134
138
  )
135
139
 
136
140
  started_at = self._model_utils.get_timestamp()
@@ -214,3 +218,7 @@ class CommandExecutor:
214
218
  type=error_recovery_type,
215
219
  )
216
220
  )
221
+
222
+ def cancel_tasks(self, message: str | None = None) -> None:
223
+ """Cancel all concurrent tasks."""
224
+ self._task_handler.cancel_all(message=message)
@@ -17,6 +17,7 @@ from .run_control import RunControlHandler
17
17
  from .command_executor import CommandExecutor
18
18
  from .queue_worker import QueueWorker
19
19
  from .status_bar import StatusBarHandler
20
+ from .task_handler import TaskHandler
20
21
 
21
22
 
22
23
  def create_queue_worker(
@@ -76,7 +77,9 @@ def create_queue_worker(
76
77
  rail_lights_handler = RailLightsHandler(
77
78
  hardware_api=hardware_api,
78
79
  )
79
-
80
+ task_handler = TaskHandler(
81
+ state_store=state_store, action_dispatcher=action_dispatcher
82
+ )
80
83
  status_bar_handler = StatusBarHandler(hardware_api=hardware_api)
81
84
 
82
85
  command_executor = CommandExecutor(
@@ -93,6 +96,7 @@ def create_queue_worker(
93
96
  run_control=run_control_handler,
94
97
  rail_lights=rail_lights_handler,
95
98
  status_bar=status_bar_handler,
99
+ task_handler=task_handler,
96
100
  )
97
101
 
98
102
  return QueueWorker(
@@ -31,6 +31,7 @@ from ..types import (
31
31
  OnLabwareLocation,
32
32
  LabwareLocation,
33
33
  OnDeckLabwareLocation,
34
+ GripperMoveType,
34
35
  )
35
36
 
36
37
  if TYPE_CHECKING:
@@ -141,10 +142,16 @@ class LabwareMovementHandler:
141
142
  labware_definition = self._state_store.labware.get_definition(labware_id)
142
143
 
143
144
  from_labware_center = self._state_store.geometry.get_labware_grip_point(
144
- labware_definition=labware_definition, location=current_location
145
+ labware_definition=labware_definition,
146
+ location=current_location,
147
+ move_type=GripperMoveType.PICK_UP_LABWARE,
148
+ user_additional_offset=user_pick_up_offset,
145
149
  )
146
150
  to_labware_center = self._state_store.geometry.get_labware_grip_point(
147
- labware_definition=labware_definition, location=new_location
151
+ labware_definition=labware_definition,
152
+ location=new_location,
153
+ move_type=GripperMoveType.DROP_LABWARE,
154
+ user_additional_offset=user_drop_offset,
148
155
  )
149
156
 
150
157
  if use_virtual_gripper:
@@ -193,20 +200,10 @@ class LabwareMovementHandler:
193
200
  async with self._thermocycler_plate_lifter.lift_plate_for_labware_movement(
194
201
  labware_location=current_location
195
202
  ):
196
- final_offsets = (
197
- self._state_store.geometry.get_final_labware_movement_offset_vectors(
198
- from_location=current_location,
199
- to_location=new_location,
200
- additional_pick_up_offset=user_pick_up_offset,
201
- additional_drop_offset=user_drop_offset,
202
- current_labware=labware_definition,
203
- )
204
- )
205
203
  movement_waypoints = get_gripper_labware_movement_waypoints(
206
204
  from_labware_center=from_labware_center,
207
205
  to_labware_center=to_labware_center,
208
206
  gripper_home_z=gripper_homed_position.z,
209
- offset_data=final_offsets,
210
207
  post_drop_slide_offset=post_drop_slide_offset,
211
208
  gripper_home_z_offset=gripper_z_offset,
212
209
  )
@@ -83,6 +83,7 @@ class MovementHandler:
83
83
  minimum_z_height: Optional[float] = None,
84
84
  speed: Optional[float] = None,
85
85
  operation_volume: Optional[float] = None,
86
+ offset_pipette_for_reservoir_subwells: bool = False,
86
87
  ) -> Point:
87
88
  """Move to a specific well."""
88
89
  self._state_store.geometry.raise_if_labware_inaccessible_by_pipette(
@@ -143,6 +144,7 @@ class MovementHandler:
143
144
  force_direct=force_direct,
144
145
  minimum_z_height=minimum_z_height,
145
146
  operation_volume=operation_volume,
147
+ offset_pipette_for_reservoir_subwells=offset_pipette_for_reservoir_subwells,
146
148
  )
147
149
 
148
150
  speed = self._state_store.pipettes.get_movement_speed(
@@ -51,6 +51,7 @@ class QueueWorker:
51
51
  """
52
52
  if self._worker_task:
53
53
  self._worker_task.cancel()
54
+ self._command_executor.cancel_tasks("Engine cancelled")
54
55
 
55
56
  async def join(self) -> None:
56
57
  """Wait for the worker to finish, propagating any errors."""
@@ -65,7 +66,10 @@ class QueueWorker:
65
66
  pass
66
67
  except Exception as e:
67
68
  log.error("Unhandled exception in QueueWorker job", exc_info=e)
69
+ self._command_executor.cancel_tasks("Engine failed")
68
70
  raise e
71
+ else:
72
+ self._command_executor.cancel_tasks("Engine commands complete")
69
73
 
70
74
  async def _run_commands(self) -> None:
71
75
  async for command_id in self._command_generator():
@@ -31,3 +31,11 @@ class RunControlHandler:
31
31
  """Delay protocol execution for a duration."""
32
32
  if not self._state_store.config.ignore_pause:
33
33
  await asyncio.sleep(seconds)
34
+
35
+ async def wait_for_tasks(self, tasks: list[str]) -> None:
36
+ """Wait for concurrent tasks to complete."""
37
+ await self._state_store.wait_for(
38
+ condition=lambda: self._state_store.tasks.all_tasks_finished_or_any_task_failed(
39
+ task_ids=tasks
40
+ )
41
+ )
@@ -0,0 +1,157 @@
1
+ """Task handling."""
2
+
3
+ from __future__ import annotations
4
+ import logging
5
+ from typing import Protocol, AsyncIterator
6
+ from ..state.state import StateStore
7
+ from ..resources import ModelUtils, ConcurrencyProvider
8
+ from ..types import Task
9
+ import asyncio
10
+ import contextlib
11
+ from ..actions import ActionDispatcher, FinishTaskAction, StartTaskAction
12
+ from ..errors import ErrorOccurrence
13
+ from opentrons_shared_data.errors.exceptions import EnumeratedError, PythonException
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class TaskFunction(Protocol):
19
+ """The function run inside a task protocol."""
20
+
21
+ async def __call__(self, task_handler: TaskHandler) -> None:
22
+ """The function called inside a task."""
23
+ ...
24
+
25
+
26
+ class TaskHandler:
27
+ """Implementation logic for fast concurrency."""
28
+
29
+ _state_store: StateStore
30
+ _model_utils: ModelUtils
31
+ _concurrency_provider: ConcurrencyProvider
32
+
33
+ def __init__(
34
+ self,
35
+ state_store: StateStore,
36
+ action_dispatcher: ActionDispatcher,
37
+ model_utils: ModelUtils | None = None,
38
+ concurrency_provider: ConcurrencyProvider | None = None,
39
+ ) -> None:
40
+ """Initialize a TaskHandler instance."""
41
+ self._state_store = state_store
42
+ self._model_utils = model_utils or ModelUtils()
43
+ self._concurrency_provider = concurrency_provider or ConcurrencyProvider()
44
+ self._action_dispatcher = action_dispatcher
45
+
46
+ async def create_task(
47
+ self, task_function: TaskFunction, id: str | None = None
48
+ ) -> Task:
49
+ """Create a task and immediately schedules it."""
50
+ task_id = self._model_utils.ensure_id(id)
51
+ asyncio_task = asyncio.create_task(
52
+ task_function(task_handler=self), name=f"engine-task-{task_id}"
53
+ )
54
+
55
+ def _done_callback(task: asyncio.Task[None]) -> None:
56
+ try:
57
+ maybe_exception = task.exception()
58
+ except asyncio.CancelledError as e:
59
+ maybe_exception = e
60
+ if isinstance(maybe_exception, EnumeratedError):
61
+ occurence: ErrorOccurrence | None = ErrorOccurrence.from_failed(
62
+ id=self._model_utils.generate_id(),
63
+ createdAt=self._model_utils.get_timestamp(),
64
+ error=maybe_exception,
65
+ )
66
+ elif isinstance(maybe_exception, BaseException):
67
+ occurence = ErrorOccurrence.from_failed(
68
+ id=self._model_utils.generate_id(),
69
+ createdAt=self._model_utils.get_timestamp(),
70
+ error=PythonException(maybe_exception),
71
+ )
72
+ else:
73
+ occurence = None
74
+ try:
75
+ self._action_dispatcher.dispatch(
76
+ FinishTaskAction(
77
+ task_id=task_id,
78
+ finished_at=self._model_utils.get_timestamp(),
79
+ error=occurence,
80
+ ),
81
+ )
82
+ except BaseException:
83
+ log.exception("Exception in task finish dispatch.")
84
+
85
+ asyncio_task.add_done_callback(_done_callback)
86
+ task = Task(
87
+ id=task_id,
88
+ createdAt=self._model_utils.get_timestamp(),
89
+ asyncioTask=asyncio_task,
90
+ )
91
+ self._action_dispatcher.dispatch(StartTaskAction(task))
92
+ return task
93
+
94
+ @staticmethod
95
+ def _empty_queue(
96
+ queue: "asyncio.Queue[asyncio.Task[None]]", this_task: asyncio.Task[None]
97
+ ) -> None:
98
+ """Empties the queue."""
99
+ try:
100
+ while True:
101
+ task = queue.get_nowait()
102
+ if task is this_task:
103
+ break
104
+ except asyncio.QueueEmpty:
105
+ pass
106
+
107
+ @contextlib.asynccontextmanager
108
+ async def synchronize_cancel_latest(self, group_id: str) -> AsyncIterator[None]:
109
+ """Cancel current task."""
110
+ lock = self._concurrency_provider.lock_for_group(group_id)
111
+ if lock.locked():
112
+ raise asyncio.CancelledError()
113
+ async with lock:
114
+ yield
115
+
116
+ @contextlib.asynccontextmanager
117
+ async def synchronize_cancel_previous(self, group_id: str) -> AsyncIterator[None]:
118
+ """Cancel previous run."""
119
+ queue = self._concurrency_provider.queue_for_group(group_id)
120
+ while not queue.empty():
121
+ task = queue.get_nowait()
122
+ task.cancel()
123
+ this_task = asyncio.current_task()
124
+ assert this_task is not None
125
+ queue.put_nowait(this_task)
126
+ try:
127
+ yield
128
+ except asyncio.CancelledError:
129
+ raise
130
+ except BaseException:
131
+ self._empty_queue(queue, this_task)
132
+ raise
133
+ else:
134
+ self._empty_queue(queue, this_task)
135
+
136
+ @contextlib.asynccontextmanager
137
+ async def synchronize_sequential(self, group_id: str) -> AsyncIterator[None]:
138
+ """Run tasks one after the other."""
139
+ lock = self._concurrency_provider.lock_for_group(group_id)
140
+ async with lock:
141
+ yield
142
+
143
+ @contextlib.asynccontextmanager
144
+ async def synchronize_concurrent(self, group_id: str) -> AsyncIterator[None]:
145
+ """Run a list of tasks at the same time."""
146
+ yield
147
+
148
+ def cancel_all(self, message: str | None = None) -> None:
149
+ """Cancel all asyncio tasks immediately.
150
+
151
+ Do not call this more than once synchronously because
152
+ that could lead to tasks cancelling more than once.
153
+ It can be called if there are no current tasks. In that case
154
+ nothing will happen.
155
+ """
156
+ for task in self._state_store.tasks.get_all_current():
157
+ task.asyncioTask.cancel(msg=message)
@@ -59,7 +59,6 @@ from .actions import (
59
59
  AddAddressableAreaAction,
60
60
  AddModuleAction,
61
61
  HardwareStoppedAction,
62
- ResetTipsAction,
63
62
  SetPipetteMovementSpeedAction,
64
63
  )
65
64
 
@@ -322,24 +321,10 @@ class ProtocolEngine:
322
321
  )
323
322
  return completed_command
324
323
 
325
- def estop(self) -> None:
326
- """Signal to the engine that an E-stop event occurred.
327
-
328
- If an estop happens while the robot is moving, lower layers physically stop
329
- motion and raise the event as an exception, which fails the Protocol Engine
330
- command. No action from the `ProtocolEngine` caller is needed to handle that.
331
-
332
- However, if an estop happens in between commands, or in the middle of
333
- a command like `comment` or `waitForDuration` that doesn't access the hardware,
334
- `ProtocolEngine` needs to be told about it so it can interrupt the command
335
- and stop executing any more. This method is how to do that.
336
-
337
- This acts roughly like `request_stop()`. After calling this, you should call
338
- `finish()` with an EStopActivatedError.
339
- """
324
+ def _stop_from_asynchronous_error(self) -> None:
340
325
  try:
341
326
  action = self._state_store.commands.validate_action_allowed(
342
- StopAction(from_estop=True)
327
+ StopAction(from_asynchronous_error=True)
343
328
  )
344
329
  except Exception: # todo(mm, 2024-04-16): Catch a more specific type.
345
330
  # This is likely called from some hardware API callback that doesn't care
@@ -358,9 +343,72 @@ class ProtocolEngine:
358
343
  # do this because we want to make sure non-hardware commands, like
359
344
  # `waitForDuration`, are also interrupted.
360
345
  self._get_queue_worker.cancel()
346
+
347
+ def estop(self) -> None:
348
+ """Signal to the engine that an E-stop event occurred.
349
+
350
+ If an estop happens while the robot is moving, lower layers physically stop
351
+ motion and raise the event as an exception, which fails the Protocol Engine
352
+ command. No action from the `ProtocolEngine` caller is needed to handle that.
353
+
354
+ However, if an estop happens in between commands, or in the middle of
355
+ a command like `comment` or `waitForDuration` that doesn't access the hardware,
356
+ `ProtocolEngine` needs to be told about it so it can interrupt the command
357
+ and stop executing any more. This method is how to do that.
358
+
359
+ This acts roughly like `request_stop()`. After calling this, you should call
360
+ `finish()` with an EStopActivatedError.
361
+ """
361
362
  # Unlike self.request_stop(), we don't need to do
362
363
  # self._hardware_api.cancel_execution_and_running_tasks(). Since this was an
363
364
  # E-stop event, the hardware API already knows.
365
+ self._stop_from_asynchronous_error()
366
+
367
+ async def async_module_error(
368
+ self, module_model: ModuleModel, serial: str | None
369
+ ) -> bool:
370
+ """Signal to the engine that an asynchronous module error occured.
371
+
372
+ The return value of this function signals whether the error is relevant to the protocol
373
+ or not. If the function returns True, the error is relevant. The engine will stop, and
374
+ the caller should call `finish()` with the error object that signaled the error. If
375
+ the function returns False, the error is not relevant. The engine will not stop, and the
376
+ caller should not call `finish()`.
377
+
378
+ Asynchronous module errors are signaled when a module enters a hardware error state
379
+ - for instance, a thermocycler's thermistors fail because of condensation, or a
380
+ heater-shaker's wires fray and snap, or a module is accidentally disconnected. These
381
+ errors are not related to a particular command, even a currently-happening module
382
+ control command for the module in the error state.
383
+
384
+ Similar to an estop error, the error can occur at any time relative to the lifecycle
385
+ of the engine run or of any particular command.
386
+
387
+ Unlike an estop, the motion control hardware will not be raising an error and will not
388
+ stop on its own; the stop action derived from this call will do that.
389
+ """
390
+ if not self._state_store.modules.get_has_module_probably_matching_hardware_details(
391
+ module_model, serial
392
+ ):
393
+ return False
394
+ self._stop_from_asynchronous_error()
395
+ # like self.request_stop, and unlike self.estop(), we must explicitly request that the
396
+ # hardware stops execution, since not all asynchronous errors will cause the hardware
397
+ # to know that it should stop.
398
+ await self._do_hardware_stop()
399
+ return True
400
+
401
+ async def _do_hardware_stop(self) -> None:
402
+ """Make the hardware stop now."""
403
+ if self._hardware_api.is_movement_execution_taskified():
404
+ # We 'taskify' hardware controller movement functions when running protocols
405
+ # that are not backed by the engine. Such runs cannot be stopped by cancelling
406
+ # the queue worker and hence need to be stopped via the execution manager.
407
+ # `cancel_execution_and_running_tasks()` sets the execution manager in a CANCELLED state
408
+ # and cancels the running tasks, which raises an error and gets us out of the
409
+ # run function execution, just like `_queue_worker.cancel()` does for
410
+ # engine-backed runs.
411
+ await self._hardware_api.cancel_execution_and_running_tasks()
364
412
 
365
413
  async def request_stop(self) -> None:
366
414
  """Make command execution stop soon.
@@ -378,15 +426,7 @@ class ProtocolEngine:
378
426
  action = self._state_store.commands.validate_action_allowed(StopAction())
379
427
  self._action_dispatcher.dispatch(action)
380
428
  self._get_queue_worker.cancel()
381
- if self._hardware_api.is_movement_execution_taskified():
382
- # We 'taskify' hardware controller movement functions when running protocols
383
- # that are not backed by the engine. Such runs cannot be stopped by cancelling
384
- # the queue worker and hence need to be stopped via the execution manager.
385
- # `cancel_execution_and_running_tasks()` sets the execution manager in a CANCELLED state
386
- # and cancels the running tasks, which raises an error and gets us out of the
387
- # run function execution, just like `_queue_worker.cancel()` does for
388
- # engine-backed runs.
389
- await self._hardware_api.cancel_execution_and_running_tasks()
429
+ await self._do_hardware_stop()
390
430
 
391
431
  async def wait_until_complete(self) -> None:
392
432
  """Wait until there are no more commands to execute.
@@ -429,7 +469,7 @@ class ProtocolEngine:
429
469
  post_run_hardware_state: The state in which to leave the gantry and motors in
430
470
  after the run is over.
431
471
  """
432
- if self._state_store.commands.get_is_stopped_by_estop():
472
+ if self._state_store.commands.get_is_stopped_by_async_error():
433
473
  # This handles the case where the E-stop was pressed while we were *not* in the middle
434
474
  # of some hardware interaction that would raise it as an exception. For example, imagine
435
475
  # we were paused between two commands, or imagine we were executing a waitForDuration.
@@ -586,12 +626,6 @@ class ProtocolEngine:
586
626
  AddAddressableAreaAction(addressable_area_name)
587
627
  )
588
628
 
589
- def reset_tips(self, labware_id: str) -> None:
590
- """Reset the tip state of a given labware."""
591
- # TODO(mm, 2023-03-10): Safely raise an error if the given labware isn't a
592
- # tip rack?
593
- self._action_dispatcher.dispatch(ResetTipsAction(labware_id=labware_id))
594
-
595
629
  # TODO(mm, 2022-11-10): This is a method on ProtocolEngine instead of a command
596
630
  # as a quick hack to support Python protocols. We should consider making this a
597
631
  # command, or adding speed parameters to existing commands.