puda-drivers 0.0.6__tar.gz → 0.0.8__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.
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/PKG-INFO +2 -2
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/pyproject.toml +2 -2
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/core/__init__.py +2 -1
- puda_drivers-0.0.8/src/puda_drivers/core/position.py +378 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/core/serialcontroller.py +33 -18
- puda_drivers-0.0.8/src/puda_drivers/labware/__init__.py +9 -0
- puda_drivers-0.0.8/src/puda_drivers/labware/labware.py +157 -0
- puda_drivers-0.0.8/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json +1020 -0
- puda_drivers-0.0.8/src/puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +141 -0
- puda_drivers-0.0.8/src/puda_drivers/labware/trash_bin.json +41 -0
- puda_drivers-0.0.8/src/puda_drivers/machines/__init__.py +3 -0
- puda_drivers-0.0.8/src/puda_drivers/machines/first.py +242 -0
- puda_drivers-0.0.8/src/puda_drivers/move/__init__.py +4 -0
- puda_drivers-0.0.8/src/puda_drivers/move/deck.py +47 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/move/gcode.py +143 -134
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/transfer/liquid/sartorius/sartorius.py +88 -79
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/tests/example.py +6 -6
- puda_drivers-0.0.8/tests/first.py +59 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/tests/pipette.py +22 -17
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/tests/qubot.py +17 -16
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/uv.lock +3 -8
- puda_drivers-0.0.6/src/puda_drivers/move/__init__.py +0 -1
- puda_drivers-0.0.6/tests/together.py +0 -152
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/.gitignore +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/LICENSE +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/README.md +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/__init__.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/core/logging.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/move/grbl/__init__.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/move/grbl/api.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/move/grbl/constants.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/py.typed +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
- {puda_drivers-0.0.6 → puda_drivers-0.0.8}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
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
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
16
|
Classifier: Topic :: System :: Hardware
|
|
17
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
18
|
Requires-Dist: pyserial~=3.5
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "puda-drivers"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.8"
|
|
4
4
|
description = "Hardware drivers for the PUDA platform."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "zhao", email = "20024592+agentzhao@users.noreply.github.com" }
|
|
8
8
|
]
|
|
9
|
-
requires-python = ">=3.
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
10
|
classifiers = [
|
|
11
11
|
"Intended Audience :: Science/Research",
|
|
12
12
|
"Intended Audience :: Developers",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from .serialcontroller import SerialController, list_serial_ports
|
|
2
2
|
from .logging import setup_logging
|
|
3
|
+
from .position import Position
|
|
3
4
|
|
|
4
|
-
__all__ = ["SerialController", "list_serial_ports", "setup_logging"]
|
|
5
|
+
__all__ = ["SerialController", "list_serial_ports", "setup_logging", "Position"]
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Position class for handling multi-axis positions.
|
|
3
|
+
|
|
4
|
+
Supports flexible axis definitions with default x, y, z, a axes.
|
|
5
|
+
Provides JSON conversion, arithmetic operations, and dictionary/tuple compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import copy
|
|
10
|
+
from typing import Dict, Optional, Tuple, Union, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Position:
|
|
14
|
+
"""
|
|
15
|
+
Represents a position in multi-axis space.
|
|
16
|
+
|
|
17
|
+
Default axes are x, y, z, a, but can support any number of axes.
|
|
18
|
+
Supports addition, subtraction, JSON conversion, and dictionary/tuple access.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
x: Optional[float] = None,
|
|
24
|
+
y: Optional[float] = None,
|
|
25
|
+
z: Optional[float] = None,
|
|
26
|
+
a: Optional[float] = None,
|
|
27
|
+
**kwargs: float
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Initialize a Position with axis values.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
x: X axis value (optional)
|
|
34
|
+
y: Y axis value (optional)
|
|
35
|
+
z: Z axis value (optional)
|
|
36
|
+
a: A axis value (optional)
|
|
37
|
+
**kwargs: Additional axis values (e.g., b=10.0, c=20.0)
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
>>> pos = Position(x=10, y=20, z=30, a=0)
|
|
41
|
+
>>> pos = Position(x=10, y=20, b=5.0, c=10.0)
|
|
42
|
+
"""
|
|
43
|
+
self._axes: Dict[str, float] = {}
|
|
44
|
+
|
|
45
|
+
# Set default axes if provided
|
|
46
|
+
if x is not None:
|
|
47
|
+
self._axes["x"] = float(x)
|
|
48
|
+
if y is not None:
|
|
49
|
+
self._axes["y"] = float(y)
|
|
50
|
+
if z is not None:
|
|
51
|
+
self._axes["z"] = float(z)
|
|
52
|
+
if a is not None:
|
|
53
|
+
self._axes["a"] = float(a)
|
|
54
|
+
|
|
55
|
+
# Set additional axes
|
|
56
|
+
for axis_name, value in kwargs.items():
|
|
57
|
+
self._axes[axis_name.lower()] = float(value)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: Dict[str, float], case_sensitive: bool = False) -> "Position":
|
|
61
|
+
"""
|
|
62
|
+
Create a Position from a dictionary.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
data: Dictionary with axis names as keys and values as floats
|
|
66
|
+
case_sensitive: If False, converts keys to lowercase. Defaults to False.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Position instance
|
|
70
|
+
|
|
71
|
+
Examples:
|
|
72
|
+
>>> pos = Position.from_dict({"X": 10, "Y": 20, "Z": 30})
|
|
73
|
+
>>> pos = Position.from_dict({"x": 10, "y": 20, "z": 30})
|
|
74
|
+
"""
|
|
75
|
+
if case_sensitive:
|
|
76
|
+
return cls(**{k: v for k, v in data.items()})
|
|
77
|
+
else:
|
|
78
|
+
return cls(**{k.lower(): v for k, v in data.items()})
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_json(cls, json_str: str) -> "Position":
|
|
82
|
+
"""
|
|
83
|
+
Create a Position from a JSON string.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
json_str: JSON string containing axis values
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Position instance
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
>>> pos = Position.from_json('{"x": 10, "y": 20, "z": 30}')
|
|
93
|
+
"""
|
|
94
|
+
data = json.loads(json_str)
|
|
95
|
+
return cls.from_dict(data)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_tuple(cls, values: Tuple[float, ...], axes: Optional[Tuple[str, ...]] = None) -> "Position":
|
|
99
|
+
"""
|
|
100
|
+
Create a Position from a tuple of values.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
values: Tuple of float values
|
|
104
|
+
axes: Optional tuple of axis names. Defaults to ("x", "y", "z", "a")
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Position instance
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> pos = Position.from_tuple((10, 20, 30))
|
|
111
|
+
>>> pos = Position.from_tuple((10, 20, 30, 0), ("x", "y", "z", "a"))
|
|
112
|
+
"""
|
|
113
|
+
if axes is None:
|
|
114
|
+
default_axes = ("x", "y", "z", "a")
|
|
115
|
+
axes = default_axes[:len(values)]
|
|
116
|
+
|
|
117
|
+
if len(values) != len(axes):
|
|
118
|
+
raise ValueError(f"Number of values ({len(values)}) must match number of axes ({len(axes)})")
|
|
119
|
+
|
|
120
|
+
return cls(**{axis: val for axis, val in zip(axes, values)})
|
|
121
|
+
|
|
122
|
+
def to_dict(self, uppercase: bool = False) -> Dict[str, float]:
|
|
123
|
+
"""
|
|
124
|
+
Convert Position to a dictionary.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
uppercase: If True, returns uppercase keys (X, Y, Z, A). Defaults to False.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Dictionary with axis names as keys
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
>>> pos = Position(x=10, y=20)
|
|
134
|
+
>>> pos.to_dict() # {"x": 10, "y": 20}
|
|
135
|
+
>>> pos.to_dict(uppercase=True) # {"X": 10, "Y": 20}
|
|
136
|
+
"""
|
|
137
|
+
if uppercase:
|
|
138
|
+
return {k.upper(): v for k, v in self._axes.items()}
|
|
139
|
+
return self._axes.copy()
|
|
140
|
+
|
|
141
|
+
def to_json(self) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Convert Position to a JSON string.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
JSON string representation
|
|
147
|
+
"""
|
|
148
|
+
return json.dumps(self.to_dict())
|
|
149
|
+
|
|
150
|
+
def to_tuple(self, axes: Optional[Tuple[str, ...]] = None) -> Tuple[float, ...]:
|
|
151
|
+
"""
|
|
152
|
+
Convert Position to a tuple of values.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
axes: Optional tuple of axis names to include. Defaults to all axes in order.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Tuple of float values
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
>>> pos = Position(x=10, y=20, z=30)
|
|
162
|
+
>>> pos.to_tuple() # (10.0, 20.0, 30.0)
|
|
163
|
+
>>> pos.to_tuple(("x", "y")) # (10.0, 20.0)
|
|
164
|
+
"""
|
|
165
|
+
if axes is None:
|
|
166
|
+
axes = tuple(self._axes.keys())
|
|
167
|
+
|
|
168
|
+
return tuple(self._axes.get(axis, 0.0) for axis in axes)
|
|
169
|
+
|
|
170
|
+
def __getitem__(self, axis: str) -> float:
|
|
171
|
+
"""Get axis value by name (case-insensitive)."""
|
|
172
|
+
axis_lower = axis.lower()
|
|
173
|
+
return self._axes.get(axis_lower, 0.0)
|
|
174
|
+
|
|
175
|
+
def __setitem__(self, axis: str, value: float) -> None:
|
|
176
|
+
"""Set axis value by name (case-insensitive)."""
|
|
177
|
+
self._axes[axis.lower()] = float(value)
|
|
178
|
+
|
|
179
|
+
def __getattr__(self, name: str) -> float:
|
|
180
|
+
"""Get axis value as attribute (e.g., pos.x, pos.y)."""
|
|
181
|
+
if name.startswith("_"):
|
|
182
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
183
|
+
return self._axes.get(name.lower(), 0.0)
|
|
184
|
+
|
|
185
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
186
|
+
"""Set axis value as attribute (e.g., pos.x = 10)."""
|
|
187
|
+
if name.startswith("_") or name in dir(self):
|
|
188
|
+
super().__setattr__(name, value)
|
|
189
|
+
else:
|
|
190
|
+
if "_axes" in self.__dict__:
|
|
191
|
+
self._axes[name.lower()] = float(value)
|
|
192
|
+
else:
|
|
193
|
+
super().__setattr__(name, value)
|
|
194
|
+
|
|
195
|
+
def __add__(self, other: Union["Position", Dict[str, float], float]) -> "Position":
|
|
196
|
+
"""
|
|
197
|
+
Add two positions or add a scalar to all axes.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
other: Position, dict, or float to add
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
New Position instance
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
>>> pos1 = Position(x=10, y=20)
|
|
207
|
+
>>> pos2 = Position(x=5, y=10)
|
|
208
|
+
>>> pos3 = pos1 + pos2 # Position(x=15, y=30)
|
|
209
|
+
>>> pos4 = pos1 + 5 # Position(x=15, y=25)
|
|
210
|
+
"""
|
|
211
|
+
if isinstance(other, Position):
|
|
212
|
+
result = Position()
|
|
213
|
+
all_axes = set(self._axes.keys()) | set(other._axes.keys())
|
|
214
|
+
for axis in all_axes:
|
|
215
|
+
result._axes[axis] = self[axis] + other[axis]
|
|
216
|
+
return result
|
|
217
|
+
elif isinstance(other, dict):
|
|
218
|
+
other_pos = Position.from_dict(other)
|
|
219
|
+
return self + other_pos
|
|
220
|
+
elif isinstance(other, (int, float)):
|
|
221
|
+
result = Position()
|
|
222
|
+
for axis, value in self._axes.items():
|
|
223
|
+
result._axes[axis] = value + other
|
|
224
|
+
return result
|
|
225
|
+
else:
|
|
226
|
+
return NotImplemented
|
|
227
|
+
|
|
228
|
+
def __radd__(self, other: Union[Dict[str, float], float]) -> "Position":
|
|
229
|
+
"""Right-side addition."""
|
|
230
|
+
return self + other
|
|
231
|
+
|
|
232
|
+
def __sub__(self, other: Union["Position", Dict[str, float], float]) -> "Position":
|
|
233
|
+
"""
|
|
234
|
+
Subtract two positions or subtract a scalar from all axes.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
other: Position, dict, or float to subtract
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
New Position instance
|
|
241
|
+
|
|
242
|
+
Examples:
|
|
243
|
+
>>> pos1 = Position(x=10, y=20)
|
|
244
|
+
>>> pos2 = Position(x=5, y=10)
|
|
245
|
+
>>> pos3 = pos1 - pos2 # Position(x=5, y=10)
|
|
246
|
+
>>> pos4 = pos1 - 5 # Position(x=5, y=15)
|
|
247
|
+
"""
|
|
248
|
+
if isinstance(other, Position):
|
|
249
|
+
result = Position()
|
|
250
|
+
all_axes = set(self._axes.keys()) | set(other._axes.keys())
|
|
251
|
+
for axis in all_axes:
|
|
252
|
+
result._axes[axis] = self[axis] - other[axis]
|
|
253
|
+
return result
|
|
254
|
+
elif isinstance(other, dict):
|
|
255
|
+
other_pos = Position.from_dict(other)
|
|
256
|
+
return self - other_pos
|
|
257
|
+
elif isinstance(other, (int, float)):
|
|
258
|
+
result = Position()
|
|
259
|
+
for axis, value in self._axes.items():
|
|
260
|
+
result._axes[axis] = value - other
|
|
261
|
+
return result
|
|
262
|
+
else:
|
|
263
|
+
return NotImplemented
|
|
264
|
+
|
|
265
|
+
def __rsub__(self, other: Union[Dict[str, float], float]) -> "Position":
|
|
266
|
+
"""Right-side subtraction."""
|
|
267
|
+
if isinstance(other, dict):
|
|
268
|
+
other_pos = Position.from_dict(other)
|
|
269
|
+
return other_pos - self
|
|
270
|
+
elif isinstance(other, (int, float)):
|
|
271
|
+
result = Position()
|
|
272
|
+
for axis, value in self._axes.items():
|
|
273
|
+
result._axes[axis] = other - value
|
|
274
|
+
return result
|
|
275
|
+
else:
|
|
276
|
+
return NotImplemented
|
|
277
|
+
|
|
278
|
+
def __mul__(self, scalar: float) -> "Position":
|
|
279
|
+
"""
|
|
280
|
+
Multiply all axes by a scalar.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
scalar: Scalar value to multiply
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
New Position instance
|
|
287
|
+
|
|
288
|
+
Examples:
|
|
289
|
+
>>> pos = Position(x=10, y=20)
|
|
290
|
+
>>> pos2 = pos * 2 # Position(x=20, y=40)
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(scalar, (int, float)):
|
|
293
|
+
result = Position()
|
|
294
|
+
for axis, value in self._axes.items():
|
|
295
|
+
result._axes[axis] = value * scalar
|
|
296
|
+
return result
|
|
297
|
+
return NotImplemented
|
|
298
|
+
|
|
299
|
+
def __rmul__(self, scalar: float) -> "Position":
|
|
300
|
+
"""Right-side multiplication."""
|
|
301
|
+
return self * scalar
|
|
302
|
+
|
|
303
|
+
def __truediv__(self, scalar: float) -> "Position":
|
|
304
|
+
"""
|
|
305
|
+
Divide all axes by a scalar.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
scalar: Scalar value to divide by
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
New Position instance
|
|
312
|
+
|
|
313
|
+
Examples:
|
|
314
|
+
>>> pos = Position(x=10, y=20)
|
|
315
|
+
>>> pos2 = pos / 2 # Position(x=5, y=10)
|
|
316
|
+
"""
|
|
317
|
+
if isinstance(scalar, (int, float)):
|
|
318
|
+
if scalar == 0:
|
|
319
|
+
raise ZeroDivisionError("Cannot divide Position by zero")
|
|
320
|
+
result = Position()
|
|
321
|
+
for axis, value in self._axes.items():
|
|
322
|
+
result._axes[axis] = value / scalar
|
|
323
|
+
return result
|
|
324
|
+
return NotImplemented
|
|
325
|
+
|
|
326
|
+
def __neg__(self) -> "Position":
|
|
327
|
+
"""Negate all axes."""
|
|
328
|
+
result = Position()
|
|
329
|
+
for axis, value in self._axes.items():
|
|
330
|
+
result._axes[axis] = -value
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
def __abs__(self) -> "Position":
|
|
334
|
+
"""Absolute value of all axes."""
|
|
335
|
+
result = Position()
|
|
336
|
+
for axis, value in self._axes.items():
|
|
337
|
+
result._axes[axis] = abs(value)
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
def __eq__(self, other: object) -> bool:
|
|
341
|
+
"""Check equality with another Position."""
|
|
342
|
+
if not isinstance(other, Position):
|
|
343
|
+
return False
|
|
344
|
+
return self._axes == other._axes
|
|
345
|
+
|
|
346
|
+
def __repr__(self) -> str:
|
|
347
|
+
"""String representation of Position."""
|
|
348
|
+
if not self._axes:
|
|
349
|
+
return "Position()"
|
|
350
|
+
axis_strs = [f"{k}={v}" for k, v in sorted(self._axes.items())]
|
|
351
|
+
return f"Position({', '.join(axis_strs)})"
|
|
352
|
+
|
|
353
|
+
def __str__(self) -> str:
|
|
354
|
+
"""Human-readable string representation."""
|
|
355
|
+
return self.__repr__()
|
|
356
|
+
|
|
357
|
+
def get_axes(self) -> Tuple[str, ...]:
|
|
358
|
+
"""Get tuple of all axis names."""
|
|
359
|
+
return tuple(sorted(self._axes.keys()))
|
|
360
|
+
|
|
361
|
+
def has_axis(self, axis: str) -> bool:
|
|
362
|
+
"""Check if position has a specific axis."""
|
|
363
|
+
return axis.lower() in self._axes
|
|
364
|
+
|
|
365
|
+
def copy(self) -> "Position":
|
|
366
|
+
"""Create a copy of this Position."""
|
|
367
|
+
return copy.copy(self)
|
|
368
|
+
|
|
369
|
+
# swap x and y axes
|
|
370
|
+
def swap_xy(self) -> "Position":
|
|
371
|
+
"""Swap x and y axes."""
|
|
372
|
+
self._axes["x"], self._axes["y"] = self._axes["y"], self._axes["x"]
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
# get x and y coordinates only
|
|
376
|
+
def get_xy(self) -> "Position":
|
|
377
|
+
"""Get x and y coordinates only."""
|
|
378
|
+
return Position(x=self._axes["x"], y=self._axes["y"])
|
|
@@ -5,7 +5,7 @@ Generic Serial Controller for communicating with devices over serial ports.
|
|
|
5
5
|
import time
|
|
6
6
|
import logging
|
|
7
7
|
from typing import Optional, List, Tuple
|
|
8
|
-
from abc import ABC
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
9
|
import serial
|
|
10
10
|
import serial.tools.list_ports
|
|
11
11
|
|
|
@@ -25,14 +25,6 @@ def list_serial_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str,
|
|
|
25
25
|
|
|
26
26
|
Returns:
|
|
27
27
|
List of tuples, where each tuple contains (port_name, description, hwid).
|
|
28
|
-
|
|
29
|
-
Example:
|
|
30
|
-
>>> from puda_drivers.core import list_serial_ports
|
|
31
|
-
>>> ports = list_serial_ports()
|
|
32
|
-
>>> for port, desc, hwid in ports:
|
|
33
|
-
... print(f"{port}: {desc}")
|
|
34
|
-
>>> # Filter for specific devices
|
|
35
|
-
>>> sartorius_ports = list_serial_ports(filter_desc="Sartorius")
|
|
36
28
|
"""
|
|
37
29
|
all_ports = serial.tools.list_ports.comports()
|
|
38
30
|
filtered_ports = []
|
|
@@ -44,6 +36,9 @@ def list_serial_ports(filter_desc: Optional[str] = None) -> List[Tuple[str, str,
|
|
|
44
36
|
|
|
45
37
|
|
|
46
38
|
class SerialController(ABC):
|
|
39
|
+
"""
|
|
40
|
+
Abstract base class for serial controllers.
|
|
41
|
+
"""
|
|
47
42
|
DEFAULT_BAUDRATE = 9600
|
|
48
43
|
DEFAULT_TIMEOUT = 30 # seconds
|
|
49
44
|
POLL_INTERVAL = 0.1 # seconds
|
|
@@ -55,8 +50,6 @@ class SerialController(ABC):
|
|
|
55
50
|
self.timeout = timeout
|
|
56
51
|
self._logger = logger
|
|
57
52
|
|
|
58
|
-
self.connect()
|
|
59
|
-
|
|
60
53
|
def connect(self) -> None:
|
|
61
54
|
"""
|
|
62
55
|
Establishes the serial connection to the port.
|
|
@@ -163,7 +156,7 @@ class SerialController(ABC):
|
|
|
163
156
|
if b"ok" in response or b"err" in response:
|
|
164
157
|
break
|
|
165
158
|
|
|
166
|
-
#
|
|
159
|
+
# for sartorius since res not returning ok or err
|
|
167
160
|
if b"\xba\r" in response:
|
|
168
161
|
break
|
|
169
162
|
else:
|
|
@@ -179,11 +172,11 @@ class SerialController(ABC):
|
|
|
179
172
|
# Decode once and check the decoded string
|
|
180
173
|
decoded_response = response.decode("utf-8", errors="ignore").strip()
|
|
181
174
|
|
|
182
|
-
if "ok" in decoded_response.lower():
|
|
175
|
+
if "ok" in decoded_response.lower():
|
|
183
176
|
self._logger.debug("<- Received response: %r", decoded_response)
|
|
184
|
-
elif "err" in decoded_response.lower():
|
|
177
|
+
elif "err" in decoded_response.lower():
|
|
185
178
|
self._logger.error("<- Received error: %r", decoded_response)
|
|
186
|
-
elif "º" in decoded_response: # for sartorius
|
|
179
|
+
elif "º" in decoded_response: # for sartorius (since res not returning ok or err)
|
|
187
180
|
self._logger.debug("<- Received response: %r", decoded_response)
|
|
188
181
|
else:
|
|
189
182
|
self._logger.warning(
|
|
@@ -191,8 +184,15 @@ class SerialController(ABC):
|
|
|
191
184
|
)
|
|
192
185
|
|
|
193
186
|
return decoded_response
|
|
187
|
+
|
|
188
|
+
@abstractmethod
|
|
189
|
+
def _build_command(self, command: str, value: Optional[str] = None) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Build a command string according to the device protocol.
|
|
192
|
+
"""
|
|
193
|
+
pass
|
|
194
194
|
|
|
195
|
-
def execute(self, command: str) -> str:
|
|
195
|
+
def execute(self, command: str, value: Optional[str] = None) -> str:
|
|
196
196
|
"""
|
|
197
197
|
Send a command and read the response atomically.
|
|
198
198
|
|
|
@@ -211,5 +211,20 @@ class SerialController(ABC):
|
|
|
211
211
|
serial.SerialException: If device is not connected or communication fails
|
|
212
212
|
serial.SerialTimeoutException: If no response is received within timeout
|
|
213
213
|
"""
|
|
214
|
-
self._send_command(command)
|
|
215
|
-
|
|
214
|
+
self._send_command(self._build_command(command, value))
|
|
215
|
+
|
|
216
|
+
# Increase timeout by 60 seconds for G28 (homing) command
|
|
217
|
+
original_timeout = self.timeout
|
|
218
|
+
if "G28" in command.upper():
|
|
219
|
+
self.timeout = original_timeout + 60
|
|
220
|
+
# Also update the serial connection's timeout if connected
|
|
221
|
+
if self.is_connected and self._serial:
|
|
222
|
+
self._serial.timeout = self.timeout
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
return self._read_response()
|
|
226
|
+
finally:
|
|
227
|
+
# Restore original timeout
|
|
228
|
+
self.timeout = original_timeout
|
|
229
|
+
if self.is_connected and self._serial:
|
|
230
|
+
self._serial.timeout = original_timeout
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# puda_drivers/labware/__init__.py
|
|
2
|
+
|
|
3
|
+
# Import from the sub-packages (folders)
|
|
4
|
+
from .labware import StandardLabware
|
|
5
|
+
|
|
6
|
+
# Export get_available_labware as a standalone function
|
|
7
|
+
get_available_labware = StandardLabware.get_available_labware
|
|
8
|
+
|
|
9
|
+
__all__ = ["StandardLabware", "get_available_labware"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# src/puda_drivers/labware/labware.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import inspect
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from typing import List
|
|
9
|
+
from puda_drivers.core import Position
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StandardLabware(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Generic Parent Class for all Labware on a microplate
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, labware_name: str):
|
|
17
|
+
"""
|
|
18
|
+
Initialize the labware.
|
|
19
|
+
Args:
|
|
20
|
+
name: The name of the labware.
|
|
21
|
+
rows: The number of rows in the labware.
|
|
22
|
+
cols: The number of columns in the labware.
|
|
23
|
+
"""
|
|
24
|
+
self._definition = self.load_definition(file_name=labware_name + ".json")
|
|
25
|
+
self.name = self._definition.get("metadata", {}).get("displayName", "displayName not found")
|
|
26
|
+
self._wells = self._definition.get("wells", {})
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def get_available_labware() -> List[str]:
|
|
30
|
+
"""
|
|
31
|
+
Get all available labware names from JSON definition files.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Sorted list of labware names (without .json extension) found in the labware directory.
|
|
35
|
+
"""
|
|
36
|
+
labware_dir = Path(__file__).parent
|
|
37
|
+
json_files = sorted(labware_dir.glob("*.json"))
|
|
38
|
+
return [f.stem for f in json_files]
|
|
39
|
+
|
|
40
|
+
def load_definition(self, file_name: str = "definition.json") -> Dict[str, Any]:
|
|
41
|
+
"""
|
|
42
|
+
Load a definition.json file from the class's module directory.
|
|
43
|
+
|
|
44
|
+
This method automatically finds the definition.json file in the
|
|
45
|
+
same directory as the class that defines it.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
file_name: Name of the definition file (default: "definition.json")
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dictionary containing the labware definition
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
FileNotFoundError: If the definition file doesn't exist
|
|
55
|
+
"""
|
|
56
|
+
# Get the file path of the class that defines this method
|
|
57
|
+
class_file = Path(inspect.getfile(self.__class__))
|
|
58
|
+
definition_path = class_file.parent / file_name
|
|
59
|
+
|
|
60
|
+
if not definition_path.exists():
|
|
61
|
+
raise FileNotFoundError(
|
|
62
|
+
f"Definition file '{file_name}' not found in {class_file.parent}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
with open(definition_path, "r", encoding="utf-8") as f:
|
|
66
|
+
return json.load(f)
|
|
67
|
+
|
|
68
|
+
def __str__(self):
|
|
69
|
+
"""
|
|
70
|
+
Return a string representation of the labware.
|
|
71
|
+
"""
|
|
72
|
+
lines = [
|
|
73
|
+
f"Labware name: {self.name}",
|
|
74
|
+
f"Height in mm: {self.get_height()}",
|
|
75
|
+
"Distances away from origin (0,0) for each well in mm"
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
for well_id, well_data in self._wells.items():
|
|
79
|
+
x, y, z = well_data.get("x"), well_data.get("y"), well_data.get("z")
|
|
80
|
+
|
|
81
|
+
if x is None or y is None or z is None:
|
|
82
|
+
raise KeyError(f"Well '{well_id}' has missing coordinates in labware definition")
|
|
83
|
+
|
|
84
|
+
lines.append(f"Well {well_id}: x:{x}, y:{y}, z:{z}")
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def wells(self) -> List[str]:
|
|
89
|
+
"""
|
|
90
|
+
Get a list of all well IDs in the labware.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of well identifiers (e.g., ["A1", "A2", "B1", ...])
|
|
94
|
+
"""
|
|
95
|
+
return list(self._wells.keys())
|
|
96
|
+
|
|
97
|
+
def get_well_position(self, well_id: str) -> Position:
|
|
98
|
+
"""
|
|
99
|
+
Get the position of a well from definition.json.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
well_id: Well identifier (e.g., "A1", "H12")
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Position with x, y, z coordinates
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
KeyError: If well_id doesn't exist in the tip rack
|
|
109
|
+
"""
|
|
110
|
+
# Validate location exists in JSON definition
|
|
111
|
+
well_id_upper = well_id.upper()
|
|
112
|
+
if well_id_upper not in self._wells:
|
|
113
|
+
raise KeyError(f"Well '{well_id}' not found in tip rack definition")
|
|
114
|
+
|
|
115
|
+
# Get the well data from the definition
|
|
116
|
+
well_data = self._wells.get(well_id_upper, {})
|
|
117
|
+
|
|
118
|
+
# Return position of the well (x, y are already center coordinates)
|
|
119
|
+
return Position(
|
|
120
|
+
x=well_data.get("x", 0.0),
|
|
121
|
+
y=well_data.get("y", 0.0),
|
|
122
|
+
z=well_data.get("z", 0.0),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def get_height(self) -> float:
|
|
126
|
+
"""
|
|
127
|
+
Get the height of the labware.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Height of the labware (zDimension)
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
KeyError: If dimensions or zDimension is not found in the definition
|
|
134
|
+
"""
|
|
135
|
+
dimensions = self._definition.get("dimensions")
|
|
136
|
+
if dimensions is None:
|
|
137
|
+
raise KeyError("'dimensions' not found in labware definition")
|
|
138
|
+
|
|
139
|
+
if "zDimension" not in dimensions:
|
|
140
|
+
raise KeyError("'zDimension' not found in labware dimensions")
|
|
141
|
+
|
|
142
|
+
return dimensions["zDimension"]
|
|
143
|
+
|
|
144
|
+
def get_insert_depth(self) -> float:
|
|
145
|
+
"""
|
|
146
|
+
Get the insert depth of the labware.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Insert depth of the labware
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
KeyError: If insert_depth is not found in the definition
|
|
153
|
+
"""
|
|
154
|
+
if "insert_depth" not in self._definition:
|
|
155
|
+
raise KeyError("'insert_depth' not found in labware definition")
|
|
156
|
+
|
|
157
|
+
return self._definition["insert_depth"]
|