puda-drivers 0.0.18__tar.gz → 0.0.19__tar.gz

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 (40) hide show
  1. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/PKG-INFO +1 -1
  2. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/pyproject.toml +1 -1
  3. puda_drivers-0.0.19/src/puda_drivers/labware/MEA_cell_MTP.json +56 -0
  4. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/labware/labware.py +44 -18
  5. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +2 -1
  6. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/labware/trash_bin.json +2 -1
  7. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/machines/first.py +230 -165
  8. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/move/gcode.py +14 -23
  9. puda_drivers-0.0.19/src/puda_drivers/py.typed +0 -0
  10. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/first.py +3 -0
  11. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/.gitignore +0 -0
  12. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/LICENSE +0 -0
  13. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/README.md +0 -0
  14. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/__init__.py +0 -0
  15. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/core/__init__.py +0 -0
  16. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/core/logging.py +0 -0
  17. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/core/position.py +0 -0
  18. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/core/serialcontroller.py +0 -0
  19. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/cv/__init__.py +0 -0
  20. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/cv/camera.py +0 -0
  21. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/labware/__init__.py +0 -0
  22. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json +0 -0
  23. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/machines/__init__.py +0 -0
  24. /puda_drivers-0.0.18/src/puda_drivers/py.typed → /puda_drivers-0.0.19/src/puda_drivers/measure/electrode.py +0 -0
  25. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/move/__init__.py +0 -0
  26. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/move/deck.py +0 -0
  27. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/move/grbl/__init__.py +0 -0
  28. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/move/grbl/api.py +0 -0
  29. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/move/grbl/constants.py +0 -0
  30. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
  31. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
  32. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
  33. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/rLine.py +0 -0
  34. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/example.py +0 -0
  35. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/pipette.py +0 -0
  36. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/poll_position.py +0 -0
  37. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/qubot.py +0 -0
  38. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/test_deck.py +0 -0
  39. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/test_position_polling.py +0 -0
  40. {puda_drivers-0.0.18 → puda_drivers-0.0.19}/tests/webcam.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Hardware drivers for the PUDA platform.
5
5
  Project-URL: Homepage, https://github.com/PUDAP/puda
6
6
  Project-URL: Issues, https://github.com/PUDAP/puda/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puda-drivers"
3
- version = "0.0.18"
3
+ version = "0.0.19"
4
4
  description = "Hardware drivers for the PUDA platform."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,56 @@
1
+ {
2
+
3
+ "ordering": [
4
+ [
5
+ "A1"
6
+ ]
7
+
8
+ ],
9
+ "brand": {
10
+ "brand": "Custom",
11
+ "brandId": []
12
+ },
13
+ "metadata": {
14
+ "displayName": "MEA cell MTP",
15
+ "displayCategory": "functionalLabware",
16
+ "tags": []
17
+ },
18
+ "dimensions": {
19
+ "xDimension": 127,
20
+ "yDimension": 85,
21
+ "zDimension": 28
22
+ },
23
+ "wells": {
24
+ "A1": {
25
+ "shape": "square",
26
+ "x": 63.5,
27
+ "y": 42.5,
28
+ "z": 25
29
+ }
30
+ },
31
+ "groups": [
32
+ {
33
+ "metadata": {
34
+ "wellBottomShape": "flat"
35
+ },
36
+ "wells": [
37
+ "A1"
38
+ ]
39
+ }
40
+ ],
41
+ "parameters": {
42
+ "format": "irregular",
43
+ "quirks": [],
44
+ "isTiprack": false,
45
+ "isMagneticModuleCompatible": false,
46
+ "loadName": "MEA_cell_MTP"
47
+ },
48
+ "namespace": "custom_beta",
49
+ "version": 1,
50
+ "schemaVersion": 2,
51
+ "cornerOffsetFromSlot": {
52
+ "x": 0,
53
+ "y": 0,
54
+ "z": 0
55
+ }
56
+ }
@@ -3,14 +3,14 @@
3
3
  import json
4
4
  import inspect
5
5
  from pathlib import Path
6
- from typing import Dict, Any, List
6
+ from typing import Dict, Any, List, Optional
7
7
  from abc import ABC
8
8
  from puda_drivers.core import Position
9
9
 
10
10
 
11
11
  class StandardLabware(ABC):
12
12
  """
13
- Generic Parent Class for all Labware on a microplate
13
+ Generic Parent Class for all Labware on a microplate
14
14
  """
15
15
  def __init__(self, labware_name: str):
16
16
  """
@@ -63,7 +63,7 @@ class StandardLabware(ABC):
63
63
 
64
64
  with open(definition_path, "r", encoding="utf-8") as f:
65
65
  return json.load(f)
66
-
66
+
67
67
  def __str__(self):
68
68
  """
69
69
  Return a string representation of the labware.
@@ -92,7 +92,7 @@ class StandardLabware(ABC):
92
92
  List of well identifiers (e.g., ["A1", "A2", "B1", ...])
93
93
  """
94
94
  return list(self._wells.keys())
95
-
95
+
96
96
  def get_well_position(self, well_id: str) -> Position:
97
97
  """
98
98
  Get the position of a well from definition.json.
@@ -110,39 +110,65 @@ class StandardLabware(ABC):
110
110
  well_id_upper = well_id.upper()
111
111
  if well_id_upper not in self._wells:
112
112
  raise KeyError(f"Well '{well_id}' not found in tip rack definition")
113
-
113
+
114
114
  # Get the well data from the definition
115
115
  well_data = self._wells.get(well_id_upper, {})
116
-
116
+
117
117
  # Return position of the well (x, y are already center coordinates)
118
118
  return Position(
119
119
  x=well_data.get("x", 0.0),
120
120
  y=well_data.get("y", 0.0),
121
- z=well_data.get("z", 0.0),
121
+ z=well_data.get("z", 0.0),
122
122
  )
123
-
124
- def get_height(self) -> float:
123
+
124
+ def get_height(self, well_name: Optional[str] = None) -> float:
125
125
  """
126
126
  Get the height of the labware.
127
-
127
+
128
+ Args:
129
+ well_name: Optional well name within the labware (e.g., "A1" for a well in a tiprack)
130
+ If not provided, the height of the labware is returned (zDimension).
128
131
  Returns:
129
- Height of the labware (zDimension)
132
+ Height of the labware (zDimension) or the height of the well (z)
130
133
 
131
134
  Raises:
132
- KeyError: If dimensions or zDimension is not found in the definition
135
+ KeyError: If dimensions or zDimension is not found in the definition, or if well_name is not found in the labware
133
136
  """
134
137
  dimensions = self._definition.get("dimensions")
135
138
  if dimensions is None:
136
139
  raise KeyError("'dimensions' not found in labware definition")
137
140
 
138
- if "zDimension" not in dimensions:
139
- raise KeyError("'zDimension' not found in labware dimensions")
140
-
141
- return dimensions["zDimension"]
142
-
141
+ if well_name:
142
+ well_data = self._wells.get(well_name.upper(), {})
143
+ if well_data is None:
144
+ raise KeyError(f"Well '{well_name}' not found in labware definition")
145
+ return well_data.get("z")
146
+ else:
147
+ if "zDimension" not in dimensions:
148
+ raise KeyError("'zDimension' not found in labware dimensions")
149
+ return dimensions["zDimension"]
150
+
143
151
  def get_insert_depth(self) -> float:
144
152
  """
145
- Get the insert depth of the labware.
153
+ Get the insert depth of the labware. This should be added to all labware definitions.
154
+
155
+ insert_depth represents the maximum internal Z-axis clearance of the
156
+ labware. It is calculated as the absolute difference between the Top
157
+ Plane (the highest point of the well opening) and the Internal Floor
158
+ (the physical bottom of the well).
159
+
160
+ Top of Labware (Z = 0 reference)
161
+ │ │
162
+ ┌─────┴──────┴─────┐ <─── Opening
163
+ │ │
164
+ │ SPACE │ }
165
+ │ AVAILABLE │ }
166
+ │ FOR │ } insert_depth
167
+ │ INSERTION │ } (e.g., 40mm)
168
+ │ │ }
169
+ └──────┬───────────┘
170
+
171
+ Bottom of Well (Limit)
146
172
 
147
173
  Returns:
148
174
  Insert depth of the labware
@@ -1,4 +1,5 @@
1
1
  {
2
+ "insert_depth": 70,
2
3
  "ordering": [
3
4
  [
4
5
  "A1",
@@ -30,7 +31,7 @@
30
31
  "dimensions": {
31
32
  "xDimension": 128,
32
33
  "yDimension": 85.5,
33
- "zDimension": 54
34
+ "zDimension": 74
34
35
  },
35
36
  "wells": {
36
37
  "A1": {
@@ -1,4 +1,5 @@
1
1
  {
2
+ "insert_depth": 0,
2
3
  "ordering": [
3
4
  ["A1"]
4
5
  ],
@@ -11,7 +12,7 @@
11
12
  "dimensions": {
12
13
  "xDimension": 127.76,
13
14
  "yDimension": 85.48,
14
- "zDimension": 0
15
+ "zDimension": 10
15
16
  },
16
17
  "wells": {
17
18
  "A1": {
@@ -29,12 +29,7 @@ class First:
29
29
 
30
30
  # Default configuration values
31
31
  DEFAULT_QUBOT_PORT = "/dev/ttyACM0"
32
- DEFAULT_QUBOT_BAUDRATE = 9600
33
- DEFAULT_QUBOT_FEEDRATE = 3000
34
-
35
32
  DEFAULT_SARTORIUS_PORT = "/dev/ttyUSB0"
36
- DEFAULT_SARTORIUS_BAUDRATE = 9600
37
-
38
33
  DEFAULT_CAMERA_INDEX = 0
39
34
 
40
35
  # origin position of Z and A axes
@@ -52,8 +47,11 @@ class First:
52
47
  # Height from z and a origin to the deck
53
48
  CEILING_HEIGHT = 192.2
54
49
 
55
- # Tip length
56
- TIP_LENGTH = 59
50
+ # Pipette Tip length
51
+ TIP_LENGTH = 59 # mm
52
+
53
+ # Electrode length
54
+ ELECTRODE_LENGTH = 2 # mm
57
55
 
58
56
  # Slot origins (the bottom left corner of the slot relative to the deck origin)
59
57
  SLOT_ORIGINS = {
@@ -146,6 +144,18 @@ class First:
146
144
  self.pipette.initialize()
147
145
  time.sleep(3) # need to wait for the pipette to initialize
148
146
  self._logger.info("Machine startup complete - ready for operations")
147
+
148
+ def home(self):
149
+ """
150
+ Home the qubot gantry to establish a known position.
151
+
152
+ This method homes the gantry to its reference position, which is useful
153
+ for establishing a known starting point before operations or after
154
+ potential position drift.
155
+ """
156
+ self._logger.info("Homing qubot gantry...")
157
+ self.qubot.home()
158
+ self._logger.info("Qubot gantry homing complete")
149
159
 
150
160
  def shutdown(self):
151
161
  """
@@ -158,9 +168,19 @@ class First:
158
168
  self.pipette.disconnect()
159
169
  self.camera.disconnect()
160
170
  self._logger.info("Machine shutdown complete")
171
+
172
+ def wait(self, seconds: float):
173
+ """
174
+ Wait for a specified number of seconds.
175
+
176
+ Args:
177
+ seconds: Number of seconds to wait (can be a float for fractional seconds)
178
+ """
179
+ self._logger.debug("Waiting for %.2f seconds", seconds)
180
+ time.sleep(seconds)
181
+ self._logger.debug("Waited for %.2f seconds", seconds)
161
182
 
162
183
  ### Queue (public commands) ###
163
-
164
184
  async def get_position(self) -> Dict[str, Union[Dict[str, float], int]]:
165
185
  """
166
186
  Get the current position of the machine. Both QuBot and Sartorius are queried.
@@ -183,47 +203,47 @@ class First:
183
203
  Get the current deck layout.
184
204
 
185
205
  Returns:
186
- Dictionary mapping slot names (e.g., "A1") to labware classes.
206
+ Dictionary mapping deck slot names (e.g., "A1") to labware classes.
187
207
 
188
208
  Raises:
189
209
  None
190
210
  """
191
211
  return self.deck.to_dict()
192
212
 
193
- def load_labware(self, slot: str, labware_name: str):
213
+ def load_labware(self, deck_slot: str, labware_name: str):
194
214
  """
195
- Load a labware object into a slot.
215
+ Load a labware object into a deck slot.
196
216
 
197
217
  Args:
198
- slot: Slot name (e.g., 'A1', 'B2')
218
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
199
219
  labware_name: Name of the labware class to load
200
220
 
201
221
  Raises:
202
- KeyError: If slot is not found in deck
222
+ KeyError: If deck_slot is not found in deck
203
223
  """
204
- self._logger.info("Loading labware '%s' into slot '%s'", labware_name, slot)
205
- self.deck.load_labware(slot=slot, labware_name=labware_name)
206
- self._logger.debug("Labware '%s' loaded into slot '%s'", labware_name, slot)
224
+ self._logger.info("Loading labware '%s' into deck slot '%s'", labware_name, deck_slot)
225
+ self.deck.load_labware(slot=deck_slot, labware_name=labware_name)
226
+ self._logger.debug("Labware '%s' loaded into deck slot '%s'", labware_name, deck_slot)
207
227
 
208
- def remove_labware(self, slot: str):
228
+ def remove_labware(self, deck_slot: str):
209
229
  """
210
- Remove labware from a slot.
230
+ Remove labware from a deck slot.
211
231
 
212
232
  Args:
213
- slot: Slot name (e.g., 'A1', 'B2')
233
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
214
234
 
215
235
  Raises:
216
- KeyError: If slot is not found in deck
236
+ KeyError: If deck_slot is not found in deck
217
237
  """
218
- self.deck.empty_slot(slot=slot)
219
- self._logger.debug("Slot '%s' emptied", slot)
238
+ self.deck.empty_slot(slot=deck_slot)
239
+ self._logger.debug("Deck slot '%s' emptied", deck_slot)
220
240
 
221
241
  def load_deck(self, deck_layout: Dict[str, Type[StandardLabware]]):
222
242
  """
223
243
  Load multiple labware into the deck at once.
224
244
 
225
245
  Args:
226
- deck_layout: Dictionary mapping slot names (e.g., "A1") to labware classes.
246
+ deck_layout: Dictionary mapping deck slot names (e.g., "A1") to labware classes.
227
247
  Each class will be instantiated automatically.
228
248
 
229
249
  Example:
@@ -234,17 +254,18 @@ class First:
234
254
  })
235
255
  """
236
256
  self._logger.info("Loading deck layout with %d labware items", len(deck_layout))
237
- for slot, labware_name in deck_layout.items():
238
- self.load_labware(slot=slot, labware_name=labware_name)
257
+ for deck_slot, labware_name in deck_layout.items():
258
+ self.load_labware(deck_slot=deck_slot, labware_name=labware_name)
239
259
  self._logger.info("Deck layout loaded successfully")
240
260
 
241
- def attach_tip(self, slot: str, well: Optional[str] = None):
261
+ ### Pipette operations ###
262
+ def attach_tip(self, deck_slot: str, well_name: Optional[str] = None):
242
263
  """
243
- Attach a tip from a slot.
264
+ Attach a tip from a deck slot.
244
265
 
245
266
  Args:
246
- slot: Slot name (e.g., 'A1', 'B2')
247
- well: Optional well name within the slot (e.g., 'A1' for a well in a tiprack)
267
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
268
+ well_name: Optional well name within the deck slot (e.g., 'A1' for a well in a tiprack)
248
269
 
249
270
  Note:
250
271
  This method is idempotent - if a tip is already attached, it will
@@ -254,21 +275,20 @@ class First:
254
275
  self._logger.warning("Tip already attached - skipping attachment (idempotent operation)")
255
276
  return
256
277
 
257
- self._logger.info("Attaching tip from slot '%s'%s", slot, f", well '{well}'" if well else "")
258
- pos = self._get_absolute_z_position(slot, well)
278
+ self._logger.info("Attaching tip from deck slot '%s'%s", deck_slot, f", well '{well_name}'" if well_name else "")
279
+ pos = self._get_absolute_z_position(deck_slot, well_name)
259
280
  self._logger.debug("Moving to position %s for tip attachment", pos)
260
281
  # return the offset from the origin
261
282
  self.qubot.move_absolute(position=pos)
262
283
 
263
284
  # attach tip (move slowly down)
264
- labware = self.deck[slot]
285
+ labware = self.deck[deck_slot]
265
286
  if labware is None:
266
- self._logger.error("Cannot attach tip: no labware loaded in slot '%s'", slot)
267
- raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before attaching tips.")
268
- insert_depth = labware.get_insert_depth()
269
- self._logger.debug("Moving down by %s mm to insert tip", insert_depth)
287
+ self._logger.error("Cannot attach tip: no labware loaded in deck slot '%s'", deck_slot)
288
+ raise ValueError(f"No labware loaded in deck slot '{deck_slot}'. Load labware before attaching tips.")
289
+ self._logger.debug("Moving down by %s mm to insert tip", labware.get_insert_depth())
270
290
  self.qubot.move_relative(
271
- position=Position(z=-insert_depth),
291
+ position=Position(z=-labware.get_insert_depth()),
272
292
  feed=500
273
293
  )
274
294
  self.pipette.set_tip_attached(attached=True)
@@ -277,33 +297,33 @@ class First:
277
297
  self.qubot.home(axis="Z")
278
298
  self._logger.debug("Z axis homed after tip attachment")
279
299
 
280
- def drop_tip(self, *, slot: str, well: str, height_from_bottom: float = 0.0):
300
+ def drop_tip(self, *, deck_slot: str, well_name: str, height_from_bottom: float = 0.0):
281
301
  """
282
- Drop a tip into a slot.
302
+ Drop a tip into a deck slot.
283
303
 
284
304
  Args:
285
- slot: Slot name (e.g., 'A1', 'B2')
286
- well: Well name within the slot (e.g., 'A1' for a well in a tiprack)
305
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
306
+ well_name: Well name within the deck slot (e.g., 'A1' for a well in a tiprack)
287
307
  height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
288
- Positive values move up from the bottom. Negative values
289
- may cause a ValueError if the resulting position is outside
290
- the Z axis limits.
308
+ Must be non-negative. Positive values move up from the bottom.
291
309
 
292
310
  Raises:
293
- ValueError: If no tip is attached, or if the resulting position is outside
294
- the Z axis limits.
311
+ ValueError: If no tip is attached, if height_from_bottom is negative, or if
312
+ the resulting position is outside the Z axis limits.
295
313
  """
314
+ if height_from_bottom < 0:
315
+ self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
316
+ raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
317
+
296
318
  if not self.pipette.is_tip_attached():
297
319
  self._logger.error("Cannot drop tip: no tip attached")
298
320
  raise ValueError("Tip not attached")
299
321
 
300
- self._logger.info("Dropping tip into slot '%s', well '%s'", slot, well)
301
- pos = self._get_absolute_z_position(slot, well)
322
+ self._logger.info("Dropping tip into deck slot '%s', well '%s'", deck_slot, well_name)
323
+ pos = self._get_absolute_z_position(deck_slot, well_name)
302
324
  # add height from bottom
303
325
  pos += Position(z=height_from_bottom)
304
- # move up by the tip length
305
- pos += Position(z=self.TIP_LENGTH)
306
- self._logger.debug("Moving to position %s (adjusted for tip length) for tip drop", pos)
326
+ self._logger.debug("Moving to position %s for tip drop", pos)
307
327
  self.qubot.move_absolute(position=pos)
308
328
 
309
329
  self._logger.debug("Ejecting tip")
@@ -312,71 +332,204 @@ class First:
312
332
  self.pipette.set_tip_attached(attached=False)
313
333
  self._logger.info("Tip dropped successfully")
314
334
 
315
- def aspirate_from(self, *, slot: str, well: str, amount: int, height_from_bottom: float = 0.0):
335
+ def aspirate_from(self, *, deck_slot: str, well_name: str, amount: int, height_from_bottom: float = 0.0):
316
336
  """
317
- Aspirate a volume of liquid from a slot.
337
+ Aspirate a volume of liquid from a deck slot.
318
338
 
319
339
  Args:
320
- slot: Slot name (e.g., 'A1', 'B2')
321
- well: Well name within the slot (e.g., 'A1')
340
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
341
+ well_name: Well name within the deck slot (e.g., 'A1')
322
342
  amount: Volume to aspirate in µL
323
343
  height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
324
- Positive values move up from the bottom. Negative values
325
- may cause a ValueError if the resulting position is outside
326
- the Z axis limits.
344
+ Must be non-negative. Positive values move up from the bottom.
327
345
 
328
346
  Raises:
329
- ValueError: If no tip is attached, or if the resulting position is outside
330
- the Z axis limits.
347
+ ValueError: If no tip is attached, if height_from_bottom is negative, or if
348
+ the resulting position is outside the Z axis limits.
331
349
  """
350
+ if height_from_bottom < 0:
351
+ self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
352
+ raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
353
+
332
354
  if not self.pipette.is_tip_attached():
333
355
  self._logger.error("Cannot aspirate: no tip attached")
334
356
  raise ValueError("Tip not attached")
335
357
 
336
- self._logger.info("Aspirating %d µL from slot '%s', well '%s'", amount, slot, well)
337
- pos = self._get_absolute_z_position(slot, well)
358
+ self._logger.info("Aspirating %d µL from deck slot '%s', well '%s'", amount, deck_slot, well_name)
359
+
360
+ pos = self._get_absolute_z_position(deck_slot, well_name)
338
361
  # add height from bottom
339
362
  pos += Position(z=height_from_bottom)
363
+ # subtract insert depth to get the bottom of the well
364
+ pos -= Position(z=self.deck[deck_slot].get_insert_depth())
365
+
340
366
  self._logger.debug("Moving Z axis to position %s", pos)
341
367
  self.qubot.move_absolute(position=pos)
342
-
343
368
  self._logger.debug("Aspirating %d µL", amount)
344
369
  self.pipette.aspirate(amount=amount)
345
370
  time.sleep(5)
346
- self._logger.info("Aspiration completed: %d µL from slot '%s', well '%s'", amount, slot, well)
371
+ self._logger.info("Aspiration completed: %d µL from deck slot '%s', well '%s'", amount, deck_slot, well_name)
347
372
 
348
- def dispense_to(self, *, slot: str, well: str, amount: int, height_from_bottom: float = 0.0):
373
+ def dispense_to(self, *, deck_slot: str, well_name: str, amount: int, height_from_bottom: float = 0.0):
349
374
  """
350
- Dispense a volume of liquid to a slot.
375
+ Dispense a volume of liquid to a deck slot.
351
376
 
352
377
  Args:
353
- slot: Slot name (e.g., 'A1', 'B2')
354
- well: Well name within the slot (e.g., 'A1')
378
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
379
+ well_name: Well name within the deck slot (e.g., 'A1')
355
380
  amount: Volume to dispense in µL
356
381
  height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
357
- Positive values move up from the bottom. Negative values
358
- may cause a ValueError if the resulting position is outside
359
- the Z axis limits.
382
+ Must be non-negative. Positive values move up from the bottom.
360
383
 
361
384
  Raises:
362
- ValueError: If no tip is attached, or if the resulting position is outside
363
- the Z axis limits.
385
+ ValueError: If no tip is attached, if height_from_bottom is negative, or if
386
+ the resulting position is outside the Z axis limits.
364
387
  """
388
+ if height_from_bottom < 0:
389
+ self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
390
+ raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
391
+
365
392
  if not self.pipette.is_tip_attached():
366
393
  self._logger.error("Cannot dispense: no tip attached")
367
394
  raise ValueError("Tip not attached")
368
395
 
369
- self._logger.info("Dispensing %d µL to slot '%s', well '%s'", amount, slot, well)
370
- pos = self._get_absolute_z_position(slot, well)
396
+ self._logger.info("Dispensing %d µL to deck slot '%s', well '%s'", amount, deck_slot, well_name)
397
+
398
+ pos = self._get_absolute_z_position(deck_slot, well_name)
371
399
  # add height from bottom
372
400
  pos += Position(z=height_from_bottom)
401
+ # subtract insert depth to get the bottom of the well
402
+ pos -= Position(z=self.deck[deck_slot].get_insert_depth())
403
+
373
404
  self._logger.debug("Moving Z axis to position %s", pos)
374
405
  self.qubot.move_absolute(position=pos)
375
-
376
406
  self._logger.debug("Dispensing %d µL", amount)
377
407
  self.pipette.dispense(amount=amount)
378
408
  time.sleep(5)
379
- self._logger.info("Dispense completed: %d µL to slot '%s', well '%s'", amount, slot, well)
409
+ self._logger.info("Dispense completed: %d µL to deck slot '%s', well '%s'", amount, deck_slot, well_name)
410
+
411
+ # Electrode operations
412
+ def move_electrode(self, deck_slot: str, well_name: str, height_from_bottom: float = 0.0):
413
+ """
414
+ Move the electrode to a deck slot.
415
+
416
+ Args:
417
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
418
+ well_name: Well name within the deck slot (e.g., 'A1')
419
+ height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
420
+ Must be non-negative. Positive values move up from the bottom.
421
+
422
+ Raises:
423
+ ValueError: If height_from_bottom is negative.
424
+ """
425
+ if height_from_bottom < 0:
426
+ self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
427
+ raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
428
+
429
+ pos = self._get_absolute_a_position(deck_slot, well_name)
430
+ pos += Position(a=height_from_bottom)
431
+ self.qubot.move_absolute(position=pos)
432
+ self._logger.info("Electrode moved to deck slot '%s', well '%s' at height %s mm from bottom", deck_slot, well_name, height_from_bottom)
433
+
434
+ # Helper methods
435
+ def _get_slot_origin(self, deck_slot: str) -> Position:
436
+ """
437
+ Get the origin coordinates of a deck slot.
438
+
439
+ Args:
440
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
441
+
442
+ Returns:
443
+ Position for the deck slot origin
444
+
445
+ Raises:
446
+ KeyError: If deck_slot name is invalid
447
+ """
448
+ deck_slot = deck_slot.upper()
449
+ if deck_slot not in self.SLOT_ORIGINS:
450
+ self._logger.error("Invalid deck slot name: '%s'. Must be one of %s", deck_slot, list(self.SLOT_ORIGINS.keys()))
451
+ raise KeyError(f"Invalid deck slot name: {deck_slot}. Must be one of {list(self.SLOT_ORIGINS.keys())}")
452
+ pos = self.SLOT_ORIGINS[deck_slot]
453
+ self._logger.debug("Deck slot origin for '%s': %s", deck_slot, pos)
454
+ return pos
455
+
456
+ def _get_absolute_z_position(self, deck_slot: str, well_name: Optional[str] = None) -> Position:
457
+ """
458
+ Get the absolute position for a deck slot (and optionally a well within that deck slot) based on the origin
459
+
460
+ Args:
461
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
462
+ well_name: Optional well name within the deck slot (e.g., 'A1' for a well in a tiprack)
463
+
464
+ Returns:
465
+ Position with absolute coordinates
466
+
467
+ Raises:
468
+ ValueError: If well_name is specified but no labware is loaded in the deck slot
469
+ """
470
+ # Get deck slot origin
471
+ pos = self._get_slot_origin(deck_slot)
472
+
473
+ # relative well position from deck slot origin
474
+ if well_name:
475
+ labware = self.deck[deck_slot]
476
+ if labware is None:
477
+ self._logger.error("Cannot get well position: no labware loaded in deck slot '%s'", deck_slot)
478
+ raise ValueError(f"No labware loaded in deck slot '{deck_slot}'. Load labware before accessing wells.")
479
+ well_pos = labware.get_well_position(well_name).get_xy()
480
+ # the deck is rotated 90 degrees clockwise for this machine
481
+ pos += well_pos.swap_xy()
482
+ # get z
483
+ pos += Position(z=labware.get_height() - self.CEILING_HEIGHT)
484
+ # if tip attached, add tip length
485
+ if self.pipette.is_tip_attached():
486
+ pos += Position(z=self.TIP_LENGTH)
487
+ self._logger.debug("Absolute Z position for deck slot '%s', well '%s': %s", deck_slot, well_name, pos)
488
+ else:
489
+ self._logger.debug("Absolute Z position for deck slot '%s': %s", deck_slot, pos)
490
+ return pos
491
+
492
+ def _get_absolute_a_position(self, deck_slot: str, well_name: Optional[str] = None) -> Position:
493
+ """
494
+ Get the absolute position for a deck slot (and optionally a well within that deck slot) based on the origin
495
+
496
+ Args:
497
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
498
+ well_name: Optional well name within the deck slot (e.g., 'A1' for a well in a tiprack)
499
+
500
+ Returns:
501
+ Position with absolute coordinates
502
+
503
+ Raises:
504
+ ValueError: If well_name is specified but no labware is loaded in the deck slot
505
+ """
506
+ # get x and y
507
+ pos = self._get_slot_origin(deck_slot)
508
+ pos -= self.A_ORIGIN # subtract the origin to get the absolute position
509
+
510
+ # Get labware for a-axis positioning
511
+ labware = self.deck[deck_slot]
512
+ if labware is None:
513
+ self._logger.error("Cannot get electrode position: no labware loaded in deck slot '%s'", deck_slot)
514
+ raise ValueError(f"No labware loaded in deck slot '{deck_slot}'. Load labware before moving electrode.")
515
+
516
+ # get x and y for well if specified
517
+ if well_name:
518
+ well_pos = labware.get_well_position(well_name).get_xy()
519
+ pos += well_pos.swap_xy()
520
+
521
+ # get a (applies to both with and without well_name)
522
+ pos += Position(a=labware.get_height() - self.CEILING_HEIGHT)
523
+ pos += Position(a=self.ELECTRODE_LENGTH)
524
+
525
+ if well_name:
526
+ self._logger.debug("Absolute A position for deck slot '%s', well '%s': %s", deck_slot, well_name, pos)
527
+ else:
528
+ self._logger.debug("Absolute A position for deck slot '%s': %s", deck_slot, pos)
529
+
530
+ return pos
531
+
532
+ ### Camera operations ###
380
533
 
381
534
  def start_video_recording(
382
535
  self,
@@ -461,94 +614,6 @@ class First:
461
614
  """
462
615
  return self.camera.capture_image(save=save, filename=filename)
463
616
 
464
- ### Private methods ###
465
-
466
- def _get_slot_origin(self, slot: str) -> Position:
467
- """
468
- Get the origin coordinates of a slot.
469
-
470
- Args:
471
- slot: Slot name (e.g., 'A1', 'B2')
472
-
473
- Returns:
474
- Position for the slot origin
475
-
476
- Raises:
477
- KeyError: If slot name is invalid
478
- """
479
- slot = slot.upper()
480
- if slot not in self.SLOT_ORIGINS:
481
- self._logger.error("Invalid slot name: '%s'. Must be one of %s", slot, list(self.SLOT_ORIGINS.keys()))
482
- raise KeyError(f"Invalid slot name: {slot}. Must be one of {list(self.SLOT_ORIGINS.keys())}")
483
- pos = self.SLOT_ORIGINS[slot]
484
- self._logger.debug("Slot origin for '%s': %s", slot, pos)
485
- return pos
486
-
487
- def _get_absolute_z_position(self, slot: str, well: Optional[str] = None) -> Position:
488
- """
489
- Get the absolute position for a slot (and optionally a well within that slot) based on the origin
490
-
491
- Args:
492
- slot: Slot name (e.g., 'A1', 'B2')
493
- well: Optional well name within the slot (e.g., 'A1' for a well in a tiprack)
494
-
495
- Returns:
496
- Position with absolute coordinates
497
-
498
- Raises:
499
- ValueError: If well is specified but no labware is loaded in the slot
500
- """
501
- # Get slot origin
502
- pos = self._get_slot_origin(slot)
503
-
504
- # relative well position from slot origin
505
- if well:
506
- labware = self.deck[slot]
507
- if labware is None:
508
- self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
509
- raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
510
- well_pos = labware.get_well_position(well).get_xy()
511
- # the deck is rotated 90 degrees clockwise for this machine
512
- pos += well_pos.swap_xy()
513
- # get z
514
- pos += Position(z=labware.get_height() - self.CEILING_HEIGHT)
515
- self._logger.debug("Absolute Z position for slot '%s', well '%s': %s", slot, well, pos)
516
- else:
517
- self._logger.debug("Absolute Z position for slot '%s': %s", slot, pos)
518
- return pos
519
-
520
- def _get_absolute_a_position(self, slot: str, well: Optional[str] = None) -> Position:
521
- """
522
- Get the absolute position for a slot (and optionally a well within that slot) based on the origin
523
-
524
- Args:
525
- slot: Slot name (e.g., 'A1', 'B2')
526
- well: Optional well name within the slot (e.g., 'A1' for a well in a tiprack)
527
-
528
- Returns:
529
- Position with absolute coordinates
530
-
531
- Raises:
532
- ValueError: If well is specified but no labware is loaded in the slot
533
- """
534
- pos = self._get_slot_origin(slot)
535
-
536
- if well:
537
- labware = self.deck[slot]
538
- if labware is None:
539
- self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
540
- raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
541
- well_pos = labware.get_well_position(well).get_xy()
542
- pos += well_pos.swap_xy()
543
-
544
- # get a
545
- a = Position(a=labware.get_height() - self.CEILING_HEIGHT)
546
- pos += a
547
- self._logger.debug("Absolute A position for slot '%s', well '%s': %s", slot, well, pos)
548
- else:
549
- self._logger.debug("Absolute A position for slot '%s': %s", slot, pos)
550
- return pos
551
-
552
617
  ### Control (immediate commands) ###
553
618
 
554
619
  def pause(self):
@@ -332,30 +332,21 @@ class GCodeController(SerialController):
332
332
  >>> pos = Position(x=10, y=20)
333
333
  >>> controller.move_absolute(position=pos) # z and a use current values
334
334
  """
335
- # Fill in missing axes with current positions
336
- final_x = position.x if position.has_axis("x") else self._current_position.x
337
- final_y = position.y if position.has_axis("y") else self._current_position.y
338
- final_z = position.z if position.has_axis("z") else self._current_position.z
339
- final_a = position.a if position.has_axis("a") else self._current_position.a
340
-
341
- # Create final position for validation and execution
342
- final_position = Position(x=final_x, y=final_y, z=final_z, a=final_a)
343
-
344
335
  # Validate positions before executing move
345
- self._validate_move_positions(position=final_position)
336
+ self._validate_move_positions(position=position)
346
337
 
347
338
  feed_rate = feed if feed is not None else self._feed
348
339
  self._logger.info(
349
340
  "Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
350
- final_x,
351
- final_y,
352
- final_z,
353
- final_a,
341
+ position.x,
342
+ position.y,
343
+ position.z,
344
+ position.a,
354
345
  feed_rate,
355
346
  )
356
347
 
357
348
  return self._execute_move(
358
- position=final_position,
349
+ position=position,
359
350
  feed=feed_rate
360
351
  )
361
352
 
@@ -429,9 +420,9 @@ class GCodeController(SerialController):
429
420
  All coordinates are treated as absolute positions.
430
421
 
431
422
  Safe move pattern:
432
- 1. If X or Y movement is needed, first move Z to 0 (safe height)
423
+ 1. If X or Y movement is needed, first move Z and A to safe height
433
424
  2. Then move X, Y to target
434
- 3. Finally move Z and A back to original position (or target if specified)
425
+ 3. Finally move Z or A to target (only the one that needs to move, the other stays where it is)
435
426
 
436
427
  Args:
437
428
  position: Position with absolute positions for X, Y, Z, A axes
@@ -440,8 +431,8 @@ class GCodeController(SerialController):
440
431
  # Check if any movement is needed
441
432
  needs_x_move = abs(position.x - self._current_position.x) > self.TOLERANCE
442
433
  needs_y_move = abs(position.y - self._current_position.y) > self.TOLERANCE
443
- needs_z_move = abs(position.z - self._current_position.z) > self.TOLERANCE
444
- needs_a_move = abs(position.a - self._current_position.a) > self.TOLERANCE
434
+ needs_z_move = abs(position.z - 0) > self.TOLERANCE
435
+ needs_a_move = abs(position.a - 0) > self.TOLERANCE
445
436
 
446
437
  if not (needs_x_move or needs_y_move or needs_z_move or needs_a_move):
447
438
  self._logger.warning(
@@ -450,7 +441,7 @@ class GCodeController(SerialController):
450
441
  return
451
442
 
452
443
  if needs_z_move and needs_a_move:
453
- self._logger.warning(
444
+ self._logger.error(
454
445
  "Move command issued with both Z and A movement. This is not supported. Skipping transmission."
455
446
  )
456
447
  raise ValueError("Move command issued with both Z and A movement. This is not supported.")
@@ -463,7 +454,7 @@ class GCodeController(SerialController):
463
454
  self._logger.debug(
464
455
  "Safe move: Raising Z and A to safe height (%s) before XY movement", self.SAFE_MOVE_HEIGHT
465
456
  )
466
- move_cmd = f"G1 Z-5 A-5 F{self._z_feed}"
457
+ move_cmd = f"G1 Z{self.SAFE_MOVE_HEIGHT} A{self.SAFE_MOVE_HEIGHT} F{self._z_feed}"
467
458
  self.execute(move_cmd)
468
459
  self._wait_for_move()
469
460
  self._current_position.z = self.SAFE_MOVE_HEIGHT
@@ -493,13 +484,13 @@ class GCodeController(SerialController):
493
484
  if needs_y_move:
494
485
  self._current_position.y = position.y
495
486
 
496
- # Step 3: Move Z and A back to original position (or target if specified)
487
+ # Step 3: Move Z or A to target (only the one that needs to move)
497
488
  if needs_z_move:
498
489
  move_cmd = f"G1 Z{position.z} F{self._z_feed}"
499
490
  self.execute(move_cmd)
500
491
  self._wait_for_move()
501
492
  self._current_position.z = position.z
502
- elif needs_a_move:
493
+ if needs_a_move:
503
494
  move_cmd = f"G1 A{position.a} F{self._z_feed}"
504
495
  self.execute(move_cmd)
505
496
  self._wait_for_move()
File without changes
@@ -37,6 +37,9 @@ if __name__ == "__main__":
37
37
 
38
38
  machine.startup() # Connects all controllers, homes gantry, and initializes pipette
39
39
 
40
+ machine.get_absolute_a_position(slot="A3", well="A1")
41
+ # machine.move_electrode(slot="A3", well="A1", height_from_bottom=0)
42
+
40
43
  # machine.record_video(duration_seconds=10, filename="test.mp4")
41
44
  machine.start_video_recording()
42
45
  machine.attach_tip(slot="A3", well="G8")
File without changes
File without changes
File without changes