puda-drivers 0.0.4__tar.gz → 0.0.5__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 (24) hide show
  1. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/PKG-INFO +44 -3
  2. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/README.md +43 -2
  3. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/pyproject.toml +1 -1
  4. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/core/serialcontroller.py +4 -4
  5. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/move/gcode.py +119 -124
  6. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/tests/example.py +2 -2
  7. puda_drivers-0.0.4/tests/sartorius.py → puda_drivers-0.0.5/tests/pipette.py +21 -22
  8. puda_drivers-0.0.5/tests/qubot.py +109 -0
  9. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/tests/together.py +30 -16
  10. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/uv.lock +1 -1
  11. puda_drivers-0.0.4/tests/qubot.py +0 -77
  12. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/.gitignore +0 -0
  13. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/LICENSE +0 -0
  14. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/__init__.py +0 -0
  15. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/core/__init__.py +0 -0
  16. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/move/__init__.py +0 -0
  17. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/__init__.py +0 -0
  18. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/api.py +0 -0
  19. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/move/grbl/constants.py +0 -0
  20. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/py.typed +0 -0
  21. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
  22. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
  23. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
  24. {puda_drivers-0.0.4 → puda_drivers-0.0.5}/src/puda_drivers/transfer/liquid/sartorius/sartorius.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Hardware drivers for the PUDA platform.
5
5
  Project-URL: Homepage, https://github.com/zhao-bears/puda-drivers
6
6
  Project-URL: Issues, https://github.com/zhao-bears/puda-drivers/issues
@@ -56,13 +56,19 @@ from puda_drivers.move import GCodeController
56
56
  gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
57
57
  gantry.connect()
58
58
 
59
+ # Configure axis limits for safety (recommended)
60
+ gantry.set_axis_limits("X", 0, 200)
61
+ gantry.set_axis_limits("Y", -200, 0)
62
+ gantry.set_axis_limits("Z", -100, 0)
63
+ gantry.set_axis_limits("A", -180, 180)
64
+
59
65
  # Home the gantry
60
66
  gantry.home()
61
67
 
62
- # Move to absolute position
68
+ # Move to absolute position (validated against limits)
63
69
  gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
64
70
 
65
- # Move relative to current position
71
+ # Move relative to current position (validated after conversion to absolute)
66
72
  gantry.move_relative(x=20.0, y=-10.0)
67
73
 
68
74
  # Query current position
@@ -73,6 +79,8 @@ print(f"Current position: {position}")
73
79
  gantry.disconnect()
74
80
  ```
75
81
 
82
+ **Axis Limits and Validation**: The `move_absolute()` and `move_relative()` methods automatically validate that target positions are within configured axis limits. If a position is outside the limits, a `ValueError` is raised before any movement is executed. Use `set_axis_limits()` to configure limits for each axis.
83
+
76
84
  ### Liquid Handling (Sartorius)
77
85
 
78
86
  ```python
@@ -134,6 +142,7 @@ pipette.disconnect()
134
142
  - Supports X, Y, Z, and A axes
135
143
  - Configurable feed rates
136
144
  - Position synchronization and homing
145
+ - Automatic axis limit validation for safe operation
137
146
 
138
147
  ### Liquid Handling
139
148
 
@@ -142,6 +151,38 @@ pipette.disconnect()
142
151
  - Tip attachment and ejection
143
152
  - Configurable speeds and volumes
144
153
 
154
+ ## Error Handling
155
+
156
+ ### Axis Limit Validation
157
+
158
+ Both `move_absolute()` and `move_relative()` validate positions against configured axis limits before executing any movement. If a position is outside the limits, a `ValueError` is raised:
159
+
160
+ ```python
161
+ from puda_drivers.move import GCodeController
162
+
163
+ gantry = GCodeController(port_name="/dev/ttyACM0")
164
+ gantry.connect()
165
+
166
+ # Set axis limits
167
+ gantry.set_axis_limits("X", 0, 200)
168
+ gantry.set_axis_limits("Y", -200, 0)
169
+
170
+ try:
171
+ # This will raise ValueError: Value 250 outside axis limits [0, 200]
172
+ gantry.move_absolute(x=250.0, y=-50.0)
173
+ except ValueError as e:
174
+ print(f"Move rejected: {e}")
175
+
176
+ # Relative moves are also validated after conversion to absolute positions
177
+ try:
178
+ # If current X is 150, moving 100 more would exceed the limit
179
+ gantry.move_relative(x=100.0)
180
+ except ValueError as e:
181
+ print(f"Move rejected: {e}")
182
+ ```
183
+
184
+ Validation errors are automatically logged at the ERROR level before the exception is raised.
185
+
145
186
  ## Finding Serial Ports
146
187
 
147
188
  To discover available serial ports on your system:
@@ -36,13 +36,19 @@ from puda_drivers.move import GCodeController
36
36
  gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
37
37
  gantry.connect()
38
38
 
39
+ # Configure axis limits for safety (recommended)
40
+ gantry.set_axis_limits("X", 0, 200)
41
+ gantry.set_axis_limits("Y", -200, 0)
42
+ gantry.set_axis_limits("Z", -100, 0)
43
+ gantry.set_axis_limits("A", -180, 180)
44
+
39
45
  # Home the gantry
40
46
  gantry.home()
41
47
 
42
- # Move to absolute position
48
+ # Move to absolute position (validated against limits)
43
49
  gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)
44
50
 
45
- # Move relative to current position
51
+ # Move relative to current position (validated after conversion to absolute)
46
52
  gantry.move_relative(x=20.0, y=-10.0)
47
53
 
48
54
  # Query current position
@@ -53,6 +59,8 @@ print(f"Current position: {position}")
53
59
  gantry.disconnect()
54
60
  ```
55
61
 
62
+ **Axis Limits and Validation**: The `move_absolute()` and `move_relative()` methods automatically validate that target positions are within configured axis limits. If a position is outside the limits, a `ValueError` is raised before any movement is executed. Use `set_axis_limits()` to configure limits for each axis.
63
+
56
64
  ### Liquid Handling (Sartorius)
57
65
 
58
66
  ```python
@@ -114,6 +122,7 @@ pipette.disconnect()
114
122
  - Supports X, Y, Z, and A axes
115
123
  - Configurable feed rates
116
124
  - Position synchronization and homing
125
+ - Automatic axis limit validation for safe operation
117
126
 
118
127
  ### Liquid Handling
119
128
 
@@ -122,6 +131,38 @@ pipette.disconnect()
122
131
  - Tip attachment and ejection
123
132
  - Configurable speeds and volumes
124
133
 
134
+ ## Error Handling
135
+
136
+ ### Axis Limit Validation
137
+
138
+ Both `move_absolute()` and `move_relative()` validate positions against configured axis limits before executing any movement. If a position is outside the limits, a `ValueError` is raised:
139
+
140
+ ```python
141
+ from puda_drivers.move import GCodeController
142
+
143
+ gantry = GCodeController(port_name="/dev/ttyACM0")
144
+ gantry.connect()
145
+
146
+ # Set axis limits
147
+ gantry.set_axis_limits("X", 0, 200)
148
+ gantry.set_axis_limits("Y", -200, 0)
149
+
150
+ try:
151
+ # This will raise ValueError: Value 250 outside axis limits [0, 200]
152
+ gantry.move_absolute(x=250.0, y=-50.0)
153
+ except ValueError as e:
154
+ print(f"Move rejected: {e}")
155
+
156
+ # Relative moves are also validated after conversion to absolute positions
157
+ try:
158
+ # If current X is 150, moving 100 more would exceed the limit
159
+ gantry.move_relative(x=100.0)
160
+ except ValueError as e:
161
+ print(f"Move rejected: {e}")
162
+ ```
163
+
164
+ Validation errors are automatically logged at the ERROR level before the exception is raised.
165
+
125
166
  ## Finding Serial Ports
126
167
 
127
168
  To discover available serial ports on your system:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puda-drivers"
3
- version = "0.0.4"
3
+ version = "0.0.5"
4
4
  description = "Hardware drivers for the PUDA platform."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,12 +2,12 @@
2
2
  Generic Serial Controller for communicating with devices over serial ports.
3
3
  """
4
4
 
5
- import serial
6
5
  import time
7
- import serial.tools.list_ports
8
6
  import logging
9
7
  from typing import Optional, List, Tuple
10
8
  from abc import ABC
9
+ import serial
10
+ import serial.tools.list_ports
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
@@ -45,7 +45,7 @@ def list_serial_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str,
45
45
 
46
46
  class SerialController(ABC):
47
47
  DEFAULT_BAUDRATE = 9600
48
- DEFAULT_TIMEOUT = 20 # seconds
48
+ DEFAULT_TIMEOUT = 30 # seconds
49
49
  POLL_INTERVAL = 0.1 # seconds
50
50
 
51
51
  def __init__(self, port_name, baudrate=DEFAULT_BAUDRATE, timeout=DEFAULT_TIMEOUT):
@@ -127,8 +127,8 @@ class SerialController(ABC):
127
127
  try:
128
128
  self._serial.reset_input_buffer() # clear input buffer
129
129
  self._serial.reset_output_buffer() # clear output buffer
130
- self._serial.write(bytes(command, "utf-8"))
131
130
  self._serial.flush()
131
+ self._serial.write(bytes(command, "utf-8"))
132
132
 
133
133
  except serial.SerialTimeoutException as e:
134
134
  # Log the timeout error and return None as requested (no re-raise)
@@ -55,7 +55,9 @@ class GCodeController(SerialController):
55
55
 
56
56
  DEFAULT_FEEDRATE = 3000 # mm/min
57
57
  MAX_FEEDRATE = 3000 # mm/min
58
+ MAX_Z_FEED_RATE = 1000 # mm/min
58
59
  TOLERANCE = 0.01 # tolerance for position sync in mm
60
+ SAFE_MOVE_HEIGHT = -5 # safe height for Z and A axes in mm
59
61
 
60
62
  PROTOCOL_TERMINATOR = "\r"
61
63
  VALID_AXES = "XYZA"
@@ -66,6 +68,7 @@ class GCodeController(SerialController):
66
68
  baudrate: int = SerialController.DEFAULT_BAUDRATE,
67
69
  timeout: int = SerialController.DEFAULT_TIMEOUT,
68
70
  feed: int = DEFAULT_FEEDRATE,
71
+ z_feed: int = MAX_Z_FEED_RATE,
69
72
  ):
70
73
  """
71
74
  Initialize the G-code controller.
@@ -87,13 +90,14 @@ class GCodeController(SerialController):
87
90
  )
88
91
 
89
92
  # Tracks internal position state
90
- self.current_position: Dict[str, float] = {
93
+ self._current_position: Dict[str, float] = {
91
94
  "X": 0.0,
92
95
  "Y": 0.0,
93
96
  "Z": 0.0,
94
97
  "A": 0.0,
95
98
  }
96
99
  self._feed: int = feed
100
+ self._z_feed: int = z_feed
97
101
 
98
102
  # Initialize axis limits with default values
99
103
  self._axis_limits: Dict[str, AxisLimits] = {
@@ -151,6 +155,15 @@ class GCodeController(SerialController):
151
155
  """
152
156
  return f"{command}{self.PROTOCOL_TERMINATOR}"
153
157
 
158
+ def wait_for_move(self) -> None:
159
+ """
160
+ Wait for the current move to complete (M400 command).
161
+
162
+ This sends the M400 command which waits for all moves in the queue to complete
163
+ before continuing. This ensures that position updates are accurate.
164
+ """
165
+ self.execute(self._build_command("M400"))
166
+
154
167
  def _validate_axis(self, axis: str) -> str:
155
168
  """
156
169
  Validate and normalize an axis name.
@@ -292,19 +305,19 @@ class GCodeController(SerialController):
292
305
  home_target = "All"
293
306
 
294
307
  self._logger.info("[%s] homing axis/axes: %s **", cmd, home_target)
295
- self._send_command(self._build_command(cmd))
308
+ self.execute(self._build_command(cmd))
296
309
  self._logger.info("Homing of %s completed.", home_target)
297
310
 
298
311
  # Update internal position (optimistic zeroing)
299
312
  if axis:
300
- self.current_position[axis] = 0.0
313
+ self._current_position[axis] = 0.0
301
314
  else:
302
- for key in self.current_position:
303
- self.current_position[key] = 0.0
315
+ for key in self._current_position:
316
+ self._current_position[key] = 0.0
304
317
 
305
318
  self._logger.debug(
306
319
  "Internal position updated (optimistically zeroed) to %s",
307
- self.current_position,
320
+ self._current_position,
308
321
  )
309
322
 
310
323
  def move_absolute(
@@ -314,7 +327,7 @@ class GCodeController(SerialController):
314
327
  z: Optional[float] = None,
315
328
  a: Optional[float] = None,
316
329
  feed: Optional[int] = None,
317
- ) -> None:
330
+ ) -> Dict[str, float]:
318
331
  """
319
332
  Move to an absolute position (G90 + G1 command).
320
333
 
@@ -331,17 +344,26 @@ class GCodeController(SerialController):
331
344
  # Validate positions before executing move
332
345
  self._validate_move_positions(x=x, y=y, z=z, a=a)
333
346
 
347
+ # Fill in missing axes with current positions
348
+ target_x = x if x is not None else self._current_position["X"]
349
+ target_y = y if y is not None else self._current_position["Y"]
350
+ target_z = z if z is not None else self._current_position["Z"]
351
+ target_a = a if a is not None else self._current_position["A"]
352
+
334
353
  feed_rate = feed if feed is not None else self._feed
335
354
  self._logger.info(
336
355
  "Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
337
- x,
338
- y,
339
- z,
340
- a,
356
+ target_x,
357
+ target_y,
358
+ target_z,
359
+ target_a,
341
360
  feed_rate,
342
361
  )
343
362
 
344
- self._execute_move(x=x, y=y, z=z, a=a, feed=feed)
363
+ return self._execute_move(
364
+ position={"X": target_x, "Y": target_y, "Z": target_z, "A": target_a},
365
+ feed=feed_rate
366
+ )
345
367
 
346
368
  def move_relative(
347
369
  self,
@@ -350,7 +372,7 @@ class GCodeController(SerialController):
350
372
  z: Optional[float] = None,
351
373
  a: Optional[float] = None,
352
374
  feed: Optional[int] = None,
353
- ) -> None:
375
+ ) -> Dict[str, float]:
354
376
  """
355
377
  Move relative to the current position (converted to absolute move internally).
356
378
 
@@ -374,133 +396,111 @@ class GCodeController(SerialController):
374
396
  feed_rate,
375
397
  )
376
398
 
377
- # Convert relative movements to absolute positions
378
- abs_x = (self.current_position["X"] + x) if x is not None else None
379
- abs_y = (self.current_position["Y"] + y) if y is not None else None
380
- abs_z = (self.current_position["Z"] + z) if z is not None else None
381
- abs_a = (self.current_position["A"] + a) if a is not None else None
399
+ # Convert relative movements to absolute positions, filling in missing axes with current position
400
+ abs_x = (self._current_position["X"] + x) if x is not None else self._current_position["X"]
401
+ abs_y = (self._current_position["Y"] + y) if y is not None else self._current_position["Y"]
402
+ abs_z = (self._current_position["Z"] + z) if z is not None else self._current_position["Z"]
403
+ abs_a = (self._current_position["A"] + a) if a is not None else self._current_position["A"]
382
404
 
383
405
  # Validate absolute positions before executing move
384
406
  self._validate_move_positions(x=abs_x, y=abs_y, z=abs_z, a=abs_a)
385
407
 
386
- self._execute_move(x=abs_x, y=abs_y, z=abs_z, a=abs_a, feed=feed)
408
+ return self._execute_move(
409
+ position={"X": abs_x, "Y": abs_y, "Z": abs_z, "A": abs_a},
410
+ feed=feed_rate
411
+ )
387
412
 
388
413
  def _execute_move(
389
414
  self,
390
- x: Optional[float] = None,
391
- y: Optional[float] = None,
392
- z: Optional[float] = None,
393
- a: Optional[float] = None,
394
- feed: Optional[int] = None,
395
- ) -> None:
415
+ position: Dict[str, float],
416
+ feed: int,
417
+ ) -> Dict[str, float]:
396
418
  """
397
419
  Internal helper for executing G1 move commands with safe movement pattern.
398
420
  All coordinates are treated as absolute positions.
399
421
 
400
422
  Safe move pattern:
401
423
  1. If X or Y movement is needed, first move Z to 0 (safe height)
402
- 2. Then move X, Y (and optionally A) to target
403
- 3. Finally move Z to target position (if specified)
424
+ 2. Then move X, Y to target
425
+ 3. Finally move Z and A back to original position (or target if specified)
404
426
 
405
427
  Args:
406
- x: Absolute X position (optional)
407
- y: Absolute Y position (optional)
408
- z: Absolute Z position (optional)
409
- a: Absolute A position (optional)
410
- feed: Feed rate (optional)
411
- """
412
- # Calculate target positions (all absolute)
413
- target_pos = self.current_position.copy()
414
- has_x = x is not None
415
- has_y = y is not None
416
- has_z = z is not None
417
- has_a = a is not None
418
-
419
- if not (has_x or has_y or has_z or has_a):
428
+ position: Dictionary with absolute positions for X, Y, Z, A axes
429
+ feed: Feed rate for the move
430
+ """
431
+ # Check if any movement is needed
432
+ needs_x_move = abs(position["X"] - self._current_position["X"]) > self.TOLERANCE
433
+ needs_y_move = abs(position["Y"] - self._current_position["Y"]) > self.TOLERANCE
434
+ needs_z_move = abs(position["Z"] - self._current_position["Z"]) > self.TOLERANCE
435
+ needs_a_move = abs(position["A"] - self._current_position["A"]) > self.TOLERANCE
436
+
437
+ if not (needs_x_move or needs_y_move or needs_z_move or needs_a_move):
420
438
  self._logger.warning(
421
439
  "Move command issued without any axis movement. Skipping transmission."
422
440
  )
423
441
  return
442
+
443
+ if needs_z_move and needs_a_move:
444
+ self._logger.warning(
445
+ "Move command issued with both Z and A movement. This is not supported. Skipping transmission."
446
+ )
447
+ raise ValueError("Move command issued with both Z and A movement. This is not supported.")
424
448
 
425
- # Set target positions (all absolute)
426
- if has_x:
427
- target_pos["X"] = x
428
- if has_y:
429
- target_pos["Y"] = y
430
- if has_z:
431
- target_pos["Z"] = z
432
- if has_a:
433
- target_pos["A"] = a
434
-
435
- feed_rate = feed if feed is not None else self._feed
436
- if feed_rate > self.MAX_FEEDRATE:
437
- feed_rate = self.MAX_FEEDRATE
438
-
439
- # Ensure absolute mode is active
440
- self._send_command(self._build_command("G90"))
441
-
442
- # Safe move pattern: Z to 0, then XY, then Z to target
443
- needs_xy_move = has_x or has_y
444
- current_z = self.current_position["Z"]
445
- target_z = target_pos["Z"] if has_z else current_z
449
+ # Step 0: Ensure absolute mode is active
450
+ self.execute(self._build_command("G90"))
451
+ needs_xy_move = needs_x_move or needs_y_move
446
452
 
447
- # Step 1: Move Z to safe height (0) if XY movement is needed and Z is not already at 0
448
- if needs_xy_move and abs(current_z) > 0.001: # Small tolerance for floating point
449
- # Validate safe height (Z=0) is within limits
450
- self._validate_move_positions(z=0.0)
453
+ # Step 1: Move Z and A to SAFE_MOVE_HEIGHT if XY movement is needed
454
+ if needs_xy_move:
451
455
  self._logger.info(
452
- "Safe move: Raising Z to safe height (0) before XY movement"
456
+ "Safe move: Raising Z and A to safe height (%s) before XY movement", self.SAFE_MOVE_HEIGHT
453
457
  )
454
- move_cmd = f"G1 Z0 F{feed_rate}"
455
- self._send_command(self._build_command(move_cmd))
456
- self.current_position["Z"] = 0.0
457
- self._logger.debug("Z moved to safe height (0)")
458
-
459
- # Step 2: Move X, Y (and optionally A) to target
460
- if needs_xy_move or has_a:
458
+ move_cmd = f"G1 Z-5 A-5 F{self._z_feed}"
459
+ self.execute(self._build_command(move_cmd))
460
+ self.wait_for_move()
461
+ self._current_position["Z"] = self.SAFE_MOVE_HEIGHT
462
+ self._current_position["A"] = self.SAFE_MOVE_HEIGHT
463
+ self._logger.debug("Z and A moved to safe height (%s)", self.SAFE_MOVE_HEIGHT)
464
+
465
+ # Step 2: Move X, Y to target
466
+ if needs_xy_move:
461
467
  move_cmd = "G1"
462
- if has_x:
463
- move_cmd += f" X{target_pos['X']}"
464
- if has_y:
465
- move_cmd += f" Y{target_pos['Y']}"
466
- if has_a:
467
- move_cmd += f" A{target_pos['A']}"
468
- move_cmd += f" F{feed_rate}"
468
+ if needs_x_move:
469
+ move_cmd += f" X{position['X']}"
470
+ if needs_y_move:
471
+ move_cmd += f" Y{position['Y']}"
472
+ move_cmd += f" F{feed}"
469
473
 
470
474
  self._logger.info("Executing XY move command: %s", move_cmd)
471
- self._send_command(self._build_command(move_cmd))
475
+ self.execute(self._build_command(move_cmd))
476
+ self.wait_for_move()
472
477
 
473
478
  # Update position for moved axes
474
- if has_x:
475
- self.current_position["X"] = target_pos["X"]
476
- if has_y:
477
- self.current_position["Y"] = target_pos["Y"]
478
- if has_a:
479
- self.current_position["A"] = target_pos["A"]
480
-
481
- # Step 3: Move Z to target position (if Z movement was requested)
482
- if has_z:
483
- z_needs_move = abs(target_z - (0.0 if needs_xy_move else current_z)) > 0.001
484
- if z_needs_move:
485
- move_cmd = f"G1 Z{target_z} F{feed_rate}"
486
-
487
- if needs_xy_move:
488
- self._logger.info(
489
- "Safe move: Lowering Z to target position: %s", target_z
490
- )
491
- else:
492
- self._logger.info("Executing Z move command: %s", move_cmd)
493
-
494
- self._send_command(self._build_command(move_cmd))
495
- self.current_position["Z"] = target_z
479
+ if needs_x_move:
480
+ self._current_position["X"] = position['X']
481
+ if needs_y_move:
482
+ self._current_position["Y"] = position['Y']
483
+
484
+ # Step 3: Move Z and A back to original position (or target if specified)
485
+ if needs_z_move:
486
+ move_cmd = f"G1 Z{position['Z']} F{self._z_feed}"
487
+ self.execute(self._build_command(move_cmd))
488
+ self._current_position["Z"] = position['Z']
489
+ elif needs_a_move:
490
+ move_cmd = f"G1 A{position['A']} F{self._z_feed}"
491
+ self.execute(self._build_command(move_cmd))
492
+ self._current_position["A"] = position['A']
493
+ self.wait_for_move()
496
494
 
497
495
  self._logger.info(
498
- "Move complete. Final position: %s", self.current_position
496
+ "Move complete. Final position: %s", self._current_position
499
497
  )
500
- self._logger.debug("New internal position: %s", self.current_position)
498
+ self._logger.debug("New internal position: %s", self._current_position)
501
499
 
502
- # Post-move position synchronization check
500
+ # Step 4: Post-move position synchronization check
503
501
  self.sync_position()
502
+
503
+ return self._current_position
504
504
 
505
505
  def query_position(self) -> Dict[str, float]:
506
506
  """
@@ -555,8 +555,8 @@ class GCodeController(SerialController):
555
555
  queried_position = self.query_position()
556
556
 
557
557
  if not queried_position:
558
- self._logger.warning("Query position failed. Cannot synchronize.")
559
- return False, self.current_position
558
+ self._logger.error("Query position failed. Cannot synchronize.")
559
+ raise ValueError("Query position failed. Cannot synchronize.")
560
560
 
561
561
  # Compare internal vs. queried position
562
562
  axis_keys = ["X", "Y", "Z", "A"]
@@ -564,36 +564,31 @@ class GCodeController(SerialController):
564
564
 
565
565
  for axis in axis_keys:
566
566
  if (
567
- axis in self.current_position
567
+ axis in self._current_position
568
568
  and axis in queried_position
569
- and abs(self.current_position[axis] - queried_position[axis])
569
+ and abs(self._current_position[axis] - queried_position[axis])
570
570
  > self.TOLERANCE
571
571
  ):
572
572
  self._logger.warning(
573
573
  "Position mismatch found on %s axis: Internal=%.3f, Queried=%.3f",
574
574
  axis,
575
- self.current_position[axis],
575
+ self._current_position[axis],
576
576
  queried_position[axis],
577
577
  )
578
578
  adjustment_needed = True
579
579
  elif axis in queried_position:
580
580
  # Update internal position with queried position if it differs slightly
581
- self.current_position[axis] = queried_position[axis]
581
+ self._current_position[axis] = queried_position[axis]
582
582
 
583
583
  # Perform re-synchronization move if needed
584
584
  if adjustment_needed:
585
585
  self._logger.info(
586
- "** DISCREPANCY DETECTED. Moving robot back to internal position: %s **",
587
- self.current_position,
586
+ "** DISCREPANCY DETECTED. Moving robot to internal position: %s **",
587
+ self._current_position,
588
588
  )
589
589
 
590
590
  try:
591
- target_x = self.current_position.get("X")
592
- target_y = self.current_position.get("Y")
593
- target_z = self.current_position.get("Z")
594
- target_a = self.current_position.get("A")
595
-
596
- self.move_absolute(x=target_x, y=target_y, z=target_z, a=target_a)
591
+ self.move_absolute(x=self._current_position["X"], y=self._current_position["Y"], z=self._current_position["Z"], a=self._current_position["A"])
597
592
  self._logger.info("Synchronization move successfully completed.")
598
593
 
599
594
  # Recursive call to verify position after move
@@ -609,7 +604,7 @@ class GCodeController(SerialController):
609
604
  else:
610
605
  self._logger.info("No adjustment was made.")
611
606
 
612
- return adjustment_needed, self.current_position.copy()
607
+ return adjustment_needed, self._current_position.copy()
613
608
 
614
609
  def get_info(self) -> str:
615
610
  """
@@ -628,5 +623,5 @@ class GCodeController(SerialController):
628
623
  Returns:
629
624
  Dictionary containing the current internal position for all axes
630
625
  """
631
- self._logger.debug("Returning internal position: %s", self.current_position)
632
- return self.current_position.copy()
626
+ self._logger.debug("Returning internal position: %s", self._current_position)
627
+ return self._current_position.copy()
@@ -7,14 +7,14 @@ Created on Wed Nov 19 15:29:02 2025
7
7
 
8
8
 
9
9
  # %%
10
+ # import serial.tools.list_ports
10
11
  # ports = serial.tools.list_ports.comports()
11
12
  #
12
13
  # for port, desc, hwid in sorted(ports):
13
- # print("{}: {} [{}]".format(port, desc, hwid))
14
+ # print("{}: {} [{}]".format(port, desc, hwid))
14
15
 
15
16
  # %% Open connection
16
17
  import serial
17
- import serial.tools.list_ports
18
18
  import time
19
19
 
20
20
  qubot = serial.Serial("/dev/ttyACM0", 9600, dsrdtr=True)
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from puda_drivers.transfer.liquid.sartorius import SartoriusController
3
3
 
4
- # Optinal: finding ports
4
+ # Optional: finding ports
5
5
  # import serial.tools.list_ports
6
6
  # for port, desc, hwid in serial.tools.list_ports.comports():
7
7
  # print(f"{port}: {desc} [{hwid}]")
@@ -26,57 +26,56 @@ TIP_LENGTH = 70 # mm
26
26
 
27
27
 
28
28
  # --- TEST FUNCTION ---
29
- def test_sartorius_operations():
29
+ def test_pipette_operations():
30
30
  """
31
31
  Tests the initialization and core liquid handling functions
32
- of the SartoriusController mock class.
32
+ of the SartoriusController.
33
33
  """
34
- print("--- 🔬 Starting Sartorius Controller Test ---")
35
- sartorius = SartoriusController(port_name=SARTORIUS_PORT)
34
+ print("--- 🔬 Starting Pipette Controller Test ---")
35
+ pipette = SartoriusController(port_name=SARTORIUS_PORT)
36
36
 
37
37
  try:
38
38
  # 1. Initialize and Connect
39
39
  print("\n[STEP 1] Connecting to pipette...")
40
- sartorius.connect()
40
+ # SartoriusController connects automatically in __init__, no need to call connect()
41
+
42
+ # Always start with initializing
43
+ pipette.initialize()
41
44
 
42
- # # 2. Attach Tip
43
- # print("\n[STEP 2] Initialize...")
44
- # sartorius.initialize()
45
-
46
- sartorius.get_inward_speed()
45
+ pipette.get_inward_speed()
47
46
  # print("\n set inward speed to 3")
48
- # sartorius.set_inward_speed(3)
47
+ # pipette.set_inward_speed(3)
49
48
 
50
49
  # 3. Eject Tip (if any)
51
50
  # print("\n[STEP 3] Ejecting Tip (if any)...")
52
- # sartorius.eject_tip(return_position=30)
51
+ # pipette.eject_tip(return_position=30)
53
52
  # print(f"\n[STEP 3] Aspirate {TRANSFER_VOLUME} uL...")
54
- # sartorius.aspirate(amount=TRANSFER_VOLUME)
53
+ # pipette.aspirate(amount=TRANSFER_VOLUME)
55
54
 
56
55
  # 4. Dispense
57
56
  # print(f"\n[STEP 4] Dispensing {TRANSFER_VOLUME} uL...")
58
- # sartorius.dispense(amount=TRANSFER_VOLUME)
57
+ # pipette.dispense(amount=TRANSFER_VOLUME)
59
58
 
60
59
  # # 5. Eject Tip
61
60
  # print("\n[STEP 5] Ejecting Tip...")
62
- # sartorius.eject()
63
- # if not sartorius.is_tip_on():
61
+ # pipette.eject()
62
+ # if not pipette.is_tip_on():
64
63
  # print("✅ Tip check: Tip is ejected.")
65
64
  # else:
66
65
  # raise Exception("Tip ejection failed.")
67
66
  #
68
- # print("\n--- 🎉 All Sartorius operations passed! ---")
67
+ # print("\n--- 🎉 All Pipette operations passed! ---")
69
68
 
70
69
  except Exception as e:
71
70
  print(f"\n--- ❌ TEST FAILURE: {e} ---")
72
71
 
73
72
  finally:
74
73
  # 6. Disconnect
75
- if sartorius and sartorius.is_connected:
74
+ if pipette and pipette.is_connected:
76
75
  print("\n[FINAL] Disconnecting...")
77
- sartorius.disconnect()
78
- print("--- 🧪 Sartorius Controller Test Complete ---")
76
+ pipette.disconnect()
77
+ print("--- 🧪 Pipette Controller Test Complete ---")
79
78
 
80
79
 
81
80
  if __name__ == "__main__":
82
- test_sartorius_operations()
81
+ test_pipette_operations()
@@ -0,0 +1,109 @@
1
+ import logging
2
+ from puda_drivers.move import GCodeController
3
+
4
+ # Optinal: finding ports
5
+ import serial.tools.list_ports
6
+ for port, desc, hwid in serial.tools.list_ports.comports():
7
+ print(f"{port}: {desc} [{hwid}]")
8
+
9
+ # 1. Configure the root logger
10
+ # All loggers in imported modules (SerialController, GCodeController) will inherit this setup.
11
+ logging.basicConfig(
12
+ # Use logging.DEBUG to see all (DEBUG, INFO, WARNING, ERROR, CRITICAL) logs
13
+ level=logging.WARNING,
14
+ # Recommended format: includes time, logger name, level, and message
15
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
16
+ )
17
+
18
+ # 2. OPTIONAL: If you only want GCodeController's logs at specific level, you can specifically set it here
19
+ # logging.getLogger('puda_drivers.gcodecontroller').setLevel(logging.INFO)
20
+
21
+ PORT_NAME = "/dev/ttyACM0"
22
+
23
+
24
+ def main():
25
+ print("--- Starting GCode Controller Application ---")
26
+
27
+ try:
28
+ # Instantiate the qubot controller
29
+ qubot = GCodeController(port_name=PORT_NAME)
30
+
31
+ # Example: Set custom axis limits
32
+ qubot.set_axis_limits("X", 0, 330)
33
+ qubot.set_axis_limits("Y", -440, 0)
34
+ qubot.set_axis_limits("Z", -175, 0)
35
+ qubot.set_axis_limits("A", -175, 0)
36
+
37
+ # # Example: Get current axis limits
38
+ # print("\n--- Current Axis Limits ---")
39
+ # all_limits = qubot.get_axis_limits()
40
+ # for axis, limits in all_limits.items():
41
+ # print(f"{axis}: [{limits.min}, {limits.max}]")
42
+
43
+ # # Example: Get limits for a specific axis
44
+ # x_limits = qubot.get_axis_limits("X")
45
+ # print(f"\nX axis limits: [{x_limits.min}, {x_limits.max}]")
46
+
47
+ # qubot.query_position()
48
+ # Always start with homing
49
+ print("\n")
50
+ qubot.home()
51
+
52
+ # # Setting feed rate (aka move speed)
53
+ # # Should generate WARNING due to exceeding MAX_FEEDRATE (3000)
54
+ # qubot.feed = 5000
55
+
56
+ # Relative moves are converted to absolute internally, but works the same
57
+ # for anything in the Z-axis, will have to be moved individually, else error will be raised
58
+ print("\n")
59
+ qubot.move_absolute(x=00.0, y=-50.0, a=-100.0)
60
+
61
+ # print("\n")
62
+ # qubot.move_relative(x=10.0)
63
+
64
+ # Example stepping code
65
+ # for _ in range(10):
66
+ # pos = qubot.move_relative(x=10.0)
67
+ # print(f"Position: {pos}")
68
+
69
+ # sync position isalways called after move, but in case if needed you can call it manually
70
+ # qubot.sync_position()
71
+
72
+ # print("\n")
73
+ # qubot.move_absolute(x=330.0, y=-440.0, z=-175.0)
74
+
75
+ # print("\n")
76
+ # qubot.sync_position()
77
+ # Example of an ERROR - invalid axis
78
+ # try:
79
+ # qubot.home(axis="B") # Generates ERROR
80
+ # except ValueError:
81
+ # pass
82
+
83
+ # Example of an ERROR - position outside limits
84
+ # This will raise ValueError because x=250 is outside the X limits [0, 200]
85
+ # try:
86
+ # qubot.move_absolute(x=250.0) # Raises ValueError if outside limits
87
+ # except ValueError as e:
88
+ # print(f"Position validation error (expected): {e}")
89
+
90
+ # Example of relative move that would exceed limits
91
+ # This will raise ValueError if the resulting absolute position is outside limits
92
+ # try:
93
+ # qubot.move_relative(x=300.0) # If current X + 300 > 200, raises ValueError
94
+ # except ValueError as e:
95
+ # print(f"Position validation error (expected): {e}")
96
+
97
+ # Example of an ERROR - simultaneous Z and A movement
98
+ # This will raise ValueError because Z and A cannot move at the same time
99
+ # try:
100
+ # qubot.move_absolute(z=-10.0, a=-20.0) # Raises ValueError if both Z and A are moved
101
+ # except ValueError as e:
102
+ # print(f"Z/A simultaneous movement error (expected): {e}")
103
+
104
+ except Exception as e:
105
+ logging.getLogger(__name__).error("An unrecoverable error occurred: %s", e)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -1,7 +1,14 @@
1
+ import logging
1
2
  import serial
2
3
  from puda_drivers.move import GCodeController
3
4
  from puda_drivers.transfer.liquid.sartorius import SartoriusController
4
5
 
6
+ # Configure logging
7
+ logging.basicConfig(
8
+ level=logging.INFO, # Use INFO level for cleaner output during automation
9
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
10
+ )
11
+
5
12
  # Qubot Configuration
6
13
  QUBOT_PORT = "/dev/ttyACM0"
7
14
  QUBOT_BAUDRATE = 9600
@@ -14,12 +21,12 @@ TIP_LENGTH = 70 # mm
14
21
 
15
22
  # Define mock coordinates (assuming the Qubot operating space is in mm)
16
23
  # Note: These coordinates must be within the axis limits set below
17
- ASPIRATE_POS = {"X": 50.0, "Y": -50.0, "Z": -20.0, "A": -20.0} # Source well location
24
+ ASPIRATE_POS = {"X": 50.0, "Y": -50.0, "Z": -20.0, "A": 0.0} # Source well location
18
25
  DISPENSE_POS = {
19
26
  "X": 80.0,
20
27
  "Y": -80.0,
21
28
  "Z": -20.0,
22
- "A": -20.0,
29
+ "A": 0.0,
23
30
  } # Destination well location
24
31
  SAFE_Z_POS = -10.0 # High Z position to prevent collisions (within Z limits)
25
32
 
@@ -29,10 +36,10 @@ pipette = None
29
36
  # 1. Initialize and Connect Qubot
30
37
  try:
31
38
  print("Connecting to qubot")
39
+ # GCodeController connects automatically in __init__, no need to call connect()
32
40
  qubot = GCodeController(
33
41
  port_name=QUBOT_PORT, baudrate=QUBOT_BAUDRATE, feed=QUBOT_FEEDRATE
34
42
  )
35
- qubot.connect()
36
43
 
37
44
  # Set axis limits to accommodate the automation coordinates
38
45
  # Adjust these based on your actual hardware limits
@@ -46,6 +53,7 @@ try:
46
53
  for axis, limits in all_limits.items():
47
54
  print(f" {axis}: [{limits.min}, {limits.max}]")
48
55
 
56
+ # Always start with homing
49
57
  qubot.home()
50
58
  except (IOError, ValueError, serial.SerialException) as e:
51
59
  print(f"FATAL ERROR: Could not connect to Qubot GCode Controller: \n{e}")
@@ -55,10 +63,8 @@ except Exception as e:
55
63
  # 2. Initialize and Connect the Liquid Handler (Sartorius)
56
64
  try:
57
65
  print("Connecting to pipette")
58
- # The Sartorius class must be initialized with a port,
59
- # so we'll simulate the connection for demonstration.
66
+ # SartoriusController connects automatically in __init__, no need to call connect()
60
67
  pipette = SartoriusController(port_name=SARTORIUS_PORT)
61
- pipette.connect()
62
68
  pipette.initialize()
63
69
  except Exception as e:
64
70
  print(f"FATAL ERROR: Could not initialize/connect Sartorius: {e}")
@@ -78,21 +84,24 @@ def run_automation():
78
84
  print("Protocol Step 1: Attaching Tip")
79
85
  # Simulate moving to a tip rack position and attaching a tip
80
86
  print("Moving to Tip Rack and Attaching Tip...")
81
- qubot.move_absolute(x=10.0, y=-10.0, z=-10.0, feed=3000)
87
+ pos = qubot.move_absolute(x=50.0, y=-50.0, z=-50.0, feed=3000)
88
+ print(f" Position after move: {pos}")
82
89
 
83
90
  # 4. Protocol Step 2: Aspirate Liquid
84
91
  print(f"\nProtocol Step 2: Aspirating {TRANSFER_VOLUME} uL")
85
92
 
86
93
  # Move to safe Z-height before moving across the deck
87
- qubot.move_absolute(z=SAFE_Z_POS)
94
+ pos = qubot.move_absolute(z=SAFE_Z_POS)
95
+ print(f" Position after safe Z move: {pos}")
88
96
 
89
97
  # Move to the source well (ASPIRATE_POS)
90
- qubot.move_absolute(
98
+ pos = qubot.move_absolute(
91
99
  x=ASPIRATE_POS["X"], y=ASPIRATE_POS["Y"], z=ASPIRATE_POS["Z"], feed=3000
92
100
  )
101
+ print(f" Position at source well: {pos}")
93
102
 
94
103
  # Lower the tip to the aspiration depth using A axis for height
95
- qubot.move_absolute(a=ASPIRATE_POS["A"])
104
+ print(f" Position at aspiration depth: {pos}")
96
105
 
97
106
  # Perform aspiration
98
107
  pipette.aspirate(amount=TRANSFER_VOLUME)
@@ -101,27 +110,32 @@ def run_automation():
101
110
  print(f"\nProtocol Step 3: Dispensing {TRANSFER_VOLUME} uL")
102
111
 
103
112
  # Move to safe Z-height again
104
- qubot.move_absolute(z=SAFE_Z_POS)
113
+ pos = qubot.move_absolute(z=SAFE_Z_POS)
114
+ print(f" Position after safe Z move: {pos}")
105
115
 
106
116
  # Move to the destination well (DISPENSE_POS)
107
- qubot.move_absolute(
117
+ pos = qubot.move_absolute(
108
118
  x=DISPENSE_POS["X"], y=DISPENSE_POS["Y"], z=DISPENSE_POS["Z"], feed=3000
109
119
  )
120
+ print(f" Position at destination well: {pos}")
110
121
 
111
122
  # Lower the tip to the dispensing depth using A axis for height
112
- qubot.move_absolute(a=DISPENSE_POS["A"])
123
+ pos = qubot.move_absolute(a=DISPENSE_POS["A"])
124
+ print(f" Position at dispensing depth: {pos}")
113
125
 
114
126
  # Perform dispensing
115
- pipette.dispense(amount=50)
127
+ pipette.dispense(amount=TRANSFER_VOLUME)
116
128
 
117
129
  # 6. Protocol Step 4: Finalization
118
130
  print("\nProtocol Step 4: Finalizing")
119
131
 
120
132
  # Move back to safe Z-height
121
- qubot.move_absolute(z=SAFE_Z_POS)
133
+ pos = qubot.move_absolute(z=SAFE_Z_POS)
134
+ print(f" Position after safe Z move: {pos}")
122
135
 
123
136
  # Simulate moving to a trash bin and ejecting the tip
124
- qubot.move_absolute(x=10.0, y=-10.0)
137
+ pos = qubot.move_absolute(x=10.0, y=-10.0)
138
+ print(f" Position at trash bin: {pos}")
125
139
  pipette.eject_tip()
126
140
 
127
141
  except Exception as e:
@@ -9,7 +9,7 @@ resolution-markers = [
9
9
 
10
10
  [[package]]
11
11
  name = "puda-drivers"
12
- version = "0.0.4"
12
+ version = "0.0.5"
13
13
  source = { editable = "." }
14
14
  dependencies = [
15
15
  { name = "pyserial" },
@@ -1,77 +0,0 @@
1
- import logging
2
- from puda_drivers.move import GCodeController
3
-
4
- # Optinal: finding ports
5
- # import serial.tools.list_ports
6
- # for port, desc, hwid in serial.tools.list_ports.comports():
7
- # print(f"{port}: {desc} [{hwid}]")
8
-
9
- # 1. Configure the root logger
10
- # All loggers in imported modules (SerialController, GCodeController) will inherit this setup.
11
- logging.basicConfig(
12
- # Use logging.DEBUG to see all (DEBUG, INFO, WARNING, ERROR, CRITICAL) logs
13
- level=logging.DEBUG,
14
- # Recommended format: includes time, logger name, level, and message
15
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
16
- )
17
-
18
- # 2. OPTIONAL: If you only want GCodeController's logs at specific level, you can specifically set it here
19
- # logging.getLogger('drivers.gcodecontroller').setLevel(logging.INFO)
20
-
21
- PORT_NAME = "/dev/ttyACM1"
22
-
23
-
24
- def main():
25
- print("--- Starting GCode Controller Application ---")
26
-
27
- try:
28
- # Instantiate the qubot controller
29
- qubot = GCodeController(port_name=PORT_NAME)
30
-
31
- # Example: Get current axis limits
32
- print("\n--- Current Axis Limits ---")
33
- all_limits = qubot.get_axis_limits()
34
- for axis, limits in all_limits.items():
35
- print(f"{axis}: [{limits.min}, {limits.max}]")
36
-
37
- # Example: Get limits for a specific axis
38
- x_limits = qubot.get_axis_limits("X")
39
- print(f"\nX axis limits: [{x_limits.min}, {x_limits.max}]")
40
-
41
- # Example: Set custom axis limits
42
- qubot.set_axis_limits("X", -200, 200)
43
- qubot.set_axis_limits("Y", -200, 200)
44
- qubot.set_axis_limits("Z", -100, 100)
45
- qubot.set_axis_limits("A", -180, 180)
46
-
47
- qubot.query_position()
48
- # Always start with homing
49
- qubot.home()
50
- qubot.sync_position()
51
-
52
- # Should generate WARNING due to exceeding MAX_FEEDRATE (3000)
53
- # qubot.feed = 5000
54
-
55
- # Relative moves are converted to absolute internally, but works the same
56
- # qubot.move_relative(x=20.0, y=-10.0)
57
- #
58
- # qubot.move_absolute(x=50.0, y=-50.0, z=-10.0)
59
-
60
- # Example of an ERROR - invalid axis
61
- # try:
62
- # qubot.home(axis="B") # Generates ERROR
63
- # except ValueError:
64
- # pass
65
-
66
- # Example of an ERROR - position outside limits
67
- # try:
68
- # qubot.move_absolute(x=150.0) # May raise ValueError if outside limits
69
- # except ValueError as e:
70
- # print(f"Position validation error: {e}")
71
-
72
- except Exception as e:
73
- logging.getLogger(__name__).error(f"An unrecoverable error occurred: {e}")
74
-
75
-
76
- if __name__ == "__main__":
77
- main()
File without changes
File without changes