puda-drivers 0.0.7__py3-none-any.whl → 0.0.9__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/core/__init__.py +2 -1
- puda_drivers/core/position.py +378 -0
- puda_drivers/core/serialcontroller.py +23 -15
- puda_drivers/cv/__init__.py +4 -0
- puda_drivers/cv/camera.py +434 -0
- puda_drivers/labware/__init__.py +9 -0
- puda_drivers/labware/labware.py +157 -0
- puda_drivers/labware/opentrons_96_tiprack_300ul.json +1020 -0
- puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +141 -0
- puda_drivers/labware/trash_bin.json +41 -0
- puda_drivers/machines/__init__.py +3 -0
- puda_drivers/machines/first.py +255 -0
- puda_drivers/move/__init__.py +3 -0
- puda_drivers/move/deck.py +47 -0
- puda_drivers/move/gcode.py +126 -117
- puda_drivers/transfer/liquid/sartorius/sartorius.py +31 -7
- {puda_drivers-0.0.7.dist-info → puda_drivers-0.0.9.dist-info}/METADATA +3 -2
- puda_drivers-0.0.9.dist-info/RECORD +29 -0
- puda_drivers-0.0.7.dist-info/RECORD +0 -18
- {puda_drivers-0.0.7.dist-info → puda_drivers-0.0.9.dist-info}/WHEEL +0 -0
- {puda_drivers-0.0.7.dist-info → puda_drivers-0.0.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Camera controller for image and video capture.
|
|
3
|
+
|
|
4
|
+
This module provides a Python interface for controlling cameras and webcams
|
|
5
|
+
to capture images and videos for laboratory automation applications using OpenCV.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Optional, Union, List, Tuple
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_cameras(max_index: int = 10) -> List[Tuple[int, bool, Optional[tuple[int, int]]]]:
|
|
21
|
+
"""
|
|
22
|
+
Lists available cameras on the system by testing camera indices.
|
|
23
|
+
|
|
24
|
+
This is a utility function that can be used independently of any controller instance.
|
|
25
|
+
It's useful for discovering available cameras before initializing a controller.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
max_index: Maximum camera index to test (default: 10). The function will test
|
|
29
|
+
indices from 0 to max_index-1.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of tuples, where each tuple contains (index, is_available, resolution).
|
|
33
|
+
resolution is a (width, height) tuple if available, None otherwise.
|
|
34
|
+
"""
|
|
35
|
+
available_cameras = []
|
|
36
|
+
|
|
37
|
+
for index in range(max_index):
|
|
38
|
+
cap = cv2.VideoCapture(index)
|
|
39
|
+
if cap.isOpened():
|
|
40
|
+
# Try to read a frame to confirm it's actually working
|
|
41
|
+
ret, _ = cap.read()
|
|
42
|
+
if ret:
|
|
43
|
+
# Get resolution
|
|
44
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
45
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
46
|
+
resolution = (width, height) if width > 0 and height > 0 else None
|
|
47
|
+
available_cameras.append((index, resolution))
|
|
48
|
+
cap.release()
|
|
49
|
+
else:
|
|
50
|
+
cap.release()
|
|
51
|
+
|
|
52
|
+
return available_cameras
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CameraController:
|
|
56
|
+
"""
|
|
57
|
+
Controller for cameras and webcams using OpenCV.
|
|
58
|
+
|
|
59
|
+
This class provides methods for capturing images and videos from cameras.
|
|
60
|
+
Images can be returned as numpy arrays and optionally saved to the captures folder.
|
|
61
|
+
Videos can be recorded for a specified duration or controlled manually with start/stop.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
camera_index: Index or identifier for the camera device
|
|
65
|
+
resolution: Camera resolution as (width, height) tuple
|
|
66
|
+
captures_folder: Path to the folder where captured images and videos are saved
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
DEFAULT_CAPTURES_FOLDER = "captures"
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
camera_index: Union[int, str] = 0,
|
|
74
|
+
resolution: Optional[tuple[int, int]] = None,
|
|
75
|
+
captures_folder: Union[str, Path, None] = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initialize the camera controller.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
camera_index: Camera device index (0 for default) or device path/identifier
|
|
82
|
+
resolution: Optional resolution as (width, height) tuple
|
|
83
|
+
captures_folder: Path to folder for saving captured images.
|
|
84
|
+
Defaults to "captures" in the current working directory.
|
|
85
|
+
"""
|
|
86
|
+
self.camera_index = camera_index
|
|
87
|
+
self.resolution = resolution
|
|
88
|
+
self.captures_folder = Path(captures_folder) if captures_folder else Path(self.DEFAULT_CAPTURES_FOLDER)
|
|
89
|
+
self._logger = logging.getLogger(__name__)
|
|
90
|
+
self._camera: Optional[cv2.VideoCapture] = None
|
|
91
|
+
self._is_connected = False
|
|
92
|
+
|
|
93
|
+
# Video recording state
|
|
94
|
+
self._video_writer: Optional[cv2.VideoWriter] = None
|
|
95
|
+
self._is_recording = False
|
|
96
|
+
self._video_file_path: Optional[Path] = None
|
|
97
|
+
self._fps: float = 30.0 # Default FPS for video recording
|
|
98
|
+
self._recording_thread: Optional[threading.Thread] = None
|
|
99
|
+
self._stop_recording_event: Optional[threading.Event] = None
|
|
100
|
+
|
|
101
|
+
# Create captures folder if it doesn't exist
|
|
102
|
+
self.captures_folder.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
self._logger.info(
|
|
105
|
+
"Camera Controller initialized with camera_index='%s', resolution=%s, captures_folder='%s'",
|
|
106
|
+
camera_index,
|
|
107
|
+
resolution,
|
|
108
|
+
self.captures_folder,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def connect(self) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Connect to the camera device.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
IOError: If camera connection fails
|
|
117
|
+
"""
|
|
118
|
+
if self._is_connected:
|
|
119
|
+
self._logger.warning("Camera already connected. Disconnecting and reconnecting...")
|
|
120
|
+
self.disconnect()
|
|
121
|
+
|
|
122
|
+
self._logger.info("Connecting to camera %s...", self.camera_index)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
self._camera = cv2.VideoCapture(self.camera_index)
|
|
126
|
+
|
|
127
|
+
if not self._camera.isOpened():
|
|
128
|
+
raise IOError(f"Could not open camera {self.camera_index}")
|
|
129
|
+
|
|
130
|
+
# Set resolution if specified
|
|
131
|
+
if self.resolution:
|
|
132
|
+
width, height = self.resolution
|
|
133
|
+
self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
134
|
+
self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
135
|
+
self._logger.info("Resolution set to %dx%d", width, height)
|
|
136
|
+
|
|
137
|
+
self._is_connected = True
|
|
138
|
+
self._logger.info("Successfully connected to camera %s", self.camera_index)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
self._camera = None
|
|
142
|
+
self._is_connected = False
|
|
143
|
+
self._logger.error("Error connecting to camera %s: %s", self.camera_index, e)
|
|
144
|
+
raise IOError(f"Error connecting to camera {self.camera_index}: {e}") from e
|
|
145
|
+
|
|
146
|
+
def disconnect(self) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Disconnect from the camera device.
|
|
149
|
+
"""
|
|
150
|
+
# Stop any ongoing video recording before disconnecting
|
|
151
|
+
if self._is_recording:
|
|
152
|
+
self._logger.warning("Stopping video recording before disconnecting...")
|
|
153
|
+
self.stop_video_recording()
|
|
154
|
+
|
|
155
|
+
if self._camera is not None:
|
|
156
|
+
self._logger.info("Disconnecting from camera %s...", self.camera_index)
|
|
157
|
+
self._camera.release()
|
|
158
|
+
self._camera = None
|
|
159
|
+
self._is_connected = False
|
|
160
|
+
self._logger.info("Camera disconnected")
|
|
161
|
+
else:
|
|
162
|
+
self._logger.warning("Camera already disconnected or was never connected")
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def is_connected(self) -> bool:
|
|
166
|
+
"""
|
|
167
|
+
Check if the camera is currently connected.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if connected, False otherwise
|
|
171
|
+
"""
|
|
172
|
+
return self._is_connected and self._camera is not None and self._camera.isOpened()
|
|
173
|
+
|
|
174
|
+
def capture_image(
|
|
175
|
+
self,
|
|
176
|
+
save: bool = False,
|
|
177
|
+
filename: Optional[Union[str, Path]] = None
|
|
178
|
+
) -> np.ndarray:
|
|
179
|
+
"""
|
|
180
|
+
Capture a single image from the camera.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
save: If True, save the image to the captures folder
|
|
184
|
+
filename: Optional filename for the saved image. If not provided and save=True,
|
|
185
|
+
a timestamped filename will be generated. If provided without extension,
|
|
186
|
+
.jpg will be added.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Captured image as a numpy array (BGR format)
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
IOError: If camera is not connected or capture fails
|
|
193
|
+
"""
|
|
194
|
+
if not self.is_connected:
|
|
195
|
+
raise IOError("Camera is not connected. Call connect() first.")
|
|
196
|
+
|
|
197
|
+
self._logger.info("Capturing image...")
|
|
198
|
+
|
|
199
|
+
ret, frame = self._camera.read()
|
|
200
|
+
|
|
201
|
+
if not ret or frame is None:
|
|
202
|
+
self._logger.error("Failed to capture frame from camera")
|
|
203
|
+
raise IOError("Failed to capture frame from camera")
|
|
204
|
+
|
|
205
|
+
self._logger.info("Image captured successfully (shape: %s)", frame.shape)
|
|
206
|
+
|
|
207
|
+
# Save image if requested
|
|
208
|
+
if save:
|
|
209
|
+
if filename is None:
|
|
210
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
211
|
+
filename = f"capture_{timestamp}.jpg"
|
|
212
|
+
|
|
213
|
+
file_path = Path(filename)
|
|
214
|
+
# If filename doesn't have an extension, add .jpg
|
|
215
|
+
if not file_path.suffix:
|
|
216
|
+
file_path = file_path.with_suffix(".jpg")
|
|
217
|
+
|
|
218
|
+
# If filename is not absolute, save to captures folder
|
|
219
|
+
if not file_path.is_absolute():
|
|
220
|
+
file_path = self.captures_folder / file_path
|
|
221
|
+
|
|
222
|
+
# Ensure parent directory exists
|
|
223
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
cv2.imwrite(str(file_path), frame)
|
|
226
|
+
self._logger.info("Image saved to %s", file_path)
|
|
227
|
+
|
|
228
|
+
return frame
|
|
229
|
+
|
|
230
|
+
def set_resolution(self, width: int, height: int) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Set the camera resolution.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
width: Image width in pixels
|
|
236
|
+
height: Image height in pixels
|
|
237
|
+
"""
|
|
238
|
+
self.resolution = (width, height)
|
|
239
|
+
self._logger.info("Resolution set to %dx%d", width, height)
|
|
240
|
+
|
|
241
|
+
# Apply resolution to camera if connected
|
|
242
|
+
if self.is_connected:
|
|
243
|
+
self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
244
|
+
self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
245
|
+
self._logger.info("Resolution applied to camera")
|
|
246
|
+
|
|
247
|
+
def start_video_recording(
|
|
248
|
+
self,
|
|
249
|
+
filename: Optional[Union[str, Path]] = None,
|
|
250
|
+
fps: Optional[float] = None
|
|
251
|
+
) -> Path:
|
|
252
|
+
"""
|
|
253
|
+
Start recording a video.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
filename: Optional filename for the video. If not provided, a timestamped
|
|
257
|
+
filename will be generated. If provided without extension, .mp4 will be added.
|
|
258
|
+
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Path to the video file where recording is being saved
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
IOError: If camera is not connected or recording fails to start
|
|
265
|
+
ValueError: If already recording
|
|
266
|
+
"""
|
|
267
|
+
if not self.is_connected:
|
|
268
|
+
raise IOError("Camera is not connected. Call connect() first.")
|
|
269
|
+
|
|
270
|
+
if self._is_recording:
|
|
271
|
+
raise ValueError("Video recording is already in progress. Call stop_video_recording() first.")
|
|
272
|
+
|
|
273
|
+
# Generate filename if not provided
|
|
274
|
+
if filename is None:
|
|
275
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
276
|
+
filename = f"video_{timestamp}.mp4"
|
|
277
|
+
|
|
278
|
+
file_path = Path(filename)
|
|
279
|
+
# If filename doesn't have an extension, add .mp4
|
|
280
|
+
if not file_path.suffix:
|
|
281
|
+
file_path = file_path.with_suffix(".mp4")
|
|
282
|
+
|
|
283
|
+
# If filename is not absolute, save to captures folder
|
|
284
|
+
if not file_path.is_absolute():
|
|
285
|
+
file_path = self.captures_folder / file_path
|
|
286
|
+
|
|
287
|
+
# Ensure parent directory exists
|
|
288
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
|
|
290
|
+
# Get current camera resolution
|
|
291
|
+
width = int(self._camera.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
292
|
+
height = int(self._camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
293
|
+
|
|
294
|
+
# Use provided FPS or default
|
|
295
|
+
fps = fps if fps is not None else self._fps
|
|
296
|
+
|
|
297
|
+
# Define codec and create VideoWriter
|
|
298
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
299
|
+
self._video_writer = cv2.VideoWriter(str(file_path), fourcc, fps, (width, height))
|
|
300
|
+
|
|
301
|
+
if not self._video_writer.isOpened():
|
|
302
|
+
self._video_writer = None
|
|
303
|
+
raise IOError(f"Failed to initialize video writer for {file_path}")
|
|
304
|
+
|
|
305
|
+
self._is_recording = True
|
|
306
|
+
self._video_file_path = file_path
|
|
307
|
+
self._fps = fps
|
|
308
|
+
|
|
309
|
+
# Start background thread to continuously capture frames
|
|
310
|
+
self._stop_recording_event = threading.Event()
|
|
311
|
+
self._recording_thread = threading.Thread(target=self._capture_frames_loop, daemon=True)
|
|
312
|
+
self._recording_thread.start()
|
|
313
|
+
|
|
314
|
+
self._logger.info("Started video recording to %s (FPS: %.1f, Resolution: %dx%d)",
|
|
315
|
+
file_path, fps, width, height)
|
|
316
|
+
|
|
317
|
+
return file_path
|
|
318
|
+
|
|
319
|
+
def _capture_frames_loop(self) -> None:
|
|
320
|
+
"""
|
|
321
|
+
Internal method that continuously captures frames and writes them to the video.
|
|
322
|
+
Runs in a background thread while recording is active.
|
|
323
|
+
"""
|
|
324
|
+
frame_interval = 1.0 / self._fps if self._fps > 0 else 0.033 # Default to ~30 FPS
|
|
325
|
+
|
|
326
|
+
while self._is_recording and not self._stop_recording_event.is_set():
|
|
327
|
+
if self._video_writer is None or self._camera is None:
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
ret, frame = self._camera.read()
|
|
331
|
+
if ret and frame is not None:
|
|
332
|
+
self._video_writer.write(frame)
|
|
333
|
+
|
|
334
|
+
time.sleep(frame_interval)
|
|
335
|
+
|
|
336
|
+
self._logger.debug("Frame capture loop stopped")
|
|
337
|
+
|
|
338
|
+
def stop_video_recording(self) -> Optional[Path]:
|
|
339
|
+
"""
|
|
340
|
+
Stop recording a video.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Path to the saved video file, or None if no recording was in progress
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
IOError: If video writer fails to release
|
|
347
|
+
"""
|
|
348
|
+
if not self._is_recording:
|
|
349
|
+
self._logger.warning("No video recording in progress")
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
self._logger.info("Stopping video recording...")
|
|
353
|
+
|
|
354
|
+
# Signal the recording thread to stop
|
|
355
|
+
self._is_recording = False
|
|
356
|
+
if self._stop_recording_event is not None:
|
|
357
|
+
self._stop_recording_event.set()
|
|
358
|
+
|
|
359
|
+
# Wait for the recording thread to finish
|
|
360
|
+
if self._recording_thread is not None and self._recording_thread.is_alive():
|
|
361
|
+
self._recording_thread.join(timeout=2.0)
|
|
362
|
+
|
|
363
|
+
# Release video writer
|
|
364
|
+
if self._video_writer is not None:
|
|
365
|
+
self._video_writer.release()
|
|
366
|
+
self._video_writer = None
|
|
367
|
+
|
|
368
|
+
file_path = self._video_file_path
|
|
369
|
+
self._video_file_path = None
|
|
370
|
+
self._recording_thread = None
|
|
371
|
+
self._stop_recording_event = None
|
|
372
|
+
|
|
373
|
+
if file_path and file_path.exists():
|
|
374
|
+
self._logger.info("Video saved to %s", file_path)
|
|
375
|
+
else:
|
|
376
|
+
self._logger.warning("Video file may not have been saved correctly")
|
|
377
|
+
|
|
378
|
+
return file_path
|
|
379
|
+
|
|
380
|
+
def record_video(
|
|
381
|
+
self,
|
|
382
|
+
duration_seconds: float,
|
|
383
|
+
filename: Optional[Union[str, Path]] = None,
|
|
384
|
+
fps: Optional[float] = None
|
|
385
|
+
) -> Path:
|
|
386
|
+
"""
|
|
387
|
+
Record a video for a specified duration.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
duration_seconds: Duration of the video in seconds
|
|
391
|
+
filename: Optional filename for the video. If not provided, a timestamped
|
|
392
|
+
filename will be generated. If provided without extension, .mp4 will be added.
|
|
393
|
+
fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Path to the saved video file
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
IOError: If camera is not connected or recording fails
|
|
400
|
+
ValueError: If duration is not positive
|
|
401
|
+
"""
|
|
402
|
+
if not self.is_connected:
|
|
403
|
+
raise IOError("Camera is not connected. Call connect() first.")
|
|
404
|
+
|
|
405
|
+
if duration_seconds <= 0:
|
|
406
|
+
raise ValueError(f"Duration must be positive, got {duration_seconds}")
|
|
407
|
+
|
|
408
|
+
# Start recording
|
|
409
|
+
file_path = self.start_video_recording(filename=filename, fps=fps)
|
|
410
|
+
|
|
411
|
+
self._logger.info("Recording video for %.2f seconds...", duration_seconds)
|
|
412
|
+
|
|
413
|
+
start_time = time.time()
|
|
414
|
+
frame_count = 0
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
while time.time() - start_time < duration_seconds:
|
|
418
|
+
ret, frame = self._camera.read()
|
|
419
|
+
if ret and frame is not None:
|
|
420
|
+
self._video_writer.write(frame)
|
|
421
|
+
frame_count += 1
|
|
422
|
+
else:
|
|
423
|
+
self._logger.warning("Failed to read frame during video recording")
|
|
424
|
+
break
|
|
425
|
+
finally:
|
|
426
|
+
# Always stop recording, even if there was an error
|
|
427
|
+
self.stop_video_recording()
|
|
428
|
+
|
|
429
|
+
actual_duration = time.time() - start_time
|
|
430
|
+
self._logger.info("Video recording completed: %.2f seconds, %d frames (%.1f FPS actual)",
|
|
431
|
+
actual_duration, frame_count, frame_count / actual_duration if actual_duration > 0 else 0)
|
|
432
|
+
|
|
433
|
+
return file_path
|
|
434
|
+
|
|
@@ -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"]
|