puda-drivers 0.0.17__py3-none-any.whl → 0.0.19__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.
@@ -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,15 +3,14 @@
3
3
  import json
4
4
  import inspect
5
5
  from pathlib import Path
6
- from typing import Dict, Any
6
+ from typing import Dict, Any, List, Optional
7
7
  from abc import ABC
8
- from typing import List
9
8
  from puda_drivers.core import Position
10
9
 
11
10
 
12
11
  class StandardLabware(ABC):
13
12
  """
14
- Generic Parent Class for all Labware on a microplate
13
+ Generic Parent Class for all Labware on a microplate
15
14
  """
16
15
  def __init__(self, labware_name: str):
17
16
  """
@@ -64,7 +63,7 @@ class StandardLabware(ABC):
64
63
 
65
64
  with open(definition_path, "r", encoding="utf-8") as f:
66
65
  return json.load(f)
67
-
66
+
68
67
  def __str__(self):
69
68
  """
70
69
  Return a string representation of the labware.
@@ -93,7 +92,7 @@ class StandardLabware(ABC):
93
92
  List of well identifiers (e.g., ["A1", "A2", "B1", ...])
94
93
  """
95
94
  return list(self._wells.keys())
96
-
95
+
97
96
  def get_well_position(self, well_id: str) -> Position:
98
97
  """
99
98
  Get the position of a well from definition.json.
@@ -111,39 +110,65 @@ class StandardLabware(ABC):
111
110
  well_id_upper = well_id.upper()
112
111
  if well_id_upper not in self._wells:
113
112
  raise KeyError(f"Well '{well_id}' not found in tip rack definition")
114
-
113
+
115
114
  # Get the well data from the definition
116
115
  well_data = self._wells.get(well_id_upper, {})
117
-
116
+
118
117
  # Return position of the well (x, y are already center coordinates)
119
118
  return Position(
120
119
  x=well_data.get("x", 0.0),
121
120
  y=well_data.get("y", 0.0),
122
- z=well_data.get("z", 0.0),
121
+ z=well_data.get("z", 0.0),
123
122
  )
124
-
125
- def get_height(self) -> float:
123
+
124
+ def get_height(self, well_name: Optional[str] = None) -> float:
126
125
  """
127
126
  Get the height of the labware.
128
-
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).
129
131
  Returns:
130
- Height of the labware (zDimension)
132
+ Height of the labware (zDimension) or the height of the well (z)
131
133
 
132
134
  Raises:
133
- 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
134
136
  """
135
137
  dimensions = self._definition.get("dimensions")
136
138
  if dimensions is None:
137
139
  raise KeyError("'dimensions' not found in labware definition")
138
140
 
139
- if "zDimension" not in dimensions:
140
- raise KeyError("'zDimension' not found in labware dimensions")
141
-
142
- return dimensions["zDimension"]
143
-
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
+
144
151
  def get_insert_depth(self) -> float:
145
152
  """
146
- 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)
147
172
 
148
173
  Returns:
149
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 = {
@@ -141,11 +139,23 @@ class First:
141
139
  self._logger.info("Homing gantry...")
142
140
  self.qubot.home()
143
141
 
144
- # Initialize the pipette (all pipette operations need to wait 5 seconds for completion)
142
+ # Initialize the pipette
145
143
  self._logger.info("Initializing pipette...")
146
144
  self.pipette.initialize()
147
- time.sleep(5)
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,7 +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.
161
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)
182
+
183
+ ### Queue (public commands) ###
162
184
  async def get_position(self) -> Dict[str, Union[Dict[str, float], int]]:
163
185
  """
164
186
  Get the current position of the machine. Both QuBot and Sartorius are queried.
@@ -175,56 +197,53 @@ class First:
175
197
  "qubot": qubot_position.to_dict(),
176
198
  "pipette": sartorius_position,
177
199
  }
178
-
179
- ### Labware management ###
180
-
181
- def load_labware(self, slot: str, labware_name: str):
200
+
201
+ def get_deck(self):
182
202
  """
183
- Load a labware object into a slot.
203
+ Get the current deck layout.
184
204
 
185
- Args:
186
- slot: Slot name (e.g., 'A1', 'B2')
187
- labware_name: Name of the labware class to load
205
+ Returns:
206
+ Dictionary mapping deck slot names (e.g., "A1") to labware classes.
188
207
 
189
208
  Raises:
190
- KeyError: If slot is not found in deck
209
+ None
191
210
  """
192
- self._logger.info("Loading labware '%s' into slot '%s'", labware_name, slot)
193
- self.deck.load_labware(slot=slot, labware_name=labware_name)
194
- self._logger.debug("Labware '%s' loaded into slot '%s'", labware_name, slot)
195
-
196
- def remove_labware(self, slot: str):
211
+ return self.deck.to_dict()
212
+
213
+ def load_labware(self, deck_slot: str, labware_name: str):
197
214
  """
198
- Remove labware from a slot.
215
+ Load a labware object into a deck slot.
199
216
 
200
217
  Args:
201
- slot: Slot name (e.g., 'A1', 'B2')
218
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
219
+ labware_name: Name of the labware class to load
202
220
 
203
221
  Raises:
204
- KeyError: If slot is not found in deck
222
+ KeyError: If deck_slot is not found in deck
205
223
  """
206
- self.deck.empty_slot(slot=slot)
207
- self._logger.debug("Slot '%s' emptied", slot)
208
-
209
- def get_deck(self):
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)
227
+
228
+ def remove_labware(self, deck_slot: str):
210
229
  """
211
- Get the current deck layout.
230
+ Remove labware from a deck slot.
212
231
 
213
- Returns:
214
- Dictionary mapping slot names (e.g., "A1") to labware classes.
232
+ Args:
233
+ deck_slot: Deck slot name (e.g., 'A1', 'B2')
215
234
 
216
235
  Raises:
217
- None
236
+ KeyError: If deck_slot is not found in deck
218
237
  """
219
- return self.deck.to_dict()
220
-
238
+ self.deck.empty_slot(slot=deck_slot)
239
+ self._logger.debug("Deck slot '%s' emptied", deck_slot)
221
240
 
222
241
  def load_deck(self, deck_layout: Dict[str, Type[StandardLabware]]):
223
242
  """
224
243
  Load multiple labware into the deck at once.
225
244
 
226
245
  Args:
227
- 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.
228
247
  Each class will be instantiated automatically.
229
248
 
230
249
  Example:
@@ -235,19 +254,18 @@ class First:
235
254
  })
236
255
  """
237
256
  self._logger.info("Loading deck layout with %d labware items", len(deck_layout))
238
- for slot, labware_name in deck_layout.items():
239
- 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)
240
259
  self._logger.info("Deck layout loaded successfully")
241
260
 
242
- ### Liquid handling ###
243
-
244
- 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):
245
263
  """
246
- Attach a tip from a slot.
264
+ Attach a tip from a deck slot.
247
265
 
248
266
  Args:
249
- slot: Slot name (e.g., 'A1', 'B2')
250
- 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)
251
269
 
252
270
  Note:
253
271
  This method is idempotent - if a tip is already attached, it will
@@ -257,21 +275,20 @@ class First:
257
275
  self._logger.warning("Tip already attached - skipping attachment (idempotent operation)")
258
276
  return
259
277
 
260
- self._logger.info("Attaching tip from slot '%s'%s", slot, f", well '{well}'" if well else "")
261
- 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)
262
280
  self._logger.debug("Moving to position %s for tip attachment", pos)
263
281
  # return the offset from the origin
264
282
  self.qubot.move_absolute(position=pos)
265
283
 
266
284
  # attach tip (move slowly down)
267
- labware = self.deck[slot]
285
+ labware = self.deck[deck_slot]
268
286
  if labware is None:
269
- self._logger.error("Cannot attach tip: no labware loaded in slot '%s'", slot)
270
- raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before attaching tips.")
271
- insert_depth = labware.get_insert_depth()
272
- 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())
273
290
  self.qubot.move_relative(
274
- position=Position(z=-insert_depth),
291
+ position=Position(z=-labware.get_insert_depth()),
275
292
  feed=500
276
293
  )
277
294
  self.pipette.set_tip_attached(attached=True)
@@ -280,33 +297,33 @@ class First:
280
297
  self.qubot.home(axis="Z")
281
298
  self._logger.debug("Z axis homed after tip attachment")
282
299
 
283
- 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):
284
301
  """
285
- Drop a tip into a slot.
302
+ Drop a tip into a deck slot.
286
303
 
287
304
  Args:
288
- slot: Slot name (e.g., 'A1', 'B2')
289
- 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)
290
307
  height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
291
- Positive values move up from the bottom. Negative values
292
- may cause a ValueError if the resulting position is outside
293
- the Z axis limits.
308
+ Must be non-negative. Positive values move up from the bottom.
294
309
 
295
310
  Raises:
296
- ValueError: If no tip is attached, or if the resulting position is outside
297
- 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.
298
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
+
299
318
  if not self.pipette.is_tip_attached():
300
319
  self._logger.error("Cannot drop tip: no tip attached")
301
320
  raise ValueError("Tip not attached")
302
321
 
303
- self._logger.info("Dropping tip into slot '%s', well '%s'", slot, well)
304
- 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)
305
324
  # add height from bottom
306
325
  pos += Position(z=height_from_bottom)
307
- # move up by the tip length
308
- pos += Position(z=self.TIP_LENGTH)
309
- 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)
310
327
  self.qubot.move_absolute(position=pos)
311
328
 
312
329
  self._logger.debug("Ejecting tip")
@@ -315,157 +332,204 @@ class First:
315
332
  self.pipette.set_tip_attached(attached=False)
316
333
  self._logger.info("Tip dropped successfully")
317
334
 
318
- 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):
319
336
  """
320
- Aspirate a volume of liquid from a slot.
337
+ Aspirate a volume of liquid from a deck slot.
321
338
 
322
339
  Args:
323
- slot: Slot name (e.g., 'A1', 'B2')
324
- 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')
325
342
  amount: Volume to aspirate in µL
326
343
  height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
327
- Positive values move up from the bottom. Negative values
328
- may cause a ValueError if the resulting position is outside
329
- the Z axis limits.
344
+ Must be non-negative. Positive values move up from the bottom.
330
345
 
331
346
  Raises:
332
- ValueError: If no tip is attached, or if the resulting position is outside
333
- 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.
334
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
+
335
354
  if not self.pipette.is_tip_attached():
336
355
  self._logger.error("Cannot aspirate: no tip attached")
337
356
  raise ValueError("Tip not attached")
338
357
 
339
- self._logger.info("Aspirating %d µL from slot '%s', well '%s'", amount, slot, well)
340
- 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)
341
361
  # add height from bottom
342
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
+
343
366
  self._logger.debug("Moving Z axis to position %s", pos)
344
367
  self.qubot.move_absolute(position=pos)
345
-
346
368
  self._logger.debug("Aspirating %d µL", amount)
347
369
  self.pipette.aspirate(amount=amount)
348
370
  time.sleep(5)
349
- 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)
350
372
 
351
- 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):
352
374
  """
353
- Dispense a volume of liquid to a slot.
375
+ Dispense a volume of liquid to a deck slot.
354
376
 
355
377
  Args:
356
- slot: Slot name (e.g., 'A1', 'B2')
357
- 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')
358
380
  amount: Volume to dispense in µL
359
381
  height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
360
- Positive values move up from the bottom. Negative values
361
- may cause a ValueError if the resulting position is outside
362
- the Z axis limits.
382
+ Must be non-negative. Positive values move up from the bottom.
363
383
 
364
384
  Raises:
365
- ValueError: If no tip is attached, or if the resulting position is outside
366
- 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.
367
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
+
368
392
  if not self.pipette.is_tip_attached():
369
393
  self._logger.error("Cannot dispense: no tip attached")
370
394
  raise ValueError("Tip not attached")
371
395
 
372
- self._logger.info("Dispensing %d µL to slot '%s', well '%s'", amount, slot, well)
373
- 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)
374
399
  # add height from bottom
375
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
+
376
404
  self._logger.debug("Moving Z axis to position %s", pos)
377
405
  self.qubot.move_absolute(position=pos)
378
-
379
406
  self._logger.debug("Dispensing %d µL", amount)
380
407
  self.pipette.dispense(amount=amount)
381
408
  time.sleep(5)
382
- 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)
383
410
 
384
- def get_slot_origin(self, slot: str) -> Position:
411
+ # Electrode operations
412
+ def move_electrode(self, deck_slot: str, well_name: str, height_from_bottom: float = 0.0):
385
413
  """
386
- Get the origin coordinates of a slot.
414
+ Move the electrode to a deck slot.
387
415
 
388
416
  Args:
389
- slot: Slot name (e.g., 'A1', 'B2')
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')
390
441
 
391
442
  Returns:
392
- Position for the slot origin
443
+ Position for the deck slot origin
393
444
 
394
445
  Raises:
395
- KeyError: If slot name is invalid
396
- """
397
- slot = slot.upper()
398
- if slot not in self.SLOT_ORIGINS:
399
- self._logger.error("Invalid slot name: '%s'. Must be one of %s", slot, list(self.SLOT_ORIGINS.keys()))
400
- raise KeyError(f"Invalid slot name: {slot}. Must be one of {list(self.SLOT_ORIGINS.keys())}")
401
- pos = self.SLOT_ORIGINS[slot]
402
- self._logger.debug("Slot origin for '%s': %s", slot, pos)
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)
403
454
  return pos
404
455
 
405
- def get_absolute_z_position(self, slot: str, well: Optional[str] = None) -> Position:
456
+ def _get_absolute_z_position(self, deck_slot: str, well_name: Optional[str] = None) -> Position:
406
457
  """
407
- Get the absolute position for a slot (and optionally a well within that slot) based on the origin
458
+ Get the absolute position for a deck slot (and optionally a well within that deck slot) based on the origin
408
459
 
409
460
  Args:
410
- slot: Slot name (e.g., 'A1', 'B2')
411
- well: Optional well name within the slot (e.g., 'A1' for a well in a tiprack)
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)
412
463
 
413
464
  Returns:
414
465
  Position with absolute coordinates
415
466
 
416
467
  Raises:
417
- ValueError: If well is specified but no labware is loaded in the slot
468
+ ValueError: If well_name is specified but no labware is loaded in the deck slot
418
469
  """
419
- # Get slot origin
420
- pos = self.get_slot_origin(slot)
470
+ # Get deck slot origin
471
+ pos = self._get_slot_origin(deck_slot)
421
472
 
422
- # relative well position from slot origin
423
- if well:
424
- labware = self.deck[slot]
473
+ # relative well position from deck slot origin
474
+ if well_name:
475
+ labware = self.deck[deck_slot]
425
476
  if labware is None:
426
- self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
427
- raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
428
- well_pos = labware.get_well_position(well).get_xy()
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()
429
480
  # the deck is rotated 90 degrees clockwise for this machine
430
481
  pos += well_pos.swap_xy()
431
482
  # get z
432
483
  pos += Position(z=labware.get_height() - self.CEILING_HEIGHT)
433
- self._logger.debug("Absolute Z position for slot '%s', well '%s': %s", slot, well, pos)
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)
434
488
  else:
435
- self._logger.debug("Absolute Z position for slot '%s': %s", slot, pos)
489
+ self._logger.debug("Absolute Z position for deck slot '%s': %s", deck_slot, pos)
436
490
  return pos
437
491
 
438
- def get_absolute_a_position(self, slot: str, well: Optional[str] = None) -> Position:
492
+ def _get_absolute_a_position(self, deck_slot: str, well_name: Optional[str] = None) -> Position:
439
493
  """
440
- Get the absolute position for a slot (and optionally a well within that slot) based on the origin
494
+ Get the absolute position for a deck slot (and optionally a well within that deck slot) based on the origin
441
495
 
442
496
  Args:
443
- slot: Slot name (e.g., 'A1', 'B2')
444
- well: Optional well name within the slot (e.g., 'A1' for a well in a tiprack)
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)
445
499
 
446
500
  Returns:
447
501
  Position with absolute coordinates
448
502
 
449
503
  Raises:
450
- ValueError: If well is specified but no labware is loaded in the slot
504
+ ValueError: If well_name is specified but no labware is loaded in the deck slot
451
505
  """
452
- pos = self.get_slot_origin(slot)
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
453
509
 
454
- if well:
455
- labware = self.deck[slot]
456
- if labware is None:
457
- self._logger.error("Cannot get well position: no labware loaded in slot '%s'", slot)
458
- raise ValueError(f"No labware loaded in slot '{slot}'. Load labware before accessing wells.")
459
- well_pos = labware.get_well_position(well).get_xy()
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()
460
519
  pos += well_pos.swap_xy()
461
-
462
- # get a
463
- a = Position(a=labware.get_height() - self.CEILING_HEIGHT)
464
- pos += a
465
- self._logger.debug("Absolute A position for slot '%s', well '%s': %s", slot, well, pos)
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)
466
527
  else:
467
- self._logger.debug("Absolute A position for slot '%s': %s", slot, pos)
528
+ self._logger.debug("Absolute A position for deck slot '%s': %s", deck_slot, pos)
529
+
468
530
  return pos
531
+
532
+ ### Camera operations ###
469
533
 
470
534
  def start_video_recording(
471
535
  self,
@@ -529,8 +593,8 @@ class First:
529
593
  )
530
594
 
531
595
  def capture_image(
532
- self,
533
- save: bool = False,
596
+ self,
597
+ save: bool = False,
534
598
  filename: Optional[Union[str, Path]] = None
535
599
  ) -> np.ndarray:
536
600
  """
@@ -548,4 +612,24 @@ class First:
548
612
  Raises:
549
613
  IOError: If camera is not connected or capture fails
550
614
  """
551
- return self.camera.capture_image(save=save, filename=filename)
615
+ return self.camera.capture_image(save=save, filename=filename)
616
+
617
+ ### Control (immediate commands) ###
618
+
619
+ def pause(self):
620
+ """
621
+ Pause the execution of queued commands.
622
+ """
623
+ print("Pausing machine")
624
+
625
+ def resume(self):
626
+ """
627
+ Resume the execution of queued commands.
628
+ """
629
+ print("Resuming machine")
630
+
631
+ def cancel(self):
632
+ """
633
+ Cancel the execution of queued commands.
634
+ """
635
+ print("Cancelling machine")
File without changes
@@ -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()
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.17
3
+ Version: 0.0.19
4
4
  Summary: Hardware drivers for the PUDA platform.
5
- Project-URL: Homepage, https://github.com/PUDAP/puda-drivers
6
- Project-URL: Issues, https://github.com/PUDAP/puda-drivers/issues
5
+ Project-URL: Homepage, https://github.com/PUDAP/puda
6
+ Project-URL: Issues, https://github.com/PUDAP/puda/issues
7
7
  Author-email: zhao <20024592+agentzhao@users.noreply.github.com>
8
8
  License-Expression: MIT
9
9
  License-File: LICENSE
@@ -18,7 +18,6 @@ Requires-Python: >=3.10
18
18
  Requires-Dist: nats-py>=2.12.0
19
19
  Requires-Dist: opencv-python>=4.12.0.88
20
20
  Requires-Dist: pyserial~=3.5
21
- Requires-Dist: pytest>=9.0.2
22
21
  Description-Content-Type: text/markdown
23
22
 
24
23
  # puda-drivers
@@ -260,6 +259,9 @@ uv run pytest tests/ --cov=puda_drivers --cov-report=html
260
259
  # Build distribution packages
261
260
  uv build
262
261
 
262
+ # cd to puda project root
263
+ cd ...
264
+
263
265
  # Publish to PyPI
264
266
  uv publish
265
267
  # Username: __token__
@@ -6,16 +6,18 @@ puda_drivers/core/position.py,sha256=f4efmDSrKKCtqrR-GUJxVitPG20MiuGSDOWt-9TVISk
6
6
  puda_drivers/core/serialcontroller.py,sha256=8Yf9DYIhtQTIVGB1Pm6COa6qgJcM0ZxdGwOGW8Om9ss,8654
7
7
  puda_drivers/cv/__init__.py,sha256=DYiPwYOLSUsZ9ivWiHoMGGD3MZ3Ygu9qLYja_OiUodU,100
8
8
  puda_drivers/cv/camera.py,sha256=Tzxt1h3W5Z8XFanud_z-PhhJ2UYsC4OEhcak9TVu8jM,16490
9
+ puda_drivers/labware/MEA_cell_MTP.json,sha256=y3wYHp4BO4Uokg6makMq37hj5vt_WbG8de9zpDL7r0k,1052
9
10
  puda_drivers/labware/__init__.py,sha256=RlRxrJn2zyzyxv4c1KGt8Gmxv2cRO8V4zZVnnyL-I00,288
10
- puda_drivers/labware/labware.py,sha256=hZhOzSyb1GP_bm1LvQsREx4YSqLCWBTKNWDgDqfFavI,5317
11
+ puda_drivers/labware/labware.py,sha256=YjMTCilvjhXZqx9qqcPgRRO68FjAChavV9OAGMXbtZ8,6678
11
12
  puda_drivers/labware/opentrons_96_tiprack_300ul.json,sha256=jmNaworu688GEgFdxMxNRSaEp4ZOg9IumFk8bVHSzYY,19796
12
- puda_drivers/labware/polyelectric_8_wellplate_30000ul.json,sha256=esu2tej0ORs7Pfd4HwoQVUpU5mPvp2AYzE3zsCC2FDk,3104
13
- puda_drivers/labware/trash_bin.json,sha256=6dH0prXKJVXMRm096rPSiJgMr0nPRtHUKo7Z6xIxdnA,596
13
+ puda_drivers/labware/polyelectric_8_wellplate_30000ul.json,sha256=bqmUEqF5JJHZGWwUbemUuJyNL2jJKR5lhiKdUn4MVgI,3128
14
+ puda_drivers/labware/trash_bin.json,sha256=idVyjKtNdqKRT6UrYK20j867EDeV5CD5jRqgZ9Qy74M,618
14
15
  puda_drivers/machines/__init__.py,sha256=zmIk_r2T8nbPA68h3Cko8N6oL7ncoBpmvhNcAqzHmc4,45
15
- puda_drivers/machines/first.py,sha256=GYYEUGi6xx6ev-ByX-_y9_GnMaBdhPCeiu-bCnKDZw0,21417
16
+ puda_drivers/machines/first.py,sha256=B0SZTeaE3Gi2OaC2UB6Oez3HQqK2ZgegiKjn3T22njE,25584
17
+ puda_drivers/measure/electrode.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
18
  puda_drivers/move/__init__.py,sha256=NKIKckcqgyviPM0EGFcmIoaqkJM4qekR4babfdddRzM,96
17
19
  puda_drivers/move/deck.py,sha256=CprlB8i9jvNImrc5IpHUZSSvvFe8uei76fNQ4iN6n34,2394
18
- puda_drivers/move/gcode.py,sha256=ioqRS-Kom5S7Pyp9-wg4MRXGIbIDEz7SsihBBOM2m30,23784
20
+ puda_drivers/move/gcode.py,sha256=nbPCo5SGB2yzDMR3VdbTJDcFyTkImBAJSlUTjC42eZI,23248
19
21
  puda_drivers/move/grbl/__init__.py,sha256=vBeeti8DVN2dACi1rLmHN_UGIOdo0s-HZX6mIepLV5I,98
20
22
  puda_drivers/move/grbl/api.py,sha256=loj8_Vap7S9qaD0ReHhgxr9Vkl6Wp7DGzyLkZyZ6v_k,16995
21
23
  puda_drivers/move/grbl/constants.py,sha256=4736CRDzLGWVqGscLajMlrIQMyubsHfthXi4RF1CHNg,9585
@@ -23,7 +25,7 @@ puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=7fljIbu0KkumsI3NI3O64d
23
25
  puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
24
26
  puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
25
27
  puda_drivers/transfer/liquid/sartorius/rLine.py,sha256=FWEFO9tZN3cbneiozXRs77O-47Jg_8vYzWJvUrWpYxA,14531
26
- puda_drivers-0.0.17.dist-info/METADATA,sha256=YFBVlnKBI0al6Z6F9w0urGImcN133jmzsJVTcojNfEc,8420
27
- puda_drivers-0.0.17.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
28
- puda_drivers-0.0.17.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
29
- puda_drivers-0.0.17.dist-info/RECORD,,
28
+ puda_drivers-0.0.19.dist-info/METADATA,sha256=Hu24oaNZDP82BLnWmmjHA42VoKBrjLAX3ibcH0LDcjM,8409
29
+ puda_drivers-0.0.19.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
30
+ puda_drivers-0.0.19.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
31
+ puda_drivers-0.0.19.dist-info/RECORD,,