puda-drivers 0.0.2__py3-none-any.whl → 0.0.3__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.
- puda_drivers/move/gcode.py +282 -124
- puda_drivers/transfer/liquid/sartorius/sartorius.py +289 -149
- {puda_drivers-0.0.2.dist-info → puda_drivers-0.0.3.dist-info}/METADATA +1 -1
- {puda_drivers-0.0.2.dist-info → puda_drivers-0.0.3.dist-info}/RECORD +6 -6
- {puda_drivers-0.0.2.dist-info → puda_drivers-0.0.3.dist-info}/WHEEL +0 -0
- {puda_drivers-0.0.2.dist-info → puda_drivers-0.0.3.dist-info}/licenses/LICENSE +0 -0
puda_drivers/move/gcode.py
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
3
|
-
via serial communication.
|
|
2
|
+
G-code controller for motion systems.
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
This module provides a Python interface for controlling G-code compatible motion
|
|
5
|
+
systems (e.g., QuBot) via serial communication. Supports absolute and relative
|
|
6
|
+
positioning, homing, and position synchronization.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import re
|
|
9
|
-
from typing import Optional, Dict, Tuple
|
|
10
10
|
import logging
|
|
11
|
+
from typing import Optional, Dict, Tuple
|
|
12
|
+
|
|
11
13
|
from puda_drivers.core.serialcontroller import SerialController
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class GCodeController(SerialController):
|
|
17
|
+
"""
|
|
18
|
+
Controller for G-code compatible motion systems.
|
|
19
|
+
|
|
20
|
+
This class provides methods for controlling multi-axis motion systems that
|
|
21
|
+
understand G-code commands, including homing, absolute/relative movement,
|
|
22
|
+
and position synchronization.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
DEFAULT_FEEDRATE: Default feed rate in mm/min (3000)
|
|
26
|
+
MAX_FEEDRATE: Maximum allowed feed rate in mm/min (3000)
|
|
27
|
+
TOLERANCE: Position synchronization tolerance in mm (0.01)
|
|
28
|
+
"""
|
|
29
|
+
|
|
15
30
|
DEFAULT_FEEDRATE = 3000 # mm/min
|
|
16
31
|
MAX_FEEDRATE = 3000 # mm/min
|
|
17
32
|
TOLERANCE = 0.01 # tolerance for position sync in mm
|
|
18
33
|
|
|
19
34
|
PROTOCOL_TERMINATOR = "\r"
|
|
35
|
+
VALID_AXES = "XYZA"
|
|
20
36
|
|
|
21
37
|
def __init__(
|
|
22
38
|
self,
|
|
@@ -26,19 +42,22 @@ class GCodeController(SerialController):
|
|
|
26
42
|
feed: int = DEFAULT_FEEDRATE,
|
|
27
43
|
):
|
|
28
44
|
"""
|
|
29
|
-
|
|
45
|
+
Initialize the G-code controller.
|
|
30
46
|
|
|
31
47
|
Args:
|
|
32
|
-
|
|
33
|
-
baudrate
|
|
34
|
-
timeout
|
|
48
|
+
port_name: Serial port name (e.g., '/dev/ttyACM0' or 'COM3')
|
|
49
|
+
baudrate: Baud rate for serial communication. Defaults to 9600.
|
|
50
|
+
timeout: Timeout in seconds for operations. Defaults to 20.
|
|
51
|
+
feed: Initial feed rate in mm/min. Defaults to 3000.
|
|
35
52
|
"""
|
|
36
53
|
super().__init__(port_name, baudrate, timeout)
|
|
37
54
|
|
|
38
|
-
# Initialize instance-specific logger
|
|
39
55
|
self._logger = logging.getLogger(__name__)
|
|
40
56
|
self._logger.info(
|
|
41
|
-
|
|
57
|
+
"GCodeController initialized with port='%s', baudrate=%s, timeout=%s",
|
|
58
|
+
port_name,
|
|
59
|
+
baudrate,
|
|
60
|
+
timeout,
|
|
42
61
|
)
|
|
43
62
|
|
|
44
63
|
# Tracks internal position state
|
|
@@ -52,78 +71,102 @@ class GCodeController(SerialController):
|
|
|
52
71
|
self._is_absolute_mode: bool = True # absolute mode by default
|
|
53
72
|
|
|
54
73
|
@property
|
|
55
|
-
def feed(self):
|
|
56
|
-
"""
|
|
74
|
+
def feed(self) -> int:
|
|
75
|
+
"""Get the current feed rate in mm/min."""
|
|
57
76
|
return self._feed
|
|
58
77
|
|
|
59
78
|
@feed.setter
|
|
60
|
-
def feed(self, new_feed: int):
|
|
61
|
-
"""
|
|
79
|
+
def feed(self, new_feed: int) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Set the movement feed rate, enforcing the maximum limit.
|
|
62
82
|
|
|
63
|
-
|
|
83
|
+
Args:
|
|
84
|
+
new_feed: New feed rate in mm/min (must be > 0)
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If feed rate is not positive
|
|
88
|
+
"""
|
|
64
89
|
if new_feed <= 0:
|
|
65
|
-
error_msg =
|
|
90
|
+
error_msg = (
|
|
91
|
+
f"Attempted to set invalid feed rate: {new_feed}. Must be > 0."
|
|
92
|
+
)
|
|
66
93
|
self._logger.error(error_msg)
|
|
67
94
|
raise ValueError(error_msg)
|
|
68
95
|
|
|
69
|
-
# 2. Check and enforce the maximum rate
|
|
70
96
|
if new_feed > self.MAX_FEEDRATE:
|
|
71
|
-
# Log the change and cap the value
|
|
72
97
|
self._logger.warning(
|
|
73
|
-
|
|
74
|
-
|
|
98
|
+
"Requested feed rate (%s) exceeds maximum (%s). "
|
|
99
|
+
"Setting feed rate to maximum: %s.",
|
|
100
|
+
new_feed,
|
|
101
|
+
self.MAX_FEEDRATE,
|
|
102
|
+
self.MAX_FEEDRATE,
|
|
75
103
|
)
|
|
76
104
|
self._feed = self.MAX_FEEDRATE
|
|
77
105
|
else:
|
|
78
|
-
# Set the value normally
|
|
79
106
|
self._feed = new_feed
|
|
80
|
-
self._logger.debug(
|
|
107
|
+
self._logger.debug("Feed rate set to: %s mm/min.", self._feed)
|
|
81
108
|
|
|
82
109
|
def _build_command(self, command: str) -> str:
|
|
83
|
-
"""
|
|
110
|
+
"""
|
|
111
|
+
Build a G-code command with terminator.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
command: G-code command string (without terminator)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Complete command string with terminator
|
|
118
|
+
"""
|
|
84
119
|
return f"{command}{self.PROTOCOL_TERMINATOR}"
|
|
85
120
|
|
|
86
|
-
def
|
|
121
|
+
def _validate_axis(self, axis: str) -> str:
|
|
87
122
|
"""
|
|
88
|
-
|
|
123
|
+
Validate and normalize an axis name.
|
|
89
124
|
|
|
90
125
|
Args:
|
|
91
|
-
axis
|
|
92
|
-
|
|
126
|
+
axis: Axis name to validate
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Uppercase axis name
|
|
93
130
|
|
|
94
131
|
Raises:
|
|
95
|
-
ValueError: If
|
|
132
|
+
ValueError: If axis is not valid
|
|
96
133
|
"""
|
|
97
|
-
|
|
134
|
+
axis_upper = axis.upper()
|
|
135
|
+
if axis_upper not in self.VALID_AXES:
|
|
136
|
+
self._logger.error(
|
|
137
|
+
"Invalid axis '%s' provided. Must be one of: %s.",
|
|
138
|
+
axis_upper,
|
|
139
|
+
", ".join(self.VALID_AXES),
|
|
140
|
+
)
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Invalid axis. Must be one of: {', '.join(self.VALID_AXES)}."
|
|
143
|
+
)
|
|
144
|
+
return axis_upper
|
|
98
145
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if axis not in valid_axes:
|
|
103
|
-
self._logger.error(
|
|
104
|
-
f"Invalid axis '{axis}' provided for homing. Must be one of: {', '.join(valid_axes)}."
|
|
105
|
-
)
|
|
106
|
-
raise ValueError(
|
|
107
|
-
f"Invalid axis. Must be one of: {', '.join(valid_axes)}."
|
|
108
|
-
)
|
|
146
|
+
def home(self, axis: Optional[str] = None) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Home one or all axes (G28 command).
|
|
109
149
|
|
|
150
|
+
Args:
|
|
151
|
+
axis: Optional axis to home ('X', 'Y', 'Z', 'A').
|
|
152
|
+
If None, homes all axes.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If an invalid axis is provided
|
|
156
|
+
"""
|
|
157
|
+
if axis:
|
|
158
|
+
axis = self._validate_axis(axis)
|
|
110
159
|
cmd = f"G28 {axis}"
|
|
111
160
|
home_target = axis
|
|
112
161
|
else:
|
|
113
162
|
cmd = "G28"
|
|
114
163
|
home_target = "All"
|
|
115
164
|
|
|
116
|
-
|
|
117
|
-
self.
|
|
118
|
-
|
|
119
|
-
self._send_command("G28\r")
|
|
120
|
-
self._logger.info(f"Homing of {home_target} completed.")
|
|
165
|
+
self._logger.info("[%s] homing axis/axes: %s **", cmd, home_target)
|
|
166
|
+
self._send_command(self._build_command(cmd))
|
|
167
|
+
self._logger.info("Homing of %s completed.", home_target)
|
|
121
168
|
|
|
122
|
-
#
|
|
123
|
-
# self.sync_position()
|
|
124
|
-
|
|
125
|
-
# 3. Internal Position Update (Optimistic Zeroing)
|
|
126
|
-
# If the homing was for a specific axis, zero only that axis.
|
|
169
|
+
# Update internal position (optimistic zeroing)
|
|
127
170
|
if axis:
|
|
128
171
|
self.current_position[axis] = 0.0
|
|
129
172
|
else:
|
|
@@ -131,7 +174,8 @@ class GCodeController(SerialController):
|
|
|
131
174
|
self.current_position[key] = 0.0
|
|
132
175
|
|
|
133
176
|
self._logger.debug(
|
|
134
|
-
|
|
177
|
+
"Internal position updated (optimistically zeroed) to %s",
|
|
178
|
+
self.current_position,
|
|
135
179
|
)
|
|
136
180
|
|
|
137
181
|
def move_absolute(
|
|
@@ -143,10 +187,23 @@ class GCodeController(SerialController):
|
|
|
143
187
|
feed: Optional[int] = None,
|
|
144
188
|
) -> None:
|
|
145
189
|
"""
|
|
146
|
-
|
|
190
|
+
Move to an absolute position (G90 + G1 command).
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
x: Target X position (optional)
|
|
194
|
+
y: Target Y position (optional)
|
|
195
|
+
z: Target Z position (optional)
|
|
196
|
+
a: Target A position (optional)
|
|
197
|
+
feed: Feed rate for this move (optional, uses current feed if not specified)
|
|
147
198
|
"""
|
|
199
|
+
feed_rate = feed if feed is not None else self._feed
|
|
148
200
|
self._logger.info(
|
|
149
|
-
|
|
201
|
+
"Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
|
|
202
|
+
x,
|
|
203
|
+
y,
|
|
204
|
+
z,
|
|
205
|
+
a,
|
|
206
|
+
feed_rate,
|
|
150
207
|
)
|
|
151
208
|
|
|
152
209
|
# Ensure absolute mode is active
|
|
@@ -166,10 +223,23 @@ class GCodeController(SerialController):
|
|
|
166
223
|
feed: Optional[int] = None,
|
|
167
224
|
) -> None:
|
|
168
225
|
"""
|
|
169
|
-
|
|
226
|
+
Move relative to the current position (G91 + G1 command).
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
x: Relative X movement (optional)
|
|
230
|
+
y: Relative Y movement (optional)
|
|
231
|
+
z: Relative Z movement (optional)
|
|
232
|
+
a: Relative A movement (optional)
|
|
233
|
+
feed: Feed rate for this move (optional, uses current feed if not specified)
|
|
170
234
|
"""
|
|
235
|
+
feed_rate = feed if feed is not None else self._feed
|
|
171
236
|
self._logger.info(
|
|
172
|
-
|
|
237
|
+
"Preparing relative move by dX:%s, dY:%s, dZ:%s, dA:%s at F:%s",
|
|
238
|
+
x,
|
|
239
|
+
y,
|
|
240
|
+
z,
|
|
241
|
+
a,
|
|
242
|
+
feed_rate,
|
|
173
243
|
)
|
|
174
244
|
|
|
175
245
|
# Ensure relative mode is active
|
|
@@ -188,70 +258,154 @@ class GCodeController(SerialController):
|
|
|
188
258
|
a: Optional[float] = None,
|
|
189
259
|
feed: Optional[int] = None,
|
|
190
260
|
) -> None:
|
|
191
|
-
"""
|
|
261
|
+
"""
|
|
262
|
+
Internal helper for executing G1 move commands with safe movement pattern.
|
|
192
263
|
|
|
193
|
-
|
|
194
|
-
|
|
264
|
+
Safe move pattern:
|
|
265
|
+
1. If X or Y movement is needed, first move Z to 0 (safe height)
|
|
266
|
+
2. Then move X, Y (and optionally A) to target
|
|
267
|
+
3. Finally move Z to target position (if specified)
|
|
195
268
|
|
|
196
|
-
|
|
269
|
+
Args:
|
|
270
|
+
x: X axis value (optional)
|
|
271
|
+
y: Y axis value (optional)
|
|
272
|
+
z: Z axis value (optional)
|
|
273
|
+
a: A axis value (optional)
|
|
274
|
+
feed: Feed rate (optional)
|
|
275
|
+
"""
|
|
276
|
+
# Calculate target positions
|
|
197
277
|
target_pos = self.current_position.copy()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if value is not None:
|
|
203
|
-
move_cmd += f" {axis}{value}"
|
|
278
|
+
has_x = x is not None
|
|
279
|
+
has_y = y is not None
|
|
280
|
+
has_z = z is not None
|
|
281
|
+
has_a = a is not None
|
|
204
282
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
283
|
+
if not (has_x or has_y or has_z or has_a):
|
|
284
|
+
self._logger.warning(
|
|
285
|
+
"Move command issued without any axis movement. Skipping transmission."
|
|
286
|
+
)
|
|
287
|
+
return
|
|
210
288
|
|
|
211
|
-
|
|
289
|
+
# Calculate target positions
|
|
290
|
+
if self._is_absolute_mode:
|
|
291
|
+
if has_x:
|
|
292
|
+
target_pos["X"] = x
|
|
293
|
+
if has_y:
|
|
294
|
+
target_pos["Y"] = y
|
|
295
|
+
if has_z:
|
|
296
|
+
target_pos["Z"] = z
|
|
297
|
+
if has_a:
|
|
298
|
+
target_pos["A"] = a
|
|
299
|
+
else:
|
|
300
|
+
if has_x:
|
|
301
|
+
target_pos["X"] = self.current_position["X"] + x
|
|
302
|
+
if has_y:
|
|
303
|
+
target_pos["Y"] = self.current_position["Y"] + y
|
|
304
|
+
if has_z:
|
|
305
|
+
target_pos["Z"] = self.current_position["Z"] + z
|
|
306
|
+
if has_a:
|
|
307
|
+
target_pos["A"] = self.current_position["A"] + a
|
|
212
308
|
|
|
213
|
-
# 3. Add on Feed Rate to command
|
|
214
309
|
feed_rate = feed if feed is not None else self._feed
|
|
215
|
-
# Final safety check on the rate used in the command
|
|
216
310
|
if feed_rate > self.MAX_FEEDRATE:
|
|
217
311
|
feed_rate = self.MAX_FEEDRATE
|
|
218
|
-
move_cmd += f" F{feed_rate}"
|
|
219
312
|
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
return
|
|
313
|
+
# Safe move pattern: Z to 0, then XY, then Z to target
|
|
314
|
+
needs_xy_move = has_x or has_y
|
|
315
|
+
current_z = self.current_position["Z"]
|
|
316
|
+
target_z = target_pos["Z"] if has_z else current_z
|
|
226
317
|
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
318
|
+
# Step 1: Move Z to safe height (0) if XY movement is needed and Z is not already at 0
|
|
319
|
+
if needs_xy_move and abs(current_z) > 0.001: # Small tolerance for floating point
|
|
320
|
+
self._logger.info(
|
|
321
|
+
"Safe move: Raising Z to safe height (0) before XY movement"
|
|
322
|
+
)
|
|
323
|
+
if self._is_absolute_mode:
|
|
324
|
+
move_cmd = "G1 Z0"
|
|
325
|
+
else:
|
|
326
|
+
# In relative mode, move by negative current_z to reach 0
|
|
327
|
+
move_cmd = f"G1 Z{-current_z}"
|
|
328
|
+
move_cmd += f" F{feed_rate}"
|
|
329
|
+
self._send_command(self._build_command(move_cmd))
|
|
330
|
+
self.current_position["Z"] = 0.0
|
|
331
|
+
self._logger.debug("Z moved to safe height (0)")
|
|
332
|
+
|
|
333
|
+
# Step 2: Move X, Y (and optionally A) to target
|
|
334
|
+
if needs_xy_move or has_a:
|
|
335
|
+
move_cmd = "G1"
|
|
336
|
+
if has_x:
|
|
337
|
+
# Use target position in absolute mode, relative value in relative mode
|
|
338
|
+
if self._is_absolute_mode:
|
|
339
|
+
move_cmd += f" X{target_pos['X']}"
|
|
340
|
+
else:
|
|
341
|
+
move_cmd += f" X{x}"
|
|
342
|
+
if has_y:
|
|
343
|
+
if self._is_absolute_mode:
|
|
344
|
+
move_cmd += f" Y{target_pos['Y']}"
|
|
345
|
+
else:
|
|
346
|
+
move_cmd += f" Y{y}"
|
|
347
|
+
if has_a:
|
|
348
|
+
if self._is_absolute_mode:
|
|
349
|
+
move_cmd += f" A{target_pos['A']}"
|
|
350
|
+
else:
|
|
351
|
+
move_cmd += f" A{a}"
|
|
352
|
+
move_cmd += f" F{feed_rate}"
|
|
353
|
+
|
|
354
|
+
self._logger.info("Executing XY move command: %s", move_cmd)
|
|
355
|
+
self._send_command(self._build_command(move_cmd))
|
|
356
|
+
|
|
357
|
+
# Update position for moved axes
|
|
358
|
+
if has_x:
|
|
359
|
+
self.current_position["X"] = target_pos["X"]
|
|
360
|
+
if has_y:
|
|
361
|
+
self.current_position["Y"] = target_pos["Y"]
|
|
362
|
+
if has_a:
|
|
363
|
+
self.current_position["A"] = target_pos["A"]
|
|
364
|
+
|
|
365
|
+
# Step 3: Move Z to target position (if Z movement was requested)
|
|
366
|
+
if has_z:
|
|
367
|
+
z_needs_move = abs(target_z - (0.0 if needs_xy_move else current_z)) > 0.001
|
|
368
|
+
if z_needs_move:
|
|
369
|
+
if self._is_absolute_mode:
|
|
370
|
+
move_cmd = f"G1 Z{target_z} F{feed_rate}"
|
|
371
|
+
else:
|
|
372
|
+
# In relative mode, use the original z value
|
|
373
|
+
move_cmd = f"G1 Z{z} F{feed_rate}"
|
|
374
|
+
|
|
375
|
+
if needs_xy_move:
|
|
376
|
+
self._logger.info(
|
|
377
|
+
"Safe move: Lowering Z to target position: %s", target_z
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
self._logger.info("Executing Z move command: %s", move_cmd)
|
|
381
|
+
|
|
382
|
+
self._send_command(self._build_command(move_cmd))
|
|
383
|
+
self.current_position["Z"] = target_z
|
|
230
384
|
|
|
231
385
|
self._logger.info(
|
|
232
|
-
|
|
386
|
+
"Move complete. Final position: %s", self.current_position
|
|
233
387
|
)
|
|
388
|
+
self._logger.debug("New internal position: %s", self.current_position)
|
|
234
389
|
|
|
235
|
-
|
|
236
|
-
self._logger.debug(f"New internal position: {self.current_position}")
|
|
237
|
-
|
|
238
|
-
# 6. Post-Move Position Synchronization Check
|
|
390
|
+
# Post-move position synchronization check
|
|
239
391
|
self.sync_position()
|
|
240
392
|
|
|
241
393
|
def query_position(self) -> Dict[str, float]:
|
|
242
394
|
"""
|
|
243
|
-
|
|
395
|
+
Query the current machine position (M114 command).
|
|
244
396
|
|
|
245
397
|
Returns:
|
|
246
|
-
|
|
398
|
+
Dictionary containing X, Y, Z, and A positions
|
|
399
|
+
|
|
400
|
+
Note:
|
|
401
|
+
Returns an empty dictionary if the query fails or no positions are found.
|
|
247
402
|
"""
|
|
248
403
|
self._logger.info("Querying current machine position (M114).")
|
|
249
404
|
self._send_command(self._build_command("M114"))
|
|
250
405
|
res: str = self._read_response()
|
|
251
406
|
|
|
252
407
|
# Extract position values using regex
|
|
253
|
-
pattern = re.compile(r"([
|
|
254
|
-
# Find all matches (e.g., [('X', '0.000'), ('Y', '0.000'), ...])
|
|
408
|
+
pattern = re.compile(r"([XYZA]):(-?\d+\.\d+)")
|
|
255
409
|
matches = pattern.findall(res)
|
|
256
410
|
|
|
257
411
|
position_data: Dict[str, float] = {}
|
|
@@ -261,7 +415,9 @@ class GCodeController(SerialController):
|
|
|
261
415
|
position_data[axis] = float(value_str)
|
|
262
416
|
except ValueError:
|
|
263
417
|
self._logger.error(
|
|
264
|
-
|
|
418
|
+
"Failed to convert position value '%s' for axis %s to float.",
|
|
419
|
+
value_str,
|
|
420
|
+
axis,
|
|
265
421
|
)
|
|
266
422
|
continue
|
|
267
423
|
|
|
@@ -269,31 +425,33 @@ class GCodeController(SerialController):
|
|
|
269
425
|
|
|
270
426
|
def sync_position(self) -> Tuple[bool, Dict[str, float]]:
|
|
271
427
|
"""
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
428
|
+
Synchronize internal position with actual machine position.
|
|
429
|
+
|
|
430
|
+
Queries the machine position and compares it with the internal position.
|
|
431
|
+
If a discrepancy greater than the tolerance is found, attempts to correct
|
|
432
|
+
it by moving to the internal position.
|
|
275
433
|
|
|
276
434
|
Returns:
|
|
277
|
-
Tuple
|
|
278
|
-
is True if a
|
|
279
|
-
|
|
435
|
+
Tuple of (adjustment_occurred: bool, final_position: Dict[str, float])
|
|
436
|
+
where adjustment_occurred is True if a correction move was made.
|
|
437
|
+
|
|
438
|
+
Note:
|
|
439
|
+
This method may recursively call itself if a correction move is made.
|
|
280
440
|
"""
|
|
281
441
|
self._logger.info("Starting position synchronization check (M114).")
|
|
282
442
|
|
|
283
|
-
#
|
|
443
|
+
# Query the actual machine position
|
|
284
444
|
queried_position = self.query_position()
|
|
285
445
|
|
|
286
|
-
# Check if the query was unsuccessful (only returns empty dict)
|
|
287
446
|
if not queried_position:
|
|
288
447
|
self._logger.warning("Query position failed. Cannot synchronize.")
|
|
289
448
|
return False, self.current_position
|
|
290
449
|
|
|
291
|
-
#
|
|
450
|
+
# Compare internal vs. queried position
|
|
292
451
|
axis_keys = ["X", "Y", "Z", "A"]
|
|
293
452
|
adjustment_needed = False
|
|
294
453
|
|
|
295
454
|
for axis in axis_keys:
|
|
296
|
-
# Check if axis exists in both dictionaries and if they differ significantly
|
|
297
455
|
if (
|
|
298
456
|
axis in self.current_position
|
|
299
457
|
and axis in queried_position
|
|
@@ -301,25 +459,24 @@ class GCodeController(SerialController):
|
|
|
301
459
|
> self.TOLERANCE
|
|
302
460
|
):
|
|
303
461
|
self._logger.warning(
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
462
|
+
"Position mismatch found on %s axis: Internal=%.3f, Queried=%.3f",
|
|
463
|
+
axis,
|
|
464
|
+
self.current_position[axis],
|
|
465
|
+
queried_position[axis],
|
|
307
466
|
)
|
|
308
|
-
|
|
309
467
|
adjustment_needed = True
|
|
310
|
-
|
|
311
468
|
elif axis in queried_position:
|
|
312
|
-
# Update internal position with
|
|
469
|
+
# Update internal position with queried position if it differs slightly
|
|
313
470
|
self.current_position[axis] = queried_position[axis]
|
|
314
471
|
|
|
315
|
-
#
|
|
472
|
+
# Perform re-synchronization move if needed
|
|
316
473
|
if adjustment_needed:
|
|
317
474
|
self._logger.info(
|
|
318
|
-
|
|
475
|
+
"** DISCREPANCY DETECTED. Moving robot back to internal position: %s **",
|
|
476
|
+
self.current_position,
|
|
319
477
|
)
|
|
320
478
|
|
|
321
479
|
try:
|
|
322
|
-
# Extract the X, Y, Z, A coordinates from the internal position for the move
|
|
323
480
|
target_x = self.current_position.get("X")
|
|
324
481
|
target_y = self.current_position.get("Y")
|
|
325
482
|
target_z = self.current_position.get("Z")
|
|
@@ -328,31 +485,29 @@ class GCodeController(SerialController):
|
|
|
328
485
|
self.move_absolute(x=target_x, y=target_y, z=target_z, a=target_a)
|
|
329
486
|
self._logger.info("Synchronization move successfully completed.")
|
|
330
487
|
|
|
331
|
-
#
|
|
332
|
-
self.sync_position()
|
|
333
|
-
except
|
|
334
|
-
self._logger.error(
|
|
335
|
-
adjustment_needed = False
|
|
488
|
+
# Recursive call to verify position after move
|
|
489
|
+
return self.sync_position()
|
|
490
|
+
except (ValueError, RuntimeError, OSError) as e:
|
|
491
|
+
self._logger.error("Synchronization move failed: %s", e)
|
|
492
|
+
adjustment_needed = False
|
|
336
493
|
|
|
337
494
|
final_position = self.current_position.copy()
|
|
338
495
|
|
|
339
496
|
if adjustment_needed:
|
|
340
497
|
self._logger.info(
|
|
341
|
-
|
|
498
|
+
"Position check complete. Internal position is synchronized with machine."
|
|
342
499
|
)
|
|
343
500
|
else:
|
|
344
501
|
self._logger.info("No adjustment was made.")
|
|
345
502
|
|
|
346
|
-
# Note: If adjustment_needed was True, it means a move was attempted.
|
|
347
|
-
# We return the initial state of adjustment_needed (i.e., if a move was triggered).
|
|
348
503
|
return adjustment_needed, final_position
|
|
349
504
|
|
|
350
505
|
def get_info(self) -> str:
|
|
351
506
|
"""
|
|
352
|
-
|
|
507
|
+
Query machine information (M115 command).
|
|
353
508
|
|
|
354
509
|
Returns:
|
|
355
|
-
|
|
510
|
+
Machine information string from the device
|
|
356
511
|
"""
|
|
357
512
|
self._logger.info("Querying machine information (M115).")
|
|
358
513
|
self._send_command(self._build_command("M115"))
|
|
@@ -361,7 +516,10 @@ class GCodeController(SerialController):
|
|
|
361
516
|
|
|
362
517
|
def get_internal_position(self) -> Dict[str, float]:
|
|
363
518
|
"""
|
|
364
|
-
|
|
519
|
+
Get the internally tracked position.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Dictionary containing the current internal position for all axes
|
|
365
523
|
"""
|
|
366
|
-
self._logger.debug(
|
|
367
|
-
return self.current_position
|
|
524
|
+
self._logger.debug("Returning internal position: %s", self.current_position)
|
|
525
|
+
return self.current_position.copy()
|
|
@@ -1,34 +1,53 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
Sartorius rLINE pipette controller.
|
|
3
|
+
|
|
4
|
+
This module provides a Python interface for controlling Sartorius rLINE® electronic
|
|
5
|
+
pipettes and robotic dispensers via serial communication.
|
|
6
|
+
|
|
7
|
+
Reference: https://api.sartorius.com/document-hub/dam/download/34901/Sartorius-rLine-technical-user-manual-v1.1.pdf
|
|
8
|
+
"""
|
|
9
|
+
|
|
2
10
|
import logging
|
|
3
11
|
from typing import Optional
|
|
12
|
+
|
|
4
13
|
from puda_drivers.core.serialcontroller import SerialController
|
|
14
|
+
|
|
5
15
|
from .constants import STATUS_CODES
|
|
6
16
|
|
|
7
17
|
|
|
8
18
|
class SartoriusDeviceError(Exception):
|
|
9
19
|
"""Custom exception raised when the Sartorius device reports an error."""
|
|
10
20
|
|
|
11
|
-
pass
|
|
12
|
-
|
|
13
21
|
|
|
14
22
|
class SartoriusController(SerialController):
|
|
15
23
|
"""
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
Controller for Sartorius rLINE® pipettes and robotic dispensers.
|
|
25
|
+
|
|
26
|
+
This class provides methods for controlling pipette operations including
|
|
27
|
+
aspiration, dispensing, tip ejection, and speed control via serial communication.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
DEFAULT_BAUDRATE: Default baud rate for serial communication (9600)
|
|
31
|
+
DEFAULT_TIMEOUT: Default timeout for operations (10 seconds)
|
|
32
|
+
MICROLITER_PER_STEP: Conversion factor from steps to microliters (0.5 µL/step)
|
|
33
|
+
MIN_SPEED: Minimum speed setting (1)
|
|
34
|
+
MAX_SPEED: Maximum speed setting (6)
|
|
18
35
|
"""
|
|
19
36
|
|
|
20
|
-
#
|
|
37
|
+
# Protocol Constants
|
|
21
38
|
DEFAULT_BAUDRATE = 9600
|
|
22
39
|
DEFAULT_TIMEOUT = 10
|
|
23
40
|
|
|
24
41
|
PROTOCOL_SOH = "\x01"
|
|
25
42
|
SLAVE_ADDRESS = "1"
|
|
26
|
-
PROTOCOL_TERMINATOR = "º\r"
|
|
43
|
+
PROTOCOL_TERMINATOR = "º\r"
|
|
27
44
|
|
|
28
|
-
#
|
|
29
|
-
|
|
45
|
+
# Sartorius rLine Settings
|
|
46
|
+
MICROLITER_PER_STEP = 0.5
|
|
30
47
|
SUCCESS_RESPONSE = "ok"
|
|
31
48
|
ERROR_RESPONSE = "err"
|
|
49
|
+
MIN_SPEED = 1
|
|
50
|
+
MAX_SPEED = 6
|
|
32
51
|
|
|
33
52
|
def __init__(
|
|
34
53
|
self,
|
|
@@ -36,15 +55,37 @@ class SartoriusController(SerialController):
|
|
|
36
55
|
baudrate: int = DEFAULT_BAUDRATE,
|
|
37
56
|
timeout: int = DEFAULT_TIMEOUT,
|
|
38
57
|
):
|
|
58
|
+
"""
|
|
59
|
+
Initialize the Sartorius controller.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
port_name: Serial port name (e.g., '/dev/ttyUSB0' or 'COM3')
|
|
63
|
+
baudrate: Baud rate for serial communication. Defaults to 9600.
|
|
64
|
+
timeout: Timeout in seconds for operations. Defaults to 10.
|
|
65
|
+
"""
|
|
39
66
|
super().__init__(port_name, baudrate, timeout)
|
|
40
67
|
self._logger = logging.getLogger(__name__)
|
|
41
68
|
self._logger.info(
|
|
42
|
-
|
|
69
|
+
"Sartorius Controller initialized with port='%s', baudrate=%s, timeout=%s",
|
|
70
|
+
port_name,
|
|
71
|
+
baudrate,
|
|
72
|
+
timeout,
|
|
43
73
|
)
|
|
44
74
|
|
|
45
75
|
def _build_command(self, command_code: str, value: str = "") -> str:
|
|
46
|
-
"""
|
|
47
|
-
|
|
76
|
+
"""
|
|
77
|
+
Build a command string according to the Sartorius protocol.
|
|
78
|
+
|
|
79
|
+
Command format: <SOH><SLAVE_ADDRESS>R<COMMAND_CODE><VALUE><TERMINATOR>
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
command_code: Single character command code
|
|
83
|
+
value: Optional value string to append to the command
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Complete command string ready to send
|
|
87
|
+
"""
|
|
88
|
+
return (
|
|
48
89
|
self.PROTOCOL_SOH
|
|
49
90
|
+ self.SLAVE_ADDRESS
|
|
50
91
|
+ "R"
|
|
@@ -52,207 +93,306 @@ class SartoriusController(SerialController):
|
|
|
52
93
|
+ value
|
|
53
94
|
+ self.PROTOCOL_TERMINATOR
|
|
54
95
|
)
|
|
55
|
-
return cmd
|
|
56
96
|
|
|
57
|
-
def
|
|
58
|
-
"""
|
|
59
|
-
Initializes the pipette unit (RZ command).
|
|
97
|
+
def _check_response_error(self, response: str, operation: str) -> None:
|
|
60
98
|
"""
|
|
61
|
-
|
|
62
|
-
self._logger.info("** Initializing Pipette Head (RZ) **")
|
|
99
|
+
Check if a response contains an error and raise an exception if so.
|
|
63
100
|
|
|
64
|
-
|
|
65
|
-
|
|
101
|
+
Args:
|
|
102
|
+
response: Response string from the device
|
|
103
|
+
operation: Description of the operation being performed (for error message)
|
|
66
104
|
|
|
67
|
-
|
|
105
|
+
Raises:
|
|
106
|
+
SartoriusDeviceError: If the response contains an error
|
|
107
|
+
"""
|
|
108
|
+
if self.ERROR_RESPONSE in response.lower():
|
|
68
109
|
raise SartoriusDeviceError(
|
|
69
|
-
f"
|
|
110
|
+
f"{operation} failed. Device returned error: {response}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _validate_speed(self, speed: int, direction: str = "speed") -> None:
|
|
114
|
+
"""
|
|
115
|
+
Validate that a speed value is within the allowed range.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
speed: Speed value to validate
|
|
119
|
+
direction: Direction description for error message (e.g., "Inward", "Outward")
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: If speed is outside the valid range
|
|
123
|
+
"""
|
|
124
|
+
if not self.MIN_SPEED <= speed <= self.MAX_SPEED:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"{direction} speed must be between {self.MIN_SPEED} and {self.MAX_SPEED}, "
|
|
127
|
+
f"got {speed}"
|
|
70
128
|
)
|
|
129
|
+
|
|
130
|
+
def _validate_no_leading_zeros(self, value: int, command_name: str) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Validate that a numeric value has no leading zeros when converted to string.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
value: Numeric value to validate
|
|
136
|
+
command_name: Command name for error message (e.g., "RP", "RE")
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
String representation of the value
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: If the value has leading zeros
|
|
143
|
+
"""
|
|
144
|
+
value_str = str(value)
|
|
145
|
+
if len(value_str) > 1 and value_str.startswith("0"):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"{command_name} command value must not have leading zeros. "
|
|
148
|
+
f"Got: {value_str}"
|
|
149
|
+
)
|
|
150
|
+
return value_str
|
|
151
|
+
|
|
152
|
+
def _execute_command(
|
|
153
|
+
self, command_code: str, value: str = "", operation: str = ""
|
|
154
|
+
) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Execute a command and return the response.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
command_code: Command code to execute
|
|
160
|
+
value: Optional value for the command
|
|
161
|
+
operation: Description of the operation (for logging and error messages)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Response string from the device
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
SartoriusDeviceError: If the device returns an error
|
|
168
|
+
"""
|
|
169
|
+
command = self._build_command(command_code, value)
|
|
170
|
+
self._send_command(command)
|
|
171
|
+
response = self._read_response()
|
|
172
|
+
self._check_response_error(response, operation or f"Command {command_code}")
|
|
173
|
+
return response
|
|
174
|
+
|
|
175
|
+
def initialize(self) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Initialize the pipette unit (RZ command).
|
|
178
|
+
|
|
179
|
+
This command resets the pipette to its initial state and should be called
|
|
180
|
+
before performing other operations.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
SartoriusDeviceError: If initialization fails
|
|
184
|
+
"""
|
|
185
|
+
self._logger.info("** Initializing Pipette Head (RZ) **")
|
|
186
|
+
self._execute_command("Z", operation="Pipette initialization")
|
|
71
187
|
self._logger.info("** Pipette Initialization Complete **")
|
|
72
188
|
|
|
73
|
-
# different from docs
|
|
74
189
|
def get_inward_speed(self) -> int:
|
|
75
190
|
"""
|
|
76
|
-
|
|
191
|
+
Query the current aspirating speed (DI command).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Current inward speed setting (1-6)
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
SartoriusDeviceError: If the query fails
|
|
77
198
|
"""
|
|
78
|
-
command = self._build_command(command_code="DI")
|
|
79
199
|
self._logger.info("** Querying Inward Speed (DI) **")
|
|
200
|
+
response = self._execute_command("DI", operation="Inward speed query")
|
|
80
201
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
202
|
+
if len(response) < 2:
|
|
203
|
+
raise SartoriusDeviceError(
|
|
204
|
+
f"Invalid response format for inward speed query: {response}"
|
|
205
|
+
)
|
|
85
206
|
|
|
86
|
-
|
|
87
|
-
|
|
207
|
+
speed = int(response[1])
|
|
208
|
+
self._logger.info("** Current Inward Speed: %s **", speed)
|
|
209
|
+
return speed
|
|
88
210
|
|
|
89
211
|
def set_inward_speed(self, speed: int) -> None:
|
|
90
212
|
"""
|
|
91
|
-
|
|
92
|
-
"""
|
|
93
|
-
if not 1 <= speed <= 6:
|
|
94
|
-
raise ValueError("Inward speed must be between 1 and 6.")
|
|
213
|
+
Set the aspirating speed (SI command).
|
|
95
214
|
|
|
96
|
-
|
|
97
|
-
|
|
215
|
+
Args:
|
|
216
|
+
speed: Speed setting (1-6, where 1 is slowest and 6 is fastest)
|
|
98
217
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
self.
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: If speed is outside the valid range
|
|
220
|
+
SartoriusDeviceError: If setting the speed fails
|
|
221
|
+
"""
|
|
222
|
+
self._validate_speed(speed, "Inward")
|
|
223
|
+
self._logger.info("** Setting Inward Speed (SI, Speed: %s) **", speed)
|
|
224
|
+
self._execute_command("I", value=str(speed), operation="Setting inward speed")
|
|
225
|
+
self._logger.info("** Inward Speed Set to %s Successfully **", speed)
|
|
104
226
|
|
|
105
227
|
def get_outward_speed(self) -> int:
|
|
106
228
|
"""
|
|
107
|
-
|
|
229
|
+
Query the current dispensing speed (DO command).
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Current outward speed setting (1-6)
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
SartoriusDeviceError: If the query fails
|
|
108
236
|
"""
|
|
109
|
-
command = self._build_command(command_code="DO")
|
|
110
237
|
self._logger.info("** Querying Outward Speed (DO) **")
|
|
238
|
+
response = self._execute_command("DO", operation="Outward speed query")
|
|
111
239
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
240
|
+
if len(response) < 2:
|
|
241
|
+
raise SartoriusDeviceError(
|
|
242
|
+
f"Invalid response format for outward speed query: {response}"
|
|
243
|
+
)
|
|
116
244
|
|
|
117
|
-
|
|
118
|
-
|
|
245
|
+
speed = int(response[1])
|
|
246
|
+
self._logger.info("** Current Outward Speed: %s **", speed)
|
|
247
|
+
return speed
|
|
119
248
|
|
|
120
249
|
def set_outward_speed(self, speed: int) -> None:
|
|
121
250
|
"""
|
|
122
|
-
|
|
123
|
-
"""
|
|
124
|
-
if not 1 <= speed <= 6:
|
|
125
|
-
raise ValueError("Outward speed must be between 1 and 6.")
|
|
251
|
+
Set the dispensing speed (SO command).
|
|
126
252
|
|
|
127
|
-
|
|
253
|
+
Args:
|
|
254
|
+
speed: Speed setting (1-6, where 1 is slowest and 6 is fastest)
|
|
128
255
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
self._logger.info(
|
|
256
|
+
Raises:
|
|
257
|
+
ValueError: If speed is outside the valid range
|
|
258
|
+
SartoriusDeviceError: If setting the speed fails
|
|
259
|
+
"""
|
|
260
|
+
self._validate_speed(speed, "Outward")
|
|
261
|
+
self._logger.info("** Setting Outward Speed (SO, Speed: %s) **", speed)
|
|
262
|
+
self._execute_command("O", value=str(speed), operation="Setting outward speed")
|
|
263
|
+
self._logger.info("** Outward Speed Set to %s Successfully **", speed)
|
|
137
264
|
|
|
138
265
|
def run_to_position(self, position: int) -> None:
|
|
139
266
|
"""
|
|
140
|
-
|
|
141
|
-
Steps must be given without leading zeros.
|
|
142
|
-
"""
|
|
143
|
-
position_str = str(position)
|
|
144
|
-
# Check if position has leading zeros (RP030 is incorrect)
|
|
145
|
-
if len(position_str) > 1 and position_str.startswith("0"):
|
|
146
|
-
raise ValueError("Position value for RP must not have leading zeros.")
|
|
147
|
-
|
|
148
|
-
command = self._build_command(command_code="P", value=position_str)
|
|
149
|
-
self._logger.info(f"** Run to absolute Position (RP, Position: {position}) **")
|
|
150
|
-
|
|
151
|
-
self._send_command(command)
|
|
152
|
-
res: str = self._read_response()
|
|
153
|
-
if "err" in res:
|
|
154
|
-
raise SartoriusDeviceError(f"Run to position failed with error: {res}")
|
|
267
|
+
Drive the piston to an absolute step position (RP command).
|
|
155
268
|
|
|
156
|
-
|
|
269
|
+
Args:
|
|
270
|
+
position: Target position in steps (must not have leading zeros)
|
|
157
271
|
|
|
158
|
-
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: If position has leading zeros
|
|
274
|
+
SartoriusDeviceError: If the command fails
|
|
159
275
|
"""
|
|
160
|
-
|
|
276
|
+
position_str = self._validate_no_leading_zeros(position, "RP")
|
|
277
|
+
self._logger.info("** Run to absolute Position (RP, Position: %s) **", position)
|
|
278
|
+
self._execute_command("P", value=position_str, operation="Run to position")
|
|
279
|
+
self._logger.info("** Reached Position %s Successfully **", position)
|
|
280
|
+
|
|
281
|
+
def aspirate(self, amount: float) -> None:
|
|
161
282
|
"""
|
|
162
|
-
|
|
163
|
-
command = self._build_command(command_code="I", value=str(steps))
|
|
164
|
-
self._logger.info(f"** Aspirating {amount} uL (RI{steps}) **")
|
|
283
|
+
Aspirate fluid from the current location.
|
|
165
284
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if "err" in res:
|
|
169
|
-
raise SartoriusDeviceError(f"Aspirate failed with error: {res}")
|
|
170
|
-
self._logger.info(f"** Aspirated {amount} uL Successfully **")
|
|
285
|
+
Args:
|
|
286
|
+
amount: Volume to aspirate in microliters (µL)
|
|
171
287
|
|
|
172
|
-
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: If amount is negative or zero
|
|
290
|
+
SartoriusDeviceError: If aspiration fails
|
|
173
291
|
"""
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"""
|
|
177
|
-
steps = amount * self.STEPS_PER_MICROLITER
|
|
292
|
+
if amount <= 0:
|
|
293
|
+
raise ValueError(f"Aspiration amount must be positive, got {amount}")
|
|
178
294
|
|
|
179
|
-
|
|
180
|
-
self._logger.info(
|
|
295
|
+
steps = int(amount / self.MICROLITER_PER_STEP)
|
|
296
|
+
self._logger.info("** Aspirating %s uL (RI%s steps) **", amount, steps)
|
|
297
|
+
self._execute_command("I", value=str(steps), operation="Aspirate")
|
|
298
|
+
self._logger.info("** Aspirated %s uL Successfully **", amount)
|
|
181
299
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
raise SartoriusDeviceError(f"Dispense failed with error: {res}")
|
|
186
|
-
self._logger.info(f"** Dispensed {amount} uL Successfully **")
|
|
300
|
+
def dispense(self, amount: float) -> None:
|
|
301
|
+
"""
|
|
302
|
+
Dispense fluid at the current location.
|
|
187
303
|
|
|
188
|
-
|
|
304
|
+
Args:
|
|
305
|
+
amount: Volume to dispense in microliters (µL)
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
ValueError: If amount is negative or zero
|
|
309
|
+
SartoriusDeviceError: If dispensing fails
|
|
189
310
|
"""
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
311
|
+
if amount <= 0:
|
|
312
|
+
raise ValueError(f"Dispense amount must be positive, got {amount}")
|
|
313
|
+
|
|
314
|
+
steps = int(amount / self.MICROLITER_PER_STEP)
|
|
315
|
+
self._logger.info("** Dispensing %s uL (RO%s steps) **", amount, steps)
|
|
316
|
+
self._execute_command("O", value=str(steps), operation="Dispense")
|
|
317
|
+
self._logger.info("** Dispensed %s uL Successfully **", amount)
|
|
318
|
+
|
|
319
|
+
def eject_tip(self, return_position: int = 30) -> None:
|
|
193
320
|
"""
|
|
194
|
-
|
|
195
|
-
position_str = str(return_position)
|
|
196
|
-
if len(position_str) > 1 and position_str.startswith("0"):
|
|
197
|
-
raise ValueError(
|
|
198
|
-
"Return position value for RE must not have leading zeros."
|
|
199
|
-
)
|
|
200
|
-
command = self._build_command(command_code="E", value=position_str)
|
|
201
|
-
self._logger.info(
|
|
202
|
-
f"** Ejecting Tip and returning to position {return_position} (RE {return_position}) **"
|
|
203
|
-
)
|
|
204
|
-
else:
|
|
205
|
-
command = self._build_command(command_code="E")
|
|
206
|
-
self._logger.info("** Ejecting Tip and returning to position 0 (RE) **")
|
|
321
|
+
Eject the pipette tip (RE command).
|
|
207
322
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
323
|
+
Args:
|
|
324
|
+
return_position: Position to return to after ejection. Defaults to 30.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
ValueError: If return_position has leading zeros
|
|
328
|
+
SartoriusDeviceError: If tip ejection fails
|
|
329
|
+
"""
|
|
330
|
+
position_str = self._validate_no_leading_zeros(return_position, "RE")
|
|
331
|
+
self._logger.info(
|
|
332
|
+
"** Ejecting Tip and returning to position %s (RE %s) **",
|
|
333
|
+
return_position,
|
|
334
|
+
return_position,
|
|
335
|
+
)
|
|
336
|
+
self._execute_command(
|
|
337
|
+
"E", value=position_str, operation="Eject tip with return position"
|
|
338
|
+
)
|
|
212
339
|
self._logger.info("** Tip Ejection Complete **")
|
|
213
340
|
|
|
214
341
|
def run_blowout(self, return_position: Optional[int] = None) -> None:
|
|
215
342
|
"""
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
343
|
+
Run the blowout cycle to clear residual liquid (RB command).
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
return_position: Optional position to return to after blowout.
|
|
347
|
+
If None, completes blowout without returning.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
ValueError: If return_position has leading zeros
|
|
351
|
+
SartoriusDeviceError: If blowout fails
|
|
219
352
|
"""
|
|
220
353
|
if return_position is not None:
|
|
221
|
-
position_str =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
"Return position value for RB must not have leading zeros."
|
|
225
|
-
)
|
|
226
|
-
command = self._build_command(command_code="B", value=position_str)
|
|
354
|
+
position_str = self._validate_no_leading_zeros(
|
|
355
|
+
return_position, "RB"
|
|
356
|
+
)
|
|
227
357
|
self._logger.info(
|
|
228
|
-
|
|
358
|
+
"** Running Blowout and returning to position %s (RB %s) **",
|
|
359
|
+
return_position,
|
|
360
|
+
return_position,
|
|
361
|
+
)
|
|
362
|
+
self._execute_command(
|
|
363
|
+
"B", value=position_str, operation="Blowout with return position"
|
|
229
364
|
)
|
|
230
365
|
else:
|
|
231
|
-
command = self._build_command(command_code="B")
|
|
232
366
|
self._logger.info("** Running Blowout (RB) **")
|
|
367
|
+
self._execute_command("B", operation="Blowout")
|
|
233
368
|
|
|
234
|
-
self._send_command(command)
|
|
235
|
-
res: str = self._read_response()
|
|
236
|
-
if "err" in res:
|
|
237
|
-
raise SartoriusDeviceError(f"Blowout failed with error: {res}")
|
|
238
369
|
self._logger.info("** Blowout Complete **")
|
|
239
370
|
|
|
240
371
|
def get_status(self) -> str:
|
|
241
372
|
"""
|
|
242
|
-
|
|
243
|
-
|
|
373
|
+
Query the current status of the pipette (DS command).
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Status code character (single character string)
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
SartoriusDeviceError: If the status query fails
|
|
244
380
|
"""
|
|
245
|
-
command = self._build_command(command_code="DS")
|
|
246
381
|
self._logger.info("** Querying Pipette Status (DS) **")
|
|
382
|
+
response = self._execute_command("DS", operation="Status query")
|
|
247
383
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
384
|
+
if len(response) < 2:
|
|
385
|
+
raise SartoriusDeviceError(
|
|
386
|
+
f"Invalid response format for status query: {response}"
|
|
387
|
+
)
|
|
252
388
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
389
|
+
status_code = response[1]
|
|
390
|
+
if status_code in STATUS_CODES:
|
|
391
|
+
status_message = STATUS_CODES[status_code]
|
|
392
|
+
self._logger.info("Pipette Status Code [%s]: %s", status_code, status_message)
|
|
256
393
|
else:
|
|
257
|
-
self._logger.
|
|
258
|
-
|
|
394
|
+
self._logger.warning(
|
|
395
|
+
"Pipette Status Code [%s]: Unknown Status Code", status_code
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return status_code
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
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
|
|
@@ -3,15 +3,15 @@ puda_drivers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
3
3
|
puda_drivers/core/__init__.py,sha256=JM6eWTelwcmjTGM3gprQlJWzPGEpIdRrDmbCHtGoKyM,119
|
|
4
4
|
puda_drivers/core/serialcontroller.py,sha256=-wdQqq30yb6dSsNwA2FQec5HUh0U3j71OOMt6pbQIpc,7230
|
|
5
5
|
puda_drivers/move/__init__.py,sha256=i7G5VKD5FgnmC21TLxoASVtC88IrPUTLDJrTnp99u-0,35
|
|
6
|
-
puda_drivers/move/gcode.py,sha256=
|
|
6
|
+
puda_drivers/move/gcode.py,sha256=1KkpgwXBiNrcV4Ilc-6n-ADQwOVwVgK_9x08LKhbE5g,18106
|
|
7
7
|
puda_drivers/move/grbl/__init__.py,sha256=vBeeti8DVN2dACi1rLmHN_UGIOdo0s-HZX6mIepLV5I,98
|
|
8
8
|
puda_drivers/move/grbl/api.py,sha256=loj8_Vap7S9qaD0ReHhgxr9Vkl6Wp7DGzyLkZyZ6v_k,16995
|
|
9
9
|
puda_drivers/move/grbl/constants.py,sha256=4736CRDzLGWVqGscLajMlrIQMyubsHfthXi4RF1CHNg,9585
|
|
10
10
|
puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=QGpKz5YUwa8xCdSMXeZ0iRU-hRVqAWNPK0mlMTuzv8I,101
|
|
11
11
|
puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
|
|
12
12
|
puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
|
|
13
|
-
puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=
|
|
14
|
-
puda_drivers-0.0.
|
|
15
|
-
puda_drivers-0.0.
|
|
16
|
-
puda_drivers-0.0.
|
|
17
|
-
puda_drivers-0.0.
|
|
13
|
+
puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=iW3v-YHjj4ZAfGv0x0J-XV-Y0fAAhS6xmSg2ozQm4UI,13803
|
|
14
|
+
puda_drivers-0.0.3.dist-info/METADATA,sha256=and-Tnldz1Er_yVN76nJdmDDCsqoiEEJmyIovzaXNMI,5153
|
|
15
|
+
puda_drivers-0.0.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
16
|
+
puda_drivers-0.0.3.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
|
|
17
|
+
puda_drivers-0.0.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|