puda-drivers 0.0.17__tar.gz → 0.0.19__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.17 → puda_drivers-0.0.19}/PKG-INFO +6 -4
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/README.md +3 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/pyproject.toml +7 -3
- puda_drivers-0.0.19/src/puda_drivers/labware/MEA_cell_MTP.json +56 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/labware/labware.py +44 -19
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/labware/polyelectric_8_wellplate_30000ul.json +2 -1
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/labware/trash_bin.json +2 -1
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/machines/first.py +225 -141
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/move/gcode.py +14 -23
- puda_drivers-0.0.19/src/puda_drivers/py.typed +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/first.py +3 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/.gitignore +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/LICENSE +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/core/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/core/logging.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/core/position.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/core/serialcontroller.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/cv/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/cv/camera.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/labware/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/machines/__init__.py +0 -0
- /puda_drivers-0.0.17/src/puda_drivers/py.typed → /puda_drivers-0.0.19/src/puda_drivers/measure/electrode.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/move/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/move/deck.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/move/grbl/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/move/grbl/api.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/move/grbl/constants.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/__init__.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/api.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/constants.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/rLine.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/example.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/pipette.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/poll_position.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/qubot.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/test_deck.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/test_position_polling.py +0 -0
- {puda_drivers-0.0.17 → puda_drivers-0.0.19}/tests/webcam.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.19
|
|
4
4
|
Summary: Hardware drivers for the PUDA platform.
|
|
5
|
-
Project-URL: Homepage, https://github.com/PUDAP/puda
|
|
6
|
-
Project-URL: Issues, https://github.com/PUDAP/puda
|
|
5
|
+
Project-URL: Homepage, https://github.com/PUDAP/puda
|
|
6
|
+
Project-URL: Issues, https://github.com/PUDAP/puda/issues
|
|
7
7
|
Author-email: zhao <20024592+agentzhao@users.noreply.github.com>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -18,7 +18,6 @@ Requires-Python: >=3.10
|
|
|
18
18
|
Requires-Dist: nats-py>=2.12.0
|
|
19
19
|
Requires-Dist: opencv-python>=4.12.0.88
|
|
20
20
|
Requires-Dist: pyserial~=3.5
|
|
21
|
-
Requires-Dist: pytest>=9.0.2
|
|
22
21
|
Description-Content-Type: text/markdown
|
|
23
22
|
|
|
24
23
|
# puda-drivers
|
|
@@ -260,6 +259,9 @@ uv run pytest tests/ --cov=puda_drivers --cov-report=html
|
|
|
260
259
|
# Build distribution packages
|
|
261
260
|
uv build
|
|
262
261
|
|
|
262
|
+
# cd to puda project root
|
|
263
|
+
cd ...
|
|
264
|
+
|
|
263
265
|
# Publish to PyPI
|
|
264
266
|
uv publish
|
|
265
267
|
# Username: __token__
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "puda-drivers"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.19"
|
|
4
4
|
description = "Hardware drivers for the PUDA platform."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -18,10 +18,14 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
license = "MIT"
|
|
20
20
|
license-files = ["LICEN[CS]E*"]
|
|
21
|
+
|
|
21
22
|
dependencies = [
|
|
22
23
|
"nats-py>=2.12.0",
|
|
23
24
|
"opencv-python>=4.12.0.88",
|
|
24
25
|
"pyserial~=3.5",
|
|
26
|
+
]
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
25
29
|
"pytest>=9.0.2",
|
|
26
30
|
]
|
|
27
31
|
|
|
@@ -30,5 +34,5 @@ requires = ["hatchling >= 1.26"]
|
|
|
30
34
|
build-backend = "hatchling.build"
|
|
31
35
|
|
|
32
36
|
[project.urls]
|
|
33
|
-
Homepage = "https://github.com/PUDAP/puda
|
|
34
|
-
Issues = "https://github.com/PUDAP/puda
|
|
37
|
+
Homepage = "https://github.com/PUDAP/puda"
|
|
38
|
+
Issues = "https://github.com/PUDAP/puda/issues"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
|
|
3
|
+
"ordering": [
|
|
4
|
+
[
|
|
5
|
+
"A1"
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
],
|
|
9
|
+
"brand": {
|
|
10
|
+
"brand": "Custom",
|
|
11
|
+
"brandId": []
|
|
12
|
+
},
|
|
13
|
+
"metadata": {
|
|
14
|
+
"displayName": "MEA cell MTP",
|
|
15
|
+
"displayCategory": "functionalLabware",
|
|
16
|
+
"tags": []
|
|
17
|
+
},
|
|
18
|
+
"dimensions": {
|
|
19
|
+
"xDimension": 127,
|
|
20
|
+
"yDimension": 85,
|
|
21
|
+
"zDimension": 28
|
|
22
|
+
},
|
|
23
|
+
"wells": {
|
|
24
|
+
"A1": {
|
|
25
|
+
"shape": "square",
|
|
26
|
+
"x": 63.5,
|
|
27
|
+
"y": 42.5,
|
|
28
|
+
"z": 25
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"groups": [
|
|
32
|
+
{
|
|
33
|
+
"metadata": {
|
|
34
|
+
"wellBottomShape": "flat"
|
|
35
|
+
},
|
|
36
|
+
"wells": [
|
|
37
|
+
"A1"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"parameters": {
|
|
42
|
+
"format": "irregular",
|
|
43
|
+
"quirks": [],
|
|
44
|
+
"isTiprack": false,
|
|
45
|
+
"isMagneticModuleCompatible": false,
|
|
46
|
+
"loadName": "MEA_cell_MTP"
|
|
47
|
+
},
|
|
48
|
+
"namespace": "custom_beta",
|
|
49
|
+
"version": 1,
|
|
50
|
+
"schemaVersion": 2,
|
|
51
|
+
"cornerOffsetFromSlot": {
|
|
52
|
+
"x": 0,
|
|
53
|
+
"y": 0,
|
|
54
|
+
"z": 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import inspect
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Dict, Any
|
|
6
|
+
from typing import Dict, Any, List, Optional
|
|
7
7
|
from abc import ABC
|
|
8
|
-
from typing import List
|
|
9
8
|
from puda_drivers.core import Position
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
class StandardLabware(ABC):
|
|
13
12
|
"""
|
|
14
|
-
Generic Parent Class for all Labware on a microplate
|
|
13
|
+
Generic Parent Class for all Labware on a microplate
|
|
15
14
|
"""
|
|
16
15
|
def __init__(self, labware_name: str):
|
|
17
16
|
"""
|
|
@@ -64,7 +63,7 @@ class StandardLabware(ABC):
|
|
|
64
63
|
|
|
65
64
|
with open(definition_path, "r", encoding="utf-8") as f:
|
|
66
65
|
return json.load(f)
|
|
67
|
-
|
|
66
|
+
|
|
68
67
|
def __str__(self):
|
|
69
68
|
"""
|
|
70
69
|
Return a string representation of the labware.
|
|
@@ -93,7 +92,7 @@ class StandardLabware(ABC):
|
|
|
93
92
|
List of well identifiers (e.g., ["A1", "A2", "B1", ...])
|
|
94
93
|
"""
|
|
95
94
|
return list(self._wells.keys())
|
|
96
|
-
|
|
95
|
+
|
|
97
96
|
def get_well_position(self, well_id: str) -> Position:
|
|
98
97
|
"""
|
|
99
98
|
Get the position of a well from definition.json.
|
|
@@ -111,39 +110,65 @@ class StandardLabware(ABC):
|
|
|
111
110
|
well_id_upper = well_id.upper()
|
|
112
111
|
if well_id_upper not in self._wells:
|
|
113
112
|
raise KeyError(f"Well '{well_id}' not found in tip rack definition")
|
|
114
|
-
|
|
113
|
+
|
|
115
114
|
# Get the well data from the definition
|
|
116
115
|
well_data = self._wells.get(well_id_upper, {})
|
|
117
|
-
|
|
116
|
+
|
|
118
117
|
# Return position of the well (x, y are already center coordinates)
|
|
119
118
|
return Position(
|
|
120
119
|
x=well_data.get("x", 0.0),
|
|
121
120
|
y=well_data.get("y", 0.0),
|
|
122
|
-
z=well_data.get("z", 0.0),
|
|
121
|
+
z=well_data.get("z", 0.0),
|
|
123
122
|
)
|
|
124
|
-
|
|
125
|
-
def get_height(self) -> float:
|
|
123
|
+
|
|
124
|
+
def get_height(self, well_name: Optional[str] = None) -> float:
|
|
126
125
|
"""
|
|
127
126
|
Get the height of the labware.
|
|
128
|
-
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
well_name: Optional well name within the labware (e.g., "A1" for a well in a tiprack)
|
|
130
|
+
If not provided, the height of the labware is returned (zDimension).
|
|
129
131
|
Returns:
|
|
130
|
-
Height of the labware (zDimension)
|
|
132
|
+
Height of the labware (zDimension) or the height of the well (z)
|
|
131
133
|
|
|
132
134
|
Raises:
|
|
133
|
-
KeyError: If dimensions or zDimension is not found in the definition
|
|
135
|
+
KeyError: If dimensions or zDimension is not found in the definition, or if well_name is not found in the labware
|
|
134
136
|
"""
|
|
135
137
|
dimensions = self._definition.get("dimensions")
|
|
136
138
|
if dimensions is None:
|
|
137
139
|
raise KeyError("'dimensions' not found in labware definition")
|
|
138
140
|
|
|
139
|
-
if
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
if well_name:
|
|
142
|
+
well_data = self._wells.get(well_name.upper(), {})
|
|
143
|
+
if well_data is None:
|
|
144
|
+
raise KeyError(f"Well '{well_name}' not found in labware definition")
|
|
145
|
+
return well_data.get("z")
|
|
146
|
+
else:
|
|
147
|
+
if "zDimension" not in dimensions:
|
|
148
|
+
raise KeyError("'zDimension' not found in labware dimensions")
|
|
149
|
+
return dimensions["zDimension"]
|
|
150
|
+
|
|
144
151
|
def get_insert_depth(self) -> float:
|
|
145
152
|
"""
|
|
146
|
-
Get the insert depth of the labware.
|
|
153
|
+
Get the insert depth of the labware. This should be added to all labware definitions.
|
|
154
|
+
|
|
155
|
+
insert_depth represents the maximum internal Z-axis clearance of the
|
|
156
|
+
labware. It is calculated as the absolute difference between the Top
|
|
157
|
+
Plane (the highest point of the well opening) and the Internal Floor
|
|
158
|
+
(the physical bottom of the well).
|
|
159
|
+
|
|
160
|
+
Top of Labware (Z = 0 reference)
|
|
161
|
+
│ │
|
|
162
|
+
┌─────┴──────┴─────┐ <─── Opening
|
|
163
|
+
│ │
|
|
164
|
+
│ SPACE │ }
|
|
165
|
+
│ AVAILABLE │ }
|
|
166
|
+
│ FOR │ } insert_depth
|
|
167
|
+
│ INSERTION │ } (e.g., 40mm)
|
|
168
|
+
│ │ }
|
|
169
|
+
└──────┬───────────┘
|
|
170
|
+
│
|
|
171
|
+
Bottom of Well (Limit)
|
|
147
172
|
|
|
148
173
|
Returns:
|
|
149
174
|
Insert depth of the labware
|
|
@@ -29,12 +29,7 @@ class First:
|
|
|
29
29
|
|
|
30
30
|
# Default configuration values
|
|
31
31
|
DEFAULT_QUBOT_PORT = "/dev/ttyACM0"
|
|
32
|
-
DEFAULT_QUBOT_BAUDRATE = 9600
|
|
33
|
-
DEFAULT_QUBOT_FEEDRATE = 3000
|
|
34
|
-
|
|
35
32
|
DEFAULT_SARTORIUS_PORT = "/dev/ttyUSB0"
|
|
36
|
-
DEFAULT_SARTORIUS_BAUDRATE = 9600
|
|
37
|
-
|
|
38
33
|
DEFAULT_CAMERA_INDEX = 0
|
|
39
34
|
|
|
40
35
|
# origin position of Z and A axes
|
|
@@ -52,8 +47,11 @@ class First:
|
|
|
52
47
|
# Height from z and a origin to the deck
|
|
53
48
|
CEILING_HEIGHT = 192.2
|
|
54
49
|
|
|
55
|
-
# Tip length
|
|
56
|
-
TIP_LENGTH = 59
|
|
50
|
+
# Pipette Tip length
|
|
51
|
+
TIP_LENGTH = 59 # mm
|
|
52
|
+
|
|
53
|
+
# Electrode length
|
|
54
|
+
ELECTRODE_LENGTH = 2 # mm
|
|
57
55
|
|
|
58
56
|
# Slot origins (the bottom left corner of the slot relative to the deck origin)
|
|
59
57
|
SLOT_ORIGINS = {
|
|
@@ -141,11 +139,23 @@ class First:
|
|
|
141
139
|
self._logger.info("Homing gantry...")
|
|
142
140
|
self.qubot.home()
|
|
143
141
|
|
|
144
|
-
# Initialize the pipette
|
|
142
|
+
# Initialize the pipette
|
|
145
143
|
self._logger.info("Initializing pipette...")
|
|
146
144
|
self.pipette.initialize()
|
|
147
|
-
time.sleep(
|
|
145
|
+
time.sleep(3) # need to wait for the pipette to initialize
|
|
148
146
|
self._logger.info("Machine startup complete - ready for operations")
|
|
147
|
+
|
|
148
|
+
def home(self):
|
|
149
|
+
"""
|
|
150
|
+
Home the qubot gantry to establish a known position.
|
|
151
|
+
|
|
152
|
+
This method homes the gantry to its reference position, which is useful
|
|
153
|
+
for establishing a known starting point before operations or after
|
|
154
|
+
potential position drift.
|
|
155
|
+
"""
|
|
156
|
+
self._logger.info("Homing qubot gantry...")
|
|
157
|
+
self.qubot.home()
|
|
158
|
+
self._logger.info("Qubot gantry homing complete")
|
|
149
159
|
|
|
150
160
|
def shutdown(self):
|
|
151
161
|
"""
|
|
@@ -158,7 +168,19 @@ class First:
|
|
|
158
168
|
self.pipette.disconnect()
|
|
159
169
|
self.camera.disconnect()
|
|
160
170
|
self._logger.info("Machine shutdown complete")
|
|
171
|
+
|
|
172
|
+
def wait(self, seconds: float):
|
|
173
|
+
"""
|
|
174
|
+
Wait for a specified number of seconds.
|
|
161
175
|
|
|
176
|
+
Args:
|
|
177
|
+
seconds: Number of seconds to wait (can be a float for fractional seconds)
|
|
178
|
+
"""
|
|
179
|
+
self._logger.debug("Waiting for %.2f seconds", seconds)
|
|
180
|
+
time.sleep(seconds)
|
|
181
|
+
self._logger.debug("Waited for %.2f seconds", seconds)
|
|
182
|
+
|
|
183
|
+
### Queue (public commands) ###
|
|
162
184
|
async def get_position(self) -> Dict[str, Union[Dict[str, float], int]]:
|
|
163
185
|
"""
|
|
164
186
|
Get the current position of the machine. Both QuBot and Sartorius are queried.
|
|
@@ -175,56 +197,53 @@ class First:
|
|
|
175
197
|
"qubot": qubot_position.to_dict(),
|
|
176
198
|
"pipette": sartorius_position,
|
|
177
199
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def load_labware(self, slot: str, labware_name: str):
|
|
200
|
+
|
|
201
|
+
def get_deck(self):
|
|
182
202
|
"""
|
|
183
|
-
|
|
203
|
+
Get the current deck layout.
|
|
184
204
|
|
|
185
|
-
|
|
186
|
-
slot
|
|
187
|
-
labware_name: Name of the labware class to load
|
|
205
|
+
Returns:
|
|
206
|
+
Dictionary mapping deck slot names (e.g., "A1") to labware classes.
|
|
188
207
|
|
|
189
208
|
Raises:
|
|
190
|
-
|
|
209
|
+
None
|
|
191
210
|
"""
|
|
192
|
-
self.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def remove_labware(self, slot: str):
|
|
211
|
+
return self.deck.to_dict()
|
|
212
|
+
|
|
213
|
+
def load_labware(self, deck_slot: str, labware_name: str):
|
|
197
214
|
"""
|
|
198
|
-
|
|
215
|
+
Load a labware object into a deck slot.
|
|
199
216
|
|
|
200
217
|
Args:
|
|
201
|
-
|
|
218
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
219
|
+
labware_name: Name of the labware class to load
|
|
202
220
|
|
|
203
221
|
Raises:
|
|
204
|
-
KeyError: If
|
|
222
|
+
KeyError: If deck_slot is not found in deck
|
|
205
223
|
"""
|
|
206
|
-
self.
|
|
207
|
-
self.
|
|
208
|
-
|
|
209
|
-
|
|
224
|
+
self._logger.info("Loading labware '%s' into deck slot '%s'", labware_name, deck_slot)
|
|
225
|
+
self.deck.load_labware(slot=deck_slot, labware_name=labware_name)
|
|
226
|
+
self._logger.debug("Labware '%s' loaded into deck slot '%s'", labware_name, deck_slot)
|
|
227
|
+
|
|
228
|
+
def remove_labware(self, deck_slot: str):
|
|
210
229
|
"""
|
|
211
|
-
|
|
230
|
+
Remove labware from a deck slot.
|
|
212
231
|
|
|
213
|
-
|
|
214
|
-
|
|
232
|
+
Args:
|
|
233
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
215
234
|
|
|
216
235
|
Raises:
|
|
217
|
-
|
|
236
|
+
KeyError: If deck_slot is not found in deck
|
|
218
237
|
"""
|
|
219
|
-
|
|
220
|
-
|
|
238
|
+
self.deck.empty_slot(slot=deck_slot)
|
|
239
|
+
self._logger.debug("Deck slot '%s' emptied", deck_slot)
|
|
221
240
|
|
|
222
241
|
def load_deck(self, deck_layout: Dict[str, Type[StandardLabware]]):
|
|
223
242
|
"""
|
|
224
243
|
Load multiple labware into the deck at once.
|
|
225
244
|
|
|
226
245
|
Args:
|
|
227
|
-
deck_layout: Dictionary mapping slot names (e.g., "A1") to labware classes.
|
|
246
|
+
deck_layout: Dictionary mapping deck slot names (e.g., "A1") to labware classes.
|
|
228
247
|
Each class will be instantiated automatically.
|
|
229
248
|
|
|
230
249
|
Example:
|
|
@@ -235,19 +254,18 @@ class First:
|
|
|
235
254
|
})
|
|
236
255
|
"""
|
|
237
256
|
self._logger.info("Loading deck layout with %d labware items", len(deck_layout))
|
|
238
|
-
for
|
|
239
|
-
self.load_labware(
|
|
257
|
+
for deck_slot, labware_name in deck_layout.items():
|
|
258
|
+
self.load_labware(deck_slot=deck_slot, labware_name=labware_name)
|
|
240
259
|
self._logger.info("Deck layout loaded successfully")
|
|
241
260
|
|
|
242
|
-
###
|
|
243
|
-
|
|
244
|
-
def attach_tip(self, slot: str, well: Optional[str] = None):
|
|
261
|
+
### Pipette operations ###
|
|
262
|
+
def attach_tip(self, deck_slot: str, well_name: Optional[str] = None):
|
|
245
263
|
"""
|
|
246
|
-
Attach a tip from a slot.
|
|
264
|
+
Attach a tip from a deck slot.
|
|
247
265
|
|
|
248
266
|
Args:
|
|
249
|
-
|
|
250
|
-
|
|
267
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
268
|
+
well_name: Optional well name within the deck slot (e.g., 'A1' for a well in a tiprack)
|
|
251
269
|
|
|
252
270
|
Note:
|
|
253
271
|
This method is idempotent - if a tip is already attached, it will
|
|
@@ -257,21 +275,20 @@ class First:
|
|
|
257
275
|
self._logger.warning("Tip already attached - skipping attachment (idempotent operation)")
|
|
258
276
|
return
|
|
259
277
|
|
|
260
|
-
self._logger.info("Attaching tip from slot '%s'%s",
|
|
261
|
-
pos = self.
|
|
278
|
+
self._logger.info("Attaching tip from deck slot '%s'%s", deck_slot, f", well '{well_name}'" if well_name else "")
|
|
279
|
+
pos = self._get_absolute_z_position(deck_slot, well_name)
|
|
262
280
|
self._logger.debug("Moving to position %s for tip attachment", pos)
|
|
263
281
|
# return the offset from the origin
|
|
264
282
|
self.qubot.move_absolute(position=pos)
|
|
265
283
|
|
|
266
284
|
# attach tip (move slowly down)
|
|
267
|
-
labware = self.deck[
|
|
285
|
+
labware = self.deck[deck_slot]
|
|
268
286
|
if labware is None:
|
|
269
|
-
self._logger.error("Cannot attach tip: no labware loaded in slot '%s'",
|
|
270
|
-
raise ValueError(f"No labware loaded in slot '{
|
|
271
|
-
|
|
272
|
-
self._logger.debug("Moving down by %s mm to insert tip", insert_depth)
|
|
287
|
+
self._logger.error("Cannot attach tip: no labware loaded in deck slot '%s'", deck_slot)
|
|
288
|
+
raise ValueError(f"No labware loaded in deck slot '{deck_slot}'. Load labware before attaching tips.")
|
|
289
|
+
self._logger.debug("Moving down by %s mm to insert tip", labware.get_insert_depth())
|
|
273
290
|
self.qubot.move_relative(
|
|
274
|
-
position=Position(z=-
|
|
291
|
+
position=Position(z=-labware.get_insert_depth()),
|
|
275
292
|
feed=500
|
|
276
293
|
)
|
|
277
294
|
self.pipette.set_tip_attached(attached=True)
|
|
@@ -280,33 +297,33 @@ class First:
|
|
|
280
297
|
self.qubot.home(axis="Z")
|
|
281
298
|
self._logger.debug("Z axis homed after tip attachment")
|
|
282
299
|
|
|
283
|
-
def drop_tip(self,
|
|
300
|
+
def drop_tip(self, *, deck_slot: str, well_name: str, height_from_bottom: float = 0.0):
|
|
284
301
|
"""
|
|
285
|
-
Drop a tip into a slot.
|
|
302
|
+
Drop a tip into a deck slot.
|
|
286
303
|
|
|
287
304
|
Args:
|
|
288
|
-
|
|
289
|
-
|
|
305
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
306
|
+
well_name: Well name within the deck slot (e.g., 'A1' for a well in a tiprack)
|
|
290
307
|
height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
|
|
291
|
-
Positive values move up from the bottom.
|
|
292
|
-
may cause a ValueError if the resulting position is outside
|
|
293
|
-
the Z axis limits.
|
|
308
|
+
Must be non-negative. Positive values move up from the bottom.
|
|
294
309
|
|
|
295
310
|
Raises:
|
|
296
|
-
ValueError: If no tip is attached,
|
|
297
|
-
the Z axis limits.
|
|
311
|
+
ValueError: If no tip is attached, if height_from_bottom is negative, or if
|
|
312
|
+
the resulting position is outside the Z axis limits.
|
|
298
313
|
"""
|
|
314
|
+
if height_from_bottom < 0:
|
|
315
|
+
self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
|
|
316
|
+
raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
|
|
317
|
+
|
|
299
318
|
if not self.pipette.is_tip_attached():
|
|
300
319
|
self._logger.error("Cannot drop tip: no tip attached")
|
|
301
320
|
raise ValueError("Tip not attached")
|
|
302
321
|
|
|
303
|
-
self._logger.info("Dropping tip into slot '%s', well '%s'",
|
|
304
|
-
pos = self.
|
|
322
|
+
self._logger.info("Dropping tip into deck slot '%s', well '%s'", deck_slot, well_name)
|
|
323
|
+
pos = self._get_absolute_z_position(deck_slot, well_name)
|
|
305
324
|
# add height from bottom
|
|
306
325
|
pos += Position(z=height_from_bottom)
|
|
307
|
-
|
|
308
|
-
pos += Position(z=self.TIP_LENGTH)
|
|
309
|
-
self._logger.debug("Moving to position %s (adjusted for tip length) for tip drop", pos)
|
|
326
|
+
self._logger.debug("Moving to position %s for tip drop", pos)
|
|
310
327
|
self.qubot.move_absolute(position=pos)
|
|
311
328
|
|
|
312
329
|
self._logger.debug("Ejecting tip")
|
|
@@ -315,157 +332,204 @@ class First:
|
|
|
315
332
|
self.pipette.set_tip_attached(attached=False)
|
|
316
333
|
self._logger.info("Tip dropped successfully")
|
|
317
334
|
|
|
318
|
-
def aspirate_from(self,
|
|
335
|
+
def aspirate_from(self, *, deck_slot: str, well_name: str, amount: int, height_from_bottom: float = 0.0):
|
|
319
336
|
"""
|
|
320
|
-
Aspirate a volume of liquid from a slot.
|
|
337
|
+
Aspirate a volume of liquid from a deck slot.
|
|
321
338
|
|
|
322
339
|
Args:
|
|
323
|
-
|
|
324
|
-
|
|
340
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
341
|
+
well_name: Well name within the deck slot (e.g., 'A1')
|
|
325
342
|
amount: Volume to aspirate in µL
|
|
326
343
|
height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
|
|
327
|
-
Positive values move up from the bottom.
|
|
328
|
-
may cause a ValueError if the resulting position is outside
|
|
329
|
-
the Z axis limits.
|
|
344
|
+
Must be non-negative. Positive values move up from the bottom.
|
|
330
345
|
|
|
331
346
|
Raises:
|
|
332
|
-
ValueError: If no tip is attached,
|
|
333
|
-
the Z axis limits.
|
|
347
|
+
ValueError: If no tip is attached, if height_from_bottom is negative, or if
|
|
348
|
+
the resulting position is outside the Z axis limits.
|
|
334
349
|
"""
|
|
350
|
+
if height_from_bottom < 0:
|
|
351
|
+
self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
|
|
352
|
+
raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
|
|
353
|
+
|
|
335
354
|
if not self.pipette.is_tip_attached():
|
|
336
355
|
self._logger.error("Cannot aspirate: no tip attached")
|
|
337
356
|
raise ValueError("Tip not attached")
|
|
338
357
|
|
|
339
|
-
self._logger.info("Aspirating %d µL from slot '%s', well '%s'", amount,
|
|
340
|
-
|
|
358
|
+
self._logger.info("Aspirating %d µL from deck slot '%s', well '%s'", amount, deck_slot, well_name)
|
|
359
|
+
|
|
360
|
+
pos = self._get_absolute_z_position(deck_slot, well_name)
|
|
341
361
|
# add height from bottom
|
|
342
362
|
pos += Position(z=height_from_bottom)
|
|
363
|
+
# subtract insert depth to get the bottom of the well
|
|
364
|
+
pos -= Position(z=self.deck[deck_slot].get_insert_depth())
|
|
365
|
+
|
|
343
366
|
self._logger.debug("Moving Z axis to position %s", pos)
|
|
344
367
|
self.qubot.move_absolute(position=pos)
|
|
345
|
-
|
|
346
368
|
self._logger.debug("Aspirating %d µL", amount)
|
|
347
369
|
self.pipette.aspirate(amount=amount)
|
|
348
370
|
time.sleep(5)
|
|
349
|
-
self._logger.info("Aspiration completed: %d µL from slot '%s', well '%s'", amount,
|
|
371
|
+
self._logger.info("Aspiration completed: %d µL from deck slot '%s', well '%s'", amount, deck_slot, well_name)
|
|
350
372
|
|
|
351
|
-
def dispense_to(self,
|
|
373
|
+
def dispense_to(self, *, deck_slot: str, well_name: str, amount: int, height_from_bottom: float = 0.0):
|
|
352
374
|
"""
|
|
353
|
-
Dispense a volume of liquid to a slot.
|
|
375
|
+
Dispense a volume of liquid to a deck slot.
|
|
354
376
|
|
|
355
377
|
Args:
|
|
356
|
-
|
|
357
|
-
|
|
378
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
379
|
+
well_name: Well name within the deck slot (e.g., 'A1')
|
|
358
380
|
amount: Volume to dispense in µL
|
|
359
381
|
height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
|
|
360
|
-
Positive values move up from the bottom.
|
|
361
|
-
may cause a ValueError if the resulting position is outside
|
|
362
|
-
the Z axis limits.
|
|
382
|
+
Must be non-negative. Positive values move up from the bottom.
|
|
363
383
|
|
|
364
384
|
Raises:
|
|
365
|
-
ValueError: If no tip is attached,
|
|
366
|
-
the Z axis limits.
|
|
385
|
+
ValueError: If no tip is attached, if height_from_bottom is negative, or if
|
|
386
|
+
the resulting position is outside the Z axis limits.
|
|
367
387
|
"""
|
|
388
|
+
if height_from_bottom < 0:
|
|
389
|
+
self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
|
|
390
|
+
raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
|
|
391
|
+
|
|
368
392
|
if not self.pipette.is_tip_attached():
|
|
369
393
|
self._logger.error("Cannot dispense: no tip attached")
|
|
370
394
|
raise ValueError("Tip not attached")
|
|
371
395
|
|
|
372
|
-
self._logger.info("Dispensing %d µL to slot '%s', well '%s'", amount,
|
|
373
|
-
|
|
396
|
+
self._logger.info("Dispensing %d µL to deck slot '%s', well '%s'", amount, deck_slot, well_name)
|
|
397
|
+
|
|
398
|
+
pos = self._get_absolute_z_position(deck_slot, well_name)
|
|
374
399
|
# add height from bottom
|
|
375
400
|
pos += Position(z=height_from_bottom)
|
|
401
|
+
# subtract insert depth to get the bottom of the well
|
|
402
|
+
pos -= Position(z=self.deck[deck_slot].get_insert_depth())
|
|
403
|
+
|
|
376
404
|
self._logger.debug("Moving Z axis to position %s", pos)
|
|
377
405
|
self.qubot.move_absolute(position=pos)
|
|
378
|
-
|
|
379
406
|
self._logger.debug("Dispensing %d µL", amount)
|
|
380
407
|
self.pipette.dispense(amount=amount)
|
|
381
408
|
time.sleep(5)
|
|
382
|
-
self._logger.info("Dispense completed: %d µL to slot '%s', well '%s'", amount,
|
|
409
|
+
self._logger.info("Dispense completed: %d µL to deck slot '%s', well '%s'", amount, deck_slot, well_name)
|
|
383
410
|
|
|
384
|
-
|
|
411
|
+
# Electrode operations
|
|
412
|
+
def move_electrode(self, deck_slot: str, well_name: str, height_from_bottom: float = 0.0):
|
|
385
413
|
"""
|
|
386
|
-
|
|
414
|
+
Move the electrode to a deck slot.
|
|
387
415
|
|
|
388
416
|
Args:
|
|
389
|
-
|
|
417
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
418
|
+
well_name: Well name within the deck slot (e.g., 'A1')
|
|
419
|
+
height_from_bottom: Height from the bottom of the well in mm. Defaults to 0.0.
|
|
420
|
+
Must be non-negative. Positive values move up from the bottom.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
ValueError: If height_from_bottom is negative.
|
|
424
|
+
"""
|
|
425
|
+
if height_from_bottom < 0:
|
|
426
|
+
self._logger.error("height_from_bottom must be non-negative, got %f", height_from_bottom)
|
|
427
|
+
raise ValueError(f"height_from_bottom must be non-negative, got {height_from_bottom}")
|
|
428
|
+
|
|
429
|
+
pos = self._get_absolute_a_position(deck_slot, well_name)
|
|
430
|
+
pos += Position(a=height_from_bottom)
|
|
431
|
+
self.qubot.move_absolute(position=pos)
|
|
432
|
+
self._logger.info("Electrode moved to deck slot '%s', well '%s' at height %s mm from bottom", deck_slot, well_name, height_from_bottom)
|
|
433
|
+
|
|
434
|
+
# Helper methods
|
|
435
|
+
def _get_slot_origin(self, deck_slot: str) -> Position:
|
|
436
|
+
"""
|
|
437
|
+
Get the origin coordinates of a deck slot.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
390
441
|
|
|
391
442
|
Returns:
|
|
392
|
-
Position for the slot origin
|
|
443
|
+
Position for the deck slot origin
|
|
393
444
|
|
|
394
445
|
Raises:
|
|
395
|
-
KeyError: If
|
|
396
|
-
"""
|
|
397
|
-
|
|
398
|
-
if
|
|
399
|
-
self._logger.error("Invalid slot name: '%s'. Must be one of %s",
|
|
400
|
-
raise KeyError(f"Invalid slot name: {
|
|
401
|
-
pos = self.SLOT_ORIGINS[
|
|
402
|
-
self._logger.debug("
|
|
446
|
+
KeyError: If deck_slot name is invalid
|
|
447
|
+
"""
|
|
448
|
+
deck_slot = deck_slot.upper()
|
|
449
|
+
if deck_slot not in self.SLOT_ORIGINS:
|
|
450
|
+
self._logger.error("Invalid deck slot name: '%s'. Must be one of %s", deck_slot, list(self.SLOT_ORIGINS.keys()))
|
|
451
|
+
raise KeyError(f"Invalid deck slot name: {deck_slot}. Must be one of {list(self.SLOT_ORIGINS.keys())}")
|
|
452
|
+
pos = self.SLOT_ORIGINS[deck_slot]
|
|
453
|
+
self._logger.debug("Deck slot origin for '%s': %s", deck_slot, pos)
|
|
403
454
|
return pos
|
|
404
455
|
|
|
405
|
-
def
|
|
456
|
+
def _get_absolute_z_position(self, deck_slot: str, well_name: Optional[str] = None) -> Position:
|
|
406
457
|
"""
|
|
407
|
-
Get the absolute position for a slot (and optionally a well within that slot) based on the origin
|
|
458
|
+
Get the absolute position for a deck slot (and optionally a well within that deck slot) based on the origin
|
|
408
459
|
|
|
409
460
|
Args:
|
|
410
|
-
|
|
411
|
-
|
|
461
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
462
|
+
well_name: Optional well name within the deck slot (e.g., 'A1' for a well in a tiprack)
|
|
412
463
|
|
|
413
464
|
Returns:
|
|
414
465
|
Position with absolute coordinates
|
|
415
466
|
|
|
416
467
|
Raises:
|
|
417
|
-
ValueError: If
|
|
468
|
+
ValueError: If well_name is specified but no labware is loaded in the deck slot
|
|
418
469
|
"""
|
|
419
|
-
# Get slot origin
|
|
420
|
-
pos = self.
|
|
470
|
+
# Get deck slot origin
|
|
471
|
+
pos = self._get_slot_origin(deck_slot)
|
|
421
472
|
|
|
422
|
-
# relative well position from slot origin
|
|
423
|
-
if
|
|
424
|
-
labware = self.deck[
|
|
473
|
+
# relative well position from deck slot origin
|
|
474
|
+
if well_name:
|
|
475
|
+
labware = self.deck[deck_slot]
|
|
425
476
|
if labware is None:
|
|
426
|
-
self._logger.error("Cannot get well position: no labware loaded in slot '%s'",
|
|
427
|
-
raise ValueError(f"No labware loaded in slot '{
|
|
428
|
-
well_pos = labware.get_well_position(
|
|
477
|
+
self._logger.error("Cannot get well position: no labware loaded in deck slot '%s'", deck_slot)
|
|
478
|
+
raise ValueError(f"No labware loaded in deck slot '{deck_slot}'. Load labware before accessing wells.")
|
|
479
|
+
well_pos = labware.get_well_position(well_name).get_xy()
|
|
429
480
|
# the deck is rotated 90 degrees clockwise for this machine
|
|
430
481
|
pos += well_pos.swap_xy()
|
|
431
482
|
# get z
|
|
432
483
|
pos += Position(z=labware.get_height() - self.CEILING_HEIGHT)
|
|
433
|
-
|
|
484
|
+
# if tip attached, add tip length
|
|
485
|
+
if self.pipette.is_tip_attached():
|
|
486
|
+
pos += Position(z=self.TIP_LENGTH)
|
|
487
|
+
self._logger.debug("Absolute Z position for deck slot '%s', well '%s': %s", deck_slot, well_name, pos)
|
|
434
488
|
else:
|
|
435
|
-
self._logger.debug("Absolute Z position for slot '%s': %s",
|
|
489
|
+
self._logger.debug("Absolute Z position for deck slot '%s': %s", deck_slot, pos)
|
|
436
490
|
return pos
|
|
437
491
|
|
|
438
|
-
def
|
|
492
|
+
def _get_absolute_a_position(self, deck_slot: str, well_name: Optional[str] = None) -> Position:
|
|
439
493
|
"""
|
|
440
|
-
Get the absolute position for a slot (and optionally a well within that slot) based on the origin
|
|
494
|
+
Get the absolute position for a deck slot (and optionally a well within that deck slot) based on the origin
|
|
441
495
|
|
|
442
496
|
Args:
|
|
443
|
-
|
|
444
|
-
|
|
497
|
+
deck_slot: Deck slot name (e.g., 'A1', 'B2')
|
|
498
|
+
well_name: Optional well name within the deck slot (e.g., 'A1' for a well in a tiprack)
|
|
445
499
|
|
|
446
500
|
Returns:
|
|
447
501
|
Position with absolute coordinates
|
|
448
502
|
|
|
449
503
|
Raises:
|
|
450
|
-
ValueError: If
|
|
504
|
+
ValueError: If well_name is specified but no labware is loaded in the deck slot
|
|
451
505
|
"""
|
|
452
|
-
|
|
506
|
+
# get x and y
|
|
507
|
+
pos = self._get_slot_origin(deck_slot)
|
|
508
|
+
pos -= self.A_ORIGIN # subtract the origin to get the absolute position
|
|
453
509
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
510
|
+
# Get labware for a-axis positioning
|
|
511
|
+
labware = self.deck[deck_slot]
|
|
512
|
+
if labware is None:
|
|
513
|
+
self._logger.error("Cannot get electrode position: no labware loaded in deck slot '%s'", deck_slot)
|
|
514
|
+
raise ValueError(f"No labware loaded in deck slot '{deck_slot}'. Load labware before moving electrode.")
|
|
515
|
+
|
|
516
|
+
# get x and y for well if specified
|
|
517
|
+
if well_name:
|
|
518
|
+
well_pos = labware.get_well_position(well_name).get_xy()
|
|
460
519
|
pos += well_pos.swap_xy()
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
520
|
+
|
|
521
|
+
# get a (applies to both with and without well_name)
|
|
522
|
+
pos += Position(a=labware.get_height() - self.CEILING_HEIGHT)
|
|
523
|
+
pos += Position(a=self.ELECTRODE_LENGTH)
|
|
524
|
+
|
|
525
|
+
if well_name:
|
|
526
|
+
self._logger.debug("Absolute A position for deck slot '%s', well '%s': %s", deck_slot, well_name, pos)
|
|
466
527
|
else:
|
|
467
|
-
self._logger.debug("Absolute A position for slot '%s': %s",
|
|
528
|
+
self._logger.debug("Absolute A position for deck slot '%s': %s", deck_slot, pos)
|
|
529
|
+
|
|
468
530
|
return pos
|
|
531
|
+
|
|
532
|
+
### Camera operations ###
|
|
469
533
|
|
|
470
534
|
def start_video_recording(
|
|
471
535
|
self,
|
|
@@ -529,8 +593,8 @@ class First:
|
|
|
529
593
|
)
|
|
530
594
|
|
|
531
595
|
def capture_image(
|
|
532
|
-
self,
|
|
533
|
-
save: bool = False,
|
|
596
|
+
self,
|
|
597
|
+
save: bool = False,
|
|
534
598
|
filename: Optional[Union[str, Path]] = None
|
|
535
599
|
) -> np.ndarray:
|
|
536
600
|
"""
|
|
@@ -548,4 +612,24 @@ class First:
|
|
|
548
612
|
Raises:
|
|
549
613
|
IOError: If camera is not connected or capture fails
|
|
550
614
|
"""
|
|
551
|
-
return self.camera.capture_image(save=save, filename=filename)
|
|
615
|
+
return self.camera.capture_image(save=save, filename=filename)
|
|
616
|
+
|
|
617
|
+
### Control (immediate commands) ###
|
|
618
|
+
|
|
619
|
+
def pause(self):
|
|
620
|
+
"""
|
|
621
|
+
Pause the execution of queued commands.
|
|
622
|
+
"""
|
|
623
|
+
print("Pausing machine")
|
|
624
|
+
|
|
625
|
+
def resume(self):
|
|
626
|
+
"""
|
|
627
|
+
Resume the execution of queued commands.
|
|
628
|
+
"""
|
|
629
|
+
print("Resuming machine")
|
|
630
|
+
|
|
631
|
+
def cancel(self):
|
|
632
|
+
"""
|
|
633
|
+
Cancel the execution of queued commands.
|
|
634
|
+
"""
|
|
635
|
+
print("Cancelling machine")
|
|
@@ -332,30 +332,21 @@ class GCodeController(SerialController):
|
|
|
332
332
|
>>> pos = Position(x=10, y=20)
|
|
333
333
|
>>> controller.move_absolute(position=pos) # z and a use current values
|
|
334
334
|
"""
|
|
335
|
-
# Fill in missing axes with current positions
|
|
336
|
-
final_x = position.x if position.has_axis("x") else self._current_position.x
|
|
337
|
-
final_y = position.y if position.has_axis("y") else self._current_position.y
|
|
338
|
-
final_z = position.z if position.has_axis("z") else self._current_position.z
|
|
339
|
-
final_a = position.a if position.has_axis("a") else self._current_position.a
|
|
340
|
-
|
|
341
|
-
# Create final position for validation and execution
|
|
342
|
-
final_position = Position(x=final_x, y=final_y, z=final_z, a=final_a)
|
|
343
|
-
|
|
344
335
|
# Validate positions before executing move
|
|
345
|
-
self._validate_move_positions(position=
|
|
336
|
+
self._validate_move_positions(position=position)
|
|
346
337
|
|
|
347
338
|
feed_rate = feed if feed is not None else self._feed
|
|
348
339
|
self._logger.info(
|
|
349
340
|
"Preparing absolute move to X:%s, Y:%s, Z:%s, A:%s at F:%s",
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
341
|
+
position.x,
|
|
342
|
+
position.y,
|
|
343
|
+
position.z,
|
|
344
|
+
position.a,
|
|
354
345
|
feed_rate,
|
|
355
346
|
)
|
|
356
347
|
|
|
357
348
|
return self._execute_move(
|
|
358
|
-
position=
|
|
349
|
+
position=position,
|
|
359
350
|
feed=feed_rate
|
|
360
351
|
)
|
|
361
352
|
|
|
@@ -429,9 +420,9 @@ class GCodeController(SerialController):
|
|
|
429
420
|
All coordinates are treated as absolute positions.
|
|
430
421
|
|
|
431
422
|
Safe move pattern:
|
|
432
|
-
1. If X or Y movement is needed, first move Z to
|
|
423
|
+
1. If X or Y movement is needed, first move Z and A to safe height
|
|
433
424
|
2. Then move X, Y to target
|
|
434
|
-
3. Finally move Z
|
|
425
|
+
3. Finally move Z or A to target (only the one that needs to move, the other stays where it is)
|
|
435
426
|
|
|
436
427
|
Args:
|
|
437
428
|
position: Position with absolute positions for X, Y, Z, A axes
|
|
@@ -440,8 +431,8 @@ class GCodeController(SerialController):
|
|
|
440
431
|
# Check if any movement is needed
|
|
441
432
|
needs_x_move = abs(position.x - self._current_position.x) > self.TOLERANCE
|
|
442
433
|
needs_y_move = abs(position.y - self._current_position.y) > self.TOLERANCE
|
|
443
|
-
needs_z_move = abs(position.z -
|
|
444
|
-
needs_a_move = abs(position.a -
|
|
434
|
+
needs_z_move = abs(position.z - 0) > self.TOLERANCE
|
|
435
|
+
needs_a_move = abs(position.a - 0) > self.TOLERANCE
|
|
445
436
|
|
|
446
437
|
if not (needs_x_move or needs_y_move or needs_z_move or needs_a_move):
|
|
447
438
|
self._logger.warning(
|
|
@@ -450,7 +441,7 @@ class GCodeController(SerialController):
|
|
|
450
441
|
return
|
|
451
442
|
|
|
452
443
|
if needs_z_move and needs_a_move:
|
|
453
|
-
self._logger.
|
|
444
|
+
self._logger.error(
|
|
454
445
|
"Move command issued with both Z and A movement. This is not supported. Skipping transmission."
|
|
455
446
|
)
|
|
456
447
|
raise ValueError("Move command issued with both Z and A movement. This is not supported.")
|
|
@@ -463,7 +454,7 @@ class GCodeController(SerialController):
|
|
|
463
454
|
self._logger.debug(
|
|
464
455
|
"Safe move: Raising Z and A to safe height (%s) before XY movement", self.SAFE_MOVE_HEIGHT
|
|
465
456
|
)
|
|
466
|
-
move_cmd = f"G1 Z
|
|
457
|
+
move_cmd = f"G1 Z{self.SAFE_MOVE_HEIGHT} A{self.SAFE_MOVE_HEIGHT} F{self._z_feed}"
|
|
467
458
|
self.execute(move_cmd)
|
|
468
459
|
self._wait_for_move()
|
|
469
460
|
self._current_position.z = self.SAFE_MOVE_HEIGHT
|
|
@@ -493,13 +484,13 @@ class GCodeController(SerialController):
|
|
|
493
484
|
if needs_y_move:
|
|
494
485
|
self._current_position.y = position.y
|
|
495
486
|
|
|
496
|
-
# Step 3: Move Z
|
|
487
|
+
# Step 3: Move Z or A to target (only the one that needs to move)
|
|
497
488
|
if needs_z_move:
|
|
498
489
|
move_cmd = f"G1 Z{position.z} F{self._z_feed}"
|
|
499
490
|
self.execute(move_cmd)
|
|
500
491
|
self._wait_for_move()
|
|
501
492
|
self._current_position.z = position.z
|
|
502
|
-
|
|
493
|
+
if needs_a_move:
|
|
503
494
|
move_cmd = f"G1 A{position.a} F{self._z_feed}"
|
|
504
495
|
self.execute(move_cmd)
|
|
505
496
|
self._wait_for_move()
|
|
File without changes
|
|
@@ -37,6 +37,9 @@ if __name__ == "__main__":
|
|
|
37
37
|
|
|
38
38
|
machine.startup() # Connects all controllers, homes gantry, and initializes pipette
|
|
39
39
|
|
|
40
|
+
machine.get_absolute_a_position(slot="A3", well="A1")
|
|
41
|
+
# machine.move_electrode(slot="A3", well="A1", height_from_bottom=0)
|
|
42
|
+
|
|
40
43
|
# machine.record_video(duration_seconds=10, filename="test.mp4")
|
|
41
44
|
machine.start_video_recording()
|
|
42
45
|
machine.attach_tip(slot="A3", well="G8")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/labware/opentrons_96_tiprack_300ul.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/__init__.py
RENAMED
|
File without changes
|
{puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/api.py
RENAMED
|
File without changes
|
{puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/constants.py
RENAMED
|
File without changes
|
{puda_drivers-0.0.17 → puda_drivers-0.0.19}/src/puda_drivers/transfer/liquid/sartorius/rLine.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|