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.
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
@@ -13,7 +13,7 @@ from .pipetting_common import (
13
13
  aspirate_while_tracking,
14
14
  )
15
15
  from .movement_common import (
16
- LiquidHandlingWellLocationMixin,
16
+ DynamicLiquidHandlingWellLocationMixin,
17
17
  DestinationPositionResult,
18
18
  StallOrCollisionError,
19
19
  move_to_well,
@@ -45,7 +45,7 @@ class AspirateWhileTrackingParams(
45
45
  PipetteIdMixin,
46
46
  AspirateVolumeMixin,
47
47
  FlowRateMixin,
48
- LiquidHandlingWellLocationMixin,
48
+ DynamicLiquidHandlingWellLocationMixin,
49
49
  ):
50
50
  """Parameters required to aspirate from a specific well."""
51
51
 
@@ -107,14 +107,20 @@ class AspirateWhileTrackingImplementation(
107
107
  )
108
108
  state_update = StateUpdate()
109
109
 
110
+ end_point = self._state_view.geometry.get_well_position(
111
+ labware_id=params.labwareId,
112
+ well_name=params.wellName,
113
+ well_location=params.trackToLocation,
114
+ operation_volume=-params.volume,
115
+ pipette_id=params.pipetteId,
116
+ )
110
117
  move_result = await move_to_well(
111
118
  movement=self._movement,
112
119
  model_utils=self._model_utils,
113
120
  pipette_id=params.pipetteId,
114
121
  labware_id=params.labwareId,
115
122
  well_name=params.wellName,
116
- well_location=params.wellLocation,
117
- operation_volume=-params.volume,
123
+ well_location=params.trackFromLocation,
118
124
  )
119
125
  state_update.append(move_result.state_update)
120
126
  if isinstance(move_result, DefinedErrorData):
@@ -128,6 +134,7 @@ class AspirateWhileTrackingImplementation(
128
134
  well_name=params.wellName,
129
135
  volume=params.volume,
130
136
  flow_rate=params.flowRate,
137
+ end_point=end_point,
131
138
  location_if_error={
132
139
  "retryLocation": (
133
140
  move_result.public.position.x,
@@ -138,7 +145,48 @@ class AspirateWhileTrackingImplementation(
138
145
  command_note_adder=self._command_note_adder,
139
146
  pipetting=self._pipetting,
140
147
  model_utils=self._model_utils,
148
+ movement_delay=params.movement_delay,
141
149
  )
150
+ if isinstance(aspirate_result, DefinedErrorData):
151
+ if isinstance(aspirate_result.public, OverpressureError):
152
+ return DefinedErrorData(
153
+ public=OverpressureError(
154
+ id=aspirate_result.public.id,
155
+ createdAt=aspirate_result.public.createdAt,
156
+ wrappedErrors=aspirate_result.public.wrappedErrors,
157
+ errorInfo=aspirate_result.public.errorInfo,
158
+ ),
159
+ state_update=aspirate_result.state_update.set_liquid_operated(
160
+ labware_id=params.labwareId,
161
+ well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well(
162
+ params.labwareId,
163
+ params.wellName,
164
+ params.pipetteId,
165
+ ),
166
+ volume_added=CLEAR,
167
+ ),
168
+ state_update_if_false_positive=aspirate_result.state_update_if_false_positive,
169
+ )
170
+ elif isinstance(aspirate_result.public, StallOrCollisionError):
171
+ return DefinedErrorData(
172
+ public=StallOrCollisionError(
173
+ id=aspirate_result.public.id,
174
+ createdAt=aspirate_result.public.createdAt,
175
+ wrappedErrors=aspirate_result.public.wrappedErrors,
176
+ errorInfo=aspirate_result.public.errorInfo,
177
+ ),
178
+ state_update=aspirate_result.state_update.set_liquid_operated(
179
+ labware_id=params.labwareId,
180
+ well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well(
181
+ params.labwareId,
182
+ params.wellName,
183
+ params.pipetteId,
184
+ ),
185
+ volume_added=CLEAR,
186
+ ),
187
+ state_update_if_false_positive=aspirate_result.state_update_if_false_positive,
188
+ )
189
+
142
190
  position_after_aspirate = await self._gantry_mover.get_position(
143
191
  params.pipetteId
144
192
  )
@@ -147,21 +195,6 @@ class AspirateWhileTrackingImplementation(
147
195
  y=position_after_aspirate.y,
148
196
  z=position_after_aspirate.z,
149
197
  )
150
- if isinstance(aspirate_result, DefinedErrorData):
151
- return DefinedErrorData(
152
- public=aspirate_result.public,
153
- state_update=aspirate_result.state_update.set_liquid_operated(
154
- labware_id=params.labwareId,
155
- well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well(
156
- params.labwareId,
157
- params.wellName,
158
- params.pipetteId,
159
- ),
160
- volume_added=CLEAR,
161
- ),
162
- state_update_if_false_positive=aspirate_result.state_update_if_false_positive,
163
- )
164
-
165
198
  return SuccessData(
166
199
  public=AspirateWhileTrackingResult(
167
200
  volume=aspirate_result.public.volume,
@@ -0,0 +1,302 @@
1
+ """Command models to capture an image with a camera."""
2
+ from __future__ import annotations
3
+ from typing import Optional, TYPE_CHECKING, Tuple, Any
4
+ from datetime import datetime
5
+
6
+ from typing_extensions import Literal, Type
7
+ from pydantic import BaseModel, Field
8
+ from pydantic.json_schema import SkipJsonSchema
9
+
10
+ from opentrons_shared_data.data_files import MimeType
11
+ from opentrons.system.camera import (
12
+ CONTRAST_DEFAULT,
13
+ BRIGHTNESS_DEFAULT,
14
+ SATURATION_DEFAULT,
15
+ ZOOM_MIN,
16
+ ZOOM_MAX,
17
+ ZOOM_DEFAULT,
18
+ RESOLUTION_MIN,
19
+ RESOLUTION_MAX,
20
+ RESOLUTION_DEFAULT,
21
+ )
22
+ from ..types import PreconditionTypes
23
+ from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
24
+ from ..errors import (
25
+ CameraDisabledError,
26
+ CameraSettingsInvalidError,
27
+ FileNameInvalidError,
28
+ )
29
+ from ..errors.error_occurrence import ErrorOccurrence
30
+
31
+ from ..resources.file_provider import (
32
+ ImageCaptureCmdFileNameMetadata,
33
+ )
34
+ from ..resources import FileProvider
35
+ from ..resources.file_provider import SPECIAL_CHARACTERS
36
+ from ..resources import CameraProvider
37
+ from ..resources.camera_provider import ImageParameters
38
+ from ..state import update_types
39
+
40
+ if TYPE_CHECKING:
41
+ from opentrons.protocol_engine.state.state import StateView
42
+
43
+
44
+ def _remove_default(s: dict[str, Any]) -> None:
45
+ s.pop("default", None)
46
+
47
+
48
+ CaptureImageCommandType = Literal["captureImage"]
49
+
50
+
51
+ class CaptureImageParams(BaseModel):
52
+ """Input parameters for an image capture."""
53
+
54
+ fileName: str | SkipJsonSchema[None] = Field(
55
+ None,
56
+ description="Optional file name to use when storing the results of an Image Capture.",
57
+ json_schema_extra=_remove_default,
58
+ )
59
+ resolution: Optional[Tuple[int, int]] = Field(
60
+ None,
61
+ description="Width by height resolution in pixels for the image to be captured with.",
62
+ )
63
+ zoom: Optional[float] = Field(
64
+ None,
65
+ description="Multiplier to use when cropping and scaling a captured image. Scale is 1.0 to 2.0.",
66
+ )
67
+ pan: Optional[Tuple[int, int]] = Field(
68
+ None,
69
+ description="X/Y (pixels) position to pan to for a given zoom. Default is the center of the image.",
70
+ )
71
+ contrast: Optional[float] = Field(
72
+ None,
73
+ description="The contrast to use when processing an image. Scale is 0% to 100%.",
74
+ )
75
+ brightness: Optional[float] = Field(
76
+ None,
77
+ description="The brightness to use when processing an image. Scale is 0% to 100%.",
78
+ )
79
+ saturation: Optional[float] = Field(
80
+ None,
81
+ description="The saturation to use when processing an image. Scale is 0% to 100%.",
82
+ )
83
+
84
+
85
+ class CaptureImageResult(BaseModel):
86
+ """Result data from running an image capture."""
87
+
88
+ fileId: Optional[str] = Field(
89
+ None,
90
+ description="File ID for image files output as a result of an image capture action.",
91
+ )
92
+ resolution: Tuple[int, int] = Field(
93
+ ...,
94
+ description="Width by height resolution in pixels the image was captured with.",
95
+ )
96
+ zoom: float = Field(
97
+ ...,
98
+ description="Multiplier used when cropping and scaling the captured image. Scale is 1.0 to 2.0.",
99
+ )
100
+ pan: Optional[Tuple[int, int]] = Field(
101
+ None,
102
+ description="X/Y (pixels) position panned to.",
103
+ )
104
+ contrast: float = Field(
105
+ ...,
106
+ description="The contrast used when processing the image. Scale is 0% to 100%.",
107
+ )
108
+ brightness: float = Field(
109
+ ...,
110
+ description="The brightness used when processing the image. Scale is 0% to 100%.",
111
+ )
112
+ saturation: float = Field(
113
+ ...,
114
+ description="The saturation used when processing the image. Scale is 0% to 100%.",
115
+ )
116
+
117
+
118
+ def _converted_image_params(params: CaptureImageParams) -> ImageParameters:
119
+ return ImageParameters(
120
+ resolution=params.resolution,
121
+ zoom=params.zoom,
122
+ pan=params.pan,
123
+ contrast=(
124
+ (params.contrast / 100) * 2.0 if params.contrast is not None else None
125
+ ),
126
+ brightness=(
127
+ int(((params.brightness * 256) // 100) - 128) * -1
128
+ if params.brightness is not None
129
+ else None
130
+ ),
131
+ saturation=(
132
+ (params.saturation / 100) * 2.0 if params.saturation is not None else None
133
+ ),
134
+ )
135
+
136
+
137
+ def _revert_image_parameters(
138
+ file_id: Optional[str], image_params: ImageParameters
139
+ ) -> CaptureImageResult:
140
+ contrast = (
141
+ image_params.contrast if image_params.contrast is not None else CONTRAST_DEFAULT
142
+ )
143
+ brightness = (
144
+ image_params.brightness
145
+ if image_params.brightness is not None
146
+ else BRIGHTNESS_DEFAULT
147
+ )
148
+ saturation = (
149
+ image_params.saturation
150
+ if image_params.saturation is not None
151
+ else SATURATION_DEFAULT
152
+ )
153
+ # todo (chb, 2025-10-29): Eventually we will have override defaults that can be passed into the Camera state, load those if they exist
154
+
155
+ return CaptureImageResult(
156
+ fileId=file_id,
157
+ resolution=image_params.resolution
158
+ if image_params.resolution is not None
159
+ else RESOLUTION_DEFAULT,
160
+ zoom=image_params.zoom if image_params.zoom is not None else ZOOM_DEFAULT,
161
+ pan=image_params.pan,
162
+ contrast=(contrast / 2) * 100.0,
163
+ brightness=round((((brightness * -1) + 128) * 100) / 256),
164
+ saturation=(saturation / 2) * 100.0,
165
+ )
166
+
167
+
168
+ def _validate_image_params(params: CaptureImageParams) -> None:
169
+ # Validate the filename param provided to fail analysis
170
+ if params.fileName is not None and set(SPECIAL_CHARACTERS).intersection(
171
+ set(params.fileName)
172
+ ):
173
+ raise FileNameInvalidError(
174
+ message=f"Capture image filename cannot contain character(s): {SPECIAL_CHARACTERS.intersection(set(params.fileName))}"
175
+ )
176
+
177
+ # Validate the image filter parameters
178
+ if params.zoom is not None and (params.zoom < ZOOM_MIN or params.zoom > ZOOM_MAX):
179
+ raise CameraSettingsInvalidError(
180
+ message="Capture image zoom must be a valid value from 1.0X to 2.0X zoom."
181
+ )
182
+ if params.resolution is not None and (
183
+ params.resolution[0] < RESOLUTION_MIN[0]
184
+ or params.resolution[1] < RESOLUTION_MIN[1]
185
+ or params.resolution[0] > RESOLUTION_MAX[0]
186
+ or params.resolution[1] > RESOLUTION_MAX[1]
187
+ ):
188
+ raise CameraSettingsInvalidError(
189
+ message="Capture image resolution must be a valid resolution from 240p through 8K resolutuon."
190
+ )
191
+ if params.brightness is not None and (
192
+ params.brightness < 0 or params.brightness > 100
193
+ ):
194
+ raise CameraSettingsInvalidError(
195
+ message="Capture image brightness must be a percentage from 0% to 100%."
196
+ )
197
+ if params.contrast is not None and (params.contrast < 0 or params.contrast > 100):
198
+ raise CameraSettingsInvalidError(
199
+ message="Capture image contrast must be a percentage from 0% to 100%."
200
+ )
201
+ if params.saturation is not None and (
202
+ params.saturation < 0 or params.saturation > 100
203
+ ):
204
+ raise CameraSettingsInvalidError(
205
+ message="Capture image saturation must be a percentage from 0% to 100%."
206
+ )
207
+
208
+
209
+ class CaptureImageImpl(
210
+ AbstractCommandImpl[CaptureImageParams, SuccessData[CaptureImageResult]]
211
+ ):
212
+ """Execution implementation of an image capture."""
213
+
214
+ def __init__(
215
+ self,
216
+ state_view: StateView,
217
+ file_provider: FileProvider,
218
+ camera_provider: CameraProvider,
219
+ **unused_dependencies: object,
220
+ ) -> None:
221
+ self._state_view = state_view
222
+ self._file_provider = file_provider
223
+ self._camera_provider = camera_provider
224
+
225
+ async def execute(
226
+ self, params: CaptureImageParams
227
+ ) -> SuccessData[CaptureImageResult]:
228
+ """Initiate an image capture with a camera."""
229
+ state_update = update_types.StateUpdate()
230
+ state_update.precondition_update = update_types.PreconditionUpdate(
231
+ {PreconditionTypes.IS_CAMERA_USED: True}
232
+ )
233
+
234
+ # Validate that the provided parameters are all acceptable. We do this here and in system/camera.py to ensure analysis fails properly.
235
+ _validate_image_params(params)
236
+
237
+ # Handle capturing an image with the CameraProvider - Engine camera settings take priority
238
+ camera_settings = await self._camera_provider.get_camera_settings()
239
+ engine_camera_settings = self._state_view.camera.get_enablement_settings()
240
+ if (
241
+ engine_camera_settings is None and camera_settings.cameraEnabled is False
242
+ ) or (
243
+ engine_camera_settings is not None
244
+ and engine_camera_settings.cameraEnabled is False
245
+ ):
246
+ raise CameraDisabledError(
247
+ "Cannot capture image because Camera is disabled."
248
+ )
249
+
250
+ parameters = _converted_image_params(params=params)
251
+ camera_data = await self._camera_provider.capture_image(
252
+ self._state_view.config.robot_type, parameters
253
+ )
254
+
255
+ # Conditionally save file if camera data was returned - in simulation we don't return anything.
256
+ file_id: str | None = None
257
+ if camera_data:
258
+ this_cmd_id = self._state_view.commands.get_running_command_id()
259
+ prev_cmd = self._state_view.commands.get_most_recently_finalized_command()
260
+ prev_cmd_id = prev_cmd.command.id if prev_cmd is not None else None
261
+
262
+ file_info = await self._file_provider.write_file(
263
+ data=camera_data,
264
+ mime_type=MimeType.IMAGE_JPEG,
265
+ command_metadata=ImageCaptureCmdFileNameMetadata(
266
+ step_number=len(self._state_view.commands.get_all()),
267
+ command_timestamp=datetime.now(),
268
+ base_filename=params.fileName,
269
+ command_id=this_cmd_id or "",
270
+ prev_command_id=prev_cmd_id or "",
271
+ ),
272
+ )
273
+ file_id = file_info.id
274
+ state_update.files_added = update_types.FilesAddedUpdate(file_ids=[file_id])
275
+
276
+ result = _revert_image_parameters(file_id=file_id, image_params=parameters)
277
+
278
+ return SuccessData(
279
+ public=result,
280
+ state_update=state_update,
281
+ )
282
+
283
+
284
+ class CaptureImage(
285
+ BaseCommand[CaptureImageParams, CaptureImageResult, ErrorOccurrence]
286
+ ):
287
+ """A command to execute an Absorbance Reader measurement."""
288
+
289
+ commandType: CaptureImageCommandType = "captureImage"
290
+ params: CaptureImageParams
291
+ result: Optional[CaptureImageResult] = None
292
+
293
+ _ImplementationCls: Type[CaptureImageImpl] = CaptureImageImpl
294
+
295
+
296
+ class CaptureImageCreate(BaseCommandCreate[CaptureImageParams]):
297
+ """A request to execute an Absorbance Reader measurement."""
298
+
299
+ commandType: CaptureImageCommandType = "captureImage"
300
+ params: CaptureImageParams
301
+
302
+ _CommandCls: Type[CaptureImage] = CaptureImage
@@ -178,6 +178,7 @@ class AbstractCommandImpl(
178
178
  hardware_api: HardwareControlAPI,
179
179
  equipment: execution.EquipmentHandler,
180
180
  file_provider: execution.FileProvider,
181
+ camera_provider: execution.CameraProvider,
181
182
  movement: execution.MovementHandler,
182
183
  gantry_mover: execution.GantryMover,
183
184
  labware_movement: execution.LabwareMovementHandler,
@@ -441,6 +441,14 @@ from .identify_module import (
441
441
  IdentifyModuleCommandType,
442
442
  )
443
443
 
444
+ from .capture_image import (
445
+ CaptureImage,
446
+ CaptureImageParams,
447
+ CaptureImageCreate,
448
+ CaptureImageResult,
449
+ CaptureImageCommandType,
450
+ )
451
+
444
452
  Command = Annotated[
445
453
  Union[
446
454
  AirGapInPlace,
@@ -494,6 +502,7 @@ Command = Annotated[
494
502
  SealPipetteToTip,
495
503
  PressureDispense,
496
504
  UnsealPipetteFromTip,
505
+ CaptureImage,
497
506
  heater_shaker.WaitForTemperature,
498
507
  heater_shaker.SetTargetTemperature,
499
508
  heater_shaker.DeactivateHeater,
@@ -602,6 +611,7 @@ CommandParams = Union[
602
611
  SealPipetteToTipParams,
603
612
  PressureDispenseParams,
604
613
  UnsealPipetteFromTipParams,
614
+ CaptureImageParams,
605
615
  heater_shaker.WaitForTemperatureParams,
606
616
  heater_shaker.SetTargetTemperatureParams,
607
617
  heater_shaker.DeactivateHeaterParams,
@@ -708,6 +718,7 @@ CommandType = Union[
708
718
  SealPipetteToTipCommandType,
709
719
  PressureDispenseCommandType,
710
720
  UnsealPipetteFromTipCommandType,
721
+ CaptureImageCommandType,
711
722
  heater_shaker.WaitForTemperatureCommandType,
712
723
  heater_shaker.SetTargetTemperatureCommandType,
713
724
  heater_shaker.DeactivateHeaterCommandType,
@@ -815,6 +826,7 @@ CommandCreate = Annotated[
815
826
  SealPipetteToTipCreate,
816
827
  PressureDispenseCreate,
817
828
  UnsealPipetteFromTipCreate,
829
+ CaptureImageCreate,
818
830
  heater_shaker.WaitForTemperatureCreate,
819
831
  heater_shaker.SetTargetTemperatureCreate,
820
832
  heater_shaker.DeactivateHeaterCreate,
@@ -930,6 +942,7 @@ CommandResult = Union[
930
942
  SealPipetteToTipResult,
931
943
  PressureDispenseResult,
932
944
  UnsealPipetteFromTipResult,
945
+ CaptureImageResult,
933
946
  heater_shaker.WaitForTemperatureResult,
934
947
  heater_shaker.SetTargetTemperatureResult,
935
948
  heater_shaker.DeactivateHeaterResult,
@@ -19,7 +19,7 @@ from .pipetting_common import (
19
19
  dispense_while_tracking,
20
20
  )
21
21
  from .movement_common import (
22
- LiquidHandlingWellLocationMixin,
22
+ DynamicLiquidHandlingWellLocationMixin,
23
23
  DestinationPositionResult,
24
24
  StallOrCollisionError,
25
25
  move_to_well,
@@ -49,7 +49,7 @@ class DispenseWhileTrackingParams(
49
49
  PipetteIdMixin,
50
50
  DispenseVolumeMixin,
51
51
  FlowRateMixin,
52
- LiquidHandlingWellLocationMixin,
52
+ DynamicLiquidHandlingWellLocationMixin,
53
53
  ):
54
54
  """Payload required to dispense to a specific well."""
55
55
 
@@ -100,14 +100,24 @@ class DispenseWhileTrackingImplementation(
100
100
  # TODO(pbm, 10-15-24): call self._state_view.geometry.validate_dispense_volume_into_well()
101
101
 
102
102
  state_update = StateUpdate()
103
+
104
+ end_point = self._state_view.geometry.get_well_position(
105
+ labware_id=params.labwareId,
106
+ well_name=params.wellName,
107
+ well_location=params.trackToLocation,
108
+ operation_volume=params.volume,
109
+ pipette_id=params.pipetteId,
110
+ )
111
+
103
112
  move_result = await move_to_well(
104
113
  movement=self._movement,
105
114
  model_utils=self._model_utils,
106
115
  pipette_id=params.pipetteId,
107
116
  labware_id=params.labwareId,
108
117
  well_name=params.wellName,
109
- well_location=params.wellLocation,
118
+ well_location=params.trackFromLocation,
110
119
  )
120
+
111
121
  state_update.append(move_result.state_update)
112
122
  if isinstance(move_result, DefinedErrorData):
113
123
  return DefinedErrorData(
@@ -120,6 +130,7 @@ class DispenseWhileTrackingImplementation(
120
130
  well_name=well_name,
121
131
  volume=params.volume,
122
132
  flow_rate=params.flowRate,
133
+ end_point=end_point,
123
134
  push_out=params.pushOut,
124
135
  location_if_error={
125
136
  "retryLocation": (
@@ -130,7 +141,49 @@ class DispenseWhileTrackingImplementation(
130
141
  },
131
142
  pipetting=self._pipetting,
132
143
  model_utils=self._model_utils,
144
+ movement_delay=params.movement_delay,
133
145
  )
146
+
147
+ if isinstance(dispense_result, DefinedErrorData):
148
+ if isinstance(dispense_result.public, OverpressureError):
149
+ return DefinedErrorData(
150
+ public=OverpressureError(
151
+ id=dispense_result.public.id,
152
+ createdAt=dispense_result.public.createdAt,
153
+ wrappedErrors=dispense_result.public.wrappedErrors,
154
+ errorInfo=dispense_result.public.errorInfo,
155
+ ),
156
+ state_update=dispense_result.state_update.set_liquid_operated(
157
+ labware_id=params.labwareId,
158
+ well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well(
159
+ params.labwareId,
160
+ params.wellName,
161
+ params.pipetteId,
162
+ ),
163
+ volume_added=CLEAR,
164
+ ),
165
+ state_update_if_false_positive=dispense_result.state_update_if_false_positive,
166
+ )
167
+ elif isinstance(dispense_result.public, StallOrCollisionError):
168
+ return DefinedErrorData(
169
+ public=StallOrCollisionError(
170
+ id=dispense_result.public.id,
171
+ createdAt=dispense_result.public.createdAt,
172
+ wrappedErrors=dispense_result.public.wrappedErrors,
173
+ errorInfo=dispense_result.public.errorInfo,
174
+ ),
175
+ state_update=dispense_result.state_update.set_liquid_operated(
176
+ labware_id=params.labwareId,
177
+ well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well(
178
+ params.labwareId,
179
+ params.wellName,
180
+ params.pipetteId,
181
+ ),
182
+ volume_added=CLEAR,
183
+ ),
184
+ state_update_if_false_positive=dispense_result.state_update_if_false_positive,
185
+ )
186
+
134
187
  position_after_dispense = await self._gantry_mover.get_position(
135
188
  params.pipetteId
136
189
  )
@@ -139,22 +192,6 @@ class DispenseWhileTrackingImplementation(
139
192
  y=position_after_dispense.y,
140
193
  z=position_after_dispense.z,
141
194
  )
142
-
143
- if isinstance(dispense_result, DefinedErrorData):
144
- return DefinedErrorData(
145
- public=dispense_result.public,
146
- state_update=dispense_result.state_update.set_liquid_operated(
147
- labware_id=params.labwareId,
148
- well_names=self._state_view.geometry.get_wells_covered_by_pipette_with_active_well(
149
- params.labwareId,
150
- params.wellName,
151
- params.pipetteId,
152
- ),
153
- volume_added=CLEAR,
154
- ),
155
- state_update_if_false_positive=dispense_result.state_update_if_false_positive,
156
- )
157
-
158
195
  return SuccessData(
159
196
  public=DispenseWhileTrackingResult(
160
197
  volume=dispense_result.public.volume,
@@ -10,6 +10,10 @@ from opentrons_shared_data.errors import ErrorCodes
10
10
  from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated
11
11
  from opentrons_shared_data.labware.labware_definition import LabwareDefinition
12
12
 
13
+ from opentrons.protocol_engine.errors.exceptions import (
14
+ LabwarePoolNotCompatibleWithModuleError,
15
+ )
16
+
13
17
 
14
18
  from ...errors import ErrorOccurrence
15
19
  from ...types import (
@@ -35,6 +39,15 @@ if TYPE_CHECKING:
35
39
  from opentrons.protocol_engine.execution.equipment import LoadedLabwarePoolData
36
40
  from opentrons.protocol_engine.state.module_substates import FlexStackerSubState
37
41
 
42
+
43
+ # The stacker cannot dispense labware where there is no gap between the top surface
44
+ # of the bottom labware being dispensed, and bottom surface of the top labware.
45
+ # This is because the stacker latch, which holds the labware stack, needs enough
46
+ # empty space to free the bottom labware, but still hold the top labware once it
47
+ # closes.
48
+ STACKER_INCOMPATIBLE_LABWARE = set(["opentrons_tough_universal_lid"])
49
+
50
+
38
51
  INITIAL_COUNT_DESCRIPTION = dedent(
39
52
  """\
40
53
  The number of labware that should be initially stored in the stacker. This number will be silently clamped to
@@ -911,3 +924,25 @@ def build_retrieve_labware_move_updates(
911
924
  lid_offset_location,
912
925
  )
913
926
  return locations_for_ids, offset_ids_by_id
927
+
928
+
929
+ def validate_labware_pool_compatible_with_stacker(
930
+ pool_primary_definition: LabwareDefinition,
931
+ pool_adapter_definition: LabwareDefinition | None,
932
+ pool_lid_definition: LabwareDefinition | None,
933
+ ) -> None:
934
+ """Verifies that the given labware pool is compatible with the stacker."""
935
+ labware_pool = set(
936
+ lw.parameters.loadName
937
+ for lw in [
938
+ pool_primary_definition,
939
+ pool_adapter_definition,
940
+ pool_lid_definition,
941
+ ]
942
+ if lw is not None
943
+ )
944
+ incompatible_labware = list(labware_pool & STACKER_INCOMPATIBLE_LABWARE)
945
+ if incompatible_labware:
946
+ raise LabwarePoolNotCompatibleWithModuleError(
947
+ f"The stacker cannot store {incompatible_labware}"
948
+ )
@@ -31,6 +31,7 @@ from .common import (
31
31
  primary_location_sequences,
32
32
  adapter_location_sequences_with_default,
33
33
  lid_location_sequences_with_default,
34
+ validate_labware_pool_compatible_with_stacker,
34
35
  )
35
36
 
36
37
  if TYPE_CHECKING:
@@ -210,6 +211,12 @@ class SetStoredLabwareImpl(
210
211
  )
211
212
  )
212
213
 
214
+ validate_labware_pool_compatible_with_stacker(
215
+ pool_primary_definition=labware_def,
216
+ pool_adapter_definition=adapter_def,
217
+ pool_lid_definition=lid_def,
218
+ )
219
+
213
220
  pool_height = self._state_view.geometry.get_height_of_labware_stack(
214
221
  pool_definitions
215
222
  )
@@ -100,7 +100,7 @@ class SetShakeSpeedImpl(
100
100
  async def start_shake(task_handler: TaskHandler) -> None:
101
101
  if hs_hardware_module is not None:
102
102
  async with task_handler.synchronize_cancel_previous(
103
- hs_module_substate.module_id
103
+ hs_module_substate.module_id + "-shake"
104
104
  ):
105
105
  await hs_hardware_module.set_speed(rpm=validated_speed)
106
106