puda-drivers 0.0.6__py3-none-any.whl → 0.0.8__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.
@@ -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
- # Check for expected response markers for early return for sartorius
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(): # for qubot
175
+ if "ok" in decoded_response.lower():
183
176
  self._logger.debug("<- Received response: %r", decoded_response)
184
- elif "err" in decoded_response.lower(): # for qubot
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
- return self._read_response()
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"]