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
@@ -1,36 +1,91 @@
1
1
  """File interaction resource provider."""
2
2
  from datetime import datetime
3
+ from io import StringIO
4
+ import csv
3
5
  from typing import List, Optional, Callable, Awaitable, Dict
6
+ from dataclasses import dataclass
4
7
  from pydantic import BaseModel
5
- from ..errors import StorageLimitReachedError
8
+ from opentrons_shared_data.data_files import (
9
+ DataFileInfo,
10
+ MimeType,
11
+ RunFileNameMetadata,
12
+ )
13
+
14
+ SPECIAL_CHARACTERS = {
15
+ "#",
16
+ "%",
17
+ "&",
18
+ "{",
19
+ "}",
20
+ "\\",
21
+ "/",
22
+ "<",
23
+ ">",
24
+ "*",
25
+ "$",
26
+ "!",
27
+ "?",
28
+ ".",
29
+ "'",
30
+ '"',
31
+ ":",
32
+ ";",
33
+ "@",
34
+ "`",
35
+ "|",
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FileNameCmdMetadata:
41
+ """Command metadata associated with a specific data file."""
42
+
43
+ command_id: str
44
+ prev_command_id: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ReadCmdFileNameMetadata(FileNameCmdMetadata):
49
+ """Data from a plate reader `read` command used to build the finalized file name."""
50
+
51
+ base_filename: str
52
+ wavelength: int
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ImageCaptureCmdFileNameMetadata(FileNameCmdMetadata):
57
+ """Data from a camera capture command used to build the finalized file name."""
6
58
 
59
+ step_number: int
60
+ command_timestamp: datetime
61
+ base_filename: Optional[str]
7
62
 
8
- MAXIMUM_CSV_FILE_LIMIT = 400
9
63
 
64
+ CommandFileNameMetadata = ReadCmdFileNameMetadata | ImageCaptureCmdFileNameMetadata
10
65
 
11
- class GenericCsvTransform:
12
- """Generic CSV File Type data for rows of data to be seperated by a delimeter."""
13
66
 
14
- filename: str
15
- rows: List[List[str]]
16
- delimiter: str = ","
67
+ class FileData:
68
+ """File data container for writing to a file."""
69
+
70
+ data: bytes
71
+ mime_type: MimeType
72
+ run_metadata: RunFileNameMetadata
73
+ command_metadata: CommandFileNameMetadata
17
74
 
18
75
  @staticmethod
19
76
  def build(
20
- filename: str, rows: List[List[str]], delimiter: str = ","
21
- ) -> "GenericCsvTransform":
22
- """Build a Generic CSV datatype class."""
23
- if "." in filename and not filename.endswith(".csv"):
24
- raise ValueError(
25
- f"Provided filename {filename} invalid. Only CSV file format is accepted."
26
- )
27
- elif "." not in filename:
28
- filename = f"{filename}.csv"
29
- csv = GenericCsvTransform()
30
- csv.filename = filename
31
- csv.rows = rows
32
- csv.delimiter = delimiter
33
- return csv
77
+ data: bytes,
78
+ mime_type: MimeType,
79
+ run_metadata: RunFileNameMetadata,
80
+ command_metadata: CommandFileNameMetadata,
81
+ ) -> "FileData":
82
+ """Build a generic file data class."""
83
+ file_data = FileData()
84
+ file_data.data = data
85
+ file_data.mime_type = mime_type
86
+ file_data.run_metadata = run_metadata
87
+ file_data.command_metadata = command_metadata
88
+ return file_data
34
89
 
35
90
 
36
91
  class ReadData(BaseModel):
@@ -41,7 +96,7 @@ class ReadData(BaseModel):
41
96
 
42
97
 
43
98
  class PlateReaderData(BaseModel):
44
- """Data from a Opentrons Plate Reader Read. Can be converted to CSV template format."""
99
+ """Data from an Opentrons Plate Reader Read. Can be converted to CSV format."""
45
100
 
46
101
  read_results: List[ReadData]
47
102
  reference_wavelength: Optional[int] = None
@@ -49,13 +104,8 @@ class PlateReaderData(BaseModel):
49
104
  finish_time: datetime
50
105
  serial_number: str
51
106
 
52
- def build_generic_csv( # noqa: C901
53
- self, filename: str, measurement: ReadData
54
- ) -> GenericCsvTransform:
55
- """Builds a CSV compatible object containing Plate Reader Measurements.
56
-
57
- This will also automatically reformat the provided filename to include the wavelength of those measurements.
58
- """
107
+ def build_csv_bytes(self, measurement: ReadData) -> bytes: # noqa: C901
108
+ """Builds CSV data as bytes containing Plate Reader Measurements."""
59
109
  plate_alpharows = ["A", "B", "C", "D", "E", "F", "G", "H"]
60
110
  rows = []
61
111
 
@@ -63,7 +113,7 @@ class PlateReaderData(BaseModel):
63
113
  for i in range(8):
64
114
  row = [plate_alpharows[i]]
65
115
  for j in range(12):
66
- row.append(str(measurement.data[f"{plate_alpharows[i]}{j+1}"]))
116
+ row.append(str(measurement.data[f"{plate_alpharows[i]}{j + 1}"]))
67
117
  rows.append(row)
68
118
  for i in range(3):
69
119
  rows.append([])
@@ -116,16 +166,12 @@ class PlateReaderData(BaseModel):
116
166
  ["Measurement finished at", self.finish_time.strftime("%m %d %H:%M:%S %Y")]
117
167
  )
118
168
 
119
- # Ensure the filename adheres to ruleset contains the wavelength for a given measurement
120
- if filename.endswith(".csv"):
121
- filename = filename[:-4]
122
- filename = filename + str(measurement.wavelength) + "nm.csv"
169
+ output = StringIO()
170
+ writer = csv.writer(output, delimiter=",")
171
+ writer.writerows(rows)
172
+ csv_bytes = output.getvalue().encode("utf-8")
123
173
 
124
- return GenericCsvTransform.build(
125
- filename=filename,
126
- rows=rows,
127
- delimiter=",",
128
- )
174
+ return csv_bytes
129
175
 
130
176
 
131
177
  class FileProvider:
@@ -133,29 +179,58 @@ class FileProvider:
133
179
 
134
180
  def __init__(
135
181
  self,
136
- data_files_write_csv_callback: Optional[
137
- Callable[[GenericCsvTransform], Awaitable[str]]
182
+ data_files_write_file_cb: Optional[
183
+ Callable[[FileData], Awaitable[DataFileInfo]]
138
184
  ] = None,
139
- data_files_filecount: Optional[Callable[[], Awaitable[int]]] = None,
140
185
  ) -> None:
141
186
  """Initialize the interface callbacks of the File Provider for data file handling within the Protocol Engine.
142
187
 
143
188
  Params:
144
- data_files_write_csv_callback: Callback to write a CSV file to the data files directory and add it to the database.
189
+ data_files_write_file_callback: Callback to write a file to the data files directory and add it to the database.
145
190
  data_files_filecount: Callback to check the amount of data files already present in the data files directory.
146
191
  """
147
- self._data_files_write_csv_callback = data_files_write_csv_callback
148
- self._data_files_filecount = data_files_filecount
149
-
150
- async def write_csv(self, write_data: GenericCsvTransform) -> str:
151
- """Writes the provided CSV object to a file in the Data Files directory. Returns the File ID of the file created."""
152
- if self._data_files_filecount is not None:
153
- file_count = await self._data_files_filecount()
154
- if file_count >= MAXIMUM_CSV_FILE_LIMIT:
155
- raise StorageLimitReachedError(
156
- f"Not enough space to store file {write_data.filename}."
157
- )
158
- if self._data_files_write_csv_callback is not None:
159
- return await self._data_files_write_csv_callback(write_data)
160
- # If we are in an analysis or simulation state, return an empty file ID
161
- return ""
192
+ self._data_files_write_file_cb = data_files_write_file_cb
193
+ self._run_metadata: RunFileNameMetadata | None = None
194
+
195
+ async def write_file(
196
+ self,
197
+ data: bytes,
198
+ mime_type: MimeType,
199
+ command_metadata: CommandFileNameMetadata,
200
+ ) -> DataFileInfo:
201
+ """Writes arbitrary data to a file in the Data Files directory.
202
+
203
+ Returns the `DataFileInfo` of the file created.
204
+
205
+ Raises:
206
+ Note that the callback may raise a StorageLimitReachedError.
207
+ """
208
+ if self._data_files_write_file_cb is not None:
209
+ assert self._run_metadata is not None
210
+ file_data = FileData.build(
211
+ data=data,
212
+ mime_type=mime_type,
213
+ command_metadata=command_metadata,
214
+ run_metadata=self._run_metadata,
215
+ )
216
+
217
+ return await self._data_files_write_file_cb(file_data)
218
+ # If we are in an analysis or simulation state, return an empty `DataFileInfo`
219
+ return DataFileInfo(
220
+ id="",
221
+ name="",
222
+ file_hash="",
223
+ created_at=datetime.now(),
224
+ generated=True,
225
+ stored=False,
226
+ path="",
227
+ mime_type=mime_type,
228
+ )
229
+
230
+ def set_run_metadata(self, metadata: RunFileNameMetadata) -> None:
231
+ """Sets metadata specific to the run."""
232
+ self._run_metadata = metadata
233
+
234
+ def clear_run_metadata(self) -> None:
235
+ """Clears metadata specific to the run."""
236
+ self._run_metadata = None
@@ -27,6 +27,7 @@ from .types import (
27
27
  AddressableAreaLocation,
28
28
  ModuleLocation,
29
29
  OnLabwareLocation,
30
+ WASTE_CHUTE_LOCATION,
30
31
  )
31
32
 
32
33
 
@@ -116,6 +117,7 @@ def _standardize_labware_location(
116
117
  )
117
118
  or original == OFF_DECK_LOCATION
118
119
  or original == SYSTEM_LOCATION
120
+ or original == WASTE_CHUTE_LOCATION
119
121
  ):
120
122
  return original
121
123
 
@@ -0,0 +1,54 @@
1
+ """Camera related state and store resource.
2
+
3
+ Camera settings, particularly for enablement, can be quieried from via the Camera Provider callback.
4
+ However, here Camera settings may also be provided to override or supercede those provided by the callbacks.
5
+ """
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+ from opentrons.protocol_engine.actions import AddCameraSettingsAction
9
+ from opentrons.protocol_engine.resources.camera_provider import CameraSettings
10
+
11
+ from ._abstract_store import HasState, HandlesActions
12
+ from ..actions import Action
13
+
14
+
15
+ @dataclass
16
+ class CameraState:
17
+ """State of Engine Camera override settings."""
18
+
19
+ enablement_settings: Optional[CameraSettings]
20
+ # todo(chb, 2025-10-28): Eventually we will want to extend this to include the camera configurations overrides (contrast, zoom, etc)
21
+
22
+
23
+ class CameraStore(HasState[CameraState], HandlesActions):
24
+ """Camera container."""
25
+
26
+ _state: CameraState
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize a Camera store and its state."""
30
+ self._state = CameraState(enablement_settings=None)
31
+
32
+ def handle_action(self, action: Action) -> None:
33
+ """Modify state in reaction to an action."""
34
+ if isinstance(action, AddCameraSettingsAction):
35
+ # Update the Camera Enablement settings to the newest override settings
36
+ self._state.enablement_settings = action.enablement_settings
37
+
38
+
39
+ class CameraView:
40
+ """Read-only engine created Camera state view."""
41
+
42
+ _state: CameraState
43
+
44
+ def __init__(self, state: CameraState) -> None:
45
+ """Initialize the view of Camera state.
46
+
47
+ Arguments:
48
+ state: Camera dataclass used for tracking override settings for the camera.
49
+ """
50
+ self._state = state
51
+
52
+ def get_enablement_settings(self) -> CameraSettings | None:
53
+ """Get the enablement settings override currently in use. This will take priority over Camera Provider callback provided settings."""
54
+ return self._state.enablement_settings
@@ -239,7 +239,10 @@ class CommandState:
239
239
  """Whether the run has entered error recovery."""
240
240
 
241
241
  stopped_by_async_error: bool
242
- """If this is set to True, the engine was stopped by an estop event."""
242
+ """If this is set to True, the engine was stopped by an async event."""
243
+
244
+ is_stopping_because_of_async_error: bool
245
+ """If this is set to True, the engine was stopped by an asynch event and hasn't finished stopping."""
243
246
 
244
247
  error_recovery_policy: ErrorRecoveryPolicy
245
248
  """See `CommandView.get_error_recovery_policy()`."""
@@ -273,6 +276,7 @@ class CommandStore(HasState[CommandState], HandlesActions):
273
276
  run_started_at=None,
274
277
  latest_protocol_command_hash=None,
275
278
  stopped_by_async_error=False,
279
+ is_stopping_because_of_async_error=False,
276
280
  error_recovery_policy=error_recovery_policy,
277
281
  has_entered_error_recovery=False,
278
282
  )
@@ -474,6 +478,7 @@ class CommandStore(HasState[CommandState], HandlesActions):
474
478
 
475
479
  if action.from_asynchronous_error:
476
480
  self._state.stopped_by_async_error = True
481
+ self._state.is_stopping_because_of_async_error = True
477
482
  self._state.run_result = RunResult.FAILED
478
483
  else:
479
484
  self._state.run_result = RunResult.STOPPED
@@ -499,14 +504,29 @@ class CommandStore(HasState[CommandState], HandlesActions):
499
504
  action.error_details.error,
500
505
  )
501
506
  else:
502
- # HACK(sf): There needs to be a better way to set
503
- # an estop error than this else clause
504
- if self._state.stopped_by_async_error and action.error_details:
507
+ # HACK(sf): There needs to be a better way to handle async errors than this logic
508
+ # which is way too nonlocal. The idea here is that
509
+ # (1) there's an async error that calls one of the engine async error handlers,
510
+ # which emits a stop action and then tells the orchestrator to call finish
511
+ # (2) calling stop normally would lock out the run error field (since the idea is that
512
+ # stop happens in the handler of the error that stops the run, and thus the failed
513
+ # command has already set the run error), but here either the command didn't fail or
514
+ # the command failed with an error that isn't relevant or is duplicative, so we want
515
+ # to override that
516
+ # (3) but we don't want to override it twice, because some other error handler might
517
+ # tell us to finish with the cancelled error that happens because a command was cancelled
518
+ # in reaction to (2)
519
+ # So we set and clear this stopped_by_async_error and it's awful. Let's figure out a better
520
+ # way.
521
+
522
+ if self._state.is_stopping_because_of_async_error and action.error_details:
505
523
  self._state.run_error = self._map_run_exception_to_error_occurrence(
506
524
  action.error_details.error_id,
507
525
  action.error_details.created_at,
508
526
  action.error_details.error,
509
527
  )
528
+ self._state.is_stopping_because_of_async_error = False
529
+ self._state.stopped_by_async_error = True
510
530
 
511
531
  def _handle_hardware_stopped_action(self, action: HardwareStoppedAction) -> None:
512
532
  self._state.queue_status = QueueStatus.PAUSED
@@ -68,6 +68,8 @@ from ..types import (
68
68
  CurrentPipetteLocation,
69
69
  TipGeometry,
70
70
  InStackerHopperLocation,
71
+ WASTE_CHUTE_LOCATION,
72
+ AccessibleByGripperLocation,
71
73
  OnDeckLabwareLocation,
72
74
  AddressableAreaLocation,
73
75
  AddressableOffsetVector,
@@ -198,6 +200,7 @@ class GeometryView:
198
200
  if isinstance(loc, InStackerHopperLocation) or isinstance(
199
201
  loc, NotOnDeckLocationSequenceComponent
200
202
  ):
203
+
201
204
  return False
202
205
  return True
203
206
 
@@ -396,6 +399,11 @@ class GeometryView:
396
399
  "Labware does not have a slot or module associated with it"
397
400
  " since it is no longer on the deck."
398
401
  )
402
+ elif location == WASTE_CHUTE_LOCATION:
403
+ raise errors.LabwareNotOnDeckError(
404
+ "Labware does not have a slot or module associated with it"
405
+ " since it is in the waste chute."
406
+ )
399
407
 
400
408
  def get_labware_origin_position(self, labware_id: str) -> Point:
401
409
  """Get the deck coordinates of a labware's origin.
@@ -459,7 +467,6 @@ class GeometryView:
459
467
  current_definition = self._labware.get_definition(current_labware_id)
460
468
  else:
461
469
  break
462
-
463
470
  return definitions_locations_top_to_bottom
464
471
 
465
472
  def _get_stackup_module_parent_to_child_offset(
@@ -491,7 +498,6 @@ class GeometryView:
491
498
  ) -> LabwareStackupAncestorDefinition:
492
499
  """Traverse the stackup to find the first non-labware definition."""
493
500
  current_location = top_most_lw_location
494
-
495
501
  while True:
496
502
  if isinstance(current_location, OnLabwareLocation):
497
503
  current_labware_id = current_location.labwareId
@@ -500,6 +506,7 @@ class GeometryView:
500
506
  else:
501
507
  if isinstance(current_location, ModuleLocation):
502
508
  return self._modules.get_definition(current_location.moduleId)
509
+
503
510
  elif isinstance(current_location, AddressableAreaLocation):
504
511
  return self._addressable_areas.get_addressable_area(
505
512
  current_location.addressableAreaName
@@ -508,6 +515,10 @@ class GeometryView:
508
515
  return self._addressable_areas.get_slot_definition(
509
516
  current_location.slotName.id
510
517
  )
518
+ elif current_location == WASTE_CHUTE_LOCATION:
519
+ return self._addressable_areas.get_addressable_area(
520
+ "gripperWasteChute"
521
+ )
511
522
  else:
512
523
  raise errors.InvalidLabwarePositionError(
513
524
  f"Cannot get ancestor slot of location {current_location}"
@@ -522,6 +533,8 @@ class GeometryView:
522
533
  return self._modules.get_provided_addressable_area(location.moduleId)
523
534
  elif isinstance(location, OnLabwareLocation):
524
535
  return self.get_ancestor_addressable_area_name(location.labwareId)
536
+ elif location == WASTE_CHUTE_LOCATION:
537
+ return "gripperWasteChute"
525
538
  else:
526
539
  raise errors.InvalidLabwarePositionError(
527
540
  f"Cannot get ancestor slot of location {location}"
@@ -624,7 +637,9 @@ class GeometryView:
624
637
  if meniscus_tracking:
625
638
  location = LiquidHandlingWellLocation(
626
639
  origin=WellOrigin.MENISCUS,
627
- offset=WellOffset(x=0, y=0, z=absolute_point.z),
640
+ offset=WellOffset(
641
+ x=absolute_point.x, y=absolute_point.y, z=absolute_point.z
642
+ ),
628
643
  )
629
644
  # TODO(cm): handle operationVolume being a float other than 0
630
645
  if meniscus_tracking == MeniscusTrackingTarget.END:
@@ -680,6 +695,9 @@ class GeometryView:
680
695
  return well_def.depth
681
696
 
682
697
  def _get_highest_z_from_labware_data(self, lw_data: LoadedLabware) -> float:
698
+ if lw_data.location == WASTE_CHUTE_LOCATION:
699
+ # Returns 0 so that the waste chute height is not added to the height of the lbw
700
+ return 0
683
701
  labware_pos = self.get_labware_position(lw_data.id)
684
702
  z_dim = self._labware.get_dimensions(labware_id=lw_data.id).z
685
703
  height_over_labware: float = 0
@@ -833,6 +851,11 @@ class GeometryView:
833
851
  f"Labware {self._labware.get_display_name(labware_id)} does not have a slot associated with it"
834
852
  f" since it is no longer on the deck."
835
853
  )
854
+ elif labware.location == WASTE_CHUTE_LOCATION:
855
+ raise errors.LabwareNotOnDeckError(
856
+ f"Labware {self._labware.get_display_name(labware_id)} does not have a slot associated with it"
857
+ f" since it is in the waste chute."
858
+ )
836
859
  else:
837
860
  _LOG.error(
838
861
  f"Unhandled location type in get_ancestor_slot_name: {labware.location}"
@@ -1018,9 +1041,7 @@ class GeometryView:
1018
1041
  def get_labware_grip_point(
1019
1042
  self,
1020
1043
  labware_definition: LabwareDefinition,
1021
- location: Union[
1022
- DeckSlotLocation, ModuleLocation, OnLabwareLocation, AddressableAreaLocation
1023
- ],
1044
+ location: AccessibleByGripperLocation,
1024
1045
  move_type: GripperMoveType,
1025
1046
  user_additional_offset: Point | None,
1026
1047
  ) -> Point:
@@ -1047,9 +1068,7 @@ class GeometryView:
1047
1068
  def _get_aa_origin_to_nominal_grip_point(
1048
1069
  self,
1049
1070
  labware_definition: LabwareDefinition,
1050
- location: Union[
1051
- DeckSlotLocation, ModuleLocation, OnLabwareLocation, AddressableAreaLocation
1052
- ],
1071
+ location: AccessibleByGripperLocation,
1053
1072
  move_type: GripperMoveType,
1054
1073
  ) -> Point:
1055
1074
  """Get the nominal grip point of a labware.
@@ -1440,9 +1459,16 @@ class GeometryView:
1440
1459
  def ensure_valid_gripper_location(
1441
1460
  location: LabwareLocation,
1442
1461
  ) -> Union[
1443
- DeckSlotLocation, ModuleLocation, OnLabwareLocation, AddressableAreaLocation
1462
+ DeckSlotLocation,
1463
+ ModuleLocation,
1464
+ OnLabwareLocation,
1465
+ AddressableAreaLocation,
1444
1466
  ]:
1445
1467
  """Ensure valid on-deck location for gripper, otherwise raise error."""
1468
+ if location == WASTE_CHUTE_LOCATION:
1469
+ raise errors.LabwareMovementNotAllowedError(
1470
+ "Labware movements out of the waste chute are not supported using the gripper."
1471
+ )
1446
1472
  if not isinstance(
1447
1473
  location,
1448
1474
  (
@@ -1457,6 +1483,28 @@ class GeometryView:
1457
1483
  )
1458
1484
  return location
1459
1485
 
1486
+ @staticmethod
1487
+ def ensure_valid_new_gripper_location(
1488
+ location: LabwareLocation,
1489
+ ) -> AccessibleByGripperLocation:
1490
+ """Ensure valid on-deck location for gripper, otherwise raise error."""
1491
+ if (
1492
+ not isinstance(
1493
+ location,
1494
+ (
1495
+ DeckSlotLocation,
1496
+ ModuleLocation,
1497
+ OnLabwareLocation,
1498
+ AddressableAreaLocation,
1499
+ ),
1500
+ )
1501
+ and location != WASTE_CHUTE_LOCATION
1502
+ ):
1503
+ raise errors.LabwareMovementNotAllowedError(
1504
+ "Off-deck labware movements are not supported using the gripper."
1505
+ )
1506
+ return location
1507
+
1460
1508
  # todo(mm, 2024-11-05): This may be incorrect because it does not take the following
1461
1509
  # offsets into account, which *are* taken into account for the actual gripper movement:
1462
1510
  #
@@ -1677,6 +1725,12 @@ class GeometryView:
1677
1725
  return self._recurse_labware_location_from_stacker_hopper(
1678
1726
  labware_location, building
1679
1727
  )
1728
+ elif labware_location == WASTE_CHUTE_LOCATION:
1729
+ return [
1730
+ NotOnDeckLocationSequenceComponent(
1731
+ logicalLocationName=WASTE_CHUTE_LOCATION
1732
+ )
1733
+ ]
1680
1734
  else:
1681
1735
  _LOG.warn(f"Unhandled labware location kind: {labware_location}")
1682
1736
  return building
@@ -2262,6 +2316,10 @@ class GeometryView:
2262
2316
  raise errors.LocationNotAccessibleByPipetteError(
2263
2317
  f"Cannot move pipette to {labware.loadName}, labware is off-deck."
2264
2318
  )
2319
+ elif labware_location == WASTE_CHUTE_LOCATION:
2320
+ raise errors.LocationNotAccessibleByPipetteError(
2321
+ f"Cannot move pipette to {labware.loadName}, labware is in waste chute."
2322
+ )
2265
2323
  elif isinstance(labware_location, ModuleLocation):
2266
2324
  module = self._modules.get(labware_location.moduleId)
2267
2325
  if ModuleModel.is_flex_stacker(module.model):
@@ -52,6 +52,7 @@ from ..types import (
52
52
  LabwareOffsetLocationSequence,
53
53
  LegacyLabwareOffsetLocation,
54
54
  InStackerHopperLocation,
55
+ WASTE_CHUTE_LOCATION,
55
56
  LabwareLocation,
56
57
  LoadedLabware,
57
58
  ModuleLocation,
@@ -343,14 +344,18 @@ class LabwareStore(HasState[LabwareState], HandlesActions):
343
344
  self, labware_id: str, new_location: LabwareLocation, new_offset_id: str | None
344
345
  ) -> None:
345
346
  self._state.labware_by_id[labware_id].offsetId = new_offset_id
346
-
347
347
  if isinstance(new_location, AddressableAreaLocation) and (
348
- fixture_validation.is_gripper_waste_chute(new_location.addressableAreaName)
349
- or fixture_validation.is_trash(new_location.addressableAreaName)
348
+ fixture_validation.is_trash(new_location.addressableAreaName)
350
349
  ):
351
- # If a labware has been moved into a waste chute it's been chuted away and is now technically off deck
350
+ # TODO (RC, 2025-10-07: create a specific trash off deck location)
351
+ # If a labware has been moved into trash and is now technically off deck
352
352
  new_location = OFF_DECK_LOCATION
353
-
353
+ elif isinstance(
354
+ new_location, AddressableAreaLocation
355
+ ) and fixture_validation.is_gripper_waste_chute(
356
+ new_location.addressableAreaName
357
+ ):
358
+ new_location = WASTE_CHUTE_LOCATION
354
359
  self._state.labware_by_id[labware_id].location = new_location
355
360
 
356
361
  def _set_labware_location(self, state_update: update_types.StateUpdate) -> None:
@@ -483,7 +488,6 @@ class LabwareView:
483
488
  ) -> Optional[LoadedLabware]:
484
489
  """Get the labware located in a given slot, if any."""
485
490
  loaded_labware = list(self._state.labware_by_id.values())
486
-
487
491
  for labware in loaded_labware:
488
492
  if (
489
493
  isinstance(labware.location, DeckSlotLocation)
@@ -49,6 +49,7 @@ from opentrons.protocol_engine.types import (
49
49
  OnLabwareLocation,
50
50
  LabwareMovementOffsetData,
51
51
  LabwareOffsetVector,
52
+ WASTE_CHUTE_LOCATION,
52
53
  )
53
54
 
54
55
  _OFFSET_ON_TC_OT2 = Point(x=0, y=0, z=10.7)
@@ -174,9 +175,13 @@ def _get_stackup_origin_to_lw_origin(
174
175
  slot_name=slot_name,
175
176
  is_topmost_labware=False,
176
177
  )
178
+ elif location == WASTE_CHUTE_LOCATION:
179
+ raise LabwareNotOnDeckError(
180
+ f"Cannot access {definition.metadata.displayName} because it is in the waste chute."
181
+ )
177
182
  else:
178
183
  raise LabwareNotOnDeckError(
179
- "Cannot access labware since it is not on the deck. "
184
+ f"Cannot access {definition.metadata.displayName} since it is not on the deck. "
180
185
  "Either it has been loaded off-deck or its been moved off-deck."
181
186
  )
182
187
 
@@ -267,6 +267,7 @@ class ModuleStore(HasState[ModuleState], HandlesActions):
267
267
  heater_shaker.SetTargetTemperatureResult,
268
268
  heater_shaker.DeactivateHeaterResult,
269
269
  heater_shaker.SetAndWaitForShakeSpeedResult,
270
+ heater_shaker.SetShakeSpeedResult,
270
271
  heater_shaker.DeactivateShakerResult,
271
272
  heater_shaker.OpenLabwareLatchResult,
272
273
  heater_shaker.CloseLabwareLatchResult,
@@ -430,6 +431,7 @@ class ModuleStore(HasState[ModuleState], HandlesActions):
430
431
  heater_shaker.SetTargetTemperature,
431
432
  heater_shaker.DeactivateHeater,
432
433
  heater_shaker.SetAndWaitForShakeSpeed,
434
+ heater_shaker.SetShakeSpeed,
433
435
  heater_shaker.DeactivateShaker,
434
436
  heater_shaker.OpenLabwareLatch,
435
437
  heater_shaker.CloseLabwareLatch,
@@ -465,6 +467,13 @@ class ModuleStore(HasState[ModuleState], HandlesActions):
465
467
  is_plate_shaking=True,
466
468
  plate_target_temperature=prev_state.plate_target_temperature,
467
469
  )
470
+ elif isinstance(command.result, heater_shaker.SetShakeSpeedResult):
471
+ self._state.substate_by_module_id[module_id] = HeaterShakerModuleSubState(
472
+ module_id=HeaterShakerModuleId(module_id),
473
+ labware_latch_status=prev_state.labware_latch_status,
474
+ is_plate_shaking=True,
475
+ plate_target_temperature=prev_state.plate_target_temperature,
476
+ )
468
477
  elif isinstance(command.result, heater_shaker.DeactivateShakerResult):
469
478
  self._state.substate_by_module_id[module_id] = HeaterShakerModuleSubState(
470
479
  module_id=HeaterShakerModuleId(module_id),