opentrons 8.7.0a9__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 (189) 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 +126 -49
  5. opentrons/drivers/heater_shaker/abstract.py +5 -0
  6. opentrons/drivers/heater_shaker/driver.py +10 -0
  7. opentrons/drivers/heater_shaker/simulator.py +4 -0
  8. opentrons/drivers/thermocycler/abstract.py +6 -0
  9. opentrons/drivers/thermocycler/driver.py +61 -10
  10. opentrons/drivers/thermocycler/simulator.py +6 -0
  11. opentrons/drivers/vacuum_module/__init__.py +5 -0
  12. opentrons/drivers/vacuum_module/abstract.py +93 -0
  13. opentrons/drivers/vacuum_module/driver.py +208 -0
  14. opentrons/drivers/vacuum_module/errors.py +39 -0
  15. opentrons/drivers/vacuum_module/simulator.py +85 -0
  16. opentrons/drivers/vacuum_module/types.py +79 -0
  17. opentrons/execute.py +3 -0
  18. opentrons/hardware_control/api.py +24 -5
  19. opentrons/hardware_control/backends/controller.py +8 -2
  20. opentrons/hardware_control/backends/flex_protocol.py +1 -0
  21. opentrons/hardware_control/backends/ot3controller.py +35 -2
  22. opentrons/hardware_control/backends/ot3simulator.py +3 -1
  23. opentrons/hardware_control/backends/ot3utils.py +37 -0
  24. opentrons/hardware_control/backends/simulator.py +2 -1
  25. opentrons/hardware_control/backends/subsystem_manager.py +5 -2
  26. opentrons/hardware_control/emulation/abstract_emulator.py +6 -4
  27. opentrons/hardware_control/emulation/connection_handler.py +8 -5
  28. opentrons/hardware_control/emulation/heater_shaker.py +12 -3
  29. opentrons/hardware_control/emulation/settings.py +1 -1
  30. opentrons/hardware_control/emulation/thermocycler.py +67 -15
  31. opentrons/hardware_control/module_control.py +105 -10
  32. opentrons/hardware_control/modules/__init__.py +3 -0
  33. opentrons/hardware_control/modules/absorbance_reader.py +11 -4
  34. opentrons/hardware_control/modules/flex_stacker.py +38 -9
  35. opentrons/hardware_control/modules/heater_shaker.py +42 -5
  36. opentrons/hardware_control/modules/magdeck.py +8 -4
  37. opentrons/hardware_control/modules/mod_abc.py +14 -6
  38. opentrons/hardware_control/modules/tempdeck.py +25 -5
  39. opentrons/hardware_control/modules/thermocycler.py +68 -11
  40. opentrons/hardware_control/modules/types.py +20 -1
  41. opentrons/hardware_control/modules/utils.py +11 -4
  42. opentrons/hardware_control/motion_utilities.py +6 -6
  43. opentrons/hardware_control/nozzle_manager.py +3 -0
  44. opentrons/hardware_control/ot3api.py +85 -17
  45. opentrons/hardware_control/poller.py +22 -8
  46. opentrons/hardware_control/protocols/liquid_handler.py +6 -2
  47. opentrons/hardware_control/scripts/update_module_fw.py +5 -0
  48. opentrons/hardware_control/types.py +43 -2
  49. opentrons/legacy_commands/commands.py +58 -5
  50. opentrons/legacy_commands/module_commands.py +52 -0
  51. opentrons/legacy_commands/protocol_commands.py +53 -1
  52. opentrons/legacy_commands/types.py +155 -1
  53. opentrons/motion_planning/deck_conflict.py +17 -12
  54. opentrons/motion_planning/waypoints.py +15 -29
  55. opentrons/protocol_api/__init__.py +5 -1
  56. opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
  57. opentrons/protocol_api/_types.py +8 -1
  58. opentrons/protocol_api/core/common.py +3 -1
  59. opentrons/protocol_api/core/engine/_default_labware_versions.py +33 -11
  60. opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
  61. opentrons/protocol_api/core/engine/instrument.py +109 -26
  62. opentrons/protocol_api/core/engine/labware.py +8 -1
  63. opentrons/protocol_api/core/engine/module_core.py +95 -4
  64. opentrons/protocol_api/core/engine/protocol.py +51 -2
  65. opentrons/protocol_api/core/engine/stringify.py +2 -0
  66. opentrons/protocol_api/core/engine/tasks.py +48 -0
  67. opentrons/protocol_api/core/engine/well.py +8 -0
  68. opentrons/protocol_api/core/instrument.py +19 -2
  69. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
  70. opentrons/protocol_api/core/legacy/legacy_module_core.py +33 -2
  71. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +23 -1
  72. opentrons/protocol_api/core/legacy/legacy_well_core.py +4 -0
  73. opentrons/protocol_api/core/legacy/tasks.py +19 -0
  74. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
  75. opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
  76. opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
  77. opentrons/protocol_api/core/module.py +58 -2
  78. opentrons/protocol_api/core/protocol.py +23 -2
  79. opentrons/protocol_api/core/tasks.py +31 -0
  80. opentrons/protocol_api/core/well.py +4 -0
  81. opentrons/protocol_api/instrument_context.py +388 -2
  82. opentrons/protocol_api/labware.py +10 -2
  83. opentrons/protocol_api/module_contexts.py +170 -6
  84. opentrons/protocol_api/protocol_context.py +87 -21
  85. opentrons/protocol_api/robot_context.py +41 -25
  86. opentrons/protocol_api/tasks.py +48 -0
  87. opentrons/protocol_api/validation.py +49 -3
  88. opentrons/protocol_engine/__init__.py +4 -0
  89. opentrons/protocol_engine/actions/__init__.py +6 -2
  90. opentrons/protocol_engine/actions/actions.py +31 -9
  91. opentrons/protocol_engine/clients/sync_client.py +42 -7
  92. opentrons/protocol_engine/commands/__init__.py +56 -0
  93. opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
  94. opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
  95. opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
  96. opentrons/protocol_engine/commands/aspirate.py +1 -0
  97. opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
  98. opentrons/protocol_engine/commands/capture_image.py +302 -0
  99. opentrons/protocol_engine/commands/command.py +2 -0
  100. opentrons/protocol_engine/commands/command_unions.py +62 -0
  101. opentrons/protocol_engine/commands/create_timer.py +83 -0
  102. opentrons/protocol_engine/commands/dispense.py +1 -0
  103. opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
  104. opentrons/protocol_engine/commands/drop_tip.py +32 -8
  105. opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
  106. opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
  107. opentrons/protocol_engine/commands/heater_shaker/__init__.py +14 -0
  108. opentrons/protocol_engine/commands/heater_shaker/common.py +20 -0
  109. opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +5 -4
  110. opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +136 -0
  111. opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +31 -5
  112. opentrons/protocol_engine/commands/move_labware.py +3 -4
  113. opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
  114. opentrons/protocol_engine/commands/movement_common.py +31 -2
  115. opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
  116. opentrons/protocol_engine/commands/pipetting_common.py +48 -3
  117. opentrons/protocol_engine/commands/set_tip_state.py +97 -0
  118. opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +38 -7
  119. opentrons/protocol_engine/commands/thermocycler/__init__.py +16 -0
  120. opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
  121. opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
  122. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +44 -7
  123. opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +43 -14
  124. opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +191 -0
  125. opentrons/protocol_engine/commands/touch_tip.py +1 -1
  126. opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
  127. opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
  128. opentrons/protocol_engine/create_protocol_engine.py +12 -0
  129. opentrons/protocol_engine/engine_support.py +3 -0
  130. opentrons/protocol_engine/errors/__init__.py +12 -0
  131. opentrons/protocol_engine/errors/exceptions.py +119 -0
  132. opentrons/protocol_engine/execution/__init__.py +4 -0
  133. opentrons/protocol_engine/execution/command_executor.py +62 -1
  134. opentrons/protocol_engine/execution/create_queue_worker.py +9 -2
  135. opentrons/protocol_engine/execution/labware_movement.py +13 -15
  136. opentrons/protocol_engine/execution/movement.py +2 -0
  137. opentrons/protocol_engine/execution/pipetting.py +19 -25
  138. opentrons/protocol_engine/execution/queue_worker.py +4 -0
  139. opentrons/protocol_engine/execution/run_control.py +8 -0
  140. opentrons/protocol_engine/execution/task_handler.py +157 -0
  141. opentrons/protocol_engine/protocol_engine.py +137 -36
  142. opentrons/protocol_engine/resources/__init__.py +4 -0
  143. opentrons/protocol_engine/resources/camera_provider.py +110 -0
  144. opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
  145. opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
  146. opentrons/protocol_engine/resources/file_provider.py +133 -58
  147. opentrons/protocol_engine/resources/labware_validation.py +10 -6
  148. opentrons/protocol_engine/slot_standardization.py +2 -0
  149. opentrons/protocol_engine/state/_well_math.py +60 -18
  150. opentrons/protocol_engine/state/addressable_areas.py +2 -0
  151. opentrons/protocol_engine/state/camera.py +54 -0
  152. opentrons/protocol_engine/state/commands.py +37 -14
  153. opentrons/protocol_engine/state/geometry.py +276 -379
  154. opentrons/protocol_engine/state/labware.py +62 -108
  155. opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
  156. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1336 -0
  157. opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
  158. opentrons/protocol_engine/state/modules.py +30 -8
  159. opentrons/protocol_engine/state/motion.py +44 -0
  160. opentrons/protocol_engine/state/preconditions.py +59 -0
  161. opentrons/protocol_engine/state/state.py +44 -0
  162. opentrons/protocol_engine/state/state_summary.py +4 -0
  163. opentrons/protocol_engine/state/tasks.py +139 -0
  164. opentrons/protocol_engine/state/tips.py +177 -258
  165. opentrons/protocol_engine/state/update_types.py +26 -9
  166. opentrons/protocol_engine/types/__init__.py +23 -4
  167. opentrons/protocol_engine/types/command_preconditions.py +18 -0
  168. opentrons/protocol_engine/types/deck_configuration.py +5 -1
  169. opentrons/protocol_engine/types/instrument.py +8 -1
  170. opentrons/protocol_engine/types/labware.py +1 -13
  171. opentrons/protocol_engine/types/location.py +26 -2
  172. opentrons/protocol_engine/types/module.py +11 -1
  173. opentrons/protocol_engine/types/tasks.py +38 -0
  174. opentrons/protocol_engine/types/tip.py +9 -0
  175. opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
  176. opentrons/protocol_runner/protocol_runner.py +14 -1
  177. opentrons/protocol_runner/run_orchestrator.py +49 -2
  178. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
  179. opentrons/protocols/api_support/definitions.py +1 -1
  180. opentrons/protocols/api_support/types.py +2 -1
  181. opentrons/simulate.py +51 -15
  182. opentrons/system/camera.py +334 -4
  183. opentrons/system/ffmpeg.py +110 -0
  184. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
  185. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +188 -160
  186. opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
  187. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
  188. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
  189. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
@@ -1,27 +1,24 @@
1
1
  """Tip state tracking."""
2
2
 
3
3
  from dataclasses import dataclass
4
- from enum import Enum
5
- from typing import Dict, Iterable, Optional, List, Union
4
+ from typing import Dict, Iterable, Optional, List, Set
6
5
 
7
- from opentrons.types import NozzleMapInterface
6
+ from opentrons.types import NozzleMapInterface, NozzleConfigurationType
8
7
  from opentrons.protocol_engine.state import update_types
8
+ from opentrons.protocol_engine.types import TipRackWellState
9
9
 
10
10
  from ._abstract_store import HasState, HandlesActions
11
- from ._well_math import wells_covered_dense
12
- from ..actions import Action, ResetTipsAction, get_state_updates
11
+ from ._well_math import (
12
+ wells_covered_dense,
13
+ wells_covered_by_pipette_configuration,
14
+ wells_covered_by_physical_pipette,
15
+ )
16
+ from ..actions import Action, get_state_updates
13
17
 
14
18
  from opentrons.hardware_control.nozzle_manager import NozzleMap
15
19
 
16
20
 
17
- class _TipRackWellState(Enum):
18
- """The state of a single tip in a tip rack's well."""
19
-
20
- CLEAN = "clean"
21
- USED = "used"
22
-
23
-
24
- _TipRackStateByWellName = Dict[str, _TipRackWellState]
21
+ _TipRackStateByWellName = Dict[str, TipRackWellState]
25
22
 
26
23
 
27
24
  @dataclass
@@ -49,19 +46,12 @@ class TipStore(HasState[TipState], HandlesActions):
49
46
  for state_update in get_state_updates(action):
50
47
  self._handle_state_update(state_update)
51
48
 
52
- if isinstance(action, ResetTipsAction):
53
- labware_id = action.labware_id
54
-
55
- for well_name in self._state.tips_by_labware_id[labware_id].keys():
56
- self._state.tips_by_labware_id[labware_id][
57
- well_name
58
- ] = _TipRackWellState.CLEAN
59
-
60
49
  def _handle_state_update(self, state_update: update_types.StateUpdate) -> None:
61
- if state_update.tips_used != update_types.NO_CHANGE:
62
- self._set_used_tips(
63
- labware_id=state_update.tips_used.labware_id,
64
- well_names=state_update.tips_used.well_names,
50
+ if state_update.tips_state != update_types.NO_CHANGE:
51
+ self._set_tip_state(
52
+ labware_id=state_update.tips_state.labware_id,
53
+ well_names=state_update.tips_state.well_names,
54
+ tip_state=state_update.tips_state.tip_state,
65
55
  )
66
56
 
67
57
  if state_update.loaded_labware != update_types.NO_CHANGE:
@@ -69,7 +59,7 @@ class TipStore(HasState[TipState], HandlesActions):
69
59
  definition = state_update.loaded_labware.definition
70
60
  if definition.parameters.isTiprack:
71
61
  self._state.tips_by_labware_id[labware_id] = {
72
- well_name: _TipRackWellState.CLEAN
62
+ well_name: TipRackWellState.CLEAN
73
63
  for column in definition.ordering
74
64
  for well_name in column
75
65
  }
@@ -83,7 +73,7 @@ class TipStore(HasState[TipState], HandlesActions):
83
73
  ]
84
74
  if definition.parameters.isTiprack:
85
75
  self._state.tips_by_labware_id[labware_id] = {
86
- well_name: _TipRackWellState.CLEAN
76
+ well_name: TipRackWellState.CLEAN
87
77
  for column in definition.ordering
88
78
  for well_name in column
89
79
  }
@@ -91,10 +81,12 @@ class TipStore(HasState[TipState], HandlesActions):
91
81
  column for column in definition.ordering
92
82
  ]
93
83
 
94
- def _set_used_tips(self, labware_id: str, well_names: Iterable[str]) -> None:
84
+ def _set_tip_state(
85
+ self, labware_id: str, well_names: Iterable[str], tip_state: TipRackWellState
86
+ ) -> None:
95
87
  well_states = self._state.tips_by_labware_id.get(labware_id, {})
96
88
  for well_name in well_names:
97
- well_states[well_name] = _TipRackWellState.USED
89
+ well_states[well_name] = tip_state
98
90
 
99
91
 
100
92
  class TipView:
@@ -117,226 +109,15 @@ class TipView:
117
109
  starting_tip_name: Optional[str],
118
110
  nozzle_map: Optional[NozzleMapInterface],
119
111
  ) -> Optional[str]:
120
- """Get the next available clean tip. Does not support use of a starting tip if the pipette used is in a partial configuration."""
121
- wells = self._state.tips_by_labware_id.get(labware_id, {})
122
- columns = self._state.columns_by_labware_id.get(labware_id, [])
112
+ """Gets the next available clean tip.
123
113
 
124
- # TODO(sf): I'm pretty sure this can be replaced with wells_covered_96 but I'm not quite sure how
125
- def _identify_tip_cluster(
126
- active_columns: int,
127
- active_rows: int,
128
- critical_column: int,
129
- critical_row: int,
130
- entry_well: str,
131
- ) -> Optional[List[str]]:
132
- tip_cluster: list[str] = []
133
- for i in range(active_columns):
134
- if entry_well == "A1" or entry_well == "H1":
135
- if critical_column - i >= 0:
136
- column = columns[critical_column - i]
137
- else:
138
- return None
139
- elif entry_well == "A12" or entry_well == "H12":
140
- if critical_column + i < len(columns):
141
- column = columns[critical_column + i]
142
- else:
143
- return None
144
- else:
145
- raise ValueError(
146
- f"Invalid entry well {entry_well} for tip cluster identification."
147
- )
148
- for j in range(active_rows):
149
- if entry_well == "A1" or entry_well == "A12":
150
- if critical_row - j >= 0:
151
- well = column[critical_row - j]
152
- else:
153
- return None
154
- elif entry_well == "H1" or entry_well == "H12":
155
- if critical_row + j < len(column):
156
- well = column[critical_row + j]
157
- else:
158
- return None
159
- tip_cluster.append(well)
160
-
161
- if any(well not in [*wells] for well in tip_cluster):
162
- return None
163
-
164
- return tip_cluster
165
-
166
- def _validate_tip_cluster(
167
- active_columns: int, active_rows: int, tip_cluster: List[str]
168
- ) -> Union[str, int, None]:
169
- if not any(wells[well] == _TipRackWellState.USED for well in tip_cluster):
170
- return tip_cluster[0]
171
- elif all(wells[well] == _TipRackWellState.USED for well in tip_cluster):
172
- return None
173
- else:
174
- # In the case of an 8ch pipette where a column has mixed state tips we may simply progress to the next column in our search
175
- if nozzle_map is not None and nozzle_map.physical_nozzle_count == 8:
176
- return None
177
-
178
- # In the case of a 96ch we can attempt to index in by singular rows and columns assuming that indexed direction is safe
179
- # The tip cluster list is ordered: Each row from a column in order by columns
180
- tip_cluster_final_column: list[str] = []
181
- for i in range(active_rows):
182
- tip_cluster_final_column.append(
183
- tip_cluster[((active_columns * active_rows) - 1) - i]
184
- )
185
- tip_cluster_final_row: list[str] = []
186
- for i in range(active_columns):
187
- tip_cluster_final_row.append(
188
- tip_cluster[(active_rows - 1) + (i * active_rows)]
189
- )
190
- if all(
191
- wells[well] == _TipRackWellState.USED
192
- for well in tip_cluster_final_column
193
- ):
194
- return None
195
- elif all(
196
- wells[well] == _TipRackWellState.USED
197
- for well in tip_cluster_final_row
198
- ):
199
- return None
200
- else:
201
- # Tiprack has no valid tip selection, cannot progress
202
- return -1
203
-
204
- # Search through the tiprack beginning at A1
205
- def _cluster_search_A1(active_columns: int, active_rows: int) -> Optional[str]:
206
- critical_column = active_columns - 1
207
- critical_row = active_rows - 1
208
-
209
- while critical_column < len(columns):
210
- tip_cluster = _identify_tip_cluster(
211
- active_columns, active_rows, critical_column, critical_row, "A1"
212
- )
213
- if tip_cluster is not None:
214
- result = _validate_tip_cluster(
215
- active_columns, active_rows, tip_cluster
216
- )
217
- if isinstance(result, str):
218
- return result
219
- elif isinstance(result, int) and result == -1:
220
- return None
221
- if critical_row + 1 < len(columns[0]):
222
- critical_row = critical_row + 1
223
- else:
224
- critical_column += 1
225
- critical_row = active_rows - 1
226
- return None
227
-
228
- # Search through the tiprack beginning at A12
229
- def _cluster_search_A12(active_columns: int, active_rows: int) -> Optional[str]:
230
- critical_column = len(columns) - active_columns
231
- critical_row = active_rows - 1
232
-
233
- while critical_column >= 0:
234
- tip_cluster = _identify_tip_cluster(
235
- active_columns, active_rows, critical_column, critical_row, "A12"
236
- )
237
- if tip_cluster is not None:
238
- result = _validate_tip_cluster(
239
- active_columns, active_rows, tip_cluster
240
- )
241
- if isinstance(result, str):
242
- return result
243
- elif isinstance(result, int) and result == -1:
244
- return None
245
- if critical_row + 1 < len(columns[0]):
246
- critical_row = critical_row + 1
247
- else:
248
- critical_column -= 1
249
- critical_row = active_rows - 1
250
- return None
251
-
252
- # Search through the tiprack beginning at H1
253
- def _cluster_search_H1(active_columns: int, active_rows: int) -> Optional[str]:
254
- critical_column = active_columns - 1
255
- critical_row = len(columns[critical_column]) - active_rows
256
-
257
- while critical_column <= len(columns): # change to max size of labware
258
- tip_cluster = _identify_tip_cluster(
259
- active_columns, active_rows, critical_column, critical_row, "H1"
260
- )
261
- if tip_cluster is not None:
262
- result = _validate_tip_cluster(
263
- active_columns, active_rows, tip_cluster
264
- )
265
- if isinstance(result, str):
266
- return result
267
- elif isinstance(result, int) and result == -1:
268
- return None
269
- if critical_row - 1 >= 0:
270
- critical_row = critical_row - 1
271
- else:
272
- critical_column += 1
273
- if critical_column >= len(columns):
274
- return None
275
- critical_row = len(columns[critical_column]) - active_rows
276
- return None
277
-
278
- # Search through the tiprack beginning at H12
279
- def _cluster_search_H12(active_columns: int, active_rows: int) -> Optional[str]:
280
- critical_column = len(columns) - active_columns
281
- critical_row = len(columns[critical_column]) - active_rows
282
-
283
- while critical_column >= 0:
284
- tip_cluster = _identify_tip_cluster(
285
- active_columns, active_rows, critical_column, critical_row, "H12"
286
- )
287
- if tip_cluster is not None:
288
- result = _validate_tip_cluster(
289
- active_columns, active_rows, tip_cluster
290
- )
291
- if isinstance(result, str):
292
- return result
293
- elif isinstance(result, int) and result == -1:
294
- return None
295
- if critical_row - 1 >= 0:
296
- critical_row = critical_row - 1
297
- else:
298
- critical_column -= 1
299
- if critical_column < 0:
300
- return None
301
- critical_row = len(columns[critical_column]) - active_rows
302
- return None
303
-
304
- if starting_tip_name is None and nozzle_map is not None and columns:
305
- num_channels = nozzle_map.physical_nozzle_count
306
- num_nozzle_cols = len(nozzle_map.columns)
307
- num_nozzle_rows = len(nozzle_map.rows)
308
- # Each pipette's cluster search is determined by the point of entry for a given pipette/configuration:
309
- # - Single channel pipettes always search a tiprack top to bottom, left to right
310
- # - Eight channel pipettes will begin at the top if the primary nozzle is H1 and at the bottom if
311
- # it is A1. The eight channel will always progress across the columns left to right.
312
- # - 96 Channel pipettes will begin in the corner opposite their primary/starting nozzle (if starting nozzle = A1, enter tiprack at H12)
313
- # The 96 channel will then progress towards the opposite corner, either going up or down, left or right depending on configuration.
314
-
315
- if num_channels == 1:
316
- return _cluster_search_A1(num_nozzle_cols, num_nozzle_rows)
317
- elif num_channels == 8:
318
- if nozzle_map.starting_nozzle == "A1":
319
- return _cluster_search_H1(num_nozzle_cols, num_nozzle_rows)
320
- elif nozzle_map.starting_nozzle == "H1":
321
- return _cluster_search_A1(num_nozzle_cols, num_nozzle_rows)
322
- elif num_channels == 96:
323
- if nozzle_map.starting_nozzle == "A1":
324
- return _cluster_search_H12(num_nozzle_cols, num_nozzle_rows)
325
- elif nozzle_map.starting_nozzle == "A12":
326
- return _cluster_search_H1(num_nozzle_cols, num_nozzle_rows)
327
- elif nozzle_map.starting_nozzle == "H1":
328
- return _cluster_search_A12(num_nozzle_cols, num_nozzle_rows)
329
- elif nozzle_map.starting_nozzle == "H12":
330
- return _cluster_search_A1(num_nozzle_cols, num_nozzle_rows)
331
- else:
332
- raise ValueError(
333
- f"Nozzle {nozzle_map.starting_nozzle} is an invalid starting tip for automatic tip pickup."
334
- )
335
- else:
336
- raise RuntimeError(
337
- "Invalid number of channels for automatic tip tracking."
338
- )
114
+ Does not support use of a starting tip if the pipette used is in a partial configuration.
115
+ """
116
+ if starting_tip_name is None and nozzle_map is not None:
117
+ return self._get_next_tip_with_nozzle_map(labware_id, nozzle_map)
339
118
  else:
119
+ wells = self._state.tips_by_labware_id.get(labware_id, {})
120
+ columns = self._state.columns_by_labware_id.get(labware_id, [])
340
121
  if columns and num_tips == len(columns[0]): # Get next tips for 8-channel
341
122
  column_head = [column[0] for column in columns]
342
123
  starting_column_index = 0
@@ -350,17 +131,15 @@ class TipView:
350
131
  starting_column_index = idx
351
132
 
352
133
  for column in columns[starting_column_index:]:
353
- if not any(
354
- wells[well] == _TipRackWellState.USED for well in column
355
- ):
134
+ if all(wells[well] == TipRackWellState.CLEAN for well in column):
356
135
  return column[0]
357
136
 
358
137
  elif num_tips == len(wells.keys()): # Get next tips for 96 channel
359
138
  if starting_tip_name and starting_tip_name != columns[0][0]:
360
139
  return None
361
140
 
362
- if not any(
363
- tip_state == _TipRackWellState.USED for tip_state in wells.values()
141
+ if all(
142
+ tip_state == TipRackWellState.CLEAN for tip_state in wells.values()
364
143
  ):
365
144
  return next(iter(wells))
366
145
 
@@ -369,10 +148,74 @@ class TipView:
369
148
  wells = _drop_wells_before_starting_tip(wells, starting_tip_name)
370
149
 
371
150
  for well_name, tip_state in wells.items():
372
- if tip_state == _TipRackWellState.CLEAN:
151
+ if tip_state == TipRackWellState.CLEAN:
373
152
  return well_name
374
153
  return None
375
154
 
155
+ def _get_next_tip_with_nozzle_map(
156
+ self,
157
+ labware_id: str,
158
+ nozzle_map: NozzleMapInterface,
159
+ ) -> Optional[str]:
160
+ """Get the next available clean tip for given nozzle configuration if one can be found."""
161
+ tip_well_states = self._state.tips_by_labware_id.get(labware_id, {})
162
+ wells_by_columns = self._state.columns_by_labware_id.get(labware_id, [])
163
+
164
+ def _validate_wells(well_list: Set[str], target_well: str) -> bool:
165
+ # If we are not picking up the correct number of tips it's not valid
166
+ if len(well_list) != nozzle_map.tip_count:
167
+ return False
168
+ # If not all the tips we'll be picking up are clean it's not valid
169
+ target_well_states = [tip_well_states[well_name] for well_name in well_list]
170
+ if not all(state == TipRackWellState.CLEAN for state in target_well_states):
171
+ return False
172
+ # Since we know a full configuration will always produce zero non-active overlapping wells
173
+ # we can skip the following checks if it is a full configuration.
174
+ if nozzle_map.configuration != NozzleConfigurationType.FULL:
175
+ # If we have a partial configuration we need to ensure that any wells in the way are NOT present
176
+ wells_covered_physically = set(
177
+ wells_covered_by_physical_pipette(
178
+ nozzle_map=nozzle_map, # type: ignore[arg-type]
179
+ target_well=target_well,
180
+ labware_wells_by_column=wells_by_columns,
181
+ )
182
+ )
183
+ wells_in_way_well_state = [
184
+ tip_well_states[well_name]
185
+ for well_name in wells_covered_physically.difference(well_list)
186
+ ]
187
+ # TODO(jbl 2025-08-25) this should be changed to ensure all these extra wells are EMPTY when further
188
+ # tip return work occurs
189
+ if any(
190
+ well_state == TipRackWellState.CLEAN
191
+ for well_state in wells_in_way_well_state
192
+ ):
193
+ return False
194
+
195
+ return True
196
+
197
+ # Get an ordered list of wells to most efficiently search, depending on pipette configuration
198
+ target_well_list = _resolve_well_order(wells_by_columns, nozzle_map)
199
+
200
+ for well in target_well_list:
201
+ # If the target well/tip isn't clean, skip to the next one. This will be checked
202
+ # again in _validate_wells, but we can short circuit the following checks if this is False
203
+ if tip_well_states[well] != TipRackWellState.CLEAN:
204
+ continue
205
+ # Get list of all wells (i.e. tips) that would be covered by the active nozzles
206
+ targeted_wells = set(
207
+ wells_covered_by_pipette_configuration(
208
+ nozzle_map=nozzle_map, # type: ignore[arg-type]
209
+ target_well=well,
210
+ labware_wells_by_column=wells_by_columns,
211
+ )
212
+ )
213
+ # If we are picking up the correct number of tips, return that target well
214
+ if _validate_wells(targeted_wells, target_well=well):
215
+ return well
216
+
217
+ return None
218
+
376
219
  def has_clean_tip(self, labware_id: str, well_name: str) -> bool:
377
220
  """Get whether a well in a labware has a clean tip.
378
221
 
@@ -387,12 +230,12 @@ class TipView:
387
230
  tip_rack = self._state.tips_by_labware_id.get(labware_id)
388
231
  well_state = tip_rack.get(well_name) if tip_rack else None
389
232
 
390
- return well_state == _TipRackWellState.CLEAN
233
+ return well_state == TipRackWellState.CLEAN
391
234
 
392
- def compute_tips_to_mark_as_used(
235
+ def compute_tips_to_mark_as_used_or_empty(
393
236
  self, labware_id: str, well_name: str, nozzle_map: NozzleMap
394
237
  ) -> list[str]:
395
- """Compute which tips a hypothetical tip pickup should mark as "used".
238
+ """Compute which tips a hypothetical tip pickup/drop should mark as "used" or "empty".
396
239
 
397
240
  Params:
398
241
  labware_id: The labware ID of the tip rack.
@@ -403,7 +246,15 @@ class TipView:
403
246
  The well names of all the tips that the operation will use.
404
247
  """
405
248
  columns = self._state.columns_by_labware_id.get(labware_id, [])
406
- return list(wells_covered_dense(nozzle_map, well_name, columns))
249
+ return list(
250
+ wells_covered_dense(
251
+ nozzle_map.columns,
252
+ nozzle_map.rows,
253
+ nozzle_map.starting_nozzle,
254
+ well_name,
255
+ columns,
256
+ )
257
+ )
407
258
 
408
259
 
409
260
  def _drop_wells_before_starting_tip(
@@ -411,10 +262,78 @@ def _drop_wells_before_starting_tip(
411
262
  ) -> _TipRackStateByWellName:
412
263
  """Drop any wells that come before the starting tip and return the remaining ones after."""
413
264
  seen_starting_well = False
414
- remaining_wells: dict[str, _TipRackWellState] = {}
265
+ remaining_wells: dict[str, TipRackWellState] = {}
415
266
  for well_name, tip_state in wells.items():
416
267
  if well_name == starting_tip_name:
417
268
  seen_starting_well = True
418
269
  if seen_starting_well:
419
270
  remaining_wells[well_name] = tip_state
420
271
  return remaining_wells
272
+
273
+
274
+ def _resolve_well_order( # noqa: C901
275
+ well_list: List[List[str]], nozzle_map: NozzleMapInterface
276
+ ) -> List[str]:
277
+ """Given a list of ordered columns and pipette information, returns a flat list of wells ordered for tip pick up.
278
+
279
+ Wells can be ordered in four different ways:
280
+ - Top to bottom, left to right (A1, B1, ... A2, B2, ... G12, H12)
281
+ - Top to bottom, right to left (A12, B12, ... A11, B11, ... G1, H1)
282
+ - Bottom to top, left to right (H1, G1, ... H2, G2, ... B12, A12)
283
+ - Bottom to top, right to left (A12, B12, ... A11, B11, ... G1, H1)
284
+
285
+ - Full configurations (which will always cover a single channel) will go top to bottom, left to right.
286
+ - A partial 8-channel pipette configuration will always search left to right, starting at either top to bottom for
287
+ starting nozzle H1 or bottom to top for starting nozzle A1
288
+ - A partial 96-channel pipette configuration will always begin in the opposite corner of the starting nozzle
289
+ """
290
+ if nozzle_map.configuration == NozzleConfigurationType.FULL:
291
+ return _get_top_to_bottom_left_to_right(well_list)
292
+ elif nozzle_map.physical_nozzle_count == 8:
293
+ if nozzle_map.starting_nozzle == "A1":
294
+ return _get_bottom_to_top_left_to_right(well_list)
295
+ elif nozzle_map.starting_nozzle == "H1":
296
+ return _get_top_to_bottom_left_to_right(well_list)
297
+ else:
298
+ raise ValueError(
299
+ f"Nozzle {nozzle_map.starting_nozzle} is an invalid starting tip for"
300
+ " 8-channel pipette automatic tip pickup."
301
+ )
302
+ elif nozzle_map.physical_nozzle_count == 96:
303
+ if nozzle_map.starting_nozzle == "A1":
304
+ return _get_bottom_to_top_right_to_left(well_list)
305
+ elif nozzle_map.starting_nozzle == "A12":
306
+ return _get_bottom_to_top_left_to_right(well_list)
307
+ elif nozzle_map.starting_nozzle == "H1":
308
+ return _get_top_to_bottom_right_to_left(well_list)
309
+ elif nozzle_map.starting_nozzle == "H12":
310
+ return _get_top_to_bottom_left_to_right(well_list)
311
+ else:
312
+ raise ValueError(
313
+ f"Nozzle {nozzle_map.starting_nozzle} is an invalid starting tip for 96-channel automatic tip pickup."
314
+ )
315
+ else:
316
+ raise ValueError(
317
+ f"Automatic tip pickup does not support {nozzle_map.physical_nozzle_count}-channel pipettes"
318
+ )
319
+
320
+
321
+ def _get_top_to_bottom_left_to_right(well_list: List[List[str]]) -> List[str]:
322
+ return [well for column in well_list for well in column]
323
+
324
+
325
+ def _get_bottom_to_top_left_to_right(well_list: List[List[str]]) -> List[str]:
326
+ reverse_column_ordering = [list(reversed(column)) for column in well_list]
327
+ return [well for column in reverse_column_ordering for well in column]
328
+
329
+
330
+ def _get_top_to_bottom_right_to_left(well_list: List[List[str]]) -> List[str]:
331
+ reverse_row_ordering = list(reversed(well_list))
332
+ return [well for column in reverse_row_ordering for well in column]
333
+
334
+
335
+ def _get_bottom_to_top_right_to_left(well_list: List[List[str]]) -> List[str]:
336
+ reverse_row_column_ordering = [
337
+ list(reversed(column)) for column in reversed(well_list)
338
+ ]
339
+ return [well for column in reverse_row_column_ordering for well in column]
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import dataclasses
6
6
  import enum
7
7
  import typing
8
- from typing_extensions import Self, Optional
8
+ from typing_extensions import Self
9
9
  from datetime import datetime
10
10
 
11
11
  from opentrons.hardware_control.nozzle_manager import NozzleMap
@@ -15,6 +15,7 @@ from opentrons.protocol_engine.types import (
15
15
  LabwareLocation,
16
16
  OnLabwareLocation,
17
17
  TipGeometry,
18
+ TipRackWellState,
18
19
  AspiratedFluid,
19
20
  LiquidClassRecord,
20
21
  ABSMeasureMode,
@@ -23,6 +24,7 @@ from opentrons.protocol_engine.types import (
23
24
  ModuleModel,
24
25
  ModuleDefinition,
25
26
  LabwareWellId,
27
+ PreconditionTypes,
26
28
  )
27
29
  from opentrons.types import MountType, DeckSlotName
28
30
  from opentrons_shared_data.labware.labware_definition import LabwareDefinition
@@ -235,8 +237,10 @@ class PipetteAspirateReadyUpdate:
235
237
 
236
238
 
237
239
  @dataclasses.dataclass
238
- class TipsUsedUpdate:
239
- """Represents an update that marks tips in a tip rack as used."""
240
+ class TipsStateUpdate:
241
+ """Represents an update that marks tips in a tip rack as the requested state."""
242
+
243
+ tip_state: TipRackWellState
240
244
 
241
245
  labware_id: str
242
246
  """The labware ID of the tip rack."""
@@ -398,6 +402,13 @@ class FilesAddedUpdate:
398
402
  file_ids: list[str]
399
403
 
400
404
 
405
+ @dataclasses.dataclass
406
+ class PreconditionUpdate:
407
+ """An update that changes command preconditions flags."""
408
+
409
+ preconditions: dict[PreconditionTypes, bool]
410
+
411
+
401
412
  @dataclasses.dataclass
402
413
  class AddressableAreaUsedUpdate:
403
414
  """An update that says an addressable area has been used."""
@@ -413,7 +424,7 @@ class LoadModuleUpdate:
413
424
  definition: ModuleDefinition
414
425
  slot_name: DeckSlotName
415
426
  requested_model: ModuleModel
416
- serial_number: Optional[str]
427
+ serial_number: typing.Optional[str]
417
428
 
418
429
 
419
430
  @dataclasses.dataclass
@@ -452,7 +463,7 @@ class StateUpdate:
452
463
 
453
464
  labware_lid: LabwareLidUpdate | NoChangeType = NO_CHANGE
454
465
 
455
- tips_used: TipsUsedUpdate | NoChangeType = NO_CHANGE
466
+ tips_state: TipsStateUpdate | NoChangeType = NO_CHANGE
456
467
 
457
468
  liquid_loaded: LiquidLoadedUpdate | NoChangeType = NO_CHANGE
458
469
 
@@ -474,6 +485,8 @@ class StateUpdate:
474
485
 
475
486
  ready_to_aspirate: PipetteAspirateReadyUpdate | NoChangeType = NO_CHANGE
476
487
 
488
+ precondition_update: PreconditionUpdate | NoChangeType = NO_CHANGE
489
+
477
490
  def append(self, other: Self) -> Self:
478
491
  """Apply another `StateUpdate` "on top of" this one.
479
492
 
@@ -682,7 +695,7 @@ class StateUpdate:
682
695
  definition: ModuleDefinition,
683
696
  slot_name: DeckSlotName,
684
697
  requested_model: ModuleModel,
685
- serial_number: Optional[str],
698
+ serial_number: typing.Optional[str],
686
699
  ) -> Self:
687
700
  """Add a new module to state. See `LoadModuleUpdate`."""
688
701
  self.loaded_module = LoadModuleUpdate(
@@ -729,9 +742,13 @@ class StateUpdate:
729
742
  )
730
743
  return self
731
744
 
732
- def mark_tips_as_used(self: Self, labware_id: str, well_names: list[str]) -> Self:
733
- """Mark tips in a tip rack as used. See `TipsUsedUpdate`."""
734
- self.tips_used = TipsUsedUpdate(labware_id=labware_id, well_names=well_names)
745
+ def update_tip_rack_well_state(
746
+ self: Self, tip_state: TipRackWellState, labware_id: str, well_names: list[str]
747
+ ) -> Self:
748
+ """Marks tips in a tip rack to provided tip state. See `TipsStateUpdate`."""
749
+ self.tips_state = TipsStateUpdate(
750
+ tip_state=tip_state, labware_id=labware_id, well_names=well_names
751
+ )
735
752
  return self
736
753
 
737
754
  def set_liquid_loaded(