mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-30 22:09:05 +00:00
Compare commits
411 Commits
v0.10.13
...
a25e8f6853
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a25e8f6853 | ||
|
|
5c249e66a2 | ||
|
|
93ac095e0a | ||
|
|
288d9fea91 | ||
|
|
81b28cef71 | ||
|
|
d57e5ffdae | ||
|
|
d5e0d76311 | ||
|
|
beaa1d7213 | ||
|
|
1e5f6b0c04 | ||
|
|
8a0f000bab | ||
|
|
2ffeb49acb | ||
|
|
5fec753fb9 | ||
|
|
acbaff7bb7 | ||
|
|
706323dc3e | ||
|
|
b0804d939c | ||
|
|
5ae89d8607 | ||
|
|
74d0ea3379 | ||
|
|
97788b4e07 | ||
|
|
39cc280c91 | ||
|
|
440c9965fd | ||
|
|
9cac852bc3 | ||
|
|
de662a42aa | ||
|
|
d0ac452405 | ||
|
|
152d3a7563 | ||
|
|
ef14737839 | ||
|
|
5d5569121c | ||
|
|
d23e85ade4 | ||
|
|
02afafd423 | ||
|
|
6ac510dcd2 | ||
|
|
632f9b90d1 | ||
|
|
d7c970d244 | ||
|
|
2c69e663a7 | ||
|
|
f03ff96ae4 | ||
|
|
ed56c1eba2 | ||
|
|
16ee3de086 | ||
|
|
c68903ed83 | ||
|
|
8efbbbe72a | ||
|
|
ced961050d | ||
|
|
11b2c99836 | ||
|
|
04024bc8a3 | ||
|
|
154048107d | ||
|
|
0b896870ba | ||
|
|
ee609e4aa2 | ||
|
|
5551fbf360 | ||
|
|
4a23b05abc | ||
|
|
e13b250632 | ||
|
|
6d8884a2c7 | ||
|
|
b8278c5026 | ||
|
|
53e767a054 | ||
|
|
cf7032fa81 | ||
|
|
97681ba433 | ||
|
|
3fa81ab4f6 | ||
|
|
c0e7a69553 | ||
|
|
fb6ee79577 | ||
|
|
9f4a69ddf5 | ||
|
|
05ae4e72df | ||
|
|
dbe129caab | ||
|
|
7250995891 | ||
|
|
2870c04086 | ||
|
|
343e87df0d | ||
|
|
68eddbdffd | ||
|
|
32bd234176 | ||
|
|
3d62e8bf6c | ||
|
|
efec1dd501 | ||
|
|
c16756ddb3 | ||
|
|
daf41871a1 | ||
|
|
6b0b28becf | ||
|
|
5d0807cba6 | ||
|
|
0f7366f3ee | ||
|
|
4875977d5f | ||
|
|
956b1c905b | ||
|
|
944911c52a | ||
|
|
a13b790926 | ||
|
|
9feadd68c6 | ||
|
|
c68d5246d0 | ||
|
|
49073f2c77 | ||
|
|
b2afc29f15 | ||
|
|
4061280f6b | ||
|
|
6a681e1d73 | ||
|
|
653e6e1ac3 | ||
|
|
2c774bcd1d | ||
|
|
2ba395b681 | ||
|
|
b6b3d59083 | ||
|
|
f40e3f521c | ||
|
|
7cc2fe036f | ||
|
|
f81d20bb1d | ||
|
|
db1b5a869f | ||
|
|
0136630700 | ||
|
|
3c31811f9e | ||
|
|
64f02ff129 | ||
|
|
7d097b8222 | ||
|
|
d266d21104 | ||
|
|
b6d0bbcb17 | ||
|
|
31ebff8e37 | ||
|
|
2132895ba2 | ||
|
|
850eeae55a | ||
|
|
d869c14233 | ||
|
|
24101b3cec | ||
|
|
3bf8aad4d5 | ||
|
|
a599eb70e5 | ||
|
|
0bf6994f95 | ||
|
|
c36f53791c | ||
|
|
eb4d2d96c5 | ||
|
|
8233c41b1d | ||
|
|
0dfd4ce8a8 | ||
|
|
7953b3820e | ||
|
|
eed233fa76 | ||
|
|
0c55147ee4 | ||
|
|
ce6267b8e0 | ||
|
|
975e51cd96 | ||
|
|
c5056b381c | ||
|
|
c35da65b15 | ||
|
|
659cf05be6 | ||
|
|
3b8deb4d1d | ||
|
|
c796615f9f | ||
|
|
a5bad6074f | ||
|
|
1d3a07a736 | ||
|
|
cc2cd57cdf | ||
|
|
39bb7dc627 | ||
|
|
0fda155f55 | ||
|
|
6e3eacd2f0 | ||
|
|
062f1a2153 | ||
|
|
61e8d67800 | ||
|
|
d0884cdbd8 | ||
|
|
545ea45024 | ||
|
|
b9ddee8f2c | ||
|
|
a0c5095304 | ||
|
|
e504505137 | ||
|
|
4d9d5701e9 | ||
|
|
6016c4b588 | ||
|
|
be02bef9c4 | ||
|
|
e62f0c2585 | ||
|
|
b6de0623e2 | ||
|
|
9d081e9fcd | ||
|
|
85a58e3464 | ||
|
|
85590672d8 | ||
|
|
1d4018196d | ||
|
|
5d34f742af | ||
|
|
5bef19e6d6 | ||
|
|
f816799753 | ||
|
|
a45d841769 | ||
|
|
7f0b33b3e3 | ||
|
|
2006406a24 | ||
|
|
f94985632b | ||
|
|
12ba110569 | ||
|
|
97212be8b7 | ||
|
|
9bdd42f12f | ||
|
|
627140da03 | ||
|
|
5ceedb0565 | ||
|
|
8c77a20c43 | ||
|
|
3ff894feee | ||
|
|
fa5896ffdb | ||
|
|
eb504803ac | ||
|
|
8b0c845661 | ||
|
|
693873bfa9 | ||
|
|
57da2d8da2 | ||
|
|
8d1fd01259 | ||
|
|
388259e64b | ||
|
|
2c130e7f37 | ||
|
|
9f7c3f02f9 | ||
|
|
19dd80dcdb | ||
|
|
9d5ed627a2 | ||
|
|
2d0ff87bc8 | ||
|
|
d78475de9a | ||
|
|
88ae56806c | ||
|
|
95dd8beb81 | ||
|
|
4ab3fadbec | ||
|
|
229888f834 | ||
|
|
b443b39ebf | ||
|
|
0434bbc15b | ||
|
|
5791b81954 | ||
|
|
bd51c74fab | ||
|
|
ba81cbddf8 | ||
|
|
4e92a26057 | ||
|
|
c2895bb197 | ||
|
|
0423f4f452 | ||
|
|
41390fbef9 | ||
|
|
98bdb4e7e4 | ||
|
|
30037a077a | ||
|
|
6972680099 | ||
|
|
9d2c93807d | ||
|
|
e728007bc5 | ||
|
|
9c5ecda7cc | ||
|
|
2d26c3fac6 | ||
|
|
f5753afb7c | ||
|
|
398b2dde3f | ||
|
|
62c4135938 | ||
|
|
027b4269c4 | ||
|
|
3757bd9c58 | ||
|
|
c75b7d5aae | ||
|
|
dfc635189c | ||
|
|
d8f3ebac15 | ||
|
|
4a1e703a3a | ||
|
|
55d22a7c29 | ||
|
|
03a4e4ecba | ||
|
|
2316c34cb5 | ||
|
|
a8887161d3 | ||
|
|
25834f5ba0 | ||
|
|
a1e9332b51 | ||
|
|
357fc038ef | ||
|
|
fd58ef07f3 | ||
|
|
93dee2c1dc | ||
|
|
70fbf19009 | ||
|
|
9149155232 | ||
|
|
1ca1792e3c | ||
|
|
485e7e8dd2 | ||
|
|
4ddabdcb65 | ||
|
|
a5b0325301 | ||
|
|
50b44938c7 | ||
|
|
df0d2235b0 | ||
|
|
4e434eeb97 | ||
|
|
ca027bf0eb | ||
|
|
635a332b4e | ||
|
|
edf7a117ca | ||
|
|
70b2715996 | ||
|
|
7e8dfc2dc5 | ||
|
|
9b626489a8 | ||
|
|
03fe208743 | ||
|
|
e913e540a3 | ||
|
|
aed39b648d | ||
|
|
8c8359fab3 | ||
|
|
5d20be0762 | ||
|
|
09f745d300 | ||
|
|
bbcbcde9a4 | ||
|
|
42b437cdea | ||
|
|
ffd0f2d26a | ||
|
|
32422c0b3d | ||
|
|
c44e597dc0 | ||
|
|
4eef012a8e | ||
|
|
ac69452f3c | ||
|
|
57b30f627b | ||
|
|
2d2a4ca067 | ||
|
|
a2613aad4c | ||
|
|
54f75183ff | ||
|
|
735be067dc | ||
|
|
0fe62d64f0 | ||
|
|
2d4ecec1e1 | ||
|
|
0f976a1874 | ||
|
|
b263a7e679 | ||
|
|
7c7f1b31c5 | ||
|
|
00e668e140 | ||
|
|
4989f65a0b | ||
|
|
9fa3688196 | ||
|
|
40fb1ea49c | ||
|
|
18b0bb397e | ||
|
|
65abc5dbf7 | ||
|
|
2455ca15ba | ||
|
|
05a3ff607a | ||
|
|
ec882df36d | ||
|
|
43b992e3eb | ||
|
|
6422fa5a9a | ||
|
|
434b9e98e0 | ||
|
|
040073f430 | ||
|
|
3d95c9896a | ||
|
|
9aa97ed01e | ||
|
|
0b8bdf5e0a | ||
|
|
299f010754 | ||
|
|
15ce0d6883 | ||
|
|
dec474e1a7 | ||
|
|
5f187899fc | ||
|
|
c8d16c7024 | ||
|
|
25d46dc9d5 | ||
|
|
88c4d1a9d1 | ||
|
|
81fd8291c5 | ||
|
|
3a11eb90d4 | ||
|
|
387866b9c9 | ||
|
|
7f40f141f6 | ||
|
|
6fc7ed1b88 | ||
|
|
93f0e08d75 | ||
|
|
4b43734b55 | ||
|
|
174b1914d4 | ||
|
|
704e13f030 | ||
|
|
0c42d60cf2 | ||
|
|
df33e1a214 | ||
|
|
1f49924966 | ||
|
|
609b6006e8 | ||
|
|
67c01271b7 | ||
|
|
a1783f489e | ||
|
|
a8f6527de9 | ||
|
|
54cfaf15f3 | ||
|
|
5610c28b67 | ||
|
|
cfc1ee6e79 | ||
|
|
1c9d2ee98a | ||
|
|
3fe8f4ca44 | ||
|
|
2476821dcc | ||
|
|
7b426ed5ae | ||
|
|
9bbae96447 | ||
|
|
10aabb7592 | ||
|
|
709eb0d91c | ||
|
|
14b7d52825 | ||
|
|
a5397ffe12 | ||
|
|
c6c2da69ba | ||
|
|
622e579063 | ||
|
|
196e0f7e2b | ||
|
|
a632fd495e | ||
|
|
a8cc02a126 | ||
|
|
ad2e1432c6 | ||
|
|
c3b9583eac | ||
|
|
5c47cd0c8a | ||
|
|
63ab1af45d | ||
|
|
a8419dc0c3 | ||
|
|
34f05f2e25 | ||
|
|
0dc2488f02 | ||
|
|
f13156e792 | ||
|
|
13fd1ac572 | ||
|
|
f8ef6e0686 | ||
|
|
94a7b8aaca | ||
|
|
301bea639e | ||
|
|
4b5a83efa4 | ||
|
|
2889e9be2c | ||
|
|
304aebbba7 | ||
|
|
091c9fa247 | ||
|
|
67ca45a240 | ||
|
|
7aab2ea493 | ||
|
|
62f3a6d696 | ||
|
|
eb70ad0e18 | ||
|
|
768f43880e | ||
|
|
762c3c737c | ||
|
|
ace98a4472 | ||
|
|
41eaa88c6f | ||
|
|
a1a55a2c0a | ||
|
|
2eaa0ca729 | ||
|
|
6f8f070f40 | ||
|
|
da4bd927e0 | ||
|
|
01f8816597 | ||
|
|
e5006285df | ||
|
|
573c724a5c | ||
|
|
09549d2839 | ||
|
|
50c7777cea | ||
|
|
4888f02c09 | ||
|
|
779c9693d9 | ||
|
|
ffa841a41a | ||
|
|
fc669f09f8 | ||
|
|
2ca0311de6 | ||
|
|
94cdcbf24e | ||
|
|
1cd07915e7 | ||
|
|
b600fc666d | ||
|
|
9e214c56c1 | ||
|
|
bdf27a7e82 | ||
|
|
2493fb9f94 | ||
|
|
c7a0ff67a9 | ||
|
|
711a7c65fa | ||
|
|
cde7956896 | ||
|
|
95b6fd0451 | ||
|
|
513e848d89 | ||
|
|
58d1cc4720 | ||
|
|
5676dd6589 | ||
|
|
1ae274a833 | ||
|
|
22b88c8441 | ||
|
|
81bcc1907d | ||
|
|
8cffd3dc21 | ||
|
|
a722636938 | ||
|
|
f68340d932 | ||
|
|
361eae2f6d | ||
|
|
c25283ae04 | ||
|
|
961752fb0d | ||
|
|
55165024dd | ||
|
|
6ddceb8393 | ||
|
|
4e52c7d2f4 | ||
|
|
0b56efc89d | ||
|
|
a27b93396a | ||
|
|
2a60a6c27e | ||
|
|
5dda94044d | ||
|
|
0cfc6f45e3 | ||
|
|
831f4549f9 | ||
|
|
f4d4eb06d3 | ||
|
|
e3b8164f6b | ||
|
|
78c04acc2e | ||
|
|
cd0428ea78 | ||
|
|
bdddbd57ba | ||
|
|
a312de08a5 | ||
|
|
68513b5745 | ||
|
|
19027350fb | ||
|
|
bbbdb06bbc | ||
|
|
cd84e26126 | ||
|
|
ce5bab3af1 | ||
|
|
82d9ef6bf7 | ||
|
|
332b33c6f4 | ||
|
|
1ec642ee3a | ||
|
|
7d8e6d029b | ||
|
|
5ec8a57a1f | ||
|
|
ae3c1100ae | ||
|
|
14bc2e6cda | ||
|
|
9f823a4198 | ||
|
|
02c79363c1 | ||
|
|
227ff1284a | ||
|
|
4b7bde6be5 | ||
|
|
8a669ac35a | ||
|
|
a1538da39e | ||
|
|
0063df4cf3 | ||
|
|
e570ba4976 | ||
|
|
e8c1f76dbb | ||
|
|
f791c1a342 | ||
|
|
ea60cbe891 | ||
|
|
eac9b8ab3d | ||
|
|
573bcf1a6c | ||
|
|
50e93cb1af | ||
|
|
fe1a029a9b | ||
|
|
662c063f50 | ||
|
|
01cbbba0b3 | ||
|
|
e6c556cf19 | ||
|
|
0605f305ed | ||
|
|
37d8108ec4 | ||
|
|
6081dac561 | ||
|
|
5b2d066127 | ||
|
|
06e66765e7 | ||
|
|
98ce360088 | ||
|
|
5cd0f72fbd | ||
|
|
343f394203 | ||
|
|
46aa7a7bd2 | ||
|
|
a66369e2c3 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.13
|
||||
version: 0.10.12
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.13
|
||||
version: 0.10.12
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.13"
|
||||
version: "0.10.12"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
@@ -25,7 +24,15 @@ class SimpleGraph:
|
||||
|
||||
def add_edge(self, source, target, **attrs):
|
||||
"""添加边"""
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
# edge = {"source": source, "target": target, **attrs}
|
||||
edge = {
|
||||
"source": source, "target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_io": "source",
|
||||
"target_handle_io": "target",
|
||||
**attrs
|
||||
}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -42,6 +49,7 @@ class SimpleGraph:
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
@@ -58,495 +66,8 @@ def extract_json_from_markdown(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template = OPERATION_MAPPING.get(operation)
|
||||
if not template:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||
else:
|
||||
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template": template,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||
}
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||
) -> SimpleGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||
G = SimpleGraph()
|
||||
resource_last_writer = {}
|
||||
LAB_NAME = "SynBioFactory"
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps)
|
||||
|
||||
# 检查协议步骤中的模板来判断协议类型
|
||||
has_biomek_template = any(
|
||||
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||
for step in protocol_steps
|
||||
)
|
||||
|
||||
if has_biomek_template:
|
||||
# 生物实验协议图构建
|
||||
for labware_id, labware in labware_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
labware_attrs = labware.copy()
|
||||
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||
labware_attrs["description"] = labware_id
|
||||
labware_attrs["lab_node_type"] = (
|
||||
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||
)
|
||||
labware_attrs["device_id"] = workstation_name
|
||||
|
||||
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||
|
||||
# 处理协议步骤
|
||||
prev_node = None
|
||||
for i, step in enumerate(protocol_steps):
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 添加控制流边
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||
prev_node = node_id
|
||||
|
||||
# 处理物料流
|
||||
params = step.get("parameters", {})
|
||||
if "sources" in params and params["sources"] in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||
|
||||
if "targets" in params:
|
||||
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||
|
||||
# 添加协议结束节点
|
||||
end_id = str(uuid.uuid4())
|
||||
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||
|
||||
else:
|
||||
# 有机化学协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
for item_id, item in labware_info.items():
|
||||
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# 判断节点类型
|
||||
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||
if "reactor" not in str(item_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {item_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {item_id}"
|
||||
liquid_type = [item_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template=f"{LAB_NAME}-host_node-create_resource",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
res_id=item_id,
|
||||
device_id=WORKSTATION_ID,
|
||||
class_name="container",
|
||||
parent=WORKSTATION_ID,
|
||||
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
liquid_input_slot=[-1],
|
||||
liquid_type=liquid_type,
|
||||
liquid_volume=liquid_volume,
|
||||
slot_on_deck="",
|
||||
role=item.get("role", ""),
|
||||
)
|
||||
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||
|
||||
last_control_node_id = None
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 物料流
|
||||
params = step.get("parameters", {})
|
||||
input_resources = {
|
||||
"Vessel": params.get("vessel"),
|
||||
"ToVessel": params.get("to_vessel"),
|
||||
"FromVessel": params.get("from_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources": params.get("sources"),
|
||||
"targets": params.get("targets"),
|
||||
}
|
||||
|
||||
for target_port, resource_name in input_resources.items():
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
|
||||
output_resources = {
|
||||
"VesselOut": params.get("vessel"),
|
||||
"FromVesselOut": params.get("from_vessel"),
|
||||
"ToVesselOut": params.get("to_vessel"),
|
||||
"FiltrateOut": params.get("filtrate_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources_out": params.get("sources"),
|
||||
"targets_out": params.get("targets"),
|
||||
}
|
||||
|
||||
for source_port, resource_name in output_resources.items():
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import re
|
||||
|
||||
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_port")
|
||||
tp = edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for (u, v, sp, tp) in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
|
||||
def create_workflow(
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.13',
|
||||
version='0.10.12',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
@@ -1 +1 @@
|
||||
__version__ = "0.10.13"
|
||||
__version__ = "0.10.12"
|
||||
|
||||
@@ -20,6 +20,7 @@ if unilabos_dir not in sys.path:
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
||||
@@ -49,6 +50,8 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
|
||||
|
||||
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
|
||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
|
||||
parser.add_argument(
|
||||
@@ -153,6 +156,39 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
aliases=["wf"],
|
||||
help="Upload workflow from xdl/json/python files",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-f",
|
||||
"--workflow_file",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the workflow file (JSON format)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-n",
|
||||
"--workflow_name",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Workflow name, if not provided will use the name from file or filename",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--tags",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Tags for the workflow (space-separated)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--published",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -168,7 +204,6 @@ def main():
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
|
||||
print_status("正在进行环境依赖检查...", "info")
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
@@ -241,9 +276,12 @@ def main():
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
BasicConfig.working_dir = working_dir
|
||||
|
||||
workflow_upload = args_dict.get("command") in ("workflow_upload", "wf")
|
||||
|
||||
# 使用远程资源启动
|
||||
if args_dict["use_remote_resource"]:
|
||||
if not workflow_upload and args_dict["use_remote_resource"]:
|
||||
print_status("使用远程资源启动", "info")
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
@@ -256,7 +294,6 @@ def main():
|
||||
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
@@ -285,9 +322,31 @@ def main():
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
# 处理 workflow_upload 子命令
|
||||
if workflow_upload:
|
||||
from unilabos.workflow.wf_utils import handle_workflow_upload_command
|
||||
|
||||
handle_workflow_upload_command(args_dict)
|
||||
print_status("工作流上传完成,程序退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
@@ -368,20 +427,6 @@ def main():
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
args_dict["graph"] = graph_res.physical_setup_graph
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
if args_dict["controllers"] is not None:
|
||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||
else:
|
||||
@@ -396,6 +441,7 @@ def main():
|
||||
comm_client = get_communication_client()
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(comm_client)
|
||||
|
||||
def _exit(signum, frame):
|
||||
comm_client.stop()
|
||||
sys.exit(0)
|
||||
@@ -437,16 +483,13 @@ def main():
|
||||
resource_visualization.start()
|
||||
except OSError as e:
|
||||
if "AMENT_PREFIX_PATH" in str(e):
|
||||
print_status(
|
||||
f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}",
|
||||
"warning"
|
||||
)
|
||||
print_status(f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}", "warning")
|
||||
print_status(
|
||||
"建议解决方案:\n"
|
||||
"1. 激活Conda环境: conda activate unilab\n"
|
||||
"2. 或使用 --backend simple 参数\n"
|
||||
"3. 或使用 --visual disable 参数禁用可视化",
|
||||
"info"
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -76,7 +76,8 @@ class HTTPClient:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
if not self.initialized or first_add:
|
||||
@@ -335,6 +336,67 @@ class HTTPClient:
|
||||
logger.error(f"响应内容: {response.text}")
|
||||
return None
|
||||
|
||||
def workflow_import(
|
||||
self,
|
||||
name: str,
|
||||
workflow_uuid: str,
|
||||
workflow_name: str,
|
||||
nodes: List[Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
workflow_uuid: 工作流UUID
|
||||
workflow_name: 工作流名称(data内部)
|
||||
nodes: 工作流节点列表
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||
payload = {
|
||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_name": workflow_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
# 保存响应到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -438,7 +438,7 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
@@ -503,7 +503,7 @@ class MessageProcessor:
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.debug("[MessageProcessor] Send handler started")
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
|
||||
try:
|
||||
while self.connected and self.websocket:
|
||||
@@ -965,7 +965,7 @@ class QueueProcessor:
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1175,7 +1175,6 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
else:
|
||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||
|
||||
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||
return url
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -1188,13 +1187,11 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||
return
|
||||
|
||||
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||
|
||||
# 启动两个核心线程
|
||||
self.message_processor.start()
|
||||
self.queue_processor.start()
|
||||
|
||||
logger.info("[WebSocketClient] All threads started")
|
||||
logger.trace("[WebSocketClient] All threads started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止WebSocket客户端"""
|
||||
|
||||
@@ -21,7 +21,8 @@ class BasicConfig:
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@classmethod
|
||||
def auth_secret(cls):
|
||||
@@ -65,6 +66,7 @@ def _update_config_from_module(module):
|
||||
if not attr.startswith("_"):
|
||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||
|
||||
|
||||
def _update_config_from_env():
|
||||
prefix = "UNILABOS_"
|
||||
for env_key, env_value in os.environ.items():
|
||||
|
||||
@@ -501,6 +501,8 @@ class BaseClient(UniversalDriver):
|
||||
val = result_dict.get("value")
|
||||
err = result_dict.get("error")
|
||||
|
||||
print(f"读取 {node_name} 返回值 = {val} (类型: {type(val).__name__}), 错误 = {err}")
|
||||
|
||||
print(f"读取 {node_name} 返回值 = {val} (类型: {type(val).__name__}, 错误 = {err}")
|
||||
return val, err
|
||||
except Exception as e:
|
||||
@@ -1193,6 +1195,7 @@ class OpcUaClient(BaseClient):
|
||||
|
||||
super().__init__()
|
||||
|
||||
|
||||
# ===== 关键修改:参照 BioyondWorkstation 处理 deck =====
|
||||
|
||||
super().__init__()
|
||||
@@ -1373,6 +1376,23 @@ class OpcUaClient(BaseClient):
|
||||
# 处理名称映射
|
||||
if name in self._name_mapping:
|
||||
chinese_name = self._name_mapping[name]
|
||||
# 优先从缓存获取值
|
||||
if chinese_name in self._node_values:
|
||||
return self._node_values[chinese_name]
|
||||
# 缓存中没有则直接读取
|
||||
value, _ = self.use_node(chinese_name).read()
|
||||
return value
|
||||
# 如果提供的是中文名,直接使用
|
||||
elif name in self._node_registry:
|
||||
# 优先从缓存获取值
|
||||
if name in self._node_values:
|
||||
return self._node_values[name]
|
||||
# 缓存中没有则直接读取
|
||||
value, _ = self.use_node(name).read()
|
||||
return value
|
||||
else:
|
||||
raise ValueError(f"未找到名称为 '{name}' 的节点")
|
||||
|
||||
elif name in self._node_registry:
|
||||
chinese_name = name
|
||||
else:
|
||||
@@ -1433,6 +1453,22 @@ class OpcUaClient(BaseClient):
|
||||
else:
|
||||
raise ValueError(f"未找到名称为 '{name}' 的节点")
|
||||
|
||||
# 写入值
|
||||
error = node.write(value)
|
||||
if not error:
|
||||
# 更新缓存
|
||||
if hasattr(node, 'name'):
|
||||
self._node_values[node.name] = value
|
||||
return True
|
||||
return False
|
||||
|
||||
def _refresh_worker(self):
|
||||
"""节点值刷新线程的工作函数"""
|
||||
self._refresh_running = True
|
||||
logger.info(f"节点值刷新线程已启动,刷新间隔: {self._refresh_interval}秒")
|
||||
|
||||
while self._refresh_running:
|
||||
|
||||
with self._client_lock:
|
||||
try:
|
||||
node = self.use_node(chinese_name)
|
||||
@@ -1477,6 +1513,17 @@ class OpcUaClient(BaseClient):
|
||||
|
||||
while self._connection_monitor_running:
|
||||
try:
|
||||
self.refresh_node_values()
|
||||
except Exception as e:
|
||||
logger.error(f"节点值刷新过程出错: {e}")
|
||||
|
||||
# 等待下一次刷新
|
||||
time.sleep(self._refresh_interval)
|
||||
|
||||
def start_node_refresh(self):
|
||||
"""启动节点值刷新线程"""
|
||||
if self._refresh_thread is not None and self._refresh_thread.is_alive():
|
||||
logger.warning("节点值刷新线程已在运行")
|
||||
# 检查连接状态
|
||||
if not self._check_connection():
|
||||
logger.warning("检测到连接断开,尝试重新连接...")
|
||||
@@ -1523,6 +1570,16 @@ class OpcUaClient(BaseClient):
|
||||
return
|
||||
|
||||
import threading
|
||||
self._refresh_thread = threading.Thread(target=self._refresh_worker, daemon=True)
|
||||
self._refresh_thread.start()
|
||||
|
||||
def stop_node_refresh(self):
|
||||
"""停止节点值刷新线程"""
|
||||
self._refresh_running = False
|
||||
if self._refresh_thread and self._refresh_thread.is_alive():
|
||||
self._refresh_thread.join(timeout=2.0)
|
||||
logger.info("节点值刷新线程已停止")
|
||||
|
||||
self._connection_monitor_thread = threading.Thread(
|
||||
target=self._connection_monitor_worker,
|
||||
daemon=True,
|
||||
@@ -1621,12 +1678,16 @@ class OpcUaClient(BaseClient):
|
||||
if "register_node_list_from_csv_path" in config_data:
|
||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
|
||||
# 处理CSV路径,如果是相对路径,则相对于配置文件所在目录
|
||||
|
||||
if "path" in config_data["register_node_list_from_csv_path"]:
|
||||
csv_path = config_data["register_node_list_from_csv_path"]["path"]
|
||||
if not os.path.isabs(csv_path):
|
||||
csv_path = os.path.join(config_dir, csv_path)
|
||||
config_data["register_node_list_from_csv_path"]["path"] = csv_path
|
||||
|
||||
# 直接使用字典
|
||||
|
||||
self.register_node_list_from_csv_path(**config_data["register_node_list_from_csv_path"])
|
||||
|
||||
if self.client and self._variables_to_find:
|
||||
@@ -1638,7 +1699,7 @@ class OpcUaClient(BaseClient):
|
||||
self.create_workflow_from_json(config_data["create_flow"])
|
||||
self.register_workflows_as_methods()
|
||||
|
||||
# 将所有节点注册为属性
|
||||
# 将所有节点注册为属性(只注册已找到的节点)
|
||||
self._register_nodes_as_attributes()
|
||||
|
||||
# 打印统计信息
|
||||
@@ -1658,7 +1719,98 @@ class OpcUaClient(BaseClient):
|
||||
logger.error(f"加载配置文件 {config_path} 失败: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
def print_node_registry_status(self):
|
||||
"""打印节点注册状态,用于调试"""
|
||||
print("\n" + "="*80)
|
||||
print("节点注册状态诊断报告")
|
||||
print("="*80)
|
||||
print(f"\n待查找节点总数: {len(self._variables_to_find)}")
|
||||
print(f"已找到节点总数: {len(self._node_registry)}")
|
||||
print(f"未找到节点总数: {len(self._variables_to_find) - len(self._node_registry)}")
|
||||
|
||||
# 显示已找到的节点(前10个)
|
||||
if self._node_registry:
|
||||
print(f"\n✓ 已找到的节点 (显示前10个):")
|
||||
for i, (name, node) in enumerate(list(self._node_registry.items())[:10]):
|
||||
eng_name = self._reverse_mapping.get(name, "")
|
||||
eng_info = f" ({eng_name})" if eng_name else ""
|
||||
print(f" {i+1}. '{name}'{eng_info}")
|
||||
print(f" NodeId: {node.node_id}")
|
||||
print(f" Type: {node.type}")
|
||||
|
||||
# 显示未找到的节点
|
||||
not_found = [name for name in self._variables_to_find if name not in self._node_registry]
|
||||
if not_found:
|
||||
print(f"\n✗ 未找到的节点 (显示前20个):")
|
||||
for i, name in enumerate(not_found[:20]):
|
||||
eng_name = self._reverse_mapping.get(name, "")
|
||||
eng_info = f" ({eng_name})" if eng_name else ""
|
||||
node_info = self._variables_to_find[name]
|
||||
print(f" {i+1}. '{name}'{eng_info} - {node_info['node_type']}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("提示:")
|
||||
print("1. 如果大量节点未找到,请检查CSV中的节点名称是否与服务器BrowseName完全匹配")
|
||||
print("2. 可以使用 client.browse_server_nodes() 查看服务器的实际节点结构")
|
||||
print("3. 节点名称区分大小写,且包括所有空格和特殊字符")
|
||||
print("="*80 + "\n")
|
||||
|
||||
def browse_server_nodes(self, max_depth=3, start_path=["0:Objects"]):
|
||||
"""浏览服务器节点树,用于调试和对比"""
|
||||
if not self.client:
|
||||
print("客户端未连接")
|
||||
return
|
||||
|
||||
print("\n" + "="*80)
|
||||
print(f"服务器节点浏览 (最大深度: {max_depth})")
|
||||
print("="*80 + "\n")
|
||||
|
||||
try:
|
||||
root = self.client.get_root_node()
|
||||
start_node = root.get_child(start_path)
|
||||
self._browse_node_recursive(start_node, depth=0, max_depth=max_depth)
|
||||
except Exception as e:
|
||||
print(f"浏览失败: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
def _browse_node_recursive(self, node, depth=0, max_depth=3):
|
||||
"""递归浏览节点"""
|
||||
if depth > max_depth:
|
||||
return
|
||||
|
||||
try:
|
||||
browse_name = node.get_browse_name()
|
||||
node_class = node.get_node_class()
|
||||
indent = " " * depth
|
||||
|
||||
# 显示节点信息
|
||||
print(f"{indent}├─ {browse_name.Name}")
|
||||
print(f"{indent}│ NodeId: {str(node.nodeid)}")
|
||||
print(f"{indent}│ NodeClass: {node_class}")
|
||||
|
||||
# 如果是变量,显示数据类型
|
||||
if node_class == NodeClass.Variable:
|
||||
try:
|
||||
data_type = node.get_data_type()
|
||||
print(f"{indent}│ DataType: {data_type}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 递归处理子节点(限制数量避免输出过多)
|
||||
if depth < max_depth:
|
||||
children = node.get_children()
|
||||
for i, child in enumerate(children[:20]): # 每层最多显示20个子节点
|
||||
self._browse_node_recursive(child, depth + 1, max_depth)
|
||||
if len(children) > 20:
|
||||
print(f"{indent} ... ({len(children) - 20} more children)")
|
||||
except Exception as e:
|
||||
# 忽略单个节点的错误
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
# 停止刷新线程
|
||||
self.stop_node_refresh()
|
||||
|
||||
"""断开连接并清理资源"""
|
||||
logger.info("正在断开连接...")
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ class ResourceVisualization:
|
||||
new_dev.set("device_name", node["id"]+"_")
|
||||
# if node["parent"] is not None:
|
||||
# new_dev.set("station_name", node["parent"]+'_')
|
||||
if "position" in node:
|
||||
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
@@ -136,13 +136,6 @@ class ResourceVisualization:
|
||||
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
|
||||
if "pose" in node:
|
||||
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000))
|
||||
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
|
||||
if "device_config" in node["config"]:
|
||||
for key, value in node["config"]["device_config"].items():
|
||||
new_dev.set(key, str(value))
|
||||
|
||||
307
unilabos/devices/laiyu_liquid/__init__.py
Normal file
307
unilabos/devices/laiyu_liquid/__init__.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站集成模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
|
||||
- 硬件后端和抽象接口
|
||||
- 资源定义和管理
|
||||
- 协议执行和液体传输
|
||||
- 工作台配置和布局
|
||||
|
||||
主要组件:
|
||||
- LaiYuLiquidBackend: 硬件后端实现
|
||||
- LaiYuLiquid: 液体处理器抽象接口
|
||||
- 各种资源类:枪头架、板、容器等
|
||||
- 便捷创建函数和配置管理
|
||||
|
||||
使用示例:
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidBackend,
|
||||
create_standard_deck,
|
||||
create_tip_rack_1000ul
|
||||
)
|
||||
|
||||
# 创建后端和液体处理器
|
||||
backend = LaiYuLiquidBackend()
|
||||
lh = LaiYuLiquid(backend=backend)
|
||||
|
||||
# 创建工作台
|
||||
deck = create_standard_deck()
|
||||
lh.deck = deck
|
||||
|
||||
# 设置和运行
|
||||
await lh.setup()
|
||||
"""
|
||||
|
||||
# 版本信息
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Integration Team"
|
||||
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
|
||||
|
||||
# 驱动程序导入
|
||||
from .drivers import (
|
||||
XYZStepperController,
|
||||
SOPAPipette,
|
||||
MotorAxis,
|
||||
MotorStatus,
|
||||
SOPAConfig,
|
||||
SOPAStatusCode,
|
||||
StepperMotorDriver
|
||||
)
|
||||
|
||||
# 控制器导入
|
||||
from .controllers import (
|
||||
XYZController,
|
||||
PipetteController,
|
||||
)
|
||||
|
||||
# 后端导入
|
||||
from .backend.rviz_backend import (
|
||||
LiquidHandlerRvizBackend,
|
||||
)
|
||||
|
||||
# 资源类和创建函数导入
|
||||
from .core.laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
# 主设备类和配置
|
||||
from .core.laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
# 后端创建函数导入
|
||||
from .backend import (
|
||||
LaiYuLiquidBackend,
|
||||
create_laiyu_backend,
|
||||
)
|
||||
|
||||
# 导出所有公共接口
|
||||
__all__ = [
|
||||
# 版本信息
|
||||
"__version__",
|
||||
"__author__",
|
||||
"__description__",
|
||||
|
||||
# 驱动程序
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
|
||||
# 控制器
|
||||
"PipetteController",
|
||||
"XYZController",
|
||||
|
||||
# 后端
|
||||
"LiquidHandlerRvizBackend",
|
||||
|
||||
# 资源创建函数
|
||||
"create_tip_rack_1000ul",
|
||||
"create_tip_rack_200ul",
|
||||
"create_96_well_plate",
|
||||
"create_deep_well_plate",
|
||||
"create_8_tube_rack",
|
||||
"create_standard_deck",
|
||||
"create_waste_container",
|
||||
"create_wash_container",
|
||||
"create_reagent_container",
|
||||
"load_deck_config",
|
||||
|
||||
# 后端创建函数
|
||||
"create_laiyu_backend",
|
||||
|
||||
# 主要类
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidDeck",
|
||||
|
||||
# 工具函数
|
||||
"get_version",
|
||||
"get_supported_resources",
|
||||
"create_quick_setup",
|
||||
"validate_installation",
|
||||
"print_module_info",
|
||||
"setup_logging",
|
||||
]
|
||||
|
||||
# 别名定义,为了向后兼容
|
||||
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
|
||||
LaiYuLiquidController = XYZController # 控制器别名
|
||||
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
|
||||
|
||||
# 模块级别的便捷函数
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
获取模块版本
|
||||
|
||||
Returns:
|
||||
str: 版本号
|
||||
"""
|
||||
return __version__
|
||||
|
||||
|
||||
def get_supported_resources() -> dict:
|
||||
"""
|
||||
获取支持的资源类型
|
||||
|
||||
Returns:
|
||||
dict: 支持的资源类型字典
|
||||
"""
|
||||
return {
|
||||
"tip_racks": {
|
||||
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
|
||||
},
|
||||
"containers": {
|
||||
"LaiYuLiquidContainer": LaiYuLiquidContainer,
|
||||
},
|
||||
"decks": {
|
||||
"LaiYuLiquidDeck": LaiYuLiquidDeck,
|
||||
},
|
||||
"devices": {
|
||||
"LaiYuLiquid": LaiYuLiquid,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> tuple:
|
||||
"""
|
||||
快速创建基本设置
|
||||
|
||||
Returns:
|
||||
tuple: (backend, controllers, resources) 的元组
|
||||
"""
|
||||
# 创建后端
|
||||
backend = LiquidHandlerRvizBackend()
|
||||
|
||||
# 创建控制器(使用默认端口进行演示)
|
||||
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
|
||||
|
||||
# 创建测试资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
well_plate = create_96_well_plate("96_well_plate")
|
||||
|
||||
controllers = {
|
||||
'pipette': pipette_controller,
|
||||
'xyz': xyz_controller
|
||||
}
|
||||
|
||||
resources = {
|
||||
'tip_rack_1000': tip_rack_1000,
|
||||
'tip_rack_200': tip_rack_200,
|
||||
'well_plate': well_plate
|
||||
}
|
||||
|
||||
return backend, controllers, resources
|
||||
|
||||
|
||||
def validate_installation() -> bool:
|
||||
"""
|
||||
验证模块安装是否正确
|
||||
|
||||
Returns:
|
||||
bool: 安装是否正确
|
||||
"""
|
||||
try:
|
||||
# 检查核心类是否可以导入
|
||||
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
|
||||
from .backend import LaiYuLiquidBackend
|
||||
from .controllers import XYZController, PipetteController
|
||||
from .drivers import XYZStepperController, SOPAPipette
|
||||
|
||||
# 尝试创建基本对象
|
||||
config = LaiYuLiquidConfig()
|
||||
backend = create_laiyu_backend("validation_test")
|
||||
|
||||
print("模块安装验证成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"模块安装验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_module_info():
|
||||
"""打印模块信息"""
|
||||
print(f"LaiYu_Liquid 集成模块")
|
||||
print(f"版本: {__version__}")
|
||||
print(f"作者: {__author__}")
|
||||
print(f"描述: {__description__}")
|
||||
print(f"")
|
||||
print(f"支持的资源类型:")
|
||||
|
||||
resources = get_supported_resources()
|
||||
for category, types in resources.items():
|
||||
print(f" {category}:")
|
||||
for type_name, type_class in types.items():
|
||||
print(f" - {type_name}: {type_class.__name__}")
|
||||
|
||||
print(f"")
|
||||
print(f"主要功能:")
|
||||
print(f" - 硬件集成: LaiYuLiquidBackend")
|
||||
print(f" - 抽象接口: LaiYuLiquid")
|
||||
print(f" - 资源管理: 各种资源类和创建函数")
|
||||
print(f" - 协议执行: transfer_liquid 和相关函数")
|
||||
print(f" - 配置管理: deck.json 和加载函数")
|
||||
|
||||
|
||||
# 模块初始化时的检查
|
||||
def _check_dependencies():
|
||||
"""检查依赖项"""
|
||||
try:
|
||||
import pylabrobot
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
return True
|
||||
except ImportError as e:
|
||||
import logging
|
||||
logging.warning(f"缺少依赖项 {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 执行依赖检查
|
||||
_dependencies_ok = _check_dependencies()
|
||||
|
||||
if not _dependencies_ok:
|
||||
import logging
|
||||
logging.warning("某些依赖项缺失,模块功能可能受限")
|
||||
|
||||
|
||||
# 模块级别的日志配置
|
||||
import logging
|
||||
|
||||
def setup_logging(level: str = "INFO"):
|
||||
"""
|
||||
设置模块日志
|
||||
|
||||
Args:
|
||||
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||
"""
|
||||
logger = logging.getLogger("LaiYu_Liquid")
|
||||
logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 默认日志设置
|
||||
_logger = setup_logging()
|
||||
9
unilabos/devices/laiyu_liquid/backend/__init__.py
Normal file
9
unilabos/devices/laiyu_liquid/backend/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
334
unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
Normal file
334
unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端实现
|
||||
|
||||
提供设备的后端接口和控制逻辑
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 尝试导入PyLabRobot后端
|
||||
try:
|
||||
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
# 创建模拟后端基类
|
||||
class LiquidHandlerBackend:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.is_connected = False
|
||||
|
||||
def connect(self):
|
||||
"""连接设备"""
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
pass
|
||||
|
||||
|
||||
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
||||
"""LaiYu液体处理设备后端"""
|
||||
|
||||
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
||||
"""
|
||||
初始化LaiYu液体处理设备后端
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
"""
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
||||
super().__init__()
|
||||
else:
|
||||
# 模拟版本接受 name 参数
|
||||
super().__init__(name)
|
||||
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.is_connected = False
|
||||
self.device_info = {
|
||||
"name": "LaiYu液体处理设备",
|
||||
"version": "1.0.0",
|
||||
"manufacturer": "LaiYu",
|
||||
"model": "LaiYu_Liquid_Handler"
|
||||
}
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到LaiYu液体处理设备
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在连接到LaiYu液体处理设备...")
|
||||
# 这里应该实现实际的设备连接逻辑
|
||||
# 目前返回模拟连接成功
|
||||
self.is_connected = True
|
||||
self.logger.info("成功连接到LaiYu液体处理设备")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""
|
||||
断开与LaiYu液体处理设备的连接
|
||||
|
||||
Returns:
|
||||
bool: 断开连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
||||
# 这里应该实现实际的设备断开连接逻辑
|
||||
self.is_connected = False
|
||||
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""
|
||||
检查设备是否已连接
|
||||
|
||||
Returns:
|
||||
bool: 设备是否已连接
|
||||
"""
|
||||
return self.is_connected
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备信息字典
|
||||
"""
|
||||
return self.device_info.copy()
|
||||
|
||||
def home_device(self) -> bool:
|
||||
"""
|
||||
设备归零操作
|
||||
|
||||
Returns:
|
||||
bool: 归零是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行归零操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info("正在执行设备归零操作...")
|
||||
# 这里应该实现实际的设备归零逻辑
|
||||
self.logger.info("设备归零操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"设备归零操作失败: {e}")
|
||||
return False
|
||||
|
||||
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
吸液操作
|
||||
|
||||
Args:
|
||||
volume: 吸液体积 (微升)
|
||||
location: 吸液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 吸液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行吸液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的吸液逻辑
|
||||
self.logger.info("吸液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"吸液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
排液操作
|
||||
|
||||
Args:
|
||||
volume: 排液体积 (微升)
|
||||
location: 排液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 排液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行排液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的排液逻辑
|
||||
self.logger.info("排液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"排液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
取枪头操作
|
||||
|
||||
Args:
|
||||
location: 枪头位置信息
|
||||
|
||||
Returns:
|
||||
bool: 取枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行取枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的取枪头逻辑
|
||||
self.logger.info("取枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"取枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
丢弃枪头操作
|
||||
|
||||
Args:
|
||||
location: 丢弃位置信息
|
||||
|
||||
Returns:
|
||||
bool: 丢弃枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的丢弃枪头逻辑
|
||||
self.logger.info("丢弃枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"丢弃枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def move_to(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
location: 目标位置信息
|
||||
|
||||
Returns:
|
||||
bool: 移动是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行移动操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在移动到位置: {location}")
|
||||
# 这里应该实现实际的移动逻辑
|
||||
self.logger.info("移动操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动操作失败: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备状态信息
|
||||
"""
|
||||
return {
|
||||
"connected": self.is_connected,
|
||||
"device_info": self.device_info,
|
||||
"status": "ready" if self.is_connected else "disconnected"
|
||||
}
|
||||
|
||||
# PyLabRobot 抽象方法实现
|
||||
def stop(self):
|
||||
"""停止所有操作"""
|
||||
self.logger.info("停止所有操作")
|
||||
pass
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
"""返回通道数量"""
|
||||
return 1 # 单通道移液器
|
||||
|
||||
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
||||
"""检查是否可以拾取吸头"""
|
||||
return True # 简化实现,总是返回True
|
||||
|
||||
def pick_up_tips(self, tip_rack, tip_positions):
|
||||
"""拾取多个吸头"""
|
||||
self.logger.info(f"拾取吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def drop_tips(self, tip_rack, tip_positions):
|
||||
"""丢弃多个吸头"""
|
||||
self.logger.info(f"丢弃吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def pick_up_tips96(self, tip_rack):
|
||||
"""拾取96个吸头"""
|
||||
self.logger.info("拾取96个吸头")
|
||||
pass
|
||||
|
||||
def drop_tips96(self, tip_rack):
|
||||
"""丢弃96个吸头"""
|
||||
self.logger.info("丢弃96个吸头")
|
||||
pass
|
||||
|
||||
def aspirate96(self, volume, plate, well_positions):
|
||||
"""96通道吸液"""
|
||||
self.logger.info(f"96通道吸液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def dispense96(self, volume, plate, well_positions):
|
||||
"""96通道排液"""
|
||||
self.logger.info(f"96通道排液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def pick_up_resource(self, resource, location):
|
||||
"""拾取资源"""
|
||||
self.logger.info(f"拾取资源: {resource}")
|
||||
pass
|
||||
|
||||
def drop_resource(self, resource, location):
|
||||
"""放置资源"""
|
||||
self.logger.info(f"放置资源: {resource}")
|
||||
pass
|
||||
|
||||
def move_picked_up_resource(self, resource, location):
|
||||
"""移动已拾取的资源"""
|
||||
self.logger.info(f"移动资源: {resource} 到 {location}")
|
||||
pass
|
||||
|
||||
|
||||
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
||||
"""
|
||||
创建LaiYu液体处理设备后端实例
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidBackend: 后端实例
|
||||
"""
|
||||
return LaiYuLiquidBackend(name)
|
||||
209
unilabos/devices/laiyu_liquid/backend/rviz_backend.py
Normal file
209
unilabos/devices/laiyu_liquid/backend/rviz_backend.py
Normal file
@@ -0,0 +1,209 @@
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pylabrobot.liquid_handling.backends.backend import (
|
||||
LiquidHandlerBackend,
|
||||
)
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
Drop,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadAspirationPlate,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
Pickup,
|
||||
PickupTipRack,
|
||||
ResourceDrop,
|
||||
ResourceMove,
|
||||
ResourcePickup,
|
||||
SingleChannelAspiration,
|
||||
SingleChannelDispense,
|
||||
)
|
||||
from pylabrobot.resources import Resource, Tip
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
import time
|
||||
from rclpy.action import ActionClient
|
||||
from unilabos_msgs.action import SendCmd
|
||||
import re
|
||||
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
||||
|
||||
|
||||
class LiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
||||
|
||||
_pip_length = 5
|
||||
_vol_length = 8
|
||||
_resource_length = 20
|
||||
_offset_length = 16
|
||||
_flow_rate_length = 10
|
||||
_blowout_length = 10
|
||||
_lld_z_length = 10
|
||||
_kwargs_length = 15
|
||||
_tip_type_length = 12
|
||||
_max_volume_length = 16
|
||||
_fitting_depth_length = 20
|
||||
_tip_length_length = 16
|
||||
# _pickup_method_length = 20
|
||||
_filter_length = 10
|
||||
|
||||
def __init__(self, num_channels: int = 8):
|
||||
"""Initialize a chatter box backend."""
|
||||
super().__init__()
|
||||
self._num_channels = num_channels
|
||||
# rclpy.init()
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
|
||||
async def setup(self):
|
||||
self.joint_state_publisher = JointStatePublisher()
|
||||
await super().setup()
|
||||
async def stop(self):
|
||||
pass
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {**super().serialize(), "num_channels": self.num_channels}
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
return self._num_channels
|
||||
|
||||
async def assigned_resource_callback(self, resource: Resource):
|
||||
pass
|
||||
|
||||
async def unassigned_resource_callback(self, name: str):
|
||||
pass
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
|
||||
)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
|
||||
# goback()
|
||||
|
||||
|
||||
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
|
||||
# goback()
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
ops: List[SingleChannelAspiration],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
# 执行吸液操作
|
||||
pass
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<15}"
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
ops: List[SingleChannelDispense],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<{LiquidHandlerRvizBackend._kwargs_length}}"
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||
|
||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||
pass
|
||||
|
||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||
pass
|
||||
|
||||
async def aspirate96(
|
||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||
):
|
||||
pass
|
||||
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
pass
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
# 执行资源拾取操作
|
||||
pass
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
# 执行资源移动操作
|
||||
pass
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
# 执行资源放置操作
|
||||
pass
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True
|
||||
|
||||
2620
unilabos/devices/laiyu_liquid/config/deckconfig.json
Normal file
2620
unilabos/devices/laiyu_liquid/config/deckconfig.json
Normal file
File diff suppressed because it is too large
Load Diff
14
unilabos/devices/laiyu_liquid/config/deckconfig.md
Normal file
14
unilabos/devices/laiyu_liquid/config/deckconfig.md
Normal file
@@ -0,0 +1,14 @@
|
||||
goto 171 178 57 H1
|
||||
goto 171 117 57 A1
|
||||
goto 172 178 130
|
||||
goto 173 179 133
|
||||
goto 173 180 133
|
||||
goto 173 180 138
|
||||
goto 173 180 125 (+10mm,在空的上面边缘)
|
||||
goto 173 180 130 取不到
|
||||
goto 173 180 133 取不到
|
||||
goto 173 180 135
|
||||
goto 173 180 137 取到了!!!!
|
||||
goto 173 180 131 弹出枪头 H1
|
||||
|
||||
goto 173 117 137 A1 (+10mm,可以取到新枪头了!!!!)
|
||||
25
unilabos/devices/laiyu_liquid/controllers/__init__.py
Normal file
25
unilabos/devices/laiyu_liquid/controllers/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
LaiYu_Liquid 控制器模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器:
|
||||
- 移液器控制器:提供液体处理的高级接口
|
||||
- XYZ运动控制器:提供三轴运动的高级接口
|
||||
"""
|
||||
|
||||
# 移液器控制器导入
|
||||
from .pipette_controller import PipetteController
|
||||
|
||||
# XYZ运动控制器导入
|
||||
from .xyz_controller import XYZController
|
||||
|
||||
__all__ = [
|
||||
# 移液器控制器
|
||||
"PipetteController",
|
||||
|
||||
# XYZ运动控制器
|
||||
"XYZController",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Controller Team"
|
||||
__description__ = "LaiYu_Liquid 高级控制器集合"
|
||||
1073
unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
Normal file
1073
unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
1183
unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Normal file
1183
unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
44
unilabos/devices/laiyu_liquid/core/__init__.py
Normal file
44
unilabos/devices/laiyu_liquid/core/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备核心模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的核心功能组件:
|
||||
- LaiYu_Liquid.py: 主设备类和配置管理
|
||||
- abstract_protocol.py: 抽象协议定义
|
||||
- laiyu_liquid_res.py: 设备资源管理
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
from .laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidBackend,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
from .laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 主设备类
|
||||
'LaiYuLiquid',
|
||||
'LaiYuLiquidConfig',
|
||||
'LaiYuLiquidBackend',
|
||||
|
||||
# 设备资源
|
||||
'LaiYuLiquidDeck',
|
||||
'LaiYuLiquidContainer',
|
||||
'LaiYuLiquidTipRack',
|
||||
|
||||
# 工具函数
|
||||
'create_quick_setup'
|
||||
]
|
||||
529
unilabos/devices/laiyu_liquid/core/abstract_protocol.py
Normal file
529
unilabos/devices/laiyu_liquid/core/abstract_protocol.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
LaiYu_Liquid 抽象协议实现
|
||||
|
||||
该模块提供了液体资源管理和转移的抽象协议,包括:
|
||||
- MaterialResource: 液体资源管理类
|
||||
- transfer_liquid: 液体转移函数
|
||||
- 相关的辅助类和函数
|
||||
|
||||
主要功能:
|
||||
- 管理多孔位的液体资源
|
||||
- 计算和跟踪液体体积
|
||||
- 处理液体转移操作
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Union, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import time
|
||||
|
||||
# pylabrobot 导入
|
||||
from pylabrobot.resources import Resource, Well, Plate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiquidType(Enum):
|
||||
"""液体类型枚举"""
|
||||
WATER = "water"
|
||||
ETHANOL = "ethanol"
|
||||
DMSO = "dmso"
|
||||
BUFFER = "buffer"
|
||||
SAMPLE = "sample"
|
||||
REAGENT = "reagent"
|
||||
WASTE = "waste"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiquidInfo:
|
||||
"""液体信息类"""
|
||||
liquid_type: LiquidType = LiquidType.UNKNOWN
|
||||
volume: float = 0.0 # 体积 (μL)
|
||||
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
|
||||
ph: Optional[float] = None # pH值
|
||||
temperature: Optional[float] = None # 温度 (°C)
|
||||
viscosity: Optional[float] = None # 粘度 (cP)
|
||||
density: Optional[float] = None # 密度 (g/ml)
|
||||
description: str = "" # 描述信息
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.liquid_type.value}({self.description})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WellContent:
|
||||
"""孔位内容类"""
|
||||
volume: float = 0.0 # 当前体积 (ul)
|
||||
max_volume: float = 1000.0 # 最大容量 (ul)
|
||||
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
|
||||
last_updated: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""检查是否为空"""
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""检查是否已满"""
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""可用体积"""
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
@property
|
||||
def fill_percentage(self) -> float:
|
||||
"""填充百分比"""
|
||||
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
|
||||
|
||||
def can_add_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以添加指定体积"""
|
||||
return (self.volume + volume) <= self.max_volume
|
||||
|
||||
def can_remove_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以移除指定体积"""
|
||||
return self.volume >= volume
|
||||
|
||||
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
|
||||
"""
|
||||
添加液体体积
|
||||
|
||||
Args:
|
||||
volume: 要添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if not self.can_add_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume += volume
|
||||
if liquid_info:
|
||||
self.liquid_info = liquid_info
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""
|
||||
移除液体体积
|
||||
|
||||
Args:
|
||||
volume: 要移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if not self.can_remove_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
|
||||
# 如果完全清空,重置液体信息
|
||||
if self.volume <= 0.0:
|
||||
self.volume = 0.0
|
||||
self.liquid_info = LiquidInfo()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class MaterialResource:
|
||||
"""
|
||||
液体资源管理类
|
||||
|
||||
该类用于管理液体处理过程中的资源状态,包括:
|
||||
- 跟踪多个孔位的液体体积和类型
|
||||
- 计算总体积和可用体积
|
||||
- 处理液体的添加和移除
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: Resource,
|
||||
wells: Optional[List[Well]] = None,
|
||||
default_max_volume: float = 1000.0
|
||||
):
|
||||
"""
|
||||
初始化材料资源
|
||||
|
||||
Args:
|
||||
resource: pylabrobot 资源对象
|
||||
wells: 孔位列表,如果为None则自动获取
|
||||
default_max_volume: 默认最大体积 (ul)
|
||||
"""
|
||||
self.resource = resource
|
||||
self.resource_id = str(uuid.uuid4())
|
||||
self.default_max_volume = default_max_volume
|
||||
|
||||
# 获取孔位列表
|
||||
if wells is None:
|
||||
if hasattr(resource, 'get_wells'):
|
||||
self.wells = resource.get_wells()
|
||||
elif hasattr(resource, 'wells'):
|
||||
self.wells = resource.wells
|
||||
else:
|
||||
# 如果没有孔位,创建一个虚拟孔位
|
||||
self.wells = [resource]
|
||||
else:
|
||||
self.wells = wells
|
||||
|
||||
# 初始化孔位内容
|
||||
self.well_contents: Dict[str, WellContent] = {}
|
||||
for well in self.wells:
|
||||
well_id = self._get_well_id(well)
|
||||
self.well_contents[well_id] = WellContent(
|
||||
max_volume=default_max_volume
|
||||
)
|
||||
|
||||
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
|
||||
|
||||
def _get_well_id(self, well: Union[Well, Resource]) -> str:
|
||||
"""获取孔位ID"""
|
||||
if hasattr(well, 'name'):
|
||||
return well.name
|
||||
else:
|
||||
return str(id(well))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""资源名称"""
|
||||
return self.resource.name
|
||||
|
||||
@property
|
||||
def total_volume(self) -> float:
|
||||
"""总液体体积"""
|
||||
return sum(content.volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def total_max_volume(self) -> float:
|
||||
"""总最大容量"""
|
||||
return sum(content.max_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""总可用体积"""
|
||||
return sum(content.available_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def well_count(self) -> int:
|
||||
"""孔位数量"""
|
||||
return len(self.wells)
|
||||
|
||||
@property
|
||||
def empty_wells(self) -> List[str]:
|
||||
"""空孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_empty]
|
||||
|
||||
@property
|
||||
def full_wells(self) -> List[str]:
|
||||
"""满孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_full]
|
||||
|
||||
@property
|
||||
def occupied_wells(self) -> List[str]:
|
||||
"""有液体的孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if not content.is_empty]
|
||||
|
||||
def get_well_content(self, well_id: str) -> Optional[WellContent]:
|
||||
"""获取指定孔位的内容"""
|
||||
return self.well_contents.get(well_id)
|
||||
|
||||
def get_well_volume(self, well_id: str) -> float:
|
||||
"""获取指定孔位的体积"""
|
||||
content = self.get_well_content(well_id)
|
||||
return content.volume if content else 0.0
|
||||
|
||||
def set_well_volume(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
设置指定孔位的体积
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
if volume > content.max_volume:
|
||||
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
|
||||
return False
|
||||
|
||||
content.volume = max(0.0, volume)
|
||||
if liquid_info:
|
||||
content.liquid_info = liquid_info
|
||||
content.last_updated = time.time()
|
||||
|
||||
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
|
||||
return True
|
||||
|
||||
def add_liquid(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
向指定孔位添加液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.add_volume(volume, liquid_info)
|
||||
|
||||
if success:
|
||||
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def remove_liquid(self, well_id: str, volume: float) -> bool:
|
||||
"""
|
||||
从指定孔位移除液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.remove_volume(volume)
|
||||
|
||||
if success:
|
||||
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def find_wells_with_volume(self, min_volume: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小体积的孔位
|
||||
|
||||
Args:
|
||||
min_volume: 最小体积 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.volume >= min_volume]
|
||||
|
||||
def find_wells_with_space(self, min_space: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小空间的孔位
|
||||
|
||||
Args:
|
||||
min_space: 最小空间 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.available_volume >= min_space]
|
||||
|
||||
def get_status_summary(self) -> Dict[str, Any]:
|
||||
"""获取资源状态摘要"""
|
||||
return {
|
||||
"resource_name": self.name,
|
||||
"resource_id": self.resource_id,
|
||||
"well_count": self.well_count,
|
||||
"total_volume": self.total_volume,
|
||||
"total_max_volume": self.total_max_volume,
|
||||
"available_volume": self.available_volume,
|
||||
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
|
||||
"empty_wells": len(self.empty_wells),
|
||||
"full_wells": len(self.full_wells),
|
||||
"occupied_wells": len(self.occupied_wells)
|
||||
}
|
||||
|
||||
def get_detailed_status(self) -> Dict[str, Any]:
|
||||
"""获取详细状态信息"""
|
||||
well_details = {}
|
||||
for well_id, content in self.well_contents.items():
|
||||
well_details[well_id] = {
|
||||
"volume": content.volume,
|
||||
"max_volume": content.max_volume,
|
||||
"available_volume": content.available_volume,
|
||||
"fill_percentage": content.fill_percentage,
|
||||
"liquid_type": content.liquid_info.liquid_type.value,
|
||||
"description": content.liquid_info.description,
|
||||
"last_updated": content.last_updated
|
||||
}
|
||||
|
||||
return {
|
||||
"summary": self.get_status_summary(),
|
||||
"wells": well_details
|
||||
}
|
||||
|
||||
|
||||
def transfer_liquid(
|
||||
source: MaterialResource,
|
||||
target: MaterialResource,
|
||||
volume: float,
|
||||
source_well_id: Optional[str] = None,
|
||||
target_well_id: Optional[str] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
在两个材料资源之间转移液体
|
||||
|
||||
Args:
|
||||
source: 源资源
|
||||
target: 目标资源
|
||||
volume: 转移体积 (ul)
|
||||
source_well_id: 源孔位ID,如果为None则自动选择
|
||||
target_well_id: 目标孔位ID,如果为None则自动选择
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 转移是否成功
|
||||
"""
|
||||
try:
|
||||
# 自动选择源孔位
|
||||
if source_well_id is None:
|
||||
available_wells = source.find_wells_with_volume(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
|
||||
return False
|
||||
source_well_id = available_wells[0]
|
||||
|
||||
# 自动选择目标孔位
|
||||
if target_well_id is None:
|
||||
available_wells = target.find_wells_with_space(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
|
||||
return False
|
||||
target_well_id = available_wells[0]
|
||||
|
||||
# 检查源孔位是否有足够液体
|
||||
if not source.get_well_content(source_well_id).can_remove_volume(volume):
|
||||
logger.error(f"源孔位 {source_well_id} 液体不足")
|
||||
return False
|
||||
|
||||
# 检查目标孔位是否有足够空间
|
||||
if not target.get_well_content(target_well_id).can_add_volume(volume):
|
||||
logger.error(f"目标孔位 {target_well_id} 空间不足")
|
||||
return False
|
||||
|
||||
# 获取源液体信息
|
||||
source_content = source.get_well_content(source_well_id)
|
||||
transfer_liquid_info = liquid_info or source_content.liquid_info
|
||||
|
||||
# 执行转移
|
||||
if source.remove_liquid(source_well_id, volume):
|
||||
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
|
||||
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
|
||||
return True
|
||||
else:
|
||||
# 如果目标添加失败,回滚源操作
|
||||
source.add_liquid(source_well_id, volume, source_content.liquid_info)
|
||||
logger.error("目标添加失败,已回滚源操作")
|
||||
return False
|
||||
else:
|
||||
logger.error("源移除失败")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_material_resource(
|
||||
name: str,
|
||||
resource: Resource,
|
||||
initial_volumes: Optional[Dict[str, float]] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None,
|
||||
max_volume: float = 1000.0
|
||||
) -> MaterialResource:
|
||||
"""
|
||||
创建材料资源的便捷函数
|
||||
|
||||
Args:
|
||||
name: 资源名称
|
||||
resource: pylabrobot 资源对象
|
||||
initial_volumes: 初始体积字典 {well_id: volume}
|
||||
liquid_info: 液体信息
|
||||
max_volume: 最大体积
|
||||
|
||||
Returns:
|
||||
MaterialResource: 创建的材料资源
|
||||
"""
|
||||
material_resource = MaterialResource(
|
||||
resource=resource,
|
||||
default_max_volume=max_volume
|
||||
)
|
||||
|
||||
# 设置初始体积
|
||||
if initial_volumes:
|
||||
for well_id, volume in initial_volumes.items():
|
||||
material_resource.set_well_volume(well_id, volume, liquid_info)
|
||||
|
||||
return material_resource
|
||||
|
||||
|
||||
def batch_transfer_liquid(
|
||||
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> List[bool]:
|
||||
"""
|
||||
批量液体转移
|
||||
|
||||
Args:
|
||||
transfers: 转移列表 [(source, target, volume), ...]
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
List[bool]: 每个转移操作的结果
|
||||
"""
|
||||
results = []
|
||||
|
||||
for source, target, volume in transfers:
|
||||
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
|
||||
results.append(result)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
|
||||
|
||||
success_count = sum(results)
|
||||
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
|
||||
|
||||
return results
|
||||
888
unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
Normal file
888
unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
Normal file
@@ -0,0 +1,888 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站主要集成文件
|
||||
|
||||
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
|
||||
主要包含:
|
||||
- LaiYuLiquidBackend: 硬件通信后端
|
||||
- LaiYuLiquid: 主要接口类
|
||||
- 相关的异常类和容器类
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
# 基础导入
|
||||
try:
|
||||
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
|
||||
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 pylabrobot 不可用,创建基础的模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class Well(Resource):
|
||||
pass
|
||||
|
||||
|
||||
# LaiYu_Liquid 控制器导入
|
||||
try:
|
||||
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
|
||||
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
|
||||
|
||||
CONTROLLERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
CONTROLLERS_AVAILABLE = False
|
||||
|
||||
# 创建模拟的控制器类
|
||||
class PipetteController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
return True
|
||||
|
||||
def initialize(self):
|
||||
return True
|
||||
|
||||
class XYZController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect_device(self):
|
||||
return True
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaiYuLiquidError(RuntimeError):
|
||||
"""LaiYu_Liquid 设备异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaiYuLiquidConfig:
|
||||
"""LaiYu_Liquid 设备配置"""
|
||||
|
||||
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
|
||||
address: int = 1 # 设备地址
|
||||
baudrate: int = 9600 # 波特率
|
||||
timeout: float = 5.0 # 通信超时时间
|
||||
|
||||
# 工作台尺寸
|
||||
deck_width: float = 340.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 250.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 160.0 # 工作台深度 (mm)
|
||||
|
||||
# 移液参数
|
||||
max_volume: float = 1000.0 # 最大体积 (μL)
|
||||
min_volume: float = 0.1 # 最小体积 (μL)
|
||||
|
||||
# 运动参数
|
||||
max_speed: float = 100.0 # 最大速度 (mm/s)
|
||||
acceleration: float = 50.0 # 加速度 (mm/s²)
|
||||
|
||||
# 安全参数
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
|
||||
liquid_detection: bool = True # 液面检测
|
||||
|
||||
# 取枪头相关参数
|
||||
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
|
||||
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
|
||||
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
|
||||
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
|
||||
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
|
||||
|
||||
# 丢弃枪头相关参数
|
||||
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
|
||||
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
|
||||
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
|
||||
|
||||
# 安全范围配置
|
||||
deck_width: float = 300.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 200.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 100.0 # 工作台深度 (mm)
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
position_validation: bool = True # 启用位置验证
|
||||
emergency_stop_enabled: bool = True # 启用紧急停止
|
||||
|
||||
|
||||
class LaiYuLiquidDeck:
|
||||
"""LaiYu_Liquid 工作台管理"""
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig):
|
||||
self.config = config
|
||||
self.resources: Dict[str, Resource] = {}
|
||||
self.positions: Dict[str, Tuple[float, float, float]] = {}
|
||||
|
||||
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
self.resources[name] = resource
|
||||
self.positions[name] = position
|
||||
|
||||
def get_resource(self, name: str) -> Optional[Resource]:
|
||||
"""获取资源"""
|
||||
return self.resources.get(name)
|
||||
|
||||
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
|
||||
"""获取资源位置"""
|
||||
return self.positions.get(name)
|
||||
|
||||
def list_resources(self) -> List[str]:
|
||||
"""列出所有资源"""
|
||||
return list(self.resources.keys())
|
||||
|
||||
|
||||
class LaiYuLiquidContainer:
|
||||
"""LaiYu_Liquid 容器类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 0,
|
||||
size_y: float = 0,
|
||||
size_z: float = 0,
|
||||
container_type: str = "",
|
||||
volume: float = 0.0,
|
||||
max_volume: float = 1000.0,
|
||||
lid_height: float = 0.0,
|
||||
):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.lid_height = lid_height
|
||||
self.container_type = container_type
|
||||
self.volume = volume
|
||||
self.max_volume = max_volume
|
||||
self.last_updated = time.time()
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
def add_volume(self, volume: float) -> bool:
|
||||
"""添加体积"""
|
||||
if self.volume + volume <= self.max_volume:
|
||||
self.volume += volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""移除体积"""
|
||||
if self.volume >= volume:
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
|
||||
if hasattr(resource, "name"):
|
||||
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||
|
||||
|
||||
class LaiYuLiquidTipRack:
|
||||
"""LaiYu_Liquid 吸头架类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 0,
|
||||
size_y: float = 0,
|
||||
size_z: float = 0,
|
||||
tip_count: int = 96,
|
||||
tip_volume: float = 1000.0,
|
||||
):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.tip_count = tip_count
|
||||
self.tip_volume = tip_volume
|
||||
self.tips_available = [True] * tip_count
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def available_tips(self) -> int:
|
||||
return sum(self.tips_available)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.available_tips == 0
|
||||
|
||||
def pick_tip(self, position: int) -> bool:
|
||||
"""拾取吸头"""
|
||||
if 0 <= position < self.tip_count and self.tips_available[position]:
|
||||
self.tips_available[position] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_tip(self, position: int) -> bool:
|
||||
"""检查位置是否有吸头"""
|
||||
if 0 <= position < self.tip_count:
|
||||
return self.tips_available[position]
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源到指定位置"""
|
||||
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||
|
||||
|
||||
def get_module_info():
|
||||
"""获取模块信息"""
|
||||
return {
|
||||
"name": "LaiYu_Liquid",
|
||||
"version": "1.0.0",
|
||||
"description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能",
|
||||
"author": "UniLabOS Team",
|
||||
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
|
||||
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
|
||||
}
|
||||
|
||||
|
||||
class LaiYuLiquidBackend:
|
||||
"""LaiYu_Liquid 硬件通信后端"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
|
||||
self.config = config
|
||||
self.deck = deck # 工作台引用,用于获取资源位置信息
|
||||
self.pipette_controller = None
|
||||
self.xyz_controller = None
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
|
||||
# 状态跟踪
|
||||
self.current_position = (0.0, 0.0, 0.0)
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
def _validate_position(self, x: float, y: float, z: float) -> bool:
|
||||
"""验证位置是否在安全范围内"""
|
||||
try:
|
||||
# 检查X轴范围
|
||||
if not (0 <= x <= self.config.deck_width):
|
||||
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
|
||||
return False
|
||||
|
||||
# 检查Y轴范围
|
||||
if not (0 <= y <= self.config.deck_height):
|
||||
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
|
||||
return False
|
||||
|
||||
# 检查Z轴范围(负值表示向下,0为工作台表面)
|
||||
if not (-self.config.deck_depth <= z <= self.config.safe_height):
|
||||
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"位置验证失败: {e}")
|
||||
return False
|
||||
|
||||
def _check_hardware_ready(self) -> bool:
|
||||
"""检查硬件是否准备就绪"""
|
||||
if not self.is_connected:
|
||||
logger.error("设备未连接")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
if self.xyz_controller is None:
|
||||
logger.error("XYZ控制器未初始化")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def emergency_stop(self) -> bool:
|
||||
"""紧急停止所有运动"""
|
||||
try:
|
||||
logger.warning("执行紧急停止")
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 停止XYZ控制器
|
||||
await self.xyz_controller.stop_all_motion()
|
||||
logger.info("XYZ控制器已停止")
|
||||
|
||||
if self.pipette_controller:
|
||||
# 停止移液器控制器
|
||||
await self.pipette_controller.stop()
|
||||
logger.info("移液器控制器已停止")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"紧急停止失败: {e}")
|
||||
return False
|
||||
|
||||
async def move_to_safe_position(self) -> bool:
|
||||
"""移动到安全位置"""
|
||||
try:
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
safe_position = (
|
||||
self.config.deck_width / 2, # 工作台中心X
|
||||
self.config.deck_height / 2, # 工作台中心Y
|
||||
self.config.safe_height, # 安全高度Z
|
||||
)
|
||||
|
||||
if not self._validate_position(*safe_position):
|
||||
logger.error("安全位置无效")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
await self.xyz_controller.move_to_work_coord(*safe_position)
|
||||
self.current_position = safe_position
|
||||
logger.info(f"已移动到安全位置: {safe_position}")
|
||||
return True
|
||||
else:
|
||||
# 模拟模式
|
||||
self.current_position = safe_position
|
||||
logger.info("模拟移动到安全位置")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动到安全位置失败: {e}")
|
||||
return False
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置硬件连接"""
|
||||
try:
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
# 初始化移液器控制器
|
||||
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
|
||||
|
||||
# 初始化XYZ控制器
|
||||
machine_config = MachineConfig()
|
||||
self.xyz_controller = XYZController(
|
||||
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
|
||||
)
|
||||
|
||||
# 连接设备
|
||||
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
|
||||
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
|
||||
|
||||
if pipette_connected and xyz_connected:
|
||||
self.is_connected = True
|
||||
logger.info("LaiYu_Liquid 硬件连接成功")
|
||||
return True
|
||||
else:
|
||||
logger.error("LaiYu_Liquid 硬件连接失败")
|
||||
return False
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("LaiYu_Liquid 运行在模拟模式")
|
||||
self.is_connected = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止设备"""
|
||||
try:
|
||||
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
|
||||
await asyncio.to_thread(self.pipette_controller.disconnect)
|
||||
|
||||
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
|
||||
await asyncio.to_thread(self.xyz_controller.disconnect)
|
||||
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
logger.info("LaiYu_Liquid 已停止")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 停止失败: {e}")
|
||||
|
||||
async def move_to(self, x: float, y: float, z: float) -> bool:
|
||||
"""移动到指定位置"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
# 模拟移动
|
||||
await self._ros_node.sleep(0.1) # 模拟移动时间
|
||||
self.current_position = (x, y, z)
|
||||
logger.debug(f"移动到位置: ({x}, {y}, {z})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动失败: {e}")
|
||||
return False
|
||||
|
||||
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
|
||||
"""拾取吸头 - 包含真正的Z轴下降控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if self.tip_attached:
|
||||
logger.warning("已有吸头附着,无法拾取新吸头")
|
||||
return False
|
||||
|
||||
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
|
||||
|
||||
# 获取枪头架位置信息
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
tip_position = self.deck.get_position(tip_rack)
|
||||
if tip_position is None:
|
||||
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
|
||||
return False
|
||||
|
||||
# 计算具体枪头位置(这里简化处理,实际应根据position计算偏移)
|
||||
tip_x, tip_y, tip_z = tip_position
|
||||
|
||||
# 验证所有关键位置的安全性
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
|
||||
if not (
|
||||
self._validate_position(tip_x, tip_y, safe_z)
|
||||
and self._validate_position(tip_x, tip_y, pickup_z)
|
||||
and self._validate_position(tip_x, tip_y, retract_z)
|
||||
):
|
||||
logger.error("枪头拾取位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头拾取")
|
||||
|
||||
try:
|
||||
# 1. 移动到枪头上方的安全位置
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到枪头上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到枪头位置
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到枪头位置失败")
|
||||
return False
|
||||
|
||||
# 3. 等待一小段时间确保枪头牢固附着
|
||||
await self._ros_node.sleep(0.2)
|
||||
|
||||
# 4. Z轴上升到回退高度
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 5. 更新当前位置
|
||||
self.current_position = (tip_x, tip_y, retract_z)
|
||||
|
||||
except Exception as move_error:
|
||||
logger.error(f"枪头拾取过程中发生错误: {move_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头拾取动作")
|
||||
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
|
||||
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
|
||||
|
||||
# 6. 标记枪头已附着
|
||||
self.tip_attached = True
|
||||
logger.info("吸头拾取成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"拾取吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def drop_tip(self, location: str = "trash") -> bool:
|
||||
"""丢弃吸头 - 包含真正的Z轴控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if not self.tip_attached:
|
||||
logger.warning("没有吸头附着,无需丢弃")
|
||||
return True
|
||||
|
||||
logger.info(f"开始丢弃吸头到 {location}")
|
||||
|
||||
# 确定丢弃位置
|
||||
if location == "trash":
|
||||
# 使用配置中的垃圾桶位置
|
||||
drop_x, drop_y, drop_z = self.config.trash_position
|
||||
else:
|
||||
# 尝试从deck获取指定位置
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
drop_position = self.deck.get_position(location)
|
||||
if drop_position is None:
|
||||
logger.error(f"未找到丢弃位置 {location} 的信息")
|
||||
return False
|
||||
drop_x, drop_y, drop_z = drop_position
|
||||
|
||||
# 验证丢弃位置的安全性
|
||||
safe_z = drop_z + self.config.safe_height
|
||||
drop_height_z = drop_z + self.config.tip_drop_height
|
||||
|
||||
if not (
|
||||
self._validate_position(drop_x, drop_y, safe_z)
|
||||
and self._validate_position(drop_x, drop_y, drop_height_z)
|
||||
):
|
||||
logger.error("枪头丢弃位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头丢弃")
|
||||
|
||||
try:
|
||||
# 1. 移动到丢弃位置上方的安全高度
|
||||
safe_z = drop_z + self.config.tip_drop_height
|
||||
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到丢弃位置上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到丢弃高度
|
||||
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到丢弃位置失败")
|
||||
return False
|
||||
|
||||
# 3. 执行枪头弹出动作(如果有移液器控制器)
|
||||
if self.pipette_controller:
|
||||
try:
|
||||
# 发送弹出枪头命令
|
||||
await asyncio.to_thread(self.pipette_controller.eject_tip)
|
||||
logger.info("执行枪头弹出命令")
|
||||
except Exception as e:
|
||||
logger.warning(f"枪头弹出命令失败: {e}")
|
||||
|
||||
# 4. 等待一小段时间确保枪头完全脱离
|
||||
await self._ros_node.sleep(0.3)
|
||||
|
||||
# 5. Z轴上升到安全高度
|
||||
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 6. 更新当前位置
|
||||
self.current_position = (drop_x, drop_y, safe_z)
|
||||
|
||||
except Exception as drop_error:
|
||||
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头丢弃动作")
|
||||
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
|
||||
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
|
||||
|
||||
# 7. 标记枪头已脱离,清空体积
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
logger.info("吸头丢弃成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"丢弃吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def aspirate(self, volume: float, location: str) -> bool:
|
||||
"""吸取液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.config.max_volume:
|
||||
raise LaiYuLiquidError(f"体积超出范围: {volume}")
|
||||
|
||||
# 模拟吸取
|
||||
await self._ros_node.sleep(0.3)
|
||||
self.current_volume += volume
|
||||
logger.debug(f"从 {location} 吸取 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"吸取失败: {e}")
|
||||
return False
|
||||
|
||||
async def dispense(self, volume: float, location: str) -> bool:
|
||||
"""分配液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.current_volume:
|
||||
raise LaiYuLiquidError(f"分配体积无效: {volume}")
|
||||
|
||||
# 模拟分配
|
||||
await self._ros_node.sleep(0.3)
|
||||
self.current_volume -= volume
|
||||
logger.debug(f"向 {location} 分配 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class LaiYuLiquid:
|
||||
"""LaiYu_Liquid 主要接口类"""
|
||||
|
||||
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
|
||||
# 如果传入了关键字参数,创建配置对象
|
||||
if kwargs and config is None:
|
||||
# 从kwargs中提取配置参数
|
||||
config_params = {}
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(LaiYuLiquidConfig, key):
|
||||
config_params[key] = value
|
||||
self.config = LaiYuLiquidConfig(**config_params)
|
||||
else:
|
||||
self.config = config or LaiYuLiquidConfig()
|
||||
|
||||
# 先创建deck,然后传递给backend
|
||||
self.deck = LaiYuLiquidDeck(self.config)
|
||||
self.backend = LaiYuLiquidBackend(self.config, self.deck)
|
||||
self.is_setup = False
|
||||
|
||||
@property
|
||||
def current_position(self) -> Tuple[float, float, float]:
|
||||
"""获取当前位置"""
|
||||
return self.backend.current_position
|
||||
|
||||
@property
|
||||
def current_volume(self) -> float:
|
||||
"""获取当前体积"""
|
||||
return self.backend.current_volume
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""获取连接状态"""
|
||||
return self.backend.is_connected
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""获取初始化状态"""
|
||||
return self.backend.is_initialized
|
||||
|
||||
@property
|
||||
def tip_attached(self) -> bool:
|
||||
"""获取吸头附着状态"""
|
||||
return self.backend.tip_attached
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置液体处理器"""
|
||||
try:
|
||||
success = await self.backend.setup()
|
||||
if success:
|
||||
self.is_setup = True
|
||||
logger.info("LaiYu_Liquid 设置完成")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止液体处理器"""
|
||||
await self.backend.stop()
|
||||
self.is_setup = False
|
||||
|
||||
async def transfer(
|
||||
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
|
||||
) -> bool:
|
||||
"""液体转移"""
|
||||
try:
|
||||
if not self.is_setup:
|
||||
raise LaiYuLiquidError("设备未设置")
|
||||
|
||||
# 获取源和目标位置
|
||||
source_pos = self.deck.get_position(source)
|
||||
target_pos = self.deck.get_position(target)
|
||||
tip_pos = self.deck.get_position(tip_rack)
|
||||
|
||||
if not all([source_pos, target_pos, tip_pos]):
|
||||
raise LaiYuLiquidError("位置信息不完整")
|
||||
|
||||
# 执行转移步骤
|
||||
steps = [
|
||||
("移动到吸头架", self.backend.move_to(*tip_pos)),
|
||||
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
|
||||
("移动到源位置", self.backend.move_to(*source_pos)),
|
||||
("吸取液体", self.backend.aspirate(volume, source)),
|
||||
("移动到目标位置", self.backend.move_to(*target_pos)),
|
||||
("分配液体", self.backend.dispense(volume, target)),
|
||||
("丢弃吸头", self.backend.drop_tip()),
|
||||
]
|
||||
|
||||
for step_name, step_coro in steps:
|
||||
logger.debug(f"执行步骤: {step_name}")
|
||||
success = await step_coro
|
||||
if not success:
|
||||
raise LaiYuLiquidError(f"步骤失败: {step_name}")
|
||||
|
||||
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
if resource_type == "plate":
|
||||
resource = Plate(name)
|
||||
elif resource_type == "tip_rack":
|
||||
resource = TipRack(name)
|
||||
else:
|
||||
resource = Resource(name)
|
||||
|
||||
self.deck.add_resource(name, resource, position)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取设备状态"""
|
||||
return {
|
||||
"connected": self.backend.is_connected,
|
||||
"setup": self.is_setup,
|
||||
"current_position": self.backend.current_position,
|
||||
"tip_attached": self.backend.tip_attached,
|
||||
"current_volume": self.backend.current_volume,
|
||||
"resources": self.deck.list_resources(),
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建快速设置的LaiYu液体处理工作站
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 创建默认配置
|
||||
config = LaiYuLiquidConfig()
|
||||
|
||||
# 创建工作台
|
||||
deck = LaiYuLiquidDeck(config)
|
||||
|
||||
# 导入资源创建函数
|
||||
try:
|
||||
from .laiyu_liquid_res import (
|
||||
create_tip_rack_1000ul,
|
||||
create_tip_rack_200ul,
|
||||
create_96_well_plate,
|
||||
create_waste_container,
|
||||
)
|
||||
|
||||
# 添加基本资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
plate_96 = create_96_well_plate("plate_96")
|
||||
waste = create_waste_container("waste")
|
||||
|
||||
# 添加到工作台
|
||||
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
|
||||
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
|
||||
deck.add_resource("plate_96", plate_96, (250, 50, 0))
|
||||
deck.add_resource("waste", waste, (50, 150, 0))
|
||||
|
||||
except ImportError:
|
||||
# 如果资源模块不可用,创建空的工作台
|
||||
logger.warning("资源模块不可用,创建空的工作台")
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidDeck",
|
||||
"LaiYuLiquidContainer",
|
||||
"LaiYuLiquidTipRack",
|
||||
"LaiYuLiquidError",
|
||||
"create_quick_setup",
|
||||
"get_module_info",
|
||||
]
|
||||
954
unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
Normal file
954
unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
Normal file
@@ -0,0 +1,954 @@
|
||||
"""
|
||||
LaiYu_Liquid 资源定义模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
|
||||
- 各种规格的枪头架
|
||||
- 不同类型的板和容器
|
||||
- 特殊功能位置
|
||||
- 资源创建的便捷函数
|
||||
|
||||
所有资源都基于 deck.json 中的配置参数创建。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from pathlib import Path
|
||||
|
||||
# PyLabRobot 资源导入
|
||||
try:
|
||||
from pylabrobot.resources import (
|
||||
Resource, Deck, Plate, TipRack, Container, Tip,
|
||||
Coordinate
|
||||
)
|
||||
from pylabrobot.resources.tip_rack import TipSpot
|
||||
from pylabrobot.resources.well import Well as PlateWell
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 PyLabRobot 不可用,创建模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Container(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class TipSpot(Resource):
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name)
|
||||
# 忽略其他参数
|
||||
|
||||
class PlateWell(Resource):
|
||||
pass
|
||||
|
||||
class Coordinate:
|
||||
def __init__(self, x: float, y: float, z: float):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
|
||||
# 本地导入
|
||||
from .laiyu_liquid_main import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
|
||||
|
||||
|
||||
def load_deck_config() -> Dict[str, Any]:
|
||||
"""
|
||||
加载工作台配置文件
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 配置字典
|
||||
"""
|
||||
# 优先使用最新的deckconfig.json文件
|
||||
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
|
||||
|
||||
# 如果最新配置文件不存在,回退到旧配置文件
|
||||
if not config_path.exists():
|
||||
config_path = Path(__file__).parent / "config" / "deck.json"
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
# 如果找不到配置文件,返回默认配置
|
||||
return {
|
||||
"name": "LaiYu_Liquid_Deck",
|
||||
"size_x": 340.0,
|
||||
"size_y": 250.0,
|
||||
"size_z": 160.0
|
||||
}
|
||||
|
||||
|
||||
# 加载配置
|
||||
DECK_CONFIG = load_deck_config()
|
||||
|
||||
|
||||
class LaiYuTipRack1000(LaiYuLiquidTipRack):
|
||||
"""1000μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=1000.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="1000ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
# 从配置文件中获取枪头架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tip_module = None
|
||||
|
||||
# 查找枪头架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tip_rack":
|
||||
tip_module = module
|
||||
break
|
||||
|
||||
if not tip_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=95.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tip_module.get("wells", []):
|
||||
spot_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 9.0),
|
||||
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYuTipRack200(LaiYuLiquidTipRack):
|
||||
"""200μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=200.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="200ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=72.0, # 200ul枪头长度
|
||||
maximal_volume=200.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=72.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYu96WellPlate(LaiYuLiquidContainer):
|
||||
"""96孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=14.22,
|
||||
container_type="96_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=200.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=200.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 10.0 # 96孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取96孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找96孔板模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_module = module
|
||||
break
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
|
||||
"""深孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=41.3,
|
||||
container_type="deep_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=2000.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=2000.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 35.0 # 深孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取深孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找深孔板模块(通常是第二个96孔板模块)
|
||||
plate_modules = []
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_modules.append(module)
|
||||
|
||||
# 如果有多个96孔板模块,选择第二个作为深孔板
|
||||
if len(plate_modules) > 1:
|
||||
plate_module = plate_modules[1]
|
||||
elif len(plate_modules) == 1:
|
||||
plate_module = plate_modules[0]
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWasteContainer(Container):
|
||||
"""废液容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWashContainer(Container):
|
||||
"""清洗容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuReagentContainer(Container):
|
||||
"""试剂容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0,
|
||||
max_volume=2000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYu8TubeRack(LaiYuLiquidContainer):
|
||||
"""8管试管架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=151.0,
|
||||
size_y=75.0,
|
||||
size_z=75.0,
|
||||
container_type="tube_rack",
|
||||
volume=0.0,
|
||||
max_volume=77000.0
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=8,
|
||||
well_volume=77000.0,
|
||||
well_spacing=35.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 117.0 # 试管深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取8管试管架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tube_module = None
|
||||
|
||||
# 查找8管试管架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tube_rack":
|
||||
tube_module = module
|
||||
break
|
||||
|
||||
if not tube_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 2
|
||||
cols = 4
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=29.0,
|
||||
size_y=29.0,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tube_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 29.0),
|
||||
size_y=well_config.get("diameter", 29.0),
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuTipDisposal(Resource):
|
||||
"""枪头废料位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuMaintenancePosition(Resource):
|
||||
"""维护位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0
|
||||
)
|
||||
|
||||
|
||||
# 资源创建函数
|
||||
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
|
||||
"""
|
||||
创建1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack1000: 1000μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack1000(name)
|
||||
|
||||
|
||||
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
|
||||
"""
|
||||
创建200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack200: 200μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack200(name)
|
||||
|
||||
|
||||
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
|
||||
"""
|
||||
创建96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYu96WellPlate: 96孔板实例
|
||||
"""
|
||||
return LaiYu96WellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
|
||||
"""
|
||||
创建深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYuDeepWellPlate: 深孔板实例
|
||||
"""
|
||||
return LaiYuDeepWellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
|
||||
"""
|
||||
创建8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
|
||||
Returns:
|
||||
LaiYu8TubeRack: 8管试管架实例
|
||||
"""
|
||||
return LaiYu8TubeRack(name)
|
||||
|
||||
|
||||
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
|
||||
"""
|
||||
创建废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWasteContainer: 废液容器实例
|
||||
"""
|
||||
return LaiYuWasteContainer(name)
|
||||
|
||||
|
||||
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
|
||||
"""
|
||||
创建清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWashContainer: 清洗容器实例
|
||||
"""
|
||||
return LaiYuWashContainer(name)
|
||||
|
||||
|
||||
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
|
||||
"""
|
||||
创建试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuReagentContainer: 试剂容器实例
|
||||
"""
|
||||
return LaiYuReagentContainer(name)
|
||||
|
||||
|
||||
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
|
||||
"""
|
||||
创建枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipDisposal: 枪头废料位置实例
|
||||
"""
|
||||
return LaiYuTipDisposal(name)
|
||||
|
||||
|
||||
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
|
||||
"""
|
||||
创建维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuMaintenancePosition: 维护位置实例
|
||||
"""
|
||||
return LaiYuMaintenancePosition(name)
|
||||
|
||||
|
||||
def create_standard_deck() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建标准工作台配置
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 从配置文件创建工作台
|
||||
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
|
||||
"""
|
||||
根据名称获取资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
name: 资源名称
|
||||
|
||||
Returns:
|
||||
Optional[Resource]: 找到的资源,如果不存在则返回None
|
||||
"""
|
||||
for child in deck.children:
|
||||
if child.name == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
|
||||
"""
|
||||
根据类型获取资源列表
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
resource_type: 资源类型
|
||||
|
||||
Returns:
|
||||
List[Resource]: 匹配类型的资源列表
|
||||
"""
|
||||
return [child for child in deck.children if isinstance(child, resource_type)]
|
||||
|
||||
|
||||
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
|
||||
"""
|
||||
列出所有资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: 按类型分组的资源名称字典
|
||||
"""
|
||||
resources = {
|
||||
"tip_racks": [],
|
||||
"plates": [],
|
||||
"containers": [],
|
||||
"positions": []
|
||||
}
|
||||
|
||||
for child in deck.children:
|
||||
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
|
||||
resources["tip_racks"].append(child.name)
|
||||
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
|
||||
resources["plates"].append(child.name)
|
||||
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
|
||||
resources["containers"].append(child.name)
|
||||
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
|
||||
resources["positions"].append(child.name)
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
# 导出的类别名(向后兼容)
|
||||
TipRack1000ul = LaiYuTipRack1000
|
||||
TipRack200ul = LaiYuTipRack200
|
||||
Plate96Well = LaiYu96WellPlate
|
||||
Plate96DeepWell = LaiYuDeepWellPlate
|
||||
TubeRack8 = LaiYu8TubeRack
|
||||
WasteContainer = LaiYuWasteContainer
|
||||
WashContainer = LaiYuWashContainer
|
||||
ReagentContainer = LaiYuReagentContainer
|
||||
TipDisposal = LaiYuTipDisposal
|
||||
MaintenancePosition = LaiYuMaintenancePosition
|
||||
69
unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
Normal file
69
unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 更新日志
|
||||
|
||||
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
|
||||
|
||||
## [1.0.0] - 2024-01-XX
|
||||
|
||||
### 新增功能
|
||||
- ✅ 完整的液体处理工作站集成
|
||||
- ✅ RS485 通信协议支持
|
||||
- ✅ SOPA 气动式移液器驱动
|
||||
- ✅ XYZ 三轴步进电机控制
|
||||
- ✅ PyLabRobot 兼容后端
|
||||
- ✅ 标准化资源管理系统
|
||||
- ✅ 96孔板、离心管架、枪头架支持
|
||||
- ✅ RViz 可视化后端
|
||||
- ✅ 完整的配置管理系统
|
||||
- ✅ 抽象协议实现
|
||||
- ✅ 生产级错误处理和日志记录
|
||||
|
||||
### 技术特性
|
||||
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
|
||||
- **通信协议**: RS485总线,波特率115200
|
||||
- **坐标系统**: 机械坐标与工作坐标自动转换
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **兼容性**: 完全兼容 PyLabRobot 框架
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── core/
|
||||
│ └── LaiYu_Liquid.py # 主模块文件
|
||||
├── __init__.py # 模块初始化
|
||||
├── abstract_protocol.py # 抽象协议
|
||||
├── laiyu_liquid_res.py # 资源管理
|
||||
├── rviz_backend.py # RViz后端
|
||||
├── backend/ # 后端驱动
|
||||
├── config/ # 配置文件
|
||||
├── controllers/ # 控制器
|
||||
├── docs/ # 技术文档
|
||||
└── drivers/ # 底层驱动
|
||||
```
|
||||
|
||||
### 已知问题
|
||||
- 无
|
||||
|
||||
### 依赖要求
|
||||
- Python 3.8+
|
||||
- PyLabRobot
|
||||
- pyserial
|
||||
- asyncio
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号格式
|
||||
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
|
||||
|
||||
- **MAJOR**: 不兼容的API变更
|
||||
- **MINOR**: 向后兼容的功能新增
|
||||
- **PATCH**: 向后兼容的问题修复
|
||||
|
||||
### 变更类型
|
||||
- **新增功能**: 新的功能特性
|
||||
- **变更**: 现有功能的变更
|
||||
- **弃用**: 即将移除的功能
|
||||
- **移除**: 已移除的功能
|
||||
- **修复**: 问题修复
|
||||
- **安全**: 安全相关的修复
|
||||
@@ -0,0 +1,267 @@
|
||||
# SOPA气动式移液器RS485控制指令合集
|
||||
|
||||
## 1. RS485通信基本配置
|
||||
|
||||
### 1.1 支持的设备型号
|
||||
- **仅SC-STxxx-00-13支持RS485通信**
|
||||
- 其他型号主要使用CAN通信
|
||||
|
||||
### 1.2 通信参数
|
||||
- **波特率**: 9600, 115200(默认值)
|
||||
- **地址范围**: 1~254个设备,255为广播地址
|
||||
- **通信接口**: RS485差分信号
|
||||
|
||||
### 1.3 引脚分配(10位LIF连接器)
|
||||
- **引脚7**: RS485+ (RS485通信正极)
|
||||
- **引脚8**: RS485- (RS485通信负极)
|
||||
|
||||
## 2. RS485通信协议格式
|
||||
|
||||
### 2.1 发送数据格式
|
||||
```
|
||||
头码 | 地址 | 命令/数据 | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.2 从机回应格式
|
||||
```
|
||||
头码 | 地址 | 数据(固定9字节) | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.3 格式详细说明
|
||||
- **头码**:
|
||||
- 终端调试: '/' (0x2F)
|
||||
- OEM通信: '[' (0x5B)
|
||||
- **地址**: 设备节点地址,1~254,多字节ASCII(注意:地址不可为47,69,91)
|
||||
- **命令/数据**: ASCII格式的命令字符串
|
||||
- **尾码**: 'E' (0x45)
|
||||
- **校验和**: 以上数据的累加值,1字节
|
||||
|
||||
## 3. 初始化和基本控制指令
|
||||
|
||||
### 3.1 初始化指令
|
||||
```bash
|
||||
# 初始化活塞驱动机构
|
||||
HE
|
||||
|
||||
# 示例(OEM通信):
|
||||
# 主机发送: 5B 32 48 45 1A
|
||||
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
|
||||
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
|
||||
```
|
||||
|
||||
### 3.2 枪头操作指令
|
||||
```bash
|
||||
# 顶出枪头
|
||||
RE
|
||||
|
||||
# 枪头检测状态报告
|
||||
Q28 # 返回枪头存在状态(0=不存在,1=存在)
|
||||
```
|
||||
|
||||
## 4. 移液控制指令
|
||||
|
||||
### 4.1 位置控制指令
|
||||
```bash
|
||||
# 绝对位置移动(微升)
|
||||
A[n]E
|
||||
# 示例:移动到位置0
|
||||
A0E
|
||||
|
||||
# 相对抽吸(向上移动)
|
||||
P[n]E
|
||||
# 示例:抽吸200微升
|
||||
P200E
|
||||
|
||||
# 相对分配(向下移动)
|
||||
D[n]E
|
||||
# 示例:分配200微升
|
||||
D200E
|
||||
```
|
||||
|
||||
### 4.2 速度设置指令
|
||||
```bash
|
||||
# 设置最高速度(0.1ul/秒为单位)
|
||||
s[n]E
|
||||
# 示例:设置最高速度为2000(200ul/秒)
|
||||
s2000E
|
||||
|
||||
# 设置启动速度
|
||||
b[n]E
|
||||
# 示例:设置启动速度为100(10ul/秒)
|
||||
b100E
|
||||
|
||||
# 设置断流速度
|
||||
c[n]E
|
||||
# 示例:设置断流速度为100(10ul/秒)
|
||||
c100E
|
||||
|
||||
# 设置加速度
|
||||
a[n]E
|
||||
# 示例:设置加速度为30000
|
||||
a30000E
|
||||
```
|
||||
|
||||
## 5. 液体检测和安全控制指令
|
||||
|
||||
### 5.1 吸排液检测控制
|
||||
```bash
|
||||
# 开启吸排液检测
|
||||
f1E # 开启
|
||||
f0E # 关闭
|
||||
|
||||
# 设置空吸门限
|
||||
$[n]E
|
||||
# 示例:设置空吸门限为4
|
||||
$4E
|
||||
|
||||
# 设置泡沫门限
|
||||
![n]E
|
||||
# 示例:设置泡沫门限为20
|
||||
!20E
|
||||
|
||||
# 设置堵塞门限
|
||||
%[n]E
|
||||
# 示例:设置堵塞门限为350
|
||||
%350E
|
||||
```
|
||||
|
||||
### 5.2 液位检测指令
|
||||
```bash
|
||||
# 压力式液位检测
|
||||
m0E # 设置为压力探测模式
|
||||
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
|
||||
k[n]E # 设置检测速度(100~2000)
|
||||
|
||||
# 电容式液位检测
|
||||
m1E # 设置为电容探测模式
|
||||
```
|
||||
|
||||
## 6. 状态查询和报告指令
|
||||
|
||||
### 6.1 基本状态查询
|
||||
```bash
|
||||
# 查询固件版本
|
||||
V
|
||||
|
||||
# 查询设备状态
|
||||
Q[n]
|
||||
# 常用查询参数:
|
||||
Q01 # 报告加速度
|
||||
Q02 # 报告启动速度
|
||||
Q03 # 报告断流速度
|
||||
Q06 # 报告最大速度
|
||||
Q08 # 报告节点地址
|
||||
Q11 # 报告波特率
|
||||
Q18 # 报告当前位置
|
||||
Q28 # 报告枪头存在状态
|
||||
Q29 # 报告校准系数
|
||||
Q30 # 报告空吸门限
|
||||
Q31 # 报告堵针门限
|
||||
Q32 # 报告泡沫门限
|
||||
```
|
||||
|
||||
## 7. 配置和校准指令
|
||||
|
||||
### 7.1 校准参数设置
|
||||
```bash
|
||||
# 设置校准系数
|
||||
j[n]E
|
||||
# 示例:设置校准系数为1.04
|
||||
j1.04E
|
||||
|
||||
# 设置补偿偏差
|
||||
e[n]E
|
||||
# 示例:设置补偿偏差为2.03
|
||||
e2.03E
|
||||
|
||||
# 设置吸头容量
|
||||
C[n]E
|
||||
# 示例:设置1000ul吸头
|
||||
C1000E
|
||||
```
|
||||
|
||||
### 7.2 高级控制参数
|
||||
```bash
|
||||
# 设置回吸粘度
|
||||
][n]E
|
||||
# 示例:设置回吸粘度为30
|
||||
]30E
|
||||
|
||||
# 延时控制
|
||||
M[n]E
|
||||
# 示例:延时1000毫秒
|
||||
M1000E
|
||||
```
|
||||
|
||||
## 8. 复合操作指令示例
|
||||
|
||||
### 8.1 标准移液操作
|
||||
```bash
|
||||
# 完整的200ul移液操作
|
||||
a30000b200c200s2000P200E
|
||||
# 解析:设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
|
||||
```
|
||||
|
||||
### 8.2 带检测的移液操作
|
||||
```bash
|
||||
# 带空吸检测的200ul抽吸
|
||||
a30000b200c200s2000f1P200f0E
|
||||
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
|
||||
```
|
||||
|
||||
### 8.3 液面检测操作
|
||||
```bash
|
||||
# 压力式液面检测
|
||||
m0k200L5E
|
||||
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
|
||||
|
||||
# 电容式液面检测
|
||||
m1L3E
|
||||
# 解析:电容模式 + 灵敏度3 + 执行检测
|
||||
```
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
### 9.1 状态字节说明
|
||||
- **00h**: 无错误
|
||||
- **01h**: 上次动作未完成
|
||||
- **02h**: 设备未初始化
|
||||
- **03h**: 设备过载
|
||||
- **04h**: 无效指令
|
||||
- **05h**: 液位探测故障
|
||||
- **0Dh**: 空吸
|
||||
- **0Eh**: 堵针
|
||||
- **10h**: 泡沫
|
||||
- **11h**: 吸液超过吸头容量
|
||||
|
||||
### 9.2 错误查询
|
||||
```bash
|
||||
# 查询当前错误状态
|
||||
Q # 返回状态字节和错误代码
|
||||
```
|
||||
|
||||
## 10. 通信示例
|
||||
|
||||
### 10.1 基本通信流程
|
||||
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
|
||||
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
|
||||
|
||||
### 10.2 快速指令表
|
||||
| 操作 | 指令 | 说明 |
|
||||
|------|------|------|
|
||||
| 初始化 | `HE` | 初始化设备 |
|
||||
| 退枪头 | `RE` | 顶出枪头 |
|
||||
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
|
||||
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
|
||||
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
|
||||
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
|
||||
| 电容液面检测 | `m1L3E` | cLLD检测 |
|
||||
|
||||
## 11. 注意事项
|
||||
|
||||
1. **地址限制**: RS485地址不可设为47、69、91
|
||||
2. **校验和**: 终端调试时不关心校验和,OEM通信需要校验
|
||||
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
|
||||
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
|
||||
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
|
||||
6. **波特率设置**: 默认115200,可设置为9600
|
||||
162
unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
Normal file
162
unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 步进电机B系列控制指令详解
|
||||
|
||||
## 基本通信参数
|
||||
- **通信方式**: RS485
|
||||
- **协议**: Modbus
|
||||
- **波特率**: 115200 (默认)
|
||||
- **数据位**: 8位
|
||||
- **停止位**: 1位
|
||||
- **校验位**: 无
|
||||
- **默认站号**: 1 (可设置1-254)
|
||||
|
||||
## 支持的功能码
|
||||
- **03H**: 读取寄存器
|
||||
- **06H**: 写入单个寄存器
|
||||
- **10H**: 写入多个寄存器
|
||||
|
||||
## 寄存器地址表
|
||||
|
||||
### 状态监控寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
|
||||
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
|
||||
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
|
||||
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
|
||||
| 05H | 03H | 电流 | 当前工作电流 (mA) |
|
||||
|
||||
### 控制寄存器 (读写)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
|
||||
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
|
||||
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
|
||||
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
|
||||
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
|
||||
|
||||
### 位置模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
|
||||
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
|
||||
| 12H | 03H/06H/10H | 保留 | - |
|
||||
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
|
||||
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
|
||||
|
||||
### 速度模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 60H | 03H/06H/10H | 保留 | - |
|
||||
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
|
||||
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
|
||||
### 设备参数寄存器
|
||||
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|
||||
|------|--------|------|--------|------|
|
||||
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
|
||||
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
|
||||
| E2H | 03H/06H/10H | 保留 | 0258H | - |
|
||||
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
|
||||
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
|
||||
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
|
||||
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
|
||||
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
|
||||
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
|
||||
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
|
||||
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
|
||||
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
|
||||
|
||||
### 版本信息寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| F0H | 03H | 版本号 | 固件版本信息 |
|
||||
| F1H-F4H | 03H | 型号 | 产品型号信息 |
|
||||
|
||||
## 常用控制指令示例
|
||||
|
||||
### 读取电机状态
|
||||
```
|
||||
发送: 01 03 00 00 00 01 84 0A
|
||||
接收: 01 03 02 00 01 79 84
|
||||
说明: 电机状态为0001H (正在运行)
|
||||
```
|
||||
|
||||
### 读取当前位置
|
||||
```
|
||||
发送: 01 03 00 01 00 02 95 CB
|
||||
接收: 01 03 04 00 19 00 00 2B F4
|
||||
说明: 当前位置为1638400步 (100圈)
|
||||
```
|
||||
|
||||
### 停止电机
|
||||
```
|
||||
发送: 01 10 00 04 00 01 02 00 00 A7 D4
|
||||
接收: 01 10 00 04 00 01 40 08
|
||||
说明: 急停指令
|
||||
```
|
||||
|
||||
### 位置模式运动
|
||||
```
|
||||
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
|
||||
接收: 01 10 00 10 00 06 41 CE
|
||||
说明: 以5000rpm速度运动到1638400步位置
|
||||
```
|
||||
|
||||
### 速度模式 - 正转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度正转
|
||||
```
|
||||
|
||||
### 速度模式 - 反转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度反转 (EC78H = -5000)
|
||||
```
|
||||
|
||||
### 设置设备地址
|
||||
```
|
||||
发送: 00 06 00 E0 00 02 C9 F1
|
||||
接收: 00 06 00 E0 00 02 C9 F1
|
||||
说明: 将设备地址设置为2
|
||||
```
|
||||
|
||||
## 错误码
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0001H | 功能码错误 |
|
||||
| 0002H | 地址错误 |
|
||||
| 0003H | 长度错误 |
|
||||
|
||||
## CRC校验算法
|
||||
```c
|
||||
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
|
||||
int wCrc = 0x0000FFFF;
|
||||
byte[] CRC = new byte[2];
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
wCrc ^= ((data[i + offset]) & 0xFF);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((wCrc & 0x00000001) == 1) {
|
||||
wCrc >>= 1;
|
||||
wCrc ^= 0x0000A001;
|
||||
} else {
|
||||
wCrc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
|
||||
CRC[0] = (byte) (wCrc & 0x000000FF);
|
||||
return CRC;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
1. 所有16位数据采用大端序传输
|
||||
2. 步数计算: 实际步数 = 高位<<16 | 低位
|
||||
3. 负数使用补码表示
|
||||
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
|
||||
5. 光电开关需使用NPN开漏型
|
||||
6. 限位开关: LF正向, LB反向
|
||||
1281
unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
Normal file
1281
unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
Normal file
File diff suppressed because it is too large
Load Diff
269
unilabos/devices/laiyu_liquid/docs/readme.md
Normal file
269
unilabos/devices/laiyu_liquid/docs/readme.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# LaiYu_Liquid 液体处理工作站
|
||||
|
||||
## 概述
|
||||
|
||||
LaiYu_Liquid 是一个完全集成到 UniLabOS 的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 硬件组件
|
||||
- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
|
||||
- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
|
||||
- **通信接口**: RS485转USB模块,默认波特率115200
|
||||
- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
|
||||
|
||||
### 软件架构
|
||||
- **驱动层**: 底层硬件通信驱动,支持RS485协议
|
||||
- **控制层**: 高级控制逻辑和坐标系管理
|
||||
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
|
||||
- **资源层**: 标准化的实验器具和耗材管理
|
||||
|
||||
## 🎯 生产就绪组件
|
||||
|
||||
### ✅ 核心驱动程序 (`drivers/`)
|
||||
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
|
||||
- 支持液体吸取、分配、检测
|
||||
- 完整的错误处理和状态管理
|
||||
- 生产级别的通信协议实现
|
||||
|
||||
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
|
||||
- 精确的位置控制和运动规划
|
||||
- 安全限位和错误检测
|
||||
- 高性能运动控制算法
|
||||
|
||||
### ✅ 高级控制器 (`controllers/`)
|
||||
- **`pipette_controller.py`** - 移液控制器
|
||||
- 封装高级液体处理功能
|
||||
- 支持多种液体类型和处理参数
|
||||
- 智能错误恢复机制
|
||||
|
||||
- **`xyz_controller.py`** - XYZ运动控制器
|
||||
- 坐标系管理和转换
|
||||
- 运动路径优化
|
||||
- 安全运动控制
|
||||
|
||||
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
|
||||
- **完整的液体处理抽象接口**
|
||||
- **标准化的资源管理系统**
|
||||
- **与PyLabRobot兼容的后端实现**
|
||||
- **生产级别的错误处理和日志记录**
|
||||
|
||||
### ✅ 资源管理系统
|
||||
- **`laiyu_liquid_res.py`** - 标准化资源定义
|
||||
- 96孔板、离心管架、枪头架等标准器具
|
||||
- 自动化的资源创建和配置函数
|
||||
- 与工作台布局的完美集成
|
||||
|
||||
### ✅ 配置管理 (`config/`)
|
||||
- **`config/deck.json`** - 工作台布局配置
|
||||
- 精确的空间定义和槽位管理
|
||||
- 支持多种实验器具的标准化放置
|
||||
- 可扩展的配置架构
|
||||
|
||||
- **`__init__.py`** - 模块集成和导出
|
||||
- 完整的API导出和版本管理
|
||||
- 依赖检查和安装验证
|
||||
- 专业的模块信息展示
|
||||
|
||||
<!-- ### ✅ 可视化支持
|
||||
- **`rviz_backend.py`** - RViz可视化后端
|
||||
- 实时运动状态可视化
|
||||
- 液体处理过程监控
|
||||
- 与ROS系统的无缝集成 -->
|
||||
|
||||
## 🚀 核心功能特性
|
||||
|
||||
### 液体处理能力
|
||||
- **精密体积控制**: 支持1-1000μL精确分配
|
||||
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
|
||||
- **智能检测**: 液位检测、气泡检测、堵塞检测
|
||||
- **自动化流程**: 完整的吸取-转移-分配工作流
|
||||
|
||||
### 运动控制系统
|
||||
- **三轴精密定位**: 微米级精度控制
|
||||
- **路径优化**: 智能运动规划和碰撞避免
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **坐标系管理**: 工作坐标与机械坐标的自动转换
|
||||
|
||||
### 资源管理
|
||||
- **标准化器具**: 支持96孔板、离心管架、枪头架等
|
||||
- **状态跟踪**: 实时监控液体体积、枪头状态等
|
||||
- **自动配置**: 基于JSON的灵活配置系统
|
||||
- **扩展性**: 易于添加新的器具类型
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── __init__.py # 模块初始化和API导出
|
||||
├── readme.md # 本文档
|
||||
├── backend/ # 后端驱动模块
|
||||
│ ├── __init__.py
|
||||
│ └── laiyu_backend.py # PyLabRobot兼容后端
|
||||
├── core/ # 核心模块
|
||||
│ ├── core/
|
||||
│ │ └── LaiYu_Liquid.py # 主设备类
|
||||
│ ├── abstract_protocol.py # 抽象协议
|
||||
│ └── laiyu_liquid_res.py # 设备资源定义
|
||||
├── config/ # 配置文件目录
|
||||
│ └── deck.json # 工作台布局配置
|
||||
├── controllers/ # 高级控制器
|
||||
│ ├── __init__.py
|
||||
│ ├── pipette_controller.py # 移液控制器
|
||||
│ └── xyz_controller.py # XYZ运动控制器
|
||||
├── docs/ # 技术文档
|
||||
│ ├── SOPA气动式移液器RS485控制指令.md
|
||||
│ ├── 步进电机控制指令.md
|
||||
│ └── hardware/ # 硬件相关文档
|
||||
├── drivers/ # 底层驱动程序
|
||||
│ ├── __init__.py
|
||||
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
|
||||
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
|
||||
└── tests/ # 测试文件
|
||||
```
|
||||
|
||||
## 🔧 快速开始
|
||||
|
||||
### 1. 安装和验证
|
||||
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
create_quick_setup,
|
||||
print_module_info
|
||||
)
|
||||
|
||||
# 查看模块信息
|
||||
print_module_info()
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"已创建 {len(resources)} 个资源")
|
||||
```
|
||||
|
||||
### 2. 基本使用示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_quick_setup,
|
||||
create_96_well_plate,
|
||||
create_laiyu_backend
|
||||
)
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"创建了以下资源: {list(resources.keys())}")
|
||||
|
||||
# 创建96孔板
|
||||
plate_96 = create_96_well_plate("test_plate")
|
||||
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
|
||||
|
||||
# 创建后端实例(用于PyLabRobot集成)
|
||||
backend = create_laiyu_backend("LaiYu_Device")
|
||||
print(f"后端设备: {backend.name}")
|
||||
```
|
||||
|
||||
### 3. 后端驱动使用
|
||||
|
||||
```python
|
||||
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
|
||||
|
||||
# 创建后端实例
|
||||
backend = create_laiyu_backend("LaiYu_Liquid_Station")
|
||||
|
||||
# 连接设备
|
||||
await backend.connect()
|
||||
|
||||
# 设备归位
|
||||
await backend.home_device()
|
||||
|
||||
# 获取设备状态
|
||||
status = await backend.get_status()
|
||||
print(f"设备状态: {status}")
|
||||
|
||||
# 断开连接
|
||||
await backend.disconnect()
|
||||
```
|
||||
|
||||
### 4. 资源管理示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_centrifuge_tube_rack,
|
||||
create_tip_rack,
|
||||
load_deck_config
|
||||
)
|
||||
|
||||
# 加载工作台配置
|
||||
deck_config = load_deck_config()
|
||||
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
|
||||
|
||||
# 创建不同类型的资源
|
||||
tube_rack = create_centrifuge_tube_rack("sample_rack")
|
||||
tip_rack = create_tip_rack("tip_rack_200ul")
|
||||
|
||||
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
|
||||
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
|
||||
```
|
||||
|
||||
## 🔍 技术架构
|
||||
|
||||
### 坐标系统
|
||||
- **机械坐标**: 基于步进电机的原始坐标系统
|
||||
- **工作坐标**: 用户友好的实验室坐标系统
|
||||
- **自动转换**: 透明的坐标系转换和校准
|
||||
|
||||
### 通信协议
|
||||
- **RS485总线**: 高可靠性工业通信标准
|
||||
- **Modbus协议**: 标准化的设备通信协议
|
||||
- **错误检测**: 完整的通信错误检测和恢复
|
||||
|
||||
### 安全机制
|
||||
- **限位保护**: 硬件和软件双重限位保护
|
||||
- **紧急停止**: 即时停止所有运动和操作
|
||||
- **状态监控**: 实时设备状态监控和报警
|
||||
|
||||
## 🧪 验证和测试
|
||||
|
||||
### 功能验证
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import validate_installation
|
||||
validate_installation()
|
||||
|
||||
# 查看模块信息
|
||||
from unilabos.devices.laiyu_liquid import print_module_info
|
||||
print_module_info()
|
||||
```
|
||||
|
||||
### 硬件连接测试
|
||||
```python
|
||||
# 测试SOPA移液器连接
|
||||
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
|
||||
|
||||
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
|
||||
pipette = SOPAPipette(config)
|
||||
success = pipette.connect()
|
||||
print(f"SOPA连接状态: {'成功' if success else '失败'}")
|
||||
```
|
||||
|
||||
## 📚 维护和支持
|
||||
|
||||
### 日志记录
|
||||
- **结构化日志**: 使用Python logging模块的专业日志记录
|
||||
- **错误追踪**: 详细的错误信息和堆栈跟踪
|
||||
- **性能监控**: 操作时间和性能指标记录
|
||||
|
||||
### 配置管理
|
||||
- **JSON配置**: 灵活的JSON格式配置文件
|
||||
- **参数验证**: 自动配置参数验证和错误提示
|
||||
- **热重载**: 支持配置文件的动态重载
|
||||
|
||||
### 扩展性
|
||||
- **模块化设计**: 易于扩展和定制的模块化架构
|
||||
- **插件接口**: 支持第三方插件和扩展
|
||||
- **API兼容**: 向后兼容的API设计
|
||||
|
||||
|
||||
30
unilabos/devices/laiyu_liquid/drivers/__init__.py
Normal file
30
unilabos/devices/laiyu_liquid/drivers/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
LaiYu_Liquid 驱动程序模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序:
|
||||
- SOPA移液器驱动程序
|
||||
- XYZ步进电机驱动程序
|
||||
"""
|
||||
|
||||
# SOPA移液器驱动程序导入
|
||||
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
|
||||
|
||||
# XYZ步进电机驱动程序导入
|
||||
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
|
||||
|
||||
__all__ = [
|
||||
# SOPA移液器
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"SOPAStatusCode",
|
||||
|
||||
# XYZ步进电机
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
"MotorAxis",
|
||||
"MotorStatus",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Driver Team"
|
||||
__description__ = "LaiYu_Liquid 硬件驱动程序集合"
|
||||
1079
unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Normal file
1079
unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Normal file
File diff suppressed because it is too large
Load Diff
663
unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py
Normal file
663
unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py
Normal file
@@ -0,0 +1,663 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
XYZ三轴步进电机B系列驱动程序
|
||||
支持RS485通信,Modbus协议
|
||||
"""
|
||||
|
||||
import serial
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MotorAxis(Enum):
|
||||
"""电机轴枚举"""
|
||||
X = 1
|
||||
Y = 2
|
||||
Z = 3
|
||||
|
||||
|
||||
class MotorStatus(Enum):
|
||||
"""电机状态枚举"""
|
||||
STANDBY = 0x0000 # 待机/到位
|
||||
RUNNING = 0x0001 # 运行中
|
||||
COLLISION_STOP = 0x0002 # 碰撞停
|
||||
FORWARD_LIMIT_STOP = 0x0003 # 正光电停
|
||||
REVERSE_LIMIT_STOP = 0x0004 # 反光电停
|
||||
|
||||
|
||||
class ModbusFunction(Enum):
|
||||
"""Modbus功能码"""
|
||||
READ_HOLDING_REGISTERS = 0x03
|
||||
WRITE_SINGLE_REGISTER = 0x06
|
||||
WRITE_MULTIPLE_REGISTERS = 0x10
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotorPosition:
|
||||
"""电机位置信息"""
|
||||
steps: int
|
||||
speed: int
|
||||
current: int
|
||||
status: MotorStatus
|
||||
|
||||
|
||||
class ModbusException(Exception):
|
||||
"""Modbus通信异常"""
|
||||
pass
|
||||
|
||||
|
||||
class StepperMotorDriver:
|
||||
"""步进电机驱动器基类"""
|
||||
|
||||
# 寄存器地址常量
|
||||
REG_STATUS = 0x00
|
||||
REG_POSITION_HIGH = 0x01
|
||||
REG_POSITION_LOW = 0x02
|
||||
REG_ACTUAL_SPEED = 0x03
|
||||
REG_EMERGENCY_STOP = 0x04
|
||||
REG_CURRENT = 0x05
|
||||
REG_ENABLE = 0x06
|
||||
REG_PWM_OUTPUT = 0x07
|
||||
REG_ZERO_SINGLE = 0x0E
|
||||
REG_ZERO_COMMAND = 0x0F
|
||||
|
||||
# 位置模式寄存器
|
||||
REG_TARGET_POSITION_HIGH = 0x10
|
||||
REG_TARGET_POSITION_LOW = 0x11
|
||||
REG_POSITION_SPEED = 0x13
|
||||
REG_POSITION_ACCELERATION = 0x14
|
||||
REG_POSITION_PRECISION = 0x15
|
||||
|
||||
# 速度模式寄存器
|
||||
REG_SPEED_MODE_SPEED = 0x61
|
||||
REG_SPEED_MODE_ACCELERATION = 0x62
|
||||
|
||||
# 设备参数寄存器
|
||||
REG_DEVICE_ADDRESS = 0xE0
|
||||
REG_DEFAULT_SPEED = 0xE7
|
||||
REG_DEFAULT_ACCELERATION = 0xE8
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||
"""
|
||||
初始化步进电机驱动器
|
||||
|
||||
Args:
|
||||
port: 串口端口名
|
||||
baudrate: 波特率
|
||||
timeout: 通信超时时间
|
||||
"""
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.serial_conn: Optional[serial.Serial] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
建立串口连接
|
||||
|
||||
Returns:
|
||||
连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.serial_conn = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=self.timeout
|
||||
)
|
||||
logger.info(f"已连接到串口: {self.port}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"串口连接失败: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""关闭串口连接"""
|
||||
if self.serial_conn and self.serial_conn.is_open:
|
||||
self.serial_conn.close()
|
||||
logger.info("串口连接已关闭")
|
||||
|
||||
def __enter__(self):
|
||||
"""上下文管理器入口"""
|
||||
if self.connect():
|
||||
return self
|
||||
raise ModbusException("无法建立串口连接")
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""上下文管理器出口"""
|
||||
self.disconnect()
|
||||
|
||||
@staticmethod
|
||||
def calculate_crc(data: bytes) -> bytes:
|
||||
"""
|
||||
计算Modbus CRC校验码
|
||||
|
||||
Args:
|
||||
data: 待校验的数据
|
||||
|
||||
Returns:
|
||||
CRC校验码 (2字节)
|
||||
"""
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x0001:
|
||||
crc >>= 1
|
||||
crc ^= 0xA001
|
||||
else:
|
||||
crc >>= 1
|
||||
return struct.pack('<H', crc)
|
||||
|
||||
def _send_command(self, slave_addr: int, data: bytes) -> bytes:
|
||||
"""
|
||||
发送Modbus命令并接收响应
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
data: 命令数据
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
|
||||
Raises:
|
||||
ModbusException: 通信异常
|
||||
"""
|
||||
if not self.serial_conn or not self.serial_conn.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
# 构建完整命令
|
||||
command = bytes([slave_addr]) + data
|
||||
crc = self.calculate_crc(command)
|
||||
full_command = command + crc
|
||||
|
||||
# 清空接收缓冲区
|
||||
self.serial_conn.reset_input_buffer()
|
||||
|
||||
# 发送命令
|
||||
self.serial_conn.write(full_command)
|
||||
logger.debug(f"发送命令: {' '.join(f'{b:02X}' for b in full_command)}")
|
||||
|
||||
# 等待响应
|
||||
time.sleep(0.01) # 短暂延时
|
||||
|
||||
# 读取响应
|
||||
response = self.serial_conn.read(256) # 最大读取256字节
|
||||
if not response:
|
||||
raise ModbusException("未收到响应")
|
||||
|
||||
logger.debug(f"接收响应: {' '.join(f'{b:02X}' for b in response)}")
|
||||
|
||||
# 验证CRC
|
||||
if len(response) < 3:
|
||||
raise ModbusException("响应数据长度不足")
|
||||
|
||||
data_part = response[:-2]
|
||||
received_crc = response[-2:]
|
||||
calculated_crc = self.calculate_crc(data_part)
|
||||
|
||||
if received_crc != calculated_crc:
|
||||
raise ModbusException("CRC校验失败")
|
||||
|
||||
return response
|
||||
|
||||
def read_registers(self, slave_addr: int, start_addr: int, count: int) -> list:
|
||||
"""
|
||||
读取保持寄存器
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
start_addr: 起始地址
|
||||
count: 寄存器数量
|
||||
|
||||
Returns:
|
||||
寄存器值列表
|
||||
"""
|
||||
data = struct.pack('>BHH', ModbusFunction.READ_HOLDING_REGISTERS.value, start_addr, count)
|
||||
response = self._send_command(slave_addr, data)
|
||||
|
||||
if len(response) < 5:
|
||||
raise ModbusException("响应长度不足")
|
||||
|
||||
if response[1] != ModbusFunction.READ_HOLDING_REGISTERS.value:
|
||||
raise ModbusException(f"功能码错误: {response[1]:02X}")
|
||||
|
||||
byte_count = response[2]
|
||||
values = []
|
||||
for i in range(0, byte_count, 2):
|
||||
value = struct.unpack('>H', response[3+i:5+i])[0]
|
||||
values.append(value)
|
||||
|
||||
return values
|
||||
|
||||
def write_single_register(self, slave_addr: int, addr: int, value: int) -> bool:
|
||||
"""
|
||||
写入单个寄存器
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
addr: 寄存器地址
|
||||
value: 寄存器值
|
||||
|
||||
Returns:
|
||||
写入是否成功
|
||||
"""
|
||||
data = struct.pack('>BHH', ModbusFunction.WRITE_SINGLE_REGISTER.value, addr, value)
|
||||
response = self._send_command(slave_addr, data)
|
||||
|
||||
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
|
||||
|
||||
def write_multiple_registers(self, slave_addr: int, start_addr: int, values: list) -> bool:
|
||||
"""
|
||||
写入多个寄存器
|
||||
|
||||
Args:
|
||||
slave_addr: 从站地址
|
||||
start_addr: 起始地址
|
||||
values: 寄存器值列表
|
||||
|
||||
Returns:
|
||||
写入是否成功
|
||||
"""
|
||||
byte_count = len(values) * 2
|
||||
data = struct.pack('>BHHB', ModbusFunction.WRITE_MULTIPLE_REGISTERS.value,
|
||||
start_addr, len(values), byte_count)
|
||||
|
||||
for value in values:
|
||||
data += struct.pack('>H', value)
|
||||
|
||||
response = self._send_command(slave_addr, data)
|
||||
|
||||
return len(response) >= 8 and response[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
|
||||
|
||||
|
||||
class XYZStepperController(StepperMotorDriver):
|
||||
"""XYZ三轴步进电机控制器"""
|
||||
|
||||
# 电机配置常量
|
||||
STEPS_PER_REVOLUTION = 16384 # 每圈步数
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
||||
"""
|
||||
初始化XYZ三轴步进电机控制器
|
||||
|
||||
Args:
|
||||
port: 串口端口名
|
||||
baudrate: 波特率
|
||||
timeout: 通信超时时间
|
||||
"""
|
||||
super().__init__(port, baudrate, timeout)
|
||||
self.axis_addresses = {
|
||||
MotorAxis.X: 1,
|
||||
MotorAxis.Y: 2,
|
||||
MotorAxis.Z: 3
|
||||
}
|
||||
|
||||
def degrees_to_steps(self, degrees: float) -> int:
|
||||
"""
|
||||
将角度转换为步数
|
||||
|
||||
Args:
|
||||
degrees: 角度值
|
||||
|
||||
Returns:
|
||||
对应的步数
|
||||
"""
|
||||
return int(degrees * self.STEPS_PER_REVOLUTION / 360.0)
|
||||
|
||||
def steps_to_degrees(self, steps: int) -> float:
|
||||
"""
|
||||
将步数转换为角度
|
||||
|
||||
Args:
|
||||
steps: 步数
|
||||
|
||||
Returns:
|
||||
对应的角度值
|
||||
"""
|
||||
return steps * 360.0 / self.STEPS_PER_REVOLUTION
|
||||
|
||||
def revolutions_to_steps(self, revolutions: float) -> int:
|
||||
"""
|
||||
将圈数转换为步数
|
||||
|
||||
Args:
|
||||
revolutions: 圈数
|
||||
|
||||
Returns:
|
||||
对应的步数
|
||||
"""
|
||||
return int(revolutions * self.STEPS_PER_REVOLUTION)
|
||||
|
||||
def steps_to_revolutions(self, steps: int) -> float:
|
||||
"""
|
||||
将步数转换为圈数
|
||||
|
||||
Args:
|
||||
steps: 步数
|
||||
|
||||
Returns:
|
||||
对应的圈数
|
||||
"""
|
||||
return steps / self.STEPS_PER_REVOLUTION
|
||||
|
||||
def get_motor_status(self, axis: MotorAxis) -> MotorPosition:
|
||||
"""
|
||||
获取电机状态信息
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
|
||||
Returns:
|
||||
电机位置信息
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
|
||||
# 读取状态、位置、速度、电流
|
||||
values = self.read_registers(addr, self.REG_STATUS, 6)
|
||||
|
||||
status = MotorStatus(values[0])
|
||||
position_high = values[1]
|
||||
position_low = values[2]
|
||||
speed = values[3]
|
||||
current = values[5]
|
||||
|
||||
# 合并32位位置
|
||||
position = (position_high << 16) | position_low
|
||||
# 处理有符号数
|
||||
if position > 0x7FFFFFFF:
|
||||
position -= 0x100000000
|
||||
|
||||
return MotorPosition(position, speed, current, status)
|
||||
|
||||
def emergency_stop(self, axis: MotorAxis) -> bool:
|
||||
"""
|
||||
紧急停止电机
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
return self.write_single_register(addr, self.REG_EMERGENCY_STOP, 0x0000)
|
||||
|
||||
def enable_motor(self, axis: MotorAxis, enable: bool = True) -> bool:
|
||||
"""
|
||||
使能/失能电机
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
enable: True为使能,False为失能
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
value = 0x0001 if enable else 0x0000
|
||||
return self.write_single_register(addr, self.REG_ENABLE, value)
|
||||
|
||||
def move_to_position(self, axis: MotorAxis, position: int, speed: int = 5000,
|
||||
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
position: 目标位置(步数)
|
||||
speed: 运行速度(rpm)
|
||||
acceleration: 加速度(rpm/s)
|
||||
precision: 到位精度
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
|
||||
# 处理32位位置
|
||||
if position < 0:
|
||||
position += 0x100000000
|
||||
|
||||
position_high = (position >> 16) & 0xFFFF
|
||||
position_low = position & 0xFFFF
|
||||
|
||||
values = [
|
||||
position_high, # 目标位置高位
|
||||
position_low, # 目标位置低位
|
||||
0x0000, # 保留
|
||||
speed, # 速度
|
||||
acceleration, # 加速度
|
||||
precision # 精度
|
||||
]
|
||||
|
||||
return self.write_multiple_registers(addr, self.REG_TARGET_POSITION_HIGH, values)
|
||||
|
||||
def set_speed_mode(self, axis: MotorAxis, speed: int, acceleration: int = 1000) -> bool:
|
||||
"""
|
||||
设置速度模式运行
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
speed: 运行速度(rpm),正值正转,负值反转
|
||||
acceleration: 加速度(rpm/s)
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
|
||||
# 处理负数
|
||||
if speed < 0:
|
||||
speed = 0x10000 + speed # 补码表示
|
||||
|
||||
values = [0x0000, speed, acceleration, 0x0000]
|
||||
|
||||
return self.write_multiple_registers(addr, 0x60, values)
|
||||
|
||||
def home_axis(self, axis: MotorAxis) -> bool:
|
||||
"""
|
||||
轴归零操作
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
|
||||
Returns:
|
||||
操作是否成功
|
||||
"""
|
||||
addr = self.axis_addresses[axis]
|
||||
return self.write_single_register(addr, self.REG_ZERO_SINGLE, 0x0001)
|
||||
|
||||
def wait_for_completion(self, axis: MotorAxis, timeout: float = 30.0) -> bool:
|
||||
"""
|
||||
等待电机运动完成
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
是否在超时前完成
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
status = self.get_motor_status(axis)
|
||||
if status.status == MotorStatus.STANDBY:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.warning(f"{axis.name}轴运动超时")
|
||||
return False
|
||||
|
||||
def move_xyz(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None,
|
||||
speed: int = 5000, acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
同时控制XYZ轴移动
|
||||
|
||||
Args:
|
||||
x: X轴目标位置
|
||||
y: Y轴目标位置
|
||||
z: Z轴目标位置
|
||||
speed: 运行速度
|
||||
acceleration: 加速度
|
||||
|
||||
Returns:
|
||||
各轴操作结果字典
|
||||
"""
|
||||
results = {}
|
||||
|
||||
if x is not None:
|
||||
results[MotorAxis.X] = self.move_to_position(MotorAxis.X, x, speed, acceleration)
|
||||
|
||||
if y is not None:
|
||||
results[MotorAxis.Y] = self.move_to_position(MotorAxis.Y, y, speed, acceleration)
|
||||
|
||||
if z is not None:
|
||||
results[MotorAxis.Z] = self.move_to_position(MotorAxis.Z, z, speed, acceleration)
|
||||
|
||||
return results
|
||||
|
||||
def move_xyz_degrees(self, x_deg: Optional[float] = None, y_deg: Optional[float] = None,
|
||||
z_deg: Optional[float] = None, speed: int = 5000,
|
||||
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
使用角度值同时移动多个轴到指定位置
|
||||
|
||||
Args:
|
||||
x_deg: X轴目标角度(度)
|
||||
y_deg: Y轴目标角度(度)
|
||||
z_deg: Z轴目标角度(度)
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
|
||||
Returns:
|
||||
各轴移动操作结果
|
||||
"""
|
||||
# 将角度转换为步数
|
||||
x_steps = self.degrees_to_steps(x_deg) if x_deg is not None else None
|
||||
y_steps = self.degrees_to_steps(y_deg) if y_deg is not None else None
|
||||
z_steps = self.degrees_to_steps(z_deg) if z_deg is not None else None
|
||||
|
||||
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
|
||||
|
||||
def move_xyz_revolutions(self, x_rev: Optional[float] = None, y_rev: Optional[float] = None,
|
||||
z_rev: Optional[float] = None, speed: int = 5000,
|
||||
acceleration: int = 1000) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
使用圈数值同时移动多个轴到指定位置
|
||||
|
||||
Args:
|
||||
x_rev: X轴目标圈数
|
||||
y_rev: Y轴目标圈数
|
||||
z_rev: Z轴目标圈数
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
|
||||
Returns:
|
||||
各轴移动操作结果
|
||||
"""
|
||||
# 将圈数转换为步数
|
||||
x_steps = self.revolutions_to_steps(x_rev) if x_rev is not None else None
|
||||
y_steps = self.revolutions_to_steps(y_rev) if y_rev is not None else None
|
||||
z_steps = self.revolutions_to_steps(z_rev) if z_rev is not None else None
|
||||
|
||||
return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration)
|
||||
|
||||
def move_to_position_degrees(self, axis: MotorAxis, degrees: float, speed: int = 5000,
|
||||
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||
"""
|
||||
使用角度值移动单个轴到指定位置
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
degrees: 目标角度(度)
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
precision: 精度
|
||||
|
||||
Returns:
|
||||
移动操作是否成功
|
||||
"""
|
||||
steps = self.degrees_to_steps(degrees)
|
||||
return self.move_to_position(axis, steps, speed, acceleration, precision)
|
||||
|
||||
def move_to_position_revolutions(self, axis: MotorAxis, revolutions: float, speed: int = 5000,
|
||||
acceleration: int = 1000, precision: int = 100) -> bool:
|
||||
"""
|
||||
使用圈数值移动单个轴到指定位置
|
||||
|
||||
Args:
|
||||
axis: 电机轴
|
||||
revolutions: 目标圈数
|
||||
speed: 移动速度
|
||||
acceleration: 加速度
|
||||
precision: 精度
|
||||
|
||||
Returns:
|
||||
移动操作是否成功
|
||||
"""
|
||||
steps = self.revolutions_to_steps(revolutions)
|
||||
return self.move_to_position(axis, steps, speed, acceleration, precision)
|
||||
|
||||
def stop_all_axes(self) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
紧急停止所有轴
|
||||
|
||||
Returns:
|
||||
各轴停止结果字典
|
||||
"""
|
||||
results = {}
|
||||
for axis in MotorAxis:
|
||||
results[axis] = self.emergency_stop(axis)
|
||||
return results
|
||||
|
||||
def enable_all_axes(self, enable: bool = True) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
使能/失能所有轴
|
||||
|
||||
Args:
|
||||
enable: True为使能,False为失能
|
||||
|
||||
Returns:
|
||||
各轴操作结果字典
|
||||
"""
|
||||
results = {}
|
||||
for axis in MotorAxis:
|
||||
results[axis] = self.enable_motor(axis, enable)
|
||||
return results
|
||||
|
||||
def get_all_positions(self) -> Dict[MotorAxis, MotorPosition]:
|
||||
"""
|
||||
获取所有轴的位置信息
|
||||
|
||||
Returns:
|
||||
各轴位置信息字典
|
||||
"""
|
||||
positions = {}
|
||||
for axis in MotorAxis:
|
||||
positions[axis] = self.get_motor_status(axis)
|
||||
return positions
|
||||
|
||||
def home_all_axes(self) -> Dict[MotorAxis, bool]:
|
||||
"""
|
||||
所有轴归零
|
||||
|
||||
Returns:
|
||||
各轴归零结果字典
|
||||
"""
|
||||
results = {}
|
||||
for axis in MotorAxis:
|
||||
results[axis] = self.home_axis(axis)
|
||||
return results
|
||||
13
unilabos/devices/laiyu_liquid/tests/__init__.py
Normal file
13
unilabos/devices/laiyu_liquid/tests/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备测试模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的测试用例:
|
||||
- test_deck_config.py: 工作台配置测试
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
315
unilabos/devices/laiyu_liquid/tests/test_deck_config.py
Normal file
315
unilabos/devices/laiyu_liquid/tests/test_deck_config.py
Normal file
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
测试脚本:验证更新后的deck配置是否正常工作
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
def test_config_loading():
|
||||
"""测试配置文件加载功能"""
|
||||
print("=" * 50)
|
||||
print("测试配置文件加载功能")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# 直接测试配置文件加载
|
||||
config_path = os.path.join(os.path.dirname(__file__), "controllers", "deckconfig.json")
|
||||
fallback_path = os.path.join(os.path.dirname(__file__), "config", "deck.json")
|
||||
|
||||
config = None
|
||||
config_source = ""
|
||||
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
config_source = "config/deckconfig.json"
|
||||
elif os.path.exists(fallback_path):
|
||||
with open(fallback_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
config_source = "config/deck.json"
|
||||
else:
|
||||
print("❌ 配置文件不存在")
|
||||
return False
|
||||
|
||||
print(f"✅ 配置文件加载成功: {config_source}")
|
||||
print(f" - 甲板尺寸: {config.get('size_x', 'N/A')} x {config.get('size_y', 'N/A')} x {config.get('size_z', 'N/A')}")
|
||||
print(f" - 子模块数量: {len(config.get('children', []))}")
|
||||
|
||||
# 检查各个模块是否存在
|
||||
modules = config.get('children', [])
|
||||
module_types = [module.get('type') for module in modules]
|
||||
module_names = [module.get('name') for module in modules]
|
||||
|
||||
print(f" - 模块类型: {', '.join(set(filter(None, module_types)))}")
|
||||
print(f" - 模块名称: {', '.join(filter(None, module_names))}")
|
||||
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"❌ 配置文件加载失败: {e}")
|
||||
return None
|
||||
|
||||
def test_module_coordinates(config):
|
||||
"""测试各模块的坐标信息"""
|
||||
print("\n" + "=" * 50)
|
||||
print("测试模块坐标信息")
|
||||
print("=" * 50)
|
||||
|
||||
if not config:
|
||||
print("❌ 配置为空,无法测试")
|
||||
return False
|
||||
|
||||
modules = config.get('children', [])
|
||||
|
||||
for module in modules:
|
||||
module_name = module.get('name', '未知模块')
|
||||
module_type = module.get('type', '未知类型')
|
||||
position = module.get('position', {})
|
||||
size = module.get('size', {})
|
||||
|
||||
print(f"\n模块: {module_name} ({module_type})")
|
||||
print(f" - 位置: ({position.get('x', 0)}, {position.get('y', 0)}, {position.get('z', 0)})")
|
||||
print(f" - 尺寸: {size.get('x', 0)} x {size.get('y', 0)} x {size.get('z', 0)}")
|
||||
|
||||
# 检查孔位信息
|
||||
wells = module.get('wells', [])
|
||||
if wells:
|
||||
print(f" - 孔位数量: {len(wells)}")
|
||||
|
||||
# 显示前几个和后几个孔位的坐标
|
||||
sample_wells = wells[:3] + wells[-3:] if len(wells) > 6 else wells
|
||||
for well in sample_wells:
|
||||
well_id = well.get('id', '未知')
|
||||
well_pos = well.get('position', {})
|
||||
print(f" {well_id}: ({well_pos.get('x', 0)}, {well_pos.get('y', 0)}, {well_pos.get('z', 0)})")
|
||||
else:
|
||||
print(f" - 无孔位信息")
|
||||
|
||||
return True
|
||||
|
||||
def test_coordinate_ranges(config):
|
||||
"""测试坐标范围的合理性"""
|
||||
print("\n" + "=" * 50)
|
||||
print("测试坐标范围合理性")
|
||||
print("=" * 50)
|
||||
|
||||
if not config:
|
||||
print("❌ 配置为空,无法测试")
|
||||
return False
|
||||
|
||||
deck_size = {
|
||||
'x': config.get('size_x', 340),
|
||||
'y': config.get('size_y', 250),
|
||||
'z': config.get('size_z', 160)
|
||||
}
|
||||
|
||||
print(f"甲板尺寸: {deck_size['x']} x {deck_size['y']} x {deck_size['z']}")
|
||||
|
||||
modules = config.get('children', [])
|
||||
all_coordinates = []
|
||||
|
||||
for module in modules:
|
||||
module_name = module.get('name', '未知模块')
|
||||
wells = module.get('wells', [])
|
||||
|
||||
for well in wells:
|
||||
well_pos = well.get('position', {})
|
||||
x, y, z = well_pos.get('x', 0), well_pos.get('y', 0), well_pos.get('z', 0)
|
||||
all_coordinates.append((x, y, z, f"{module_name}:{well.get('id', '未知')}"))
|
||||
|
||||
if not all_coordinates:
|
||||
print("❌ 没有找到任何坐标信息")
|
||||
return False
|
||||
|
||||
# 计算坐标范围
|
||||
x_coords = [coord[0] for coord in all_coordinates]
|
||||
y_coords = [coord[1] for coord in all_coordinates]
|
||||
z_coords = [coord[2] for coord in all_coordinates]
|
||||
|
||||
x_range = (min(x_coords), max(x_coords))
|
||||
y_range = (min(y_coords), max(y_coords))
|
||||
z_range = (min(z_coords), max(z_coords))
|
||||
|
||||
print(f"X坐标范围: {x_range[0]:.2f} ~ {x_range[1]:.2f}")
|
||||
print(f"Y坐标范围: {y_range[0]:.2f} ~ {y_range[1]:.2f}")
|
||||
print(f"Z坐标范围: {z_range[0]:.2f} ~ {z_range[1]:.2f}")
|
||||
|
||||
# 检查是否超出甲板范围
|
||||
issues = []
|
||||
if x_range[1] > deck_size['x']:
|
||||
issues.append(f"X坐标超出甲板范围: {x_range[1]} > {deck_size['x']}")
|
||||
if y_range[1] > deck_size['y']:
|
||||
issues.append(f"Y坐标超出甲板范围: {y_range[1]} > {deck_size['y']}")
|
||||
if z_range[1] > deck_size['z']:
|
||||
issues.append(f"Z坐标超出甲板范围: {z_range[1]} > {deck_size['z']}")
|
||||
|
||||
if x_range[0] < 0:
|
||||
issues.append(f"X坐标为负值: {x_range[0]}")
|
||||
if y_range[0] < 0:
|
||||
issues.append(f"Y坐标为负值: {y_range[0]}")
|
||||
if z_range[0] < 0:
|
||||
issues.append(f"Z坐标为负值: {z_range[0]}")
|
||||
|
||||
if issues:
|
||||
print("⚠️ 发现坐标问题:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
return False
|
||||
else:
|
||||
print("✅ 所有坐标都在合理范围内")
|
||||
return True
|
||||
|
||||
def test_well_spacing(config):
|
||||
"""测试孔位间距的一致性"""
|
||||
print("\n" + "=" * 50)
|
||||
print("测试孔位间距一致性")
|
||||
print("=" * 50)
|
||||
|
||||
if not config:
|
||||
print("❌ 配置为空,无法测试")
|
||||
return False
|
||||
|
||||
modules = config.get('children', [])
|
||||
|
||||
for module in modules:
|
||||
module_name = module.get('name', '未知模块')
|
||||
module_type = module.get('type', '未知类型')
|
||||
wells = module.get('wells', [])
|
||||
|
||||
if len(wells) < 2:
|
||||
continue
|
||||
|
||||
print(f"\n模块: {module_name} ({module_type})")
|
||||
|
||||
# 计算相邻孔位的间距
|
||||
spacings_x = []
|
||||
spacings_y = []
|
||||
|
||||
# 按行列排序孔位
|
||||
wells_by_row = {}
|
||||
for well in wells:
|
||||
well_id = well.get('id', '')
|
||||
if len(well_id) >= 3: # 如A01格式
|
||||
row = well_id[0]
|
||||
col = int(well_id[1:])
|
||||
if row not in wells_by_row:
|
||||
wells_by_row[row] = {}
|
||||
wells_by_row[row][col] = well
|
||||
|
||||
# 计算同行相邻孔位的X间距
|
||||
for row, cols in wells_by_row.items():
|
||||
sorted_cols = sorted(cols.keys())
|
||||
for i in range(len(sorted_cols) - 1):
|
||||
col1, col2 = sorted_cols[i], sorted_cols[i + 1]
|
||||
if col2 == col1 + 1: # 相邻列
|
||||
pos1 = cols[col1].get('position', {})
|
||||
pos2 = cols[col2].get('position', {})
|
||||
spacing = abs(pos2.get('x', 0) - pos1.get('x', 0))
|
||||
spacings_x.append(spacing)
|
||||
|
||||
# 计算同列相邻孔位的Y间距
|
||||
cols_by_row = {}
|
||||
for well in wells:
|
||||
well_id = well.get('id', '')
|
||||
if len(well_id) >= 3:
|
||||
row = ord(well_id[0]) - ord('A')
|
||||
col = int(well_id[1:])
|
||||
if col not in cols_by_row:
|
||||
cols_by_row[col] = {}
|
||||
cols_by_row[col][row] = well
|
||||
|
||||
for col, rows in cols_by_row.items():
|
||||
sorted_rows = sorted(rows.keys())
|
||||
for i in range(len(sorted_rows) - 1):
|
||||
row1, row2 = sorted_rows[i], sorted_rows[i + 1]
|
||||
if row2 == row1 + 1: # 相邻行
|
||||
pos1 = rows[row1].get('position', {})
|
||||
pos2 = rows[row2].get('position', {})
|
||||
spacing = abs(pos2.get('y', 0) - pos1.get('y', 0))
|
||||
spacings_y.append(spacing)
|
||||
|
||||
# 检查间距一致性
|
||||
if spacings_x:
|
||||
avg_x = sum(spacings_x) / len(spacings_x)
|
||||
max_diff_x = max(abs(s - avg_x) for s in spacings_x)
|
||||
print(f" - X方向平均间距: {avg_x:.2f}mm, 最大偏差: {max_diff_x:.2f}mm")
|
||||
|
||||
if spacings_y:
|
||||
avg_y = sum(spacings_y) / len(spacings_y)
|
||||
max_diff_y = max(abs(s - avg_y) for s in spacings_y)
|
||||
print(f" - Y方向平均间距: {avg_y:.2f}mm, 最大偏差: {max_diff_y:.2f}mm")
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("LaiYu液体处理设备配置测试")
|
||||
print("测试时间:", os.popen('date').read().strip())
|
||||
|
||||
# 运行所有测试
|
||||
tests = [
|
||||
("配置文件加载", test_config_loading),
|
||||
]
|
||||
|
||||
config = None
|
||||
results = []
|
||||
|
||||
for test_name, test_func in tests:
|
||||
try:
|
||||
if test_name == "配置文件加载":
|
||||
result = test_func()
|
||||
config = result if result else None
|
||||
results.append((test_name, bool(result)))
|
||||
else:
|
||||
result = test_func(config)
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
print(f"❌ 测试 {test_name} 执行失败: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# 如果配置加载成功,运行其他测试
|
||||
if config:
|
||||
additional_tests = [
|
||||
("模块坐标信息", test_module_coordinates),
|
||||
("坐标范围合理性", test_coordinate_ranges),
|
||||
("孔位间距一致性", test_well_spacing)
|
||||
]
|
||||
|
||||
for test_name, test_func in additional_tests:
|
||||
try:
|
||||
result = test_func(config)
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
print(f"❌ 测试 {test_name} 执行失败: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# 输出测试总结
|
||||
print("\n" + "=" * 50)
|
||||
print("测试总结")
|
||||
print("=" * 50)
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for test_name, result in results:
|
||||
status = "✅ 通过" if result else "❌ 失败"
|
||||
print(f" {test_name}: {status}")
|
||||
|
||||
print(f"\n总计: {passed}/{total} 个测试通过")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 所有测试通过!配置更新成功。")
|
||||
return True
|
||||
else:
|
||||
print("⚠️ 部分测试失败,需要进一步检查。")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
138
unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py
Normal file
138
unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py
Normal file
@@ -0,0 +1,138 @@
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from xyz_stepper_driver import ModbusRTUTransport, ModbusClient, XYZStepperController, MotorStatus
|
||||
|
||||
# ========== 日志配置 ==========
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("XYZ_Debug")
|
||||
|
||||
|
||||
def create_controller(port: str = "/dev/ttyUSB1", baudrate: int = 115200) -> XYZStepperController:
|
||||
"""
|
||||
初始化通信层与三轴控制器
|
||||
"""
|
||||
logger.info(f"🔧 初始化控制器: {port} @ {baudrate}bps")
|
||||
transport = ModbusRTUTransport(port=port, baudrate=baudrate)
|
||||
transport.open()
|
||||
client = ModbusClient(transport)
|
||||
return XYZStepperController(client=client, port=port, baudrate=baudrate)
|
||||
|
||||
|
||||
def load_existing_soft_zero(ctrl: XYZStepperController, path: str = "work_origin.json") -> bool:
|
||||
"""
|
||||
如果已存在软零点文件则加载,否则返回 False
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
logger.warning("⚠ 未找到已有软零点文件,将等待人工定义新零点。")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
origin = data.get("work_origin_steps", {})
|
||||
ctrl.work_origin_steps = origin
|
||||
ctrl.is_homed = True
|
||||
logger.info(f"✔ 已加载软零点文件:{path}")
|
||||
logger.info(f"当前软零点步数: {origin}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"读取软零点文件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_enable_axis(ctrl: XYZStepperController):
|
||||
"""
|
||||
依次使能 X / Y / Z 三轴
|
||||
"""
|
||||
logger.info("=== 测试各轴使能 ===")
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
try:
|
||||
result = ctrl.enable(axis, True)
|
||||
if result:
|
||||
vals = ctrl.get_status(axis)
|
||||
st = MotorStatus(vals[3])
|
||||
logger.info(f"{axis} 轴使能成功,当前状态: {st.name}")
|
||||
else:
|
||||
logger.error(f"{axis} 轴使能失败")
|
||||
except Exception as e:
|
||||
logger.error(f"{axis} 轴使能异常: {e}")
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def test_status_read(ctrl: XYZStepperController):
|
||||
"""
|
||||
读取各轴当前状态(调试)
|
||||
"""
|
||||
logger.info("=== 当前各轴状态 ===")
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
try:
|
||||
vals = ctrl.get_status(axis)
|
||||
st = MotorStatus(vals[3])
|
||||
logger.info(
|
||||
f"{axis}: steps={vals[0]}, speed={vals[1]}, "
|
||||
f"current={vals[2]}, status={st.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取 {axis} 状态失败: {e}")
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def redefine_soft_zero(ctrl: XYZStepperController):
|
||||
"""
|
||||
手动重新定义软零点
|
||||
"""
|
||||
logger.info("=== ⚙️ 重新定义软零点 ===")
|
||||
ctrl.define_current_as_zero("work_origin.json")
|
||||
logger.info("✅ 新软零点已写入 work_origin.json")
|
||||
|
||||
|
||||
def test_soft_zero_move(ctrl: XYZStepperController):
|
||||
"""
|
||||
以软零点为基准执行三轴运动测试
|
||||
"""
|
||||
logger.info("=== 测试软零点相对运动 ===")
|
||||
ctrl.move_xyz_work(x=100.0, y=100.0, z=40.0, speed=100, acc=800)
|
||||
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
ctrl.wait_complete(axis)
|
||||
|
||||
test_status_read(ctrl)
|
||||
logger.info("✅ 软零点运动测试完成")
|
||||
|
||||
|
||||
def main():
|
||||
ctrl = create_controller(port="/dev/ttyUSB1", baudrate=115200)
|
||||
|
||||
try:
|
||||
test_enable_axis(ctrl)
|
||||
test_status_read(ctrl)
|
||||
|
||||
# === 初始化或加载软零点 ===
|
||||
loaded = load_existing_soft_zero(ctrl)
|
||||
if not loaded:
|
||||
logger.info("👣 首次运行,定义软零点并保存。")
|
||||
ctrl.define_current_as_zero("work_origin.json")
|
||||
|
||||
# === 软零点回归动作 ===
|
||||
ctrl.return_to_work_origin()
|
||||
|
||||
# === 可选软零点运动测试 ===
|
||||
# test_soft_zero_move(ctrl)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("🛑 手动中断退出")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ 调试出错: {e}")
|
||||
|
||||
finally:
|
||||
if hasattr(ctrl.client, "transport"):
|
||||
ctrl.client.transport.close()
|
||||
logger.info("串口已安全关闭 ✅")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
unilabos/devices/laiyu_liquid_test/driver_status_test.py
Normal file
58
unilabos/devices/laiyu_liquid_test/driver_status_test.py
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
import logging
|
||||
from xyz_stepper_driver import (
|
||||
ModbusRTUTransport,
|
||||
ModbusClient,
|
||||
XYZStepperController,
|
||||
MotorAxis,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("XYZStepperCommTest")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
|
||||
def test_xyz_stepper_comm():
|
||||
"""仅测试 Modbus 通信是否正常(并输出寄存器数据,不做电机运动)"""
|
||||
port = "/dev/ttyUSB1"
|
||||
baudrate = 115200
|
||||
timeout = 1.2 # 略长避免响应被截断
|
||||
|
||||
logger.info(f"尝试连接 Modbus 设备 {port} ...")
|
||||
transport = ModbusRTUTransport(port, baudrate=baudrate, timeout=timeout)
|
||||
transport.open()
|
||||
|
||||
client = ModbusClient(transport)
|
||||
ctrl = XYZStepperController(client)
|
||||
|
||||
try:
|
||||
logger.info("✅ 串口已打开,开始读取三个轴状态(打印寄存器内容) ...")
|
||||
for axis in [MotorAxis.X, MotorAxis.Y, MotorAxis.Z]:
|
||||
addr = ctrl.axis_addr[axis]
|
||||
|
||||
try:
|
||||
# # 在 get_status 前打印原始寄存器内容
|
||||
# regs = client.read_registers(addr, ctrl.REG_STATUS, 6)
|
||||
# hex_regs = [f"0x{val:04X}" for val in regs]
|
||||
# logger.info(f"[{axis.name}] 原始寄存器 ({len(regs)} 个): {regs} -> {hex_regs}")
|
||||
|
||||
# 调用 get_status() 正常解析
|
||||
status = ctrl.get_status(axis)
|
||||
logger.info(
|
||||
f"[{axis.name}] ✅ 通信正常: steps={status.steps}, speed={status.speed}, "
|
||||
f"current={status.current}, status={status.status.name}"
|
||||
)
|
||||
|
||||
except Exception as e_axis:
|
||||
logger.error(f"[{axis.name}] ❌ 通信失败: {e_axis}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 通讯测试失败: {e}")
|
||||
|
||||
finally:
|
||||
transport.close()
|
||||
logger.info("🔌 串口已关闭")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_xyz_stepper_comm()
|
||||
8
unilabos/devices/laiyu_liquid_test/work_origin.json
Normal file
8
unilabos/devices/laiyu_liquid_test/work_origin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"work_origin_steps": {
|
||||
"x": 11799,
|
||||
"y": 11476,
|
||||
"z": 3312
|
||||
},
|
||||
"timestamp": "2025-11-04T15:31:09.802155"
|
||||
}
|
||||
336
unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py
Normal file
336
unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py
Normal file
@@ -0,0 +1,336 @@
|
||||
|
||||
"""
|
||||
XYZ 三轴步进电机驱动(统一字符串参数版)
|
||||
基于 Modbus RTU 协议
|
||||
Author: Xiuyu Chen (Modified by Assistant)
|
||||
"""
|
||||
|
||||
import serial # type: ignore
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# ========== 日志配置 ==========
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("XYZStepper")
|
||||
|
||||
|
||||
# ========== 层 1:Modbus RTU ==========
|
||||
class ModbusException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ModbusRTUTransport:
|
||||
"""底层串口通信层"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.2):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.ser: Optional[serial.Serial] = None
|
||||
|
||||
def open(self):
|
||||
try:
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=0.02,
|
||||
write_timeout=0.5,
|
||||
)
|
||||
logger.info(f"[RTU] 串口连接成功: {self.port}")
|
||||
except Exception as e:
|
||||
raise ModbusException(f"无法打开串口 {self.port}: {e}")
|
||||
|
||||
def close(self):
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
logger.info("[RTU] 串口已关闭")
|
||||
|
||||
def send(self, frame: bytes):
|
||||
if not self.ser or not self.ser.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(frame)
|
||||
self.ser.flush()
|
||||
logger.debug(f"[TX] {frame.hex(' ').upper()}")
|
||||
|
||||
def receive(self, expected_len: int) -> bytes:
|
||||
if not self.ser or not self.ser.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
start = time.time()
|
||||
buf = bytearray()
|
||||
while len(buf) < expected_len and (time.time() - start) < self.timeout:
|
||||
chunk = self.ser.read(expected_len - len(buf))
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
# ========== 层 2:Modbus 协议 ==========
|
||||
class ModbusFunction(Enum):
|
||||
READ_HOLDING_REGISTERS = 0x03
|
||||
WRITE_SINGLE_REGISTER = 0x06
|
||||
WRITE_MULTIPLE_REGISTERS = 0x10
|
||||
|
||||
|
||||
class ModbusClient:
|
||||
"""Modbus RTU 客户端"""
|
||||
|
||||
def __init__(self, transport: ModbusRTUTransport):
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def calc_crc(data: bytes) -> bytes:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return struct.pack("<H", crc)
|
||||
|
||||
def send_request(self, addr: int, func: int, payload: bytes) -> bytes:
|
||||
frame = bytes([addr, func]) + payload
|
||||
full = frame + self.calc_crc(frame)
|
||||
self.transport.send(full)
|
||||
time.sleep(0.01)
|
||||
resp = self.transport.ser.read(256)
|
||||
if not resp:
|
||||
raise ModbusException("未收到响应")
|
||||
|
||||
start = resp.find(bytes([addr, func]))
|
||||
if start > 0:
|
||||
resp = resp[start:]
|
||||
if len(resp) < 5:
|
||||
raise ModbusException(f"响应长度不足: {resp.hex(' ').upper()}")
|
||||
if self.calc_crc(resp[:-2]) != resp[-2:]:
|
||||
raise ModbusException("CRC 校验失败")
|
||||
return resp
|
||||
|
||||
def read_registers(self, addr: int, start: int, count: int) -> List[int]:
|
||||
payload = struct.pack(">HH", start, count)
|
||||
resp = self.send_request(addr, ModbusFunction.READ_HOLDING_REGISTERS.value, payload)
|
||||
byte_count = resp[2]
|
||||
regs = [struct.unpack(">H", resp[3 + i:5 + i])[0] for i in range(0, byte_count, 2)]
|
||||
return regs
|
||||
|
||||
def write_single_register(self, addr: int, reg: int, val: int) -> bool:
|
||||
payload = struct.pack(">HH", reg, val)
|
||||
resp = self.send_request(addr, ModbusFunction.WRITE_SINGLE_REGISTER.value, payload)
|
||||
return resp[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
|
||||
|
||||
def write_multiple_registers(self, addr: int, start: int, values: List[int]) -> bool:
|
||||
byte_count = len(values) * 2
|
||||
payload = struct.pack(">HHB", start, len(values), byte_count)
|
||||
payload += b"".join(struct.pack(">H", v & 0xFFFF) for v in values)
|
||||
resp = self.send_request(addr, ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, payload)
|
||||
return resp[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
|
||||
|
||||
|
||||
# ========== 层 3:业务逻辑 ==========
|
||||
class MotorAxis(Enum):
|
||||
X = 1
|
||||
Y = 2
|
||||
Z = 3
|
||||
|
||||
|
||||
class MotorStatus(Enum):
|
||||
STANDBY = 0
|
||||
RUNNING = 1
|
||||
COLLISION_STOP = 2
|
||||
FORWARD_LIMIT_STOP = 3
|
||||
REVERSE_LIMIT_STOP = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotorPosition:
|
||||
steps: int
|
||||
speed: int
|
||||
current: int
|
||||
status: MotorStatus
|
||||
|
||||
|
||||
class XYZStepperController:
|
||||
"""XYZ 三轴步进控制器(字符串接口版)"""
|
||||
|
||||
STEPS_PER_REV = 16384
|
||||
LEAD_MM_X, LEAD_MM_Y, LEAD_MM_Z = 80.0, 80.0, 5.0
|
||||
STEPS_PER_MM_X = STEPS_PER_REV / LEAD_MM_X
|
||||
STEPS_PER_MM_Y = STEPS_PER_REV / LEAD_MM_Y
|
||||
STEPS_PER_MM_Z = STEPS_PER_REV / LEAD_MM_Z
|
||||
|
||||
REG_STATUS, REG_POS_HIGH, REG_POS_LOW = 0x00, 0x01, 0x02
|
||||
REG_ACTUAL_SPEED, REG_CURRENT, REG_ENABLE = 0x03, 0x05, 0x06
|
||||
REG_ZERO_CMD, REG_TARGET_HIGH, REG_TARGET_LOW = 0x0F, 0x10, 0x11
|
||||
REG_SPEED, REG_ACCEL, REG_PRECISION, REG_START = 0x13, 0x14, 0x15, 0x16
|
||||
REG_COMMAND = 0x60
|
||||
|
||||
def __init__(self, client: Optional[ModbusClient] = None,
|
||||
port="/dev/ttyUSB0", baudrate=115200,
|
||||
origin_path="unilabos/devices/laiyu_liquid_test/work_origin.json"):
|
||||
if client is None:
|
||||
transport = ModbusRTUTransport(port, baudrate)
|
||||
transport.open()
|
||||
self.client = ModbusClient(transport)
|
||||
else:
|
||||
self.client = client
|
||||
|
||||
self.axis_addr = {MotorAxis.X: 1, MotorAxis.Y: 2, MotorAxis.Z: 3}
|
||||
self.work_origin_steps = {"x": 0, "y": 0, "z": 0}
|
||||
self.is_homed = False
|
||||
self._load_work_origin(origin_path)
|
||||
|
||||
# ========== 基础工具 ==========
|
||||
@staticmethod
|
||||
def s16(v: int) -> int:
|
||||
return v - 0x10000 if v & 0x8000 else v
|
||||
|
||||
@staticmethod
|
||||
def s32(h: int, l: int) -> int:
|
||||
v = (h << 16) | l
|
||||
return v - 0x100000000 if v & 0x80000000 else v
|
||||
|
||||
@classmethod
|
||||
def mm_to_steps(cls, axis: str, mm: float = 0.0) -> int:
|
||||
axis = axis.upper()
|
||||
if axis == "X":
|
||||
return int(mm * cls.STEPS_PER_MM_X)
|
||||
elif axis == "Y":
|
||||
return int(mm * cls.STEPS_PER_MM_Y)
|
||||
elif axis == "Z":
|
||||
return int(mm * cls.STEPS_PER_MM_Z)
|
||||
raise ValueError(f"未知轴: {axis}")
|
||||
|
||||
@classmethod
|
||||
def steps_to_mm(cls, axis: str, steps: int) -> float:
|
||||
axis = axis.upper()
|
||||
if axis == "X":
|
||||
return steps / cls.STEPS_PER_MM_X
|
||||
elif axis == "Y":
|
||||
return steps / cls.STEPS_PER_MM_Y
|
||||
elif axis == "Z":
|
||||
return steps / cls.STEPS_PER_MM_Z
|
||||
raise ValueError(f"未知轴: {axis}")
|
||||
|
||||
# ========== 状态与控制 ==========
|
||||
def get_status(self, axis: str = "Z") -> list:
|
||||
"""返回简化数组格式: [steps, speed, current, status_value]"""
|
||||
if isinstance(axis, MotorAxis):
|
||||
axis_enum = axis
|
||||
elif isinstance(axis, str):
|
||||
axis_enum = MotorAxis[axis.upper()]
|
||||
else:
|
||||
raise TypeError("axis 参数必须为 str 或 MotorAxis")
|
||||
|
||||
vals = self.client.read_registers(self.axis_addr[axis_enum], self.REG_STATUS, 6)
|
||||
return [
|
||||
self.s32(vals[1], vals[2]),
|
||||
self.s16(vals[3]),
|
||||
vals[4],
|
||||
int(MotorStatus(vals[0]).value)
|
||||
]
|
||||
|
||||
def enable(self, axis: str, state: bool) -> bool:
|
||||
a = MotorAxis[axis.upper()]
|
||||
return self.client.write_single_register(self.axis_addr[a], self.REG_ENABLE, 1 if state else 0)
|
||||
|
||||
def wait_complete(self, axis: str, timeout=30.0) -> bool:
|
||||
a = axis.upper()
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
vals = self.get_status(a)
|
||||
st = MotorStatus(vals[3]) # 第4个元素是状态值
|
||||
if st == MotorStatus.STANDBY:
|
||||
return True
|
||||
if st in (MotorStatus.COLLISION_STOP, MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP):
|
||||
logger.warning(f"{a} 轴异常停止: {st.name}")
|
||||
return False
|
||||
time.sleep(0.1)
|
||||
logger.warning(f"{a} 轴运动超时")
|
||||
return False
|
||||
|
||||
# ========== 控制命令 ==========
|
||||
def move_to(self, axis: str, steps: int, speed: int = 2000, acc: int = 500, precision: int = 50) -> bool:
|
||||
a = MotorAxis[axis.upper()]
|
||||
addr = self.axis_addr[a]
|
||||
hi, lo = (steps >> 16) & 0xFFFF, steps & 0xFFFF
|
||||
values = [hi, lo, speed, acc, precision]
|
||||
ok = self.client.write_multiple_registers(addr, self.REG_TARGET_HIGH, values)
|
||||
if ok:
|
||||
self.client.write_single_register(addr, self.REG_START, 1)
|
||||
return ok
|
||||
|
||||
def move_xyz_work(self, x: float = 0.0, y: float = 0.0, z: float = 0.0, speed: int = 100, acc: int = 1500):
|
||||
logger.info("🧭 执行安全多轴运动:Z→XY→Z")
|
||||
if z is not None:
|
||||
safe_z = self._to_machine_steps("Z", 0.0)
|
||||
self.move_to("Z", safe_z, speed, acc)
|
||||
self.wait_complete("Z")
|
||||
|
||||
if x is not None or y is not None:
|
||||
if x is not None:
|
||||
self.move_to("X", self._to_machine_steps("X", x), speed, acc)
|
||||
if y is not None:
|
||||
self.move_to("Y", self._to_machine_steps("Y", y), speed, acc)
|
||||
if x is not None:
|
||||
self.wait_complete("X")
|
||||
if y is not None:
|
||||
self.wait_complete("Y")
|
||||
|
||||
if z is not None:
|
||||
self.move_to("Z", self._to_machine_steps("Z", z), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
logger.info("✅ 多轴顺序运动完成")
|
||||
|
||||
# ========== 坐标与零点 ==========
|
||||
def _to_machine_steps(self, axis: str, mm: float) -> int:
|
||||
base = self.work_origin_steps.get(axis.lower(), 0)
|
||||
return base + self.mm_to_steps(axis, mm)
|
||||
|
||||
def define_current_as_zero(self, save_path="work_origin.json"):
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
origin = {}
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
vals = self.get_status(axis)
|
||||
origin[axis.lower()] = int(vals[0]) # 第1个是步数
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"work_origin_steps": origin, "timestamp": datetime.now().isoformat()}, f, indent=2)
|
||||
self.work_origin_steps = origin
|
||||
self.is_homed = True
|
||||
logger.info(f"✅ 零点已定义并保存至 {save_path}")
|
||||
|
||||
def _load_work_origin(self, path: str) -> bool:
|
||||
import json, os
|
||||
|
||||
if not os.path.exists(path):
|
||||
logger.warning("⚠️ 未找到软零点文件")
|
||||
return False
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.work_origin_steps = data.get("work_origin_steps", {"x": 0, "y": 0, "z": 0})
|
||||
self.is_homed = True
|
||||
logger.info(f"📂 软零点已加载: {self.work_origin_steps}")
|
||||
return True
|
||||
|
||||
def return_to_work_origin(self, speed: int = 200, acc: int = 800):
|
||||
logger.info("🏁 回工件软零点")
|
||||
self.move_to("Z", self._to_machine_steps("Z", 0.0), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
self.move_to("X", self.work_origin_steps.get("x", 0), speed, acc)
|
||||
self.move_to("Y", self.work_origin_steps.get("y", 0), speed, acc)
|
||||
self.wait_complete("X")
|
||||
self.wait_complete("Y")
|
||||
self.move_to("Z", self.work_origin_steps.get("z", 0), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
logger.info("🎯 回软零点完成 ✅")
|
||||
@@ -153,7 +153,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("已有枪头,无需重复拾取")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=100)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
|
||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
||||
# goback()
|
||||
@@ -202,7 +202,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
|
||||
print("无枪头,无需丢弃")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.hardware_interface.eject_tip
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
|
||||
@@ -267,7 +267,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
return
|
||||
|
||||
# 移动到吸液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
|
||||
|
||||
# 移动到排液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ class PipetteController:
|
||||
baudrate=115200
|
||||
)
|
||||
self.pipette = SOPAPipette(self.config)
|
||||
self.pipette_port = port
|
||||
self.tip_status = TipStatus.NO_TIP
|
||||
self.current_volume = 0.0
|
||||
self.max_volume = 1000.0 # 默认1000ul
|
||||
@@ -155,7 +154,7 @@ class PipetteController:
|
||||
logger.info("移液器连接成功")
|
||||
|
||||
# 连接XYZ步进电机控制器(如果提供了端口)
|
||||
if self.xyz_port != self.pipette_port:
|
||||
if self.xyz_port:
|
||||
try:
|
||||
self.xyz_controller = XYZController(self.xyz_port)
|
||||
if self.xyz_controller.connect():
|
||||
@@ -169,11 +168,6 @@ class PipetteController:
|
||||
self.xyz_controller = None
|
||||
self.xyz_connected = False
|
||||
else:
|
||||
try:
|
||||
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
|
||||
self.xyz_controller.serial_conn = self.pipette.serial_port
|
||||
self.xyz_controller.is_connected = True
|
||||
except Exception as e:
|
||||
logger.info("未配置XYZ步进电机端口,跳过运动控制器连接")
|
||||
|
||||
return True
|
||||
|
||||
@@ -6,7 +6,6 @@ import traceback
|
||||
from collections import Counter
|
||||
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
|
||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
|
||||
@@ -29,15 +28,12 @@ from pylabrobot.resources import (
|
||||
)
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
class SimpleReturn(TypedDict):
|
||||
samples: list
|
||||
volumes: list
|
||||
|
||||
|
||||
class LiquidHandlerMiddleware(LiquidHandler):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs):
|
||||
self._simulator = simulator
|
||||
self.channel_num = channel_num
|
||||
self.pending_liquids_dict = {}
|
||||
joint_config = kwargs.get("joint_config", None)
|
||||
if simulator:
|
||||
if joint_config:
|
||||
@@ -135,9 +131,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
return await self._simulate_handler.drop_tips(
|
||||
tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs
|
||||
)
|
||||
await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs)
|
||||
self.pending_liquids_dict = {}
|
||||
return
|
||||
return await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs)
|
||||
|
||||
async def return_tips(
|
||||
self, use_channels: Optional[list[int]] = None, allow_nonzero_volume: bool = False, **backend_kwargs
|
||||
@@ -160,9 +154,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
offsets = [Coordinate.zero()] * len(use_channels)
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs)
|
||||
await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs)
|
||||
self.pending_liquids_dict = {}
|
||||
return
|
||||
return await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs)
|
||||
|
||||
def _check_containers(self, resources: Sequence[Resource]):
|
||||
super()._check_containers(resources)
|
||||
@@ -179,8 +171,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.aspirate(
|
||||
resources,
|
||||
@@ -193,7 +183,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
await super().aspirate(
|
||||
return await super().aspirate(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
@@ -205,18 +195,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
||||
res_volumes.append(volume)
|
||||
self.pending_liquids_dict[channel] = {
|
||||
"sample_uuid": resource.unilabos_extra.get("sample_uuid", None),
|
||||
"volume": volume
|
||||
}
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Container],
|
||||
@@ -228,7 +206,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
blow_out_air_volume: Optional[List[Optional[float]]] = None,
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
**backend_kwargs,
|
||||
) -> SimpleReturn:
|
||||
):
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.dispense(
|
||||
resources,
|
||||
@@ -241,7 +219,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
spread,
|
||||
**backend_kwargs,
|
||||
)
|
||||
await super().dispense(
|
||||
return await super().dispense(
|
||||
resources,
|
||||
vols,
|
||||
use_channels,
|
||||
@@ -251,16 +229,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
blow_out_air_volume,
|
||||
**backend_kwargs,
|
||||
)
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_uuid = self.pending_liquids_dict[channel]["sample_uuid"]
|
||||
self.pending_liquids_dict[channel]["volume"] -= volume
|
||||
resource.unilabos_extra["sample_uuid"] = res_uuid
|
||||
res_samples.append({"name": resource.name, "sample_uuid": res_uuid})
|
||||
res_volumes.append(volume)
|
||||
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
|
||||
async def transfer(
|
||||
self,
|
||||
@@ -581,66 +549,25 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
support_touch_tip = True
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8, total_height:float = 310):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8):
|
||||
"""Initialize a LiquidHandler.
|
||||
|
||||
Args:
|
||||
backend: Backend to use.
|
||||
deck: Deck to use.
|
||||
"""
|
||||
backend_type = None
|
||||
if isinstance(backend, dict) and "type" in backend:
|
||||
backend_dict = backend.copy()
|
||||
type_str = backend_dict.pop("type")
|
||||
try:
|
||||
# Try to get class from string using globals (current module), or fallback to pylabrobot or unilabos namespaces
|
||||
backend_cls = None
|
||||
if type_str in globals():
|
||||
backend_cls = globals()[type_str]
|
||||
else:
|
||||
# Try resolving dotted notation, e.g. "xxx.yyy.ClassName"
|
||||
components = type_str.split(".")
|
||||
mod = None
|
||||
if len(components) > 1:
|
||||
module_name = ".".join(components[:-1])
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(module_name)
|
||||
except ImportError:
|
||||
mod = None
|
||||
if mod is not None:
|
||||
backend_cls = getattr(mod, components[-1], None)
|
||||
if backend_cls is None:
|
||||
# Try pylabrobot style import (if available)
|
||||
try:
|
||||
import pylabrobot
|
||||
backend_cls = getattr(pylabrobot, type_str, None)
|
||||
except Exception:
|
||||
backend_cls = None
|
||||
if backend_cls is not None and isinstance(backend_cls, type):
|
||||
backend_type = backend_cls(**backend_dict) # pass the rest of dict as kwargs
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to convert backend type '{type_str}' to class: {exc}")
|
||||
else:
|
||||
backend_type = backend
|
||||
self._simulator = simulator
|
||||
self.group_info = dict()
|
||||
super().__init__(backend_type, deck, simulator, channel_num)
|
||||
super().__init__(backend, deck, simulator, channel_num)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
@classmethod
|
||||
def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn:
|
||||
def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]):
|
||||
"""Set the liquid in a well."""
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
|
||||
well.set_liquids([(liquid_name, volume)]) # type: ignore
|
||||
res_samples.append({"name": well.name, "sample_uuid": well.unilabos_extra.get("sample_uuid", None)})
|
||||
res_volumes.append(volume)
|
||||
|
||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||
# ---------------------------------------------------------------
|
||||
# REMOVE LIQUID --------------------------------------------------
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import asyncio
|
||||
import collections
|
||||
from collections import OrderedDict
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal
|
||||
from pylabrobot.liquid_handling.standard import GripDirection
|
||||
from typing import Any, List, Dict, Optional, OrderedDict, Tuple, TypedDict, Union, Sequence, Iterator, Literal
|
||||
|
||||
from pylabrobot.liquid_handling import (
|
||||
LiquidHandlerBackend,
|
||||
@@ -30,9 +28,9 @@ from pylabrobot.liquid_handling.standard import (
|
||||
ResourceMove,
|
||||
ResourceDrop,
|
||||
)
|
||||
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack
|
||||
from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, TubeRack, PlateAdapter
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -71,35 +69,7 @@ class PRCXI9300Deck(Deck):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z)
|
||||
self.slots = [None] * 6 # PRCXI 9300 有 6 个槽位
|
||||
class PRCXI9300Container(Plate):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str,
|
||||
ordering: collections.OrderedDict,
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
|
||||
self._unilabos_state = {}
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""从给定的状态加载工作台信息。"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state)
|
||||
return data
|
||||
class PRCXI9300Plate(Plate):
|
||||
"""
|
||||
专用孔板类:
|
||||
@@ -113,43 +83,11 @@ class PRCXI9300Plate(Plate):
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 Plate 自己创建 Well 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
items = ordered_items if ordered_items is not None else ordering
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 传递 ordering 参数,让 Plate 自己创建 Well 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
@@ -186,7 +124,8 @@ class PRCXI9300Plate(Plate):
|
||||
safe_state[k] = v
|
||||
|
||||
data.update(safe_state)
|
||||
return data # 其他顶层属性也进行类型检查
|
||||
return data
|
||||
|
||||
class PRCXI9300TipRack(TipRack):
|
||||
""" 专用吸头盒类 """
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
@@ -196,43 +135,11 @@ class PRCXI9300TipRack(TipRack):
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TipRack 自己创建 Tip 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
items = ordered_items if ordered_items is not None else ordering
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 传递 ordering 参数,让 TipRack 自己创建 Tip 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
category=category,
|
||||
model=model, **kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
@@ -328,51 +235,14 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
category: str = "tube_rack",
|
||||
items: Optional[Dict[str, Any]] = None,
|
||||
ordered_items: Optional[OrderedDict] = None,
|
||||
ordering: Optional[OrderedDict] = None,
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
|
||||
# 如果 ordered_items 不为 None,直接使用
|
||||
if ordered_items is not None:
|
||||
items_to_pass = ordered_items
|
||||
ordering_param = None
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象
|
||||
items_to_pass = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items_to_pass = ordering
|
||||
ordering_param = None
|
||||
elif items is not None:
|
||||
# 兼容旧的 items 参数
|
||||
items_to_pass = items
|
||||
ordering_param = None
|
||||
else:
|
||||
items_to_pass = None
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items_to_pass is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items_to_pass,
|
||||
model=model,
|
||||
**kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
model=model,
|
||||
**kwargs)
|
||||
else:
|
||||
# 兼容处理:PLR 的 TubeRack 构造函数可能接受 items 或 ordered_items
|
||||
items_to_pass = items if items is not None else ordered_items
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=ordered_items,
|
||||
model=model,
|
||||
**kwargs)
|
||||
|
||||
@@ -505,11 +375,15 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
child_state = getattr(child, "_unilabos_state", {})
|
||||
if "Material" in child_state:
|
||||
count += 1
|
||||
tablets_info.append(
|
||||
WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"])
|
||||
WorkTablets(
|
||||
Number=count,
|
||||
Code=f"T{count}",
|
||||
Material=child_state["Material"]
|
||||
)
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
@@ -529,7 +403,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
super().post_init(ros_node)
|
||||
self._unilabos_backend.post_init(ros_node)
|
||||
|
||||
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn:
|
||||
def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]):
|
||||
return super().set_liquid(wells, liquid_names, volumes)
|
||||
|
||||
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
|
||||
@@ -786,37 +660,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0):
|
||||
return await super().move_to(well, dis_to_top, channel)
|
||||
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait)
|
||||
|
||||
async def heater_action(self, temperature: float, time: int):
|
||||
return await self._unilabos_backend.heater_action(temperature, time)
|
||||
async def move_plate(
|
||||
self,
|
||||
plate: Plate,
|
||||
to: Resource,
|
||||
intermediate_locations: Optional[List[Coordinate]] = None,
|
||||
pickup_offset: Coordinate = Coordinate.zero(),
|
||||
destination_offset: Coordinate = Coordinate.zero(),
|
||||
drop_direction: GripDirection = GripDirection.FRONT,
|
||||
pickup_direction: GripDirection = GripDirection.FRONT,
|
||||
pickup_distance_from_top: float = 13.2 - 3.33,
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
return await super().move_plate(
|
||||
plate,
|
||||
to,
|
||||
intermediate_locations,
|
||||
pickup_offset,
|
||||
destination_offset,
|
||||
drop_direction,
|
||||
pickup_direction,
|
||||
pickup_distance_from_top,
|
||||
target_plate_number = to,
|
||||
**backend_kwargs,
|
||||
)
|
||||
|
||||
class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
|
||||
|
||||
@@ -857,55 +700,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self._num_channels = channel_num
|
||||
self._execute_setup = setup
|
||||
self.debug = debug
|
||||
self.axis = "Left"
|
||||
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
step = self.api_client.shaker_action(
|
||||
time=time,
|
||||
module_no=module_no,
|
||||
amplitude=amplitude,
|
||||
is_wait=is_wait,
|
||||
)
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs):
|
||||
|
||||
resource=pickup.resource
|
||||
offset=pickup.offset
|
||||
pickup_distance_from_top=pickup.pickup_distance_from_top
|
||||
direction=pickup.direction
|
||||
|
||||
plate_number = int(resource.parent.name.replace("T", ""))
|
||||
is_whole_plate = True
|
||||
balance_height = 0
|
||||
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
|
||||
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop, **backend_kwargs):
|
||||
|
||||
|
||||
plate_number = None
|
||||
target_plate_number = backend_kwargs.get("target_plate_number", None)
|
||||
if target_plate_number is not None:
|
||||
plate_number = int(target_plate_number.name.replace("T", ""))
|
||||
|
||||
|
||||
is_whole_plate = True
|
||||
balance_height = 0
|
||||
if plate_number is None:
|
||||
raise ValueError("target_plate_number is required when dropping a resource")
|
||||
step = self.api_client.clamp_jaw_drop(plate_number, is_whole_plate, balance_height)
|
||||
self.steps_todo_list.append(step)
|
||||
return step
|
||||
|
||||
|
||||
async def heater_action(self, temperature: float, time: int):
|
||||
print(f"\n\nHeater action: temperature={temperature}, time={time}\n\n")
|
||||
# return await self.api_client.heater_action(temperature, time)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
@@ -937,11 +731,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
print(f"PRCXI9300Backend created solution with ID: {solution_id}")
|
||||
self.api_client.load_solution(solution_id)
|
||||
print(json.dumps(self.steps_todo_list, indent=2))
|
||||
if not self.api_client.start():
|
||||
return False
|
||||
if not self.api_client.wait_for_finish():
|
||||
return False
|
||||
return True
|
||||
return self.api_client.start()
|
||||
|
||||
@classmethod
|
||||
def check_channels(cls, use_channels: List[int]) -> List[int]:
|
||||
@@ -963,7 +753,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
# 清除错误代码
|
||||
self.api_client.clear_error_code()
|
||||
print("PRCXI9300 error code cleared.")
|
||||
self.api_client.call("IAutomation", "Stop")
|
||||
|
||||
# 执行重置
|
||||
print("Starting PRCXI9300 reset...")
|
||||
self.api_client.call("IAutomation", "Reset")
|
||||
@@ -987,23 +777,12 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int] = None):
|
||||
"""Pick up tips from the specified resource."""
|
||||
# INSERT_YOUR_CODE
|
||||
# Ensure use_channels is converted to a list of ints if it's an array
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
# print(f"Plate index: {plate_index}, Plate name: {plate.name}")
|
||||
# print(f"Number of children in deck: {len(deck.children)}")
|
||||
|
||||
@@ -1028,7 +807,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Load(
|
||||
axis=axis,
|
||||
dosage=0,
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -1043,23 +821,13 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int] = None):
|
||||
"""Pick up tips from the specified resource."""
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
|
||||
# 检查trash #
|
||||
if ops[0].resource.name == "trash":
|
||||
|
||||
PlateNo = ops[0].resource.parent.parent.children.index(ops[0].resource.parent) + 1
|
||||
PlateNo = ops[0].resource.parent.children.index(ops[0].resource) + 1
|
||||
|
||||
step = self.api_client.UnLoad(
|
||||
axis=axis,
|
||||
dosage=0,
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -1077,8 +845,8 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
plate_indexes.append(plate_index)
|
||||
if len(set(plate_indexes)) != 1:
|
||||
raise ValueError(
|
||||
@@ -1102,7 +870,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.UnLoad(
|
||||
axis=axis,
|
||||
dosage=0,
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -1129,9 +896,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
plate_indexes = []
|
||||
for op in targets:
|
||||
deck = op.parent.parent.parent
|
||||
deck = op.parent.parent
|
||||
plate = op.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
plate_index = deck.children.index(plate)
|
||||
plate_indexes.append(plate_index)
|
||||
|
||||
if len(set(plate_indexes)) != 1:
|
||||
@@ -1169,21 +936,12 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None):
|
||||
"""Aspirate liquid from the specified resources."""
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
plate_indexes.append(plate_index)
|
||||
|
||||
if len(set(plate_indexes)) != 1:
|
||||
@@ -1211,7 +969,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Imbibing(
|
||||
axis=axis,
|
||||
dosage=int(volumes[0]),
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -1226,21 +983,12 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int] = None):
|
||||
"""Dispense liquid into the specified resources."""
|
||||
if hasattr(use_channels, 'tolist'):
|
||||
_use_channels = use_channels.tolist()
|
||||
else:
|
||||
_use_channels = list(use_channels) if use_channels is not None else None
|
||||
if _use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif _use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(_use_channels))
|
||||
|
||||
plate_indexes = []
|
||||
for op in ops:
|
||||
plate = op.resource.parent
|
||||
deck = plate.parent.parent
|
||||
plate_index = deck.children.index(plate.parent)
|
||||
deck = plate.parent
|
||||
plate_index = deck.children.index(plate)
|
||||
plate_indexes.append(plate_index)
|
||||
|
||||
if len(set(plate_indexes)) != 1:
|
||||
@@ -1269,7 +1017,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Tapping(
|
||||
axis=axis,
|
||||
dosage=int(volumes[0]),
|
||||
plate_no=PlateNo,
|
||||
is_whole_plate=False,
|
||||
@@ -1294,8 +1041,14 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
raise NotImplementedError("The Opentrons backend does not support the 96 head.")
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
raise NotImplementedError("The Opentrons backend does not support the robotic arm.")
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
pass
|
||||
raise NotImplementedError("The Opentrons backend does not support the robotic arm.")
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
raise NotImplementedError("The Opentrons backend does not support the robotic arm.")
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True # PRCXI9300Backend does not have tip compatibility issues
|
||||
@@ -1386,28 +1139,6 @@ class PRCXI9300Api:
|
||||
def start(self) -> bool:
|
||||
return self.call("IAutomation", "Start")
|
||||
|
||||
def wait_for_finish(self) -> bool:
|
||||
success = False
|
||||
start = False
|
||||
while not success:
|
||||
status = self.step_state_list()
|
||||
if len(status) == 1:
|
||||
start = True
|
||||
if status is None:
|
||||
break
|
||||
if len(status) == 0:
|
||||
break
|
||||
if status[-1]["State"] == 2 and start:
|
||||
success = True
|
||||
elif status[-1]["State"] > 2:
|
||||
break
|
||||
elif status[-1]["State"] == 0:
|
||||
start = True
|
||||
else:
|
||||
time.sleep(1)
|
||||
return success
|
||||
|
||||
|
||||
def call(self, service: str, method: str, params: Optional[list] = None) -> Any:
|
||||
payload = json.dumps(
|
||||
{"ServiceName": service, "MethodName": method, "Paramters": params or []}, separators=(",", ":")
|
||||
@@ -1494,10 +1225,9 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": axis,
|
||||
"StepAxis": self.axis,
|
||||
"Function": "Load",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1533,10 +1263,9 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": axis,
|
||||
"StepAxis": self.axis,
|
||||
"Function": "Imbibing",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1572,10 +1301,9 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": axis,
|
||||
"StepAxis": self.axis,
|
||||
"Function": "Tapping",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1611,10 +1339,9 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": axis,
|
||||
"StepAxis": self.axis,
|
||||
"Function": "Blending",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1650,10 +1377,9 @@ class PRCXI9300Api:
|
||||
assist_fun4: str = "",
|
||||
assist_fun5: str = "",
|
||||
liquid_method: str = "NormalDispense",
|
||||
axis: str = "Left",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": axis,
|
||||
"StepAxis": self.axis,
|
||||
"Function": "UnLoad",
|
||||
"DosageNum": dosage,
|
||||
"PlateNo": plate_no,
|
||||
@@ -1672,50 +1398,6 @@ class PRCXI9300Api:
|
||||
"LiquidDispensingMethod": liquid_method,
|
||||
}
|
||||
|
||||
def clamp_jaw_pick_up(self,
|
||||
plate_no: int,
|
||||
is_whole_plate: bool,
|
||||
balance_height: int,
|
||||
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": "ClampingJaw",
|
||||
"Function": "DefectiveLift",
|
||||
"PlateNo": plate_no,
|
||||
"IsWholePlate": is_whole_plate,
|
||||
"HoleRow": 1,
|
||||
"HoleCol": 1,
|
||||
"BalanceHeight": balance_height,
|
||||
"PlateOrHoleNum": f"T{plate_no}"
|
||||
}
|
||||
|
||||
def clamp_jaw_drop(
|
||||
self,
|
||||
plate_no: int,
|
||||
is_whole_plate: bool,
|
||||
balance_height: int,
|
||||
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"StepAxis": "ClampingJaw",
|
||||
"Function": "PutDown",
|
||||
"PlateNo": plate_no,
|
||||
"IsWholePlate": is_whole_plate,
|
||||
"HoleRow": 1,
|
||||
"HoleCol": 1,
|
||||
"BalanceHeight": balance_height,
|
||||
"PlateOrHoleNum": f"T{plate_no}"
|
||||
}
|
||||
|
||||
def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
return {
|
||||
"StepAxis": "Left",
|
||||
"Function": "Shaking",
|
||||
"AssistFun1": time,
|
||||
"AssistFun2": module_no,
|
||||
"AssistFun3": amplitude,
|
||||
"AssistFun4": is_wait,
|
||||
}
|
||||
|
||||
class DefaultLayout:
|
||||
|
||||
|
||||
@@ -176,7 +176,24 @@ class BioyondV1RPC(BaseRequest):
|
||||
return {}
|
||||
|
||||
print(f"add material data: {response['data']}")
|
||||
return response.get("data", {})
|
||||
|
||||
# 自动更新缓存
|
||||
data = response.get("data", {})
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
# 如果返回的是字符串,通常是ID
|
||||
mat_id = data
|
||||
name = params.get("name")
|
||||
else:
|
||||
# 如果返回的是字典,尝试获取name和id
|
||||
name = data.get("name") or params.get("name")
|
||||
mat_id = data.get("id")
|
||||
|
||||
if name and mat_id:
|
||||
self.material_cache[name] = mat_id
|
||||
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||
|
||||
return data
|
||||
|
||||
def query_matial_type_id(self, data) -> list:
|
||||
"""查找物料typeid"""
|
||||
@@ -203,7 +220,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": {},
|
||||
"data": 0,
|
||||
})
|
||||
if not response or response['code'] != 1:
|
||||
return []
|
||||
@@ -273,6 +290,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return {}
|
||||
|
||||
# 自动更新缓存 - 移除被删除的物料
|
||||
for name, mid in list(self.material_cache.items()):
|
||||
if mid == material_id:
|
||||
del self.material_cache[name]
|
||||
print(f"已从缓存移除物料: {name}")
|
||||
break
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
@@ -1123,6 +1148,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
# 如果缓存中没有,尝试刷新缓存
|
||||
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||
self.refresh_material_cache()
|
||||
if material_name_or_id in self.material_cache:
|
||||
material_id = self.material_cache[material_name_or_id]
|
||||
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||
return material_name_or_id
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing_extensions import TypedDict
|
||||
import requests
|
||||
import pint
|
||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||
@@ -43,6 +44,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||
self.order_completion_status = {}
|
||||
|
||||
# 初始化 pint 单位注册表
|
||||
self.ureg = pint.UnitRegistry()
|
||||
|
||||
# 化合物信息
|
||||
self.compound_info = {
|
||||
"MolWt": {
|
||||
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||
},
|
||||
"FuncGroup": {
|
||||
"MDA": "Amine",
|
||||
"TDA": "Amine",
|
||||
"PAPP": "Amine",
|
||||
"BTDA": "Anhydride",
|
||||
"BPDA": "Anhydride",
|
||||
"6FAP": "Amine",
|
||||
"MPDA": "Amine",
|
||||
"SIDA": "Amine",
|
||||
"PMDA": "Anhydride",
|
||||
"ODA": "Amine",
|
||||
"4,4'-ODA": "Amine",
|
||||
"134": "Amine",
|
||||
}
|
||||
}
|
||||
|
||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||
"""项目接口通用POST调用
|
||||
|
||||
@@ -118,20 +154,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
ratio = json.loads(ratio)
|
||||
except Exception:
|
||||
ratio = {}
|
||||
root = str(Path(__file__).resolve().parents[3])
|
||||
if root not in sys.path:
|
||||
sys.path.append(root)
|
||||
try:
|
||||
mod = importlib.import_module("tem.compute")
|
||||
except Exception as e:
|
||||
raise BioyondException(f"无法导入计算模块: {e}")
|
||||
try:
|
||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||
except Exception as e:
|
||||
raise BioyondException(f"参数解析失败: {e}")
|
||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
||||
|
||||
# 2. 调用内部计算方法
|
||||
res = self._generate_experiment_design(
|
||||
ratio=ratio,
|
||||
wt_percent=wp,
|
||||
m_tot=mt,
|
||||
titration_percent=tp
|
||||
)
|
||||
|
||||
# 3. 构造返回结果
|
||||
out = {
|
||||
"solutions": res.get("solutions", []),
|
||||
"titration": res.get("titration", {}),
|
||||
@@ -140,11 +178,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
"return_info": json.dumps(res, ensure_ascii=False)
|
||||
}
|
||||
return out
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BioyondException(str(e))
|
||||
|
||||
def _generate_experiment_design(
|
||||
self,
|
||||
ratio: dict,
|
||||
wt_percent: float = 0.25,
|
||||
m_tot: float = 70,
|
||||
titration_percent: float = 0.03,
|
||||
) -> dict:
|
||||
"""内部方法:生成实验设计
|
||||
|
||||
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||
|
||||
参数:
|
||||
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||
wt_percent: 固体重量百分比
|
||||
m_tot: 反应混合物总质量(g)
|
||||
titration_percent: 滴定溶液百分比
|
||||
|
||||
返回:
|
||||
包含实验设计详细参数的字典
|
||||
"""
|
||||
# 溶剂密度
|
||||
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||
# 二酐溶解度
|
||||
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||
# 投入固体时最小溶剂体积
|
||||
V_min = 30 * self.ureg.ml
|
||||
m_tot = m_tot * self.ureg.g
|
||||
|
||||
# 保持ratio中的顺序
|
||||
compound_names = list(ratio.keys())
|
||||
compound_ratios = list(ratio.values())
|
||||
|
||||
# 验证所有化合物是否在 compound_info 中定义
|
||||
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||
if undefined_compounds:
|
||||
available = list(self.compound_info["MolWt"].keys())
|
||||
raise ValueError(
|
||||
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||
f"可用的化合物: {available}"
|
||||
)
|
||||
|
||||
# 获取各化合物的分子量和官能团类型
|
||||
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||
|
||||
# 记录化合物信息用于调试
|
||||
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||
|
||||
# 按原始顺序分离二胺和二酐
|
||||
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||
|
||||
if not diamine_compounds or not anhydride_compounds:
|
||||
raise ValueError(
|
||||
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||
)
|
||||
|
||||
# 计算加权平均分子量 (基于摩尔比)
|
||||
total_molar_ratio = sum(compound_ratios)
|
||||
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||
|
||||
# 取最后一个二酐用于滴定
|
||||
titration_anhydride = anhydride_compounds[-1]
|
||||
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||
|
||||
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||
diamine_solutions = []
|
||||
total_diamine_volume = 0 * self.ureg.ml
|
||||
|
||||
# 计算反应物的总摩尔量
|
||||
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||
|
||||
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||
# 跳过 SIDA
|
||||
if name == "SIDA":
|
||||
continue
|
||||
|
||||
# 计算该二胺需要的摩尔数
|
||||
n_diamine_needed = n_reactant * ratio_val
|
||||
|
||||
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||
|
||||
# 计算该二胺溶液的浓度
|
||||
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||
|
||||
# 计算需要移取的溶液体积
|
||||
V_diamine_needed = n_diamine_needed / c_diamine
|
||||
|
||||
diamine_solutions.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"solid_mass": m_diamine_solid.magnitude,
|
||||
"solvent_volume": V_solvent_for_this.magnitude,
|
||||
"concentration": c_diamine.magnitude,
|
||||
"volume_needed": V_diamine_needed.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
total_diamine_volume += V_diamine_needed
|
||||
|
||||
# 按原始顺序排序
|
||||
diamine_solutions.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算滴定二酐的质量
|
||||
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||
m_titration_10 = m_titration_anhydride * titration_percent
|
||||
|
||||
# 计算其他固体二酐的质量 (按顺序)
|
||||
solid_anhydride_masses = []
|
||||
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||
mass = n_reactant * ratio_val * mw
|
||||
solid_anhydride_masses.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"mass": mass.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
# 按原始顺序排序
|
||||
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算溶剂用量
|
||||
total_diamine_solution_mass = sum(
|
||||
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||
) * self.ureg.ml
|
||||
|
||||
# 预估滴定溶剂量、计算补加溶剂量
|
||||
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||
|
||||
# 检查最小溶剂体积要求
|
||||
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||
|
||||
# 如果需要,按比例放大
|
||||
scale_factor = 1.0
|
||||
if m_tot_min > m_tot:
|
||||
scale_factor = (m_tot_min / m_tot).magnitude
|
||||
m_titration_90 *= scale_factor
|
||||
m_titration_10 *= scale_factor
|
||||
m_solvent_add *= scale_factor
|
||||
m_solvent_titration *= scale_factor
|
||||
|
||||
# 更新二胺溶液用量
|
||||
for sol in diamine_solutions:
|
||||
sol["volume_needed"] *= scale_factor
|
||||
|
||||
# 更新固体二酐用量
|
||||
for anhydride in solid_anhydride_masses:
|
||||
anhydride["mass"] *= scale_factor
|
||||
|
||||
m_tot = m_tot_min
|
||||
|
||||
# 生成投料顺序
|
||||
feeding_order = []
|
||||
|
||||
# 1. 固体二酐 (按顺序)
|
||||
for anhydride in solid_anhydride_masses:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "solid_anhydride",
|
||||
"name": anhydride["name"],
|
||||
"amount": anhydride["mass"],
|
||||
"order": anhydride["order"]
|
||||
})
|
||||
|
||||
# 2. 二胺溶液 (按顺序)
|
||||
for sol in diamine_solutions:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "diamine_solution",
|
||||
"name": sol["name"],
|
||||
"amount": sol["volume_needed"],
|
||||
"order": sol["order"]
|
||||
})
|
||||
|
||||
# 3. 主要二酐粉末
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "main_anhydride",
|
||||
"name": titration_name,
|
||||
"amount": m_titration_90.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 4. 补加溶剂
|
||||
if m_solvent_add > 0:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "additional_solvent",
|
||||
"name": "溶剂",
|
||||
"amount": m_solvent_add.magnitude,
|
||||
"order": 999
|
||||
})
|
||||
|
||||
# 5. 滴定二酐溶液
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "titration_anhydride",
|
||||
"name": f"{titration_name} 滴定液",
|
||||
"amount": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 返回实验设计结果
|
||||
results = {
|
||||
"total_mass": m_tot.magnitude,
|
||||
"scale_factor": scale_factor,
|
||||
"solutions": diamine_solutions,
|
||||
"solids": solid_anhydride_masses,
|
||||
"titration": {
|
||||
"name": titration_name,
|
||||
"main_portion": m_titration_90.magnitude,
|
||||
"titration_portion": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
},
|
||||
"solvents": {
|
||||
"additional_solvent": m_solvent_add.magnitude,
|
||||
"total_liquid_volume": total_liquid_volume.magnitude
|
||||
},
|
||||
"feeding_order": feeding_order,
|
||||
"minimum_required_mass": m_tot_min.magnitude
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
# 90%10%小瓶投料任务创建方法
|
||||
def create_90_10_vial_feeding_task(self,
|
||||
order_name: str = None,
|
||||
@@ -961,6 +1236,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
'actualVolume': actual_volume
|
||||
}
|
||||
|
||||
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||
if not isinstance(report, dict):
|
||||
return report
|
||||
|
||||
data = report.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
return report
|
||||
|
||||
# 提取关键信息
|
||||
simplified = {
|
||||
'name': data.get('name'),
|
||||
'code': data.get('code'),
|
||||
'requester': data.get('requester'),
|
||||
'workflowName': data.get('workflowName'),
|
||||
'workflowStep': data.get('workflowStep'),
|
||||
'requestTime': data.get('requestTime'),
|
||||
'startPreparationTime': data.get('startPreparationTime'),
|
||||
'completeTime': data.get('completeTime'),
|
||||
'useTime': data.get('useTime'),
|
||||
'status': data.get('status'),
|
||||
'statusName': data.get('statusName'),
|
||||
}
|
||||
|
||||
# 提取物料信息(简化版)
|
||||
pre_intakes = data.get('preIntakes', [])
|
||||
if pre_intakes and isinstance(pre_intakes, list):
|
||||
first_intake = pre_intakes[0]
|
||||
sample_materials = first_intake.get('sampleMaterials', [])
|
||||
|
||||
# 简化物料信息
|
||||
simplified_materials = []
|
||||
for material in sample_materials:
|
||||
if isinstance(material, dict):
|
||||
mat_info = {
|
||||
'materialName': material.get('materialName'),
|
||||
'materialTypeName': material.get('materialTypeName'),
|
||||
'materialCode': material.get('materialCode'),
|
||||
'materialLocation': material.get('materialLocation'),
|
||||
}
|
||||
|
||||
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||
params_str = material.get('parameters', '{}')
|
||||
try:
|
||||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||
if isinstance(params, dict):
|
||||
# 只保留关键参数
|
||||
if 'density' in params:
|
||||
mat_info['density'] = params['density']
|
||||
if 'feedingHistory' in params:
|
||||
mat_info['feedingHistory'] = params['feedingHistory']
|
||||
if 'liquidVolume' in params:
|
||||
mat_info['liquidVolume'] = params['liquidVolume']
|
||||
if 'm_diamine_tot' in params:
|
||||
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||
if 'wt_diamine' in params:
|
||||
mat_info['wt_diamine'] = params['wt_diamine']
|
||||
except:
|
||||
pass
|
||||
|
||||
simplified_materials.append(mat_info)
|
||||
|
||||
simplified['sampleMaterials'] = simplified_materials
|
||||
|
||||
# 提取extraProperties中的实际值
|
||||
extra_props = first_intake.get('extraProperties', {})
|
||||
if isinstance(extra_props, dict):
|
||||
simplified_extra = {}
|
||||
for key, value in extra_props.items():
|
||||
try:
|
||||
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||
simplified_extra[key] = parsed_value
|
||||
except:
|
||||
simplified_extra[key] = value
|
||||
simplified['extraProperties'] = simplified_extra
|
||||
|
||||
return {
|
||||
'data': simplified,
|
||||
'code': report.get('code'),
|
||||
'message': report.get('message'),
|
||||
'timestamp': report.get('timestamp')
|
||||
}
|
||||
|
||||
def scheduler_start(self) -> dict:
|
||||
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
|
||||
Returns:
|
||||
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||
|
||||
Raises:
|
||||
BioyondException: 调度器启动失败时抛出异常
|
||||
"""
|
||||
result = self.hardware_interface.scheduler_start()
|
||||
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||
|
||||
if result != 1:
|
||||
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||
self.hardware_interface._logger.error(error_msg)
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
return {"return_info": result}
|
||||
|
||||
# 等待多个任务完成并获取实验报告
|
||||
def wait_for_multiple_orders_and_get_reports(self,
|
||||
batch_create_result: str = None,
|
||||
@@ -1002,7 +1379,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证batch_create_result参数
|
||||
if not batch_create_result or batch_create_result == "":
|
||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||
raise BioyondException(
|
||||
"batch_create_result参数为空,请确保:\n"
|
||||
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||
"2. batch_create节点成功执行并返回了结果\n"
|
||||
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||
)
|
||||
|
||||
# 解析batch_create_result JSON对象
|
||||
try:
|
||||
@@ -1031,7 +1413,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证提取的数据
|
||||
if not order_codes:
|
||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||
self.hardware_interface._logger.error(
|
||||
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||
)
|
||||
raise BioyondException(
|
||||
"batch_create_result中未找到order_codes或为空。\n"
|
||||
"可能的原因:\n"
|
||||
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||
"3. Bioyond系统状态异常\n"
|
||||
f"请检查batch_create任务的执行结果"
|
||||
)
|
||||
if not order_ids:
|
||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||
|
||||
@@ -1114,6 +1506,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功获取任务 {order_code} 的实验报告"
|
||||
)
|
||||
# 简化报告,去除冗余信息
|
||||
report = self._simplify_report(report)
|
||||
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
||||
"""
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
@@ -29,6 +30,90 @@ from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class ConnectionMonitor:
|
||||
"""Bioyond连接监控器"""
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_status = "unknown"
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||
self._thread.start()
|
||||
logger.info("Bioyond连接监控器已启动")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
logger.info("Bioyond连接监控器已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 使用 lightweight API 检查连接
|
||||
# query_matial_type_list 是比较快的查询
|
||||
start_time = time.time()
|
||||
result = self.workstation.hardware_interface.material_type_list()
|
||||
|
||||
status = "online" if result else "offline"
|
||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||
|
||||
if status != self._last_status:
|
||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||
self._publish_event(status, msg)
|
||||
self._last_status = status
|
||||
|
||||
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||
# self._publish_event(status, msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond连接检查异常: {e}")
|
||||
if self._last_status != "error":
|
||||
self._publish_event("error", str(e))
|
||||
self._last_status = "error"
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _publish_event(self, status, message):
|
||||
try:
|
||||
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||
event_data = {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||
# 话题: /<namespace>/events/device_status
|
||||
ns = self.workstation._ros_node.namespace
|
||||
topic = f"{ns}/events/device_status"
|
||||
|
||||
# 使用 ROS2DeviceNode 的发布功能
|
||||
# 如果没有预定义的 publisher,需要动态创建
|
||||
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||
# 这里我们先尝试用 String json 发布
|
||||
|
||||
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||
if not hasattr(self.workstation, "_device_status_pub"):
|
||||
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self.workstation._device_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布设备状态事件失败: {e}")
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
"""Bioyond资源同步器
|
||||
|
||||
@@ -239,13 +324,18 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||
|
||||
# 导入物料默认参数配置
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS, MATERIAL_TYPE_PARAMETERS
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = MATERIAL_DEFAULT_PARAMETERS.copy()
|
||||
for type_id, params in MATERIAL_TYPE_PARAMETERS.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||
@@ -468,13 +558,18 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
return material_bioyond_id
|
||||
|
||||
# 转换为 Bioyond 格式
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS, MATERIAL_TYPE_PARAMETERS
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = MATERIAL_DEFAULT_PARAMETERS.copy()
|
||||
for type_id, params in MATERIAL_TYPE_PARAMETERS.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||
@@ -584,6 +679,44 @@ class BioyondWorkstation(WorkstationBase):
|
||||
集成Bioyond物料管理的工作站实现
|
||||
"""
|
||||
|
||||
def _publish_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
task_type: str,
|
||||
status: str,
|
||||
result: dict = None,
|
||||
progress: float = 0.0,
|
||||
task_code: str = None
|
||||
):
|
||||
"""发布任务状态事件"""
|
||||
try:
|
||||
if not getattr(self, "_ros_node", None):
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"task_id": task_id,
|
||||
"task_code": task_code,
|
||||
"task_type": task_type,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
if result:
|
||||
event_data["result"] = result
|
||||
|
||||
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||
|
||||
if not hasattr(self, "_task_status_pub"):
|
||||
self._task_status_pub = self._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self._task_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布任务状态事件失败: {e}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -632,13 +765,16 @@ class BioyondWorkstation(WorkstationBase):
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||
}
|
||||
self.http_service = None # 将在 post_init 中启动
|
||||
self.http_service = None # 将在 post_init 启动
|
||||
self.connection_monitor = None # 将在 post_init 启动
|
||||
|
||||
logger.info(f"Bioyond工作站初始化完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||
try:
|
||||
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||
self.connection_monitor.stop()
|
||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||
logger.info("正在停止 HTTP 报送服务...")
|
||||
self.http_service.stop()
|
||||
@@ -648,6 +784,13 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动连接监控失败: {e}")
|
||||
|
||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||
if hasattr(self, '_http_service_config'):
|
||||
try:
|
||||
@@ -1014,7 +1157,15 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
workflow_id = self._get_workflow(actual_workflow_name)
|
||||
if workflow_id:
|
||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||
if isinstance(self.workflow_sequence, list):
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||
self._cached_workflow_sequence.append(workflow_id)
|
||||
else:
|
||||
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||
return False
|
||||
|
||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||
return True
|
||||
return False
|
||||
@@ -1215,6 +1366,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||
# 例如:更新数据库、触发后续流程等
|
||||
|
||||
# 发布任务状态事件 (running/progress update)
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_step",
|
||||
status="running",
|
||||
progress=0.5, # 步骤完成视为任务进行中
|
||||
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"step_id": data.get('stepId'),
|
||||
@@ -1249,6 +1416,17 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_sample",
|
||||
status="running",
|
||||
progress=0.7,
|
||||
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"sample_id": data.get('sampleId'),
|
||||
@@ -1288,6 +1466,32 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||
# 例如:更新物料库存、生成报表等
|
||||
|
||||
# 映射状态到事件状态
|
||||
event_status = "completed"
|
||||
if str(data.get('status')) in ["-11", "-12"]:
|
||||
event_status = "error"
|
||||
elif str(data.get('status')) == "30":
|
||||
event_status = "completed"
|
||||
else:
|
||||
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_order",
|
||||
status=event_status,
|
||||
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||
if event_status == "completed":
|
||||
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"order_code": data.get('orderCode'),
|
||||
|
||||
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
# 验证必需字段
|
||||
if 'brand' in request_data:
|
||||
if request_data['brand'] == "bioyond": # 奔曜
|
||||
error_msg = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||
material_data = request_data["text"]
|
||||
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: {error_msg}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||
message=f"物料变更报送已收到: {material_data}",
|
||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||
data=None
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -5,229 +5,6 @@ bioyond_dispensing_station:
|
||||
- bioyond_dispensing_station
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-brief_step_parameters:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
data: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-compute_experiment_design:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
type: string
|
||||
ratio:
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesignReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
used_materials: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-project_order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-query_resource_by_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_90_10_vial_feeding_tasks:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -394,6 +171,99 @@ bioyond_dispensing_station:
|
||||
title: BatchCreateDiamineSolutionTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
compute_experiment_design:
|
||||
feedback: {}
|
||||
goal:
|
||||
m_tot: m_tot
|
||||
ratio: ratio
|
||||
titration_percent: titration_percent
|
||||
wt_percent: wt_percent
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: ''
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles:
|
||||
output:
|
||||
- data_key: solutions
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: solutions
|
||||
io_type: sink
|
||||
label: Solution Data From Python
|
||||
- data_key: titration
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: titration
|
||||
io_type: sink
|
||||
label: Titration Data From Calculation Node
|
||||
- data_key: solvents
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: solvents
|
||||
io_type: sink
|
||||
label: Solvents Data From Calculation Node
|
||||
- data_key: feeding_order
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: feeding_order
|
||||
io_type: sink
|
||||
label: Feeding Order Data From Calculation Node
|
||||
result:
|
||||
feeding_order: feeding_order
|
||||
return_info: return_info
|
||||
solutions: solutions
|
||||
solvents: solvents
|
||||
titration: titration
|
||||
schema:
|
||||
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
description: 总质量(g)
|
||||
type: string
|
||||
ratio:
|
||||
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||
type: string
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
description: 滴定比例(10%部分)
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
description: 目标固含质量分数
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
type: array
|
||||
return_info:
|
||||
type: string
|
||||
solutions:
|
||||
type: array
|
||||
solvents:
|
||||
type: object
|
||||
titration:
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesign_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: ComputeExperimentDesign
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -620,6 +490,89 @@ bioyond_dispensing_station:
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_device_id: target_device_id
|
||||
transfer_groups: transfer_groups
|
||||
goal_default:
|
||||
target_device_id: ''
|
||||
transfer_groups: ''
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
target_device_id: unilabos_devices
|
||||
result: {}
|
||||
schema:
|
||||
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备)
|
||||
type: string
|
||||
transfer_groups:
|
||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||
items:
|
||||
properties:
|
||||
materials:
|
||||
description: 物料名称(手动输入,系统将通过RPC查询验证)
|
||||
type: string
|
||||
target_sites:
|
||||
description: 目标库位(手动输入,如"A01")
|
||||
type: string
|
||||
target_stack:
|
||||
description: 目标堆栈名称(从列表选择)
|
||||
enum:
|
||||
- 堆栈1左
|
||||
- 堆栈1右
|
||||
- 站内试剂存放堆栈
|
||||
type: string
|
||||
required:
|
||||
- materials
|
||||
- target_stack
|
||||
- target_sites
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal:
|
||||
|
||||
@@ -9,7 +9,6 @@ cameracontroller_device:
|
||||
goal_default:
|
||||
config: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
@@ -32,7 +31,6 @@ cameracontroller_device:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
|
||||
@@ -4,73 +4,6 @@ separator.chinwe:
|
||||
- chinwe
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-connect:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: connect参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-disconnect:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: disconnect参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-execute_command_from_outer:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
command_dict: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
command_dict:
|
||||
type: object
|
||||
required:
|
||||
- command_dict
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_command_from_outer参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_rotate_quarter:
|
||||
goal:
|
||||
direction: 顺时针
|
||||
@@ -370,44 +303,42 @@ separator.chinwe:
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
goal:
|
||||
baudrate:
|
||||
default: 9600
|
||||
description: 串口波特率
|
||||
type: integer
|
||||
motor_ids:
|
||||
default:
|
||||
- 4
|
||||
- 5
|
||||
description: 步进电机ID列表
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
port:
|
||||
default: 192.168.1.200:8899
|
||||
description: 串口号或 IP:Port
|
||||
type: string
|
||||
pump_ids:
|
||||
default:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
description: 注射泵ID列表
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
sensor_id:
|
||||
default: 6
|
||||
description: XKC传感器ID
|
||||
type: integer
|
||||
sensor_threshold:
|
||||
default: 300
|
||||
description: 传感器液位判定阈值
|
||||
type: integer
|
||||
timeout:
|
||||
default: 10.0
|
||||
type: number
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
is_connected:
|
||||
type: boolean
|
||||
sensor_level:
|
||||
type: boolean
|
||||
sensor_rssi:
|
||||
default: 10
|
||||
description: 通信超时时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- sensor_level
|
||||
- sensor_rssi
|
||||
- is_connected
|
||||
type: object
|
||||
version: 2.1.0
|
||||
|
||||
1919
unilabos/registry/devices/laiyu_liquid.yaml
Normal file
1919
unilabos/registry/devices/laiyu_liquid.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,11 @@ xyz_stepper_controller:
|
||||
- laiyu_liquid_test
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-degrees_to_steps:
|
||||
auto-define_current_as_zero:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
degrees: null
|
||||
save_path: work_origin.json
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -17,73 +17,23 @@ xyz_stepper_controller:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
degrees:
|
||||
type: number
|
||||
required:
|
||||
- degrees
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: degrees_to_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-emergency_stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
axis: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: emergency_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-enable_all_axes:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
enable: true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
enable:
|
||||
default: true
|
||||
type: boolean
|
||||
save_path:
|
||||
default: work_origin.json
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_all_axes参数
|
||||
title: define_current_as_zero参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-enable_motor:
|
||||
auto-enable:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
axis: null
|
||||
enable: true
|
||||
state: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -94,45 +44,28 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: object
|
||||
enable:
|
||||
default: true
|
||||
type: string
|
||||
state:
|
||||
type: boolean
|
||||
required:
|
||||
- axis
|
||||
- state
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_motor参数
|
||||
title: enable参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-home_all_axes:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_all_axes参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-home_axis:
|
||||
auto-move_to:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acc: 500
|
||||
axis: null
|
||||
precision: 50
|
||||
speed: 2000
|
||||
steps: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -142,67 +75,38 @@ xyz_stepper_controller:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_axis参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to_position:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
position: null
|
||||
precision: 100
|
||||
speed: 5000
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
acc:
|
||||
default: 500
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
position:
|
||||
type: integer
|
||||
type: string
|
||||
precision:
|
||||
default: 100
|
||||
default: 50
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
default: 2000
|
||||
type: integer
|
||||
steps:
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- position
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position参数
|
||||
title: move_to参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to_position_degrees:
|
||||
auto-move_xyz_work:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
degrees: null
|
||||
precision: 100
|
||||
speed: 5000
|
||||
acc: 1500
|
||||
speed: 100
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -212,314 +116,59 @@ xyz_stepper_controller:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
acc:
|
||||
default: 1500
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
degrees:
|
||||
type: number
|
||||
precision:
|
||||
speed:
|
||||
default: 100
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- degrees
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_degrees参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to_position_revolutions:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
precision: 100
|
||||
revolutions: null
|
||||
speed: 5000
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
precision:
|
||||
default: 100
|
||||
type: integer
|
||||
revolutions:
|
||||
type: number
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- revolutions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_revolutions参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
speed: 5000
|
||||
x: null
|
||||
y: null
|
||||
z: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
x:
|
||||
type: string
|
||||
y:
|
||||
type: string
|
||||
z:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz_degrees:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
speed: 5000
|
||||
x_deg: null
|
||||
y_deg: null
|
||||
z_deg: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_deg:
|
||||
type: string
|
||||
y_deg:
|
||||
type: string
|
||||
z_deg:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_degrees参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz_revolutions:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
speed: 5000
|
||||
x_rev: null
|
||||
y_rev: null
|
||||
z_rev: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_rev:
|
||||
type: string
|
||||
y_rev:
|
||||
type: string
|
||||
z_rev:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_revolutions参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-revolutions_to_steps:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
revolutions: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
revolutions:
|
||||
default: 0.0
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
type: number
|
||||
required:
|
||||
- revolutions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: revolutions_to_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_speed_mode:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
speed: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
speed:
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_speed_mode参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-steps_to_degrees:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
steps: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
steps:
|
||||
type: integer
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_degrees参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-steps_to_revolutions:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
steps: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
steps:
|
||||
type: integer
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_revolutions参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop_all_axes:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_all_axes参数
|
||||
title: move_xyz_work参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_completion:
|
||||
auto-return_to_work_origin:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acc: 800
|
||||
speed: 200
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acc:
|
||||
default: 800
|
||||
type: integer
|
||||
speed:
|
||||
default: 200
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: return_to_work_origin参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_complete:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
@@ -535,23 +184,22 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: object
|
||||
type: string
|
||||
timeout:
|
||||
default: 30.0
|
||||
type: number
|
||||
type: string
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_completion参数
|
||||
title: wait_complete参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController
|
||||
module: unilabos.devices.laiyu_liquid_test.xyz_stepper_driver:XYZStepperController
|
||||
status_types:
|
||||
all_positions: dict
|
||||
motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition
|
||||
status: list
|
||||
type: python
|
||||
config_info: []
|
||||
description: 新XYZ控制器
|
||||
@@ -562,24 +210,23 @@ xyz_stepper_controller:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 115200
|
||||
type: integer
|
||||
port:
|
||||
type: string
|
||||
timeout:
|
||||
default: 1.0
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
client:
|
||||
type: string
|
||||
origin_path:
|
||||
default: unilabos/devices/laiyu_liquid_test/work_origin.json
|
||||
type: string
|
||||
port:
|
||||
default: /dev/ttyUSB0
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
all_positions:
|
||||
type: object
|
||||
motor_status:
|
||||
type: object
|
||||
status:
|
||||
type: array
|
||||
required:
|
||||
- motor_status
|
||||
- all_positions
|
||||
- status
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -4497,9 +4497,6 @@ liquid_handler:
|
||||
simulator:
|
||||
default: false
|
||||
type: boolean
|
||||
total_height:
|
||||
default: 310
|
||||
type: number
|
||||
required:
|
||||
- backend
|
||||
- deck
|
||||
@@ -7550,35 +7547,6 @@ liquid_handler.prcxi:
|
||||
title: custom_delay参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-heater_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
temperature: null
|
||||
time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
temperature:
|
||||
type: number
|
||||
time:
|
||||
type: integer
|
||||
required:
|
||||
- temperature
|
||||
- time
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: heater_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-iter_tips:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -7720,43 +7688,6 @@ liquid_handler.prcxi:
|
||||
title: set_group参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-shaker_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
amplitude: null
|
||||
is_wait: null
|
||||
module_no: null
|
||||
time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
amplitude:
|
||||
type: integer
|
||||
is_wait:
|
||||
type: boolean
|
||||
module_no:
|
||||
type: integer
|
||||
time:
|
||||
type: integer
|
||||
required:
|
||||
- time
|
||||
- module_no
|
||||
- amplitude
|
||||
- is_wait
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: shaker_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-touch_tip:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -8416,341 +8347,6 @@ liquid_handler.prcxi:
|
||||
title: LiquidHandlerMix
|
||||
type: object
|
||||
type: LiquidHandlerMix
|
||||
move_plate:
|
||||
feedback: {}
|
||||
goal:
|
||||
destination_offset: destination_offset
|
||||
drop_direction: drop_direction
|
||||
get_direction: get_direction
|
||||
intermediate_locations: intermediate_locations
|
||||
pickup_direction: pickup_direction
|
||||
pickup_offset: pickup_offset
|
||||
plate: plate
|
||||
put_direction: put_direction
|
||||
resource_offset: resource_offset
|
||||
to: to
|
||||
goal_default:
|
||||
destination_offset:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
drop_direction: ''
|
||||
get_direction: ''
|
||||
intermediate_locations:
|
||||
- x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
pickup_direction: ''
|
||||
pickup_distance_from_top: 0.0
|
||||
pickup_offset:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
plate:
|
||||
category: ''
|
||||
children: []
|
||||
config: ''
|
||||
data: ''
|
||||
id: ''
|
||||
name: ''
|
||||
parent: ''
|
||||
pose:
|
||||
orientation:
|
||||
w: 1.0
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
position:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
put_direction: ''
|
||||
resource_offset:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
to:
|
||||
category: ''
|
||||
children: []
|
||||
config: ''
|
||||
data: ''
|
||||
id: ''
|
||||
name: ''
|
||||
parent: ''
|
||||
pose:
|
||||
orientation:
|
||||
w: 1.0
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
position:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
plate: unilabos_resources
|
||||
to: unilabos_resources
|
||||
result:
|
||||
name: name
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: LiquidHandlerMovePlate_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
destination_offset:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: destination_offset
|
||||
type: object
|
||||
drop_direction:
|
||||
type: string
|
||||
get_direction:
|
||||
type: string
|
||||
intermediate_locations:
|
||||
items:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: intermediate_locations
|
||||
type: object
|
||||
type: array
|
||||
pickup_direction:
|
||||
type: string
|
||||
pickup_distance_from_top:
|
||||
type: number
|
||||
pickup_offset:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: pickup_offset
|
||||
type: object
|
||||
plate:
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
children:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
config:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent:
|
||||
type: string
|
||||
pose:
|
||||
properties:
|
||||
orientation:
|
||||
properties:
|
||||
w:
|
||||
type: number
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
- w
|
||||
title: orientation
|
||||
type: object
|
||||
position:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: position
|
||||
type: object
|
||||
required:
|
||||
- position
|
||||
- orientation
|
||||
title: pose
|
||||
type: object
|
||||
sample_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- sample_id
|
||||
- children
|
||||
- parent
|
||||
- type
|
||||
- category
|
||||
- pose
|
||||
- config
|
||||
- data
|
||||
title: plate
|
||||
type: object
|
||||
put_direction:
|
||||
type: string
|
||||
resource_offset:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: resource_offset
|
||||
type: object
|
||||
to:
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
children:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
config:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent:
|
||||
type: string
|
||||
pose:
|
||||
properties:
|
||||
orientation:
|
||||
properties:
|
||||
w:
|
||||
type: number
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
- w
|
||||
title: orientation
|
||||
type: object
|
||||
position:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: position
|
||||
type: object
|
||||
required:
|
||||
- position
|
||||
- orientation
|
||||
title: pose
|
||||
type: object
|
||||
sample_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- sample_id
|
||||
- children
|
||||
- parent
|
||||
- type
|
||||
- category
|
||||
- pose
|
||||
- config
|
||||
- data
|
||||
title: to
|
||||
type: object
|
||||
required:
|
||||
- plate
|
||||
- to
|
||||
- intermediate_locations
|
||||
- resource_offset
|
||||
- pickup_offset
|
||||
- destination_offset
|
||||
- pickup_direction
|
||||
- drop_direction
|
||||
- get_direction
|
||||
- put_direction
|
||||
- pickup_distance_from_top
|
||||
title: LiquidHandlerMovePlate_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: LiquidHandlerMovePlate_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: LiquidHandlerMovePlate
|
||||
type: object
|
||||
type: LiquidHandlerMovePlate
|
||||
pick_up_tips:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -9737,7 +9333,34 @@ liquid_handler.prcxi:
|
||||
touch_tip: false
|
||||
use_channels:
|
||||
- 0
|
||||
handles: {}
|
||||
handles:
|
||||
input:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: targets
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
|
||||
@@ -5,73 +5,6 @@ neware_battery_test_system:
|
||||
- battery_test
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_status_summary:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: print_status_summary参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-test_connection:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: test_connection参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
debug_resource_names:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -474,8 +407,6 @@ neware_battery_test_system:
|
||||
status_types:
|
||||
channel_status: dict
|
||||
connection_info: dict
|
||||
device_summary: dict
|
||||
plate_status: dict
|
||||
status: str
|
||||
total_channels: int
|
||||
type: python
|
||||
@@ -487,30 +418,36 @@ neware_battery_test_system:
|
||||
config:
|
||||
properties:
|
||||
devtype:
|
||||
default: '27'
|
||||
type: string
|
||||
ip:
|
||||
default: 127.0.0.1
|
||||
type: string
|
||||
machine_id:
|
||||
default: 1
|
||||
type: integer
|
||||
oss_prefix:
|
||||
default: neware_backup
|
||||
description: OSS对象路径前缀
|
||||
type: string
|
||||
oss_upload_enabled:
|
||||
default: false
|
||||
description: 是否启用OSS上传功能
|
||||
type: boolean
|
||||
port:
|
||||
default: 502
|
||||
type: integer
|
||||
size_x:
|
||||
default: 50
|
||||
default: 500.0
|
||||
type: number
|
||||
size_y:
|
||||
default: 50
|
||||
default: 500.0
|
||||
type: number
|
||||
size_z:
|
||||
default: 20
|
||||
default: 2000.0
|
||||
type: number
|
||||
timeout:
|
||||
default: 20
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
@@ -522,8 +459,6 @@ neware_battery_test_system:
|
||||
type: object
|
||||
device_summary:
|
||||
type: object
|
||||
plate_status:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
total_channels:
|
||||
@@ -533,7 +468,6 @@ neware_battery_test_system:
|
||||
- channel_status
|
||||
- connection_info
|
||||
- total_channels
|
||||
- plate_status
|
||||
- device_summary
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -49,32 +49,7 @@ opcua_example:
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
auto-refresh_node_values:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
@@ -92,32 +67,7 @@ opcua_example:
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: print_cache_stats参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-read_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
node_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
node_name:
|
||||
type: string
|
||||
required:
|
||||
- node_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: read_node参数
|
||||
title: refresh_node_values参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_node_value:
|
||||
@@ -149,9 +99,50 @@ opcua_example:
|
||||
title: set_node_value参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: start_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||
status_types:
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -161,23 +152,15 @@ opcua_example:
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
cache_timeout:
|
||||
default: 5.0
|
||||
type: number
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
subscription_interval:
|
||||
default: 500
|
||||
type: integer
|
||||
refresh_interval:
|
||||
default: 1.0
|
||||
type: number
|
||||
url:
|
||||
type: string
|
||||
use_subscription:
|
||||
default: true
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
@@ -185,12 +168,9 @@ opcua_example:
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -3,106 +3,6 @@ post_process_station:
|
||||
- post_process_station
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-load_config:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
config_path: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
config_path:
|
||||
type: string
|
||||
required:
|
||||
- config_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: print_cache_stats参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_node_value:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
name: null
|
||||
value: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- value
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_node_value参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
disconnect:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -702,46 +602,29 @@ post_process_station:
|
||||
type: SendCmd
|
||||
module: unilabos.devices.workstation.post_process.post_process:OpcUaClient
|
||||
status_types:
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
acetone_tank_empty_alarm: Bool
|
||||
atomization_fast_speed: Float64
|
||||
atomization_pressure_kpa: Int32
|
||||
cleaning_complete: Bool
|
||||
device_ready: Bool
|
||||
door_open_alarm: Bool
|
||||
grab_complete: Bool
|
||||
grab_trigger: Bool
|
||||
injection_pump_push_speed: Int32
|
||||
injection_pump_suction_speed: Int32
|
||||
nmp_tank_empty_alarm: Bool
|
||||
post_process_complete: Bool
|
||||
post_process_trigger: Bool
|
||||
raw_tank_number: Int32
|
||||
reaction_tank_number: Int32
|
||||
remote_mode: Bool
|
||||
wash_slow_speed: Float64
|
||||
waste_tank_full_alarm: Bool
|
||||
water_tank_empty_alarm: Bool
|
||||
type: python
|
||||
config_info: []
|
||||
description: 后处理站
|
||||
handles: []
|
||||
icon: post_process_station.webp
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
cache_timeout:
|
||||
default: 5.0
|
||||
type: number
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
subscription_interval:
|
||||
default: 500
|
||||
type: integer
|
||||
url:
|
||||
type: string
|
||||
use_subscription:
|
||||
default: true
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
|
||||
@@ -4,213 +4,88 @@ reaction_station.bioyond:
|
||||
- reaction_station_bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-create_order:
|
||||
add_time_constraint:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal:
|
||||
duration: duration
|
||||
end_point: end_point
|
||||
end_step_key: end_step_key
|
||||
start_point: start_point
|
||||
start_step_key: start_step_key
|
||||
goal_default:
|
||||
json_str: null
|
||||
duration: 0
|
||||
end_point: 0
|
||||
end_step_key: ''
|
||||
start_point: 0
|
||||
start_step_key: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
description: 添加时间约束 - 在两个工作流之间添加时间约束
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
json_str:
|
||||
type: string
|
||||
required:
|
||||
- json_str
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_order参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-hard_delete_merged_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_ids: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_ids:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- workflow_ids
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: hard_delete_merged_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-merge_workflow_with_parameters:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
json_str: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
json_str:
|
||||
type: string
|
||||
required:
|
||||
- json_str
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: merge_workflow_with_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_temperature_cutoff_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_temperature_cutoff_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_web_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
web_workflow_json: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
web_workflow_json:
|
||||
type: string
|
||||
required:
|
||||
- web_workflow_json
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_web_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-skip_titration_steps:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
preintake_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
preintake_id:
|
||||
type: string
|
||||
required:
|
||||
- preintake_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: skip_titration_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
type: string
|
||||
check_interval:
|
||||
default: 10
|
||||
type: integer
|
||||
timeout:
|
||||
default: 7200
|
||||
duration:
|
||||
description: 时间(秒)
|
||||
type: integer
|
||||
end_point:
|
||||
default: Start
|
||||
description: 终点计时点 (Start=开始前, End=结束后)
|
||||
enum:
|
||||
- Start
|
||||
- End
|
||||
type: string
|
||||
end_step_key:
|
||||
description: 终点步骤Key (可选, 默认为空则自动选择)
|
||||
type: string
|
||||
start_point:
|
||||
default: Start
|
||||
description: 起点计时点 (Start=开始前, End=结束后)
|
||||
enum:
|
||||
- Start
|
||||
- End
|
||||
type: string
|
||||
start_step_key:
|
||||
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
|
||||
type: string
|
||||
required:
|
||||
- duration
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: add_time_constraint参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
clean_all_server_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
code: code
|
||||
message: message
|
||||
schema:
|
||||
description: 清空服务端所有非核心工作流 (保留核心流程)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_step_query:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
result:
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
code:
|
||||
description: 操作结果代码(1表示成功)
|
||||
type: integer
|
||||
message:
|
||||
description: 结果描述
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_step_query参数
|
||||
title: clean_all_server_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
drip_back:
|
||||
@@ -247,13 +122,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume
|
||||
@@ -353,13 +234,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume
|
||||
@@ -403,7 +290,7 @@ reaction_station.bioyond:
|
||||
label: Solvents Data From Calculation Node
|
||||
result: {}
|
||||
schema:
|
||||
description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||
description: 液体投料-溶剂。可以直接提供volume(mL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -423,15 +310,21 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟),默认360
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(1=否, 2=是),默认1
|
||||
default: 'NO'
|
||||
description: 是否滴定(NO=否, YES=是),默认NO
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
default: '2'
|
||||
description: 是否观察 (1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液量(μL)。可直接提供,或通过solvents参数自动计算
|
||||
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -504,15 +397,21 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟),默认90
|
||||
type: string
|
||||
titration_type:
|
||||
default: '2'
|
||||
description: 是否滴定(1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否滴定(NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
default: '2'
|
||||
description: 是否观察 (1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume_formula:
|
||||
description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
type: string
|
||||
x_value:
|
||||
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
||||
@@ -560,13 +459,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume_formula:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume_formula
|
||||
@@ -680,6 +585,35 @@ reaction_station.bioyond:
|
||||
title: reactor_taken_out参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
solid_feeding_vials:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -706,7 +640,11 @@ reaction_station.bioyond:
|
||||
description: 物料名称(用于获取试剂瓶位ID)
|
||||
type: string
|
||||
material_id:
|
||||
description: 粉末类型ID,1=盐(21分钟),2=面粉(27分钟),3=BTDA(38分钟)
|
||||
description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟)
|
||||
enum:
|
||||
- Salt
|
||||
- Flour
|
||||
- BTDA
|
||||
type: string
|
||||
temperature:
|
||||
description: 温度设定(°C)
|
||||
@@ -715,7 +653,10 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -733,6 +674,16 @@ reaction_station.bioyond:
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
|
||||
protocol_type: []
|
||||
status_types:
|
||||
average_viscosity: Float64
|
||||
force: Float64
|
||||
in_temperature: Float64
|
||||
out_temperature: Float64
|
||||
pt100_temperature: Float64
|
||||
sensor_average_temperature: Float64
|
||||
setting_temperature: Float64
|
||||
speed: Float64
|
||||
target_temperature: Float64
|
||||
viscosity: Float64
|
||||
workflow_sequence: String
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -765,34 +716,19 @@ reaction_station.reactor:
|
||||
- reactor
|
||||
- reaction_station_bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-update_metrics:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
payload: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
payload:
|
||||
type: object
|
||||
required:
|
||||
- payload
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: update_metrics参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
action_value_mappings: {}
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
|
||||
status_types: {}
|
||||
status_types:
|
||||
average_viscosity: Float64
|
||||
force: Float64
|
||||
in_temperature: Float64
|
||||
out_temperature: Float64
|
||||
pt100_temperature: Float64
|
||||
sensor_average_temperature: Float64
|
||||
setting_temperature: Float64
|
||||
speed: Float64
|
||||
target_temperature: Float64
|
||||
viscosity: Float64
|
||||
type: python
|
||||
config_info: []
|
||||
description: 反应站子设备-反应器
|
||||
|
||||
@@ -222,7 +222,7 @@ class Registry:
|
||||
abs_path = Path(path).absolute()
|
||||
resource_path = abs_path / "resources"
|
||||
files = list(resource_path.glob("*/*.yaml"))
|
||||
logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}")
|
||||
logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}")
|
||||
current_resource_number = len(self.resource_type_registry) + 1
|
||||
for i, file in enumerate(files):
|
||||
with open(file, encoding="utf-8", mode="r") as f:
|
||||
|
||||
@@ -20,6 +20,17 @@ BIOYOND_PolymerStation_Liquid_Vial:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_Measurement_Vial:
|
||||
category:
|
||||
- bottles
|
||||
class:
|
||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Measurement_Vial
|
||||
type: pylabrobot
|
||||
description: 聚合站-测量小瓶(测密度)
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_Reactor:
|
||||
category:
|
||||
- bottles
|
||||
|
||||
@@ -10,6 +10,7 @@ POST_PROCESS_Raw_1BottleCarrier:
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
POST_PROCESS_Reaction_1BottleCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
|
||||
@@ -8,3 +8,4 @@ POST_PROCESS_PolymerStation_Reagent_Bottle:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
post_process_deck:
|
||||
category:
|
||||
- post_process_deck
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.devices.workstation.post_process.decks:post_process_deck
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
PRCXI_30mm_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter
|
||||
type: pylabrobot
|
||||
@@ -14,7 +13,6 @@ PRCXI_30mm_Adapter:
|
||||
PRCXI_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter
|
||||
type: pylabrobot
|
||||
@@ -27,7 +25,6 @@ PRCXI_Adapter:
|
||||
PRCXI_Deep10_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter
|
||||
type: pylabrobot
|
||||
@@ -40,7 +37,6 @@ PRCXI_Deep10_Adapter:
|
||||
PRCXI_Deep300_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter
|
||||
type: pylabrobot
|
||||
@@ -53,7 +49,6 @@ PRCXI_Deep300_Adapter:
|
||||
PRCXI_PCR_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter
|
||||
type: pylabrobot
|
||||
@@ -66,7 +61,6 @@ PRCXI_PCR_Adapter:
|
||||
PRCXI_Reservoir_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter
|
||||
type: pylabrobot
|
||||
@@ -79,7 +73,6 @@ PRCXI_Reservoir_Adapter:
|
||||
PRCXI_Tip10_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter
|
||||
type: pylabrobot
|
||||
@@ -92,7 +85,6 @@ PRCXI_Tip10_Adapter:
|
||||
PRCXI_Tip1250_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter
|
||||
type: pylabrobot
|
||||
@@ -105,7 +97,6 @@ PRCXI_Tip1250_Adapter:
|
||||
PRCXI_Tip300_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
PRCXI_48_DeepWell:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell
|
||||
type: pylabrobot
|
||||
@@ -14,7 +13,6 @@ PRCXI_48_DeepWell:
|
||||
PRCXI_96_DeepWell:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell
|
||||
type: pylabrobot
|
||||
@@ -27,7 +25,6 @@ PRCXI_96_DeepWell:
|
||||
PRCXI_AGenBio_4_troughplate:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate
|
||||
type: pylabrobot
|
||||
@@ -40,7 +37,6 @@ PRCXI_AGenBio_4_troughplate:
|
||||
PRCXI_BioER_96_wellplate:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate
|
||||
type: pylabrobot
|
||||
@@ -53,7 +49,6 @@ PRCXI_BioER_96_wellplate:
|
||||
PRCXI_BioRad_384_wellplate:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate
|
||||
type: pylabrobot
|
||||
@@ -66,7 +61,6 @@ PRCXI_BioRad_384_wellplate:
|
||||
PRCXI_CellTreat_96_wellplate:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate
|
||||
type: pylabrobot
|
||||
@@ -79,7 +73,6 @@ PRCXI_CellTreat_96_wellplate:
|
||||
PRCXI_PCR_Plate_200uL_nonskirted:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted
|
||||
type: pylabrobot
|
||||
@@ -92,7 +85,6 @@ PRCXI_PCR_Plate_200uL_nonskirted:
|
||||
PRCXI_PCR_Plate_200uL_semiskirted:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted
|
||||
type: pylabrobot
|
||||
@@ -105,7 +97,6 @@ PRCXI_PCR_Plate_200uL_semiskirted:
|
||||
PRCXI_PCR_Plate_200uL_skirted:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted
|
||||
type: pylabrobot
|
||||
@@ -118,7 +109,6 @@ PRCXI_PCR_Plate_200uL_skirted:
|
||||
PRCXI_nest_12_troughplate:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate
|
||||
type: pylabrobot
|
||||
@@ -131,7 +121,6 @@ PRCXI_nest_12_troughplate:
|
||||
PRCXI_nest_1_troughplate:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
PRCXI_1000uL_Tips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -14,7 +13,6 @@ PRCXI_1000uL_Tips:
|
||||
PRCXI_10uL_Tips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -27,7 +25,6 @@ PRCXI_10uL_Tips:
|
||||
PRCXI_10ul_eTips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips
|
||||
type: pylabrobot
|
||||
@@ -40,7 +37,6 @@ PRCXI_10ul_eTips:
|
||||
PRCXI_1250uL_Tips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -53,7 +49,6 @@ PRCXI_1250uL_Tips:
|
||||
PRCXI_200uL_Tips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -66,7 +61,6 @@ PRCXI_200uL_Tips:
|
||||
PRCXI_300ul_Tips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
PRCXI_trash:
|
||||
category:
|
||||
- prcxi
|
||||
- trash
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
PRCXI_EP_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- tube_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter
|
||||
type: pylabrobot
|
||||
|
||||
@@ -193,3 +193,20 @@ def BIOYOND_PolymerStation_Flask(
|
||||
barcode=barcode,
|
||||
model="BIOYOND_PolymerStation_Flask",
|
||||
)
|
||||
|
||||
def BIOYOND_PolymerStation_Measurement_Vial(
|
||||
name: str,
|
||||
diameter: float = 25.0,
|
||||
height: float = 60.0,
|
||||
max_volume: float = 20000.0, # 20mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建测量小瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="BIOYOND_PolymerStation_Measurement_Vial",
|
||||
)
|
||||
|
||||
@@ -49,20 +49,17 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01~B03
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
|
||||
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
|
||||
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
|
||||
"堆栈1左": Coordinate(-200.0, 450.0, 0.0), # 左侧位置
|
||||
"堆栈1右": Coordinate(2350.0, 450.0, 0.0), # 右侧位置
|
||||
"站内试剂存放堆栈": Coordinate(730.0, 390.0, 0.0),
|
||||
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
|
||||
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
|
||||
"测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
|
||||
"测量小瓶仓库(测密度)": Coordinate(940.0, 530.0, 0.0),
|
||||
}
|
||||
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
|
||||
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
|
||||
class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -144,6 +141,7 @@ class BIOYOND_YB_Deck(Deck):
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
def YB_Deck(name: str) -> Deck:
|
||||
by=BIOYOND_YB_Deck(name=name)
|
||||
by.setup()
|
||||
|
||||
@@ -46,41 +46,55 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
)
|
||||
|
||||
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
||||
"""创建测量小瓶仓库(测密度) A01~B03"""
|
||||
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行
|
||||
布局(从下到上,从左到右):
|
||||
| A03 | B03 | ← 顶部
|
||||
| A02 | B02 | ← 中部
|
||||
| A01 | B01 | ← 底部
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列(01-03)
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_x=2, # 2列(A, B)
|
||||
num_items_y=3, # 3行(01-03,从下到上)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=40.0,
|
||||
item_dy=40.0,
|
||||
item_dx=40.0, # 列间距(A到B的横向距离)
|
||||
item_dy=40.0, # 行间距(01到02到03的竖向距离)
|
||||
item_dz=50.0,
|
||||
# 用更小的 resource_size 来表现 "小点的孔位"
|
||||
# ⭐ 竖向warehouse:槽位尺寸也是竖向的(小瓶已经是正方形,无需调整)
|
||||
resource_size_x=30.0,
|
||||
resource_size_y=30.0,
|
||||
resource_size_z=12.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
layout="row-major",
|
||||
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||
)
|
||||
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
"""创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)"""
|
||||
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行
|
||||
布局(竖向,从下到上):
|
||||
| A02 | ← 顶部
|
||||
| A01 | ← 底部
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2, # 2列(01-02)
|
||||
num_items_y=1, # 1行(A)
|
||||
num_items_x=1, # 1列
|
||||
num_items_y=2, # 2行(01-02,从下到上)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dx=96.0, # 列间距(这里只有1列,不重要)
|
||||
item_dy=137.0, # 行间距(A01到A02的竖向距离)
|
||||
item_dz=120.0,
|
||||
# ⭐ 竖向warehouse:交换槽位尺寸,使槽位框也是竖向的
|
||||
resource_size_x=86.0, # 原来的 resource_size_y
|
||||
resource_size_y=127.0, # 原来的 resource_size_x
|
||||
resource_size_z=25.0,
|
||||
category="warehouse",
|
||||
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||
)
|
||||
|
||||
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
||||
|
||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
||||
Returns:
|
||||
ResourceTreeSet: 标准化后的资源树集合
|
||||
"""
|
||||
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
||||
|
||||
# 第一步:基本预处理(处理graphml的label字段)
|
||||
outer_host_node_id = None
|
||||
@@ -779,6 +779,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
if not locations:
|
||||
logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置")
|
||||
|
||||
# ⭐ 预先检查:如果物料的任何location在竖向warehouse中,提前交换尺寸
|
||||
# 这样可以避免多个location时尺寸不一致的问题
|
||||
needs_size_swap = False
|
||||
for loc in locations:
|
||||
wh_name_check = loc.get("whName")
|
||||
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
needs_size_swap = True
|
||||
break
|
||||
|
||||
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
|
||||
original_x = plr_material.size_x
|
||||
original_y = plr_material.size_y
|
||||
plr_material.size_x = original_y
|
||||
plr_material.size_y = original_x
|
||||
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse,预先交换尺寸: {original_x}×{original_y} → {plr_material.size_x}×{plr_material.size_y}")
|
||||
|
||||
for loc in locations:
|
||||
wh_name = loc.get("whName")
|
||||
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
|
||||
@@ -800,7 +816,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
|
||||
|
||||
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
||||
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
|
||||
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
||||
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
||||
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
||||
@@ -809,12 +824,23 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
if wh_name == "堆栈1右":
|
||||
y = y - 4 # 将5-8映射到1-4
|
||||
|
||||
# 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈)
|
||||
# Bioyond的y坐标表示线性位置序号,而不是列号
|
||||
if warehouse.num_items_y == 1:
|
||||
# 1行warehouse: 直接用y作为线性索引
|
||||
idx = y - 1
|
||||
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
|
||||
# 特殊处理竖向warehouse(站内试剂存放堆栈、测量小瓶仓库)
|
||||
# 这些warehouse使用 vertical-col-major 布局
|
||||
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
# vertical-col-major 布局的坐标映射:
|
||||
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向)
|
||||
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上
|
||||
# vertical-col-major 中: row=0 对应底部,row=n-1 对应顶部
|
||||
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
|
||||
# 索引计算: idx = row * num_cols + col
|
||||
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
|
||||
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
|
||||
layer_idx = z - 1
|
||||
|
||||
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx
|
||||
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
|
||||
|
||||
# 普通横向warehouse的处理
|
||||
else:
|
||||
# 多行warehouse: 根据 layout 使用不同的索引计算
|
||||
row_idx = x - 1 # x表示行: 转为0-based
|
||||
@@ -838,6 +864,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
|
||||
if 0 <= idx < warehouse.capacity:
|
||||
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
||||
# 物料尺寸已在放入warehouse前根据需要进行了交换
|
||||
warehouse[idx] = plr_material
|
||||
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
||||
else:
|
||||
@@ -1011,11 +1038,24 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
||||
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
|
||||
|
||||
# 🎯 处理物料默认参数和单位
|
||||
# 检查是否有该物料名称的默认参数配置
|
||||
# 优先级: typeId参数 > 物料名称参数 > 默认值
|
||||
default_unit = "个" # 默认单位
|
||||
material_parameters = {}
|
||||
|
||||
if material_name in material_params:
|
||||
# 1️⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取,key 格式为 "type:<typeId>")
|
||||
type_params_key = f"type:{type_id}"
|
||||
if type_params_key in material_params:
|
||||
params_config = material_params[type_params_key].copy()
|
||||
|
||||
# 提取 unit 字段(如果有)
|
||||
if "unit" in params_config:
|
||||
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
|
||||
|
||||
# 剩余的字段放入 Parameters
|
||||
material_parameters = params_config
|
||||
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
# 2️⃣ 其次检查是否有该物料名称的默认参数配置
|
||||
elif material_name in material_params:
|
||||
params_config = material_params[material_name].copy()
|
||||
|
||||
# 提取 unit 字段(如果有)
|
||||
@@ -1024,7 +1064,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
||||
|
||||
# 剩余的字段放入 Parameters
|
||||
material_parameters = params_config
|
||||
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
|
||||
# 转换为 JSON 字符串
|
||||
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
|
||||
|
||||
@@ -42,6 +42,10 @@ def warehouse_factory(
|
||||
if layout == "row-major":
|
||||
# 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值
|
||||
y = dy + row * item_dy
|
||||
elif layout == "vertical-col-major":
|
||||
# 竖向warehouse: row=0 对应顶部(y小),row=n-1 对应底部(y大)
|
||||
# 但标签 01 应该在底部,所以使用反向映射
|
||||
y = dy + (num_items_y - row - 1) * item_dy
|
||||
else:
|
||||
# 列优先:保持原逻辑(row=0 对应较大的 y)
|
||||
y = dy + (num_items_y - row - 1) * item_dy
|
||||
@@ -66,6 +70,14 @@ def warehouse_factory(
|
||||
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
|
||||
# locations[0] 对应 row=0, y最大(前端顶部)→ 应该是 A01
|
||||
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
|
||||
elif layout == "vertical-col-major":
|
||||
# ⭐ 竖向warehouse专用布局:
|
||||
# 字母(A,B,C...)对应列(横向, x方向),数字(01,02,03...)对应行(竖向, y方向,从下到上)
|
||||
# locations 生成顺序: row→col (row=0,col=0 → row=0,col=1 → row=1,col=0 → ...)
|
||||
# 其中 row=0 对应底部(y大),row=n-1 对应顶部(y小)
|
||||
# 标签中 01 对应底部(row=0),02 对应中间(row=1),03 对应顶部(row=2)
|
||||
# 标签顺序: A01,B01,A02,B02,A03,B03
|
||||
keys = [f"{LETTERS[col]}{row + 1 + col_offset:02d}" for row in range(len_y) for col in range(len_x)]
|
||||
else:
|
||||
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
|
||||
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
|
||||
|
||||
@@ -21,7 +21,6 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.service import Service
|
||||
from unilabos_msgs.action import SendCmd
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unilabos.utils.decorator import get_topic_config, get_all_subscriptions
|
||||
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import (
|
||||
@@ -49,8 +48,7 @@ from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
DeviceNodeResourceTracker,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeInstance, ResourceDictInstance,
|
||||
)
|
||||
from unilabos.ros.x.rclpyx import get_event_loop
|
||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||
@@ -170,7 +168,6 @@ class PropertyPublisher:
|
||||
msg_type,
|
||||
initial_period: float = 5.0,
|
||||
print_publish=True,
|
||||
qos: int = 10,
|
||||
):
|
||||
self.node = node
|
||||
self.name = name
|
||||
@@ -178,11 +175,10 @@ class PropertyPublisher:
|
||||
self.get_method = get_method
|
||||
self.timer_period = initial_period
|
||||
self.print_publish = print_publish
|
||||
self.qos = qos
|
||||
|
||||
self._value = None
|
||||
try:
|
||||
self.publisher_ = node.create_publisher(msg_type, f"{name}", qos)
|
||||
self.publisher_ = node.create_publisher(msg_type, f"{name}", 10)
|
||||
except AttributeError as ex:
|
||||
self.node.lab_logger().error(
|
||||
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
|
||||
@@ -190,7 +186,7 @@ class PropertyPublisher:
|
||||
self.timer = node.create_timer(self.timer_period, self.publish_property)
|
||||
self.__loop = get_event_loop()
|
||||
str_msg_type = str(msg_type)[8:-2]
|
||||
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}")
|
||||
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒")
|
||||
|
||||
def get_property(self):
|
||||
if asyncio.iscoroutinefunction(self.get_method):
|
||||
@@ -330,10 +326,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
continue
|
||||
self.create_ros_action_server(action_name, action_value_mapping)
|
||||
|
||||
# 创建订阅者(通过 @subscribe 装饰器)
|
||||
self._topic_subscribers: Dict[str, Any] = {}
|
||||
self._setup_decorated_subscribers()
|
||||
|
||||
# 创建线程池执行器
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
|
||||
@@ -1051,29 +1043,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
|
||||
"""创建ROS发布者"""
|
||||
# 检测装饰器配置(支持 get_{attr_name} 方法和 @property)
|
||||
topic_config = {}
|
||||
|
||||
# 优先检测 get_{attr_name} 方法
|
||||
if hasattr(self.driver_instance, f"get_{attr_name}"):
|
||||
getter_method = getattr(self.driver_instance, f"get_{attr_name}")
|
||||
topic_config = get_topic_config(getter_method)
|
||||
|
||||
# 如果没有配置,检测 @property 装饰的属性
|
||||
if not topic_config:
|
||||
driver_class = type(self.driver_instance)
|
||||
if hasattr(driver_class, attr_name):
|
||||
class_attr = getattr(driver_class, attr_name)
|
||||
if isinstance(class_attr, property) and class_attr.fget is not None:
|
||||
topic_config = get_topic_config(class_attr.fget)
|
||||
|
||||
# 使用装饰器配置或默认值
|
||||
cfg_period = topic_config.get("period")
|
||||
cfg_print = topic_config.get("print_publish")
|
||||
cfg_qos = topic_config.get("qos")
|
||||
period: float = cfg_period if cfg_period is not None else initial_period
|
||||
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
|
||||
qos: int = cfg_qos if cfg_qos is not None else 10
|
||||
|
||||
# 获取属性值的方法
|
||||
def get_device_attr():
|
||||
@@ -1094,7 +1063,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
|
||||
self._property_publishers[attr_name] = PropertyPublisher(
|
||||
self, attr_name, get_device_attr, msg_type, period, print_publish, qos
|
||||
self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish
|
||||
)
|
||||
|
||||
def create_ros_action_server(self, action_name, action_value_mapping):
|
||||
@@ -1112,76 +1081,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}")
|
||||
|
||||
def _setup_decorated_subscribers(self):
|
||||
"""扫描 driver_instance 中带有 @subscribe 装饰器的方法并创建订阅者"""
|
||||
subscriptions = get_all_subscriptions(self.driver_instance)
|
||||
|
||||
for method_name, method, config in subscriptions:
|
||||
topic_template = config.get("topic")
|
||||
msg_type = config.get("msg_type")
|
||||
qos = config.get("qos", 10)
|
||||
|
||||
if not topic_template:
|
||||
self.lab_logger().warning(f"订阅方法 {method_name} 缺少 topic 配置,跳过")
|
||||
continue
|
||||
|
||||
# 如果没有指定 msg_type,尝试从类型注解推断
|
||||
if msg_type is None:
|
||||
try:
|
||||
hints = get_type_hints(method)
|
||||
# 第一个参数是 self,第二个是 msg
|
||||
param_names = list(hints.keys())
|
||||
if param_names:
|
||||
msg_type = hints[param_names[0]]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if msg_type is None:
|
||||
self.lab_logger().warning(f"订阅方法 {method_name} 缺少 msg_type 配置且无法从类型注解推断,跳过")
|
||||
continue
|
||||
|
||||
# 替换 topic 模板中的占位符
|
||||
topic = self._resolve_topic_template(topic_template)
|
||||
|
||||
self.create_ros_subscriber(topic, msg_type, method, qos)
|
||||
|
||||
def _resolve_topic_template(self, topic_template: str) -> str:
|
||||
"""
|
||||
解析 topic 模板,替换占位符
|
||||
|
||||
支持的占位符:
|
||||
- {device_id}: 设备ID
|
||||
- {namespace}: 完整命名空间
|
||||
"""
|
||||
return topic_template.format(
|
||||
device_id=self.device_id,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def create_ros_subscriber(self, topic: str, msg_type, callback, qos: int = 10):
|
||||
"""
|
||||
创建ROS订阅者
|
||||
|
||||
Args:
|
||||
topic: Topic 名称
|
||||
msg_type: ROS 消息类型
|
||||
callback: 回调方法(会自动绑定到 driver_instance)
|
||||
qos: QoS 深度配置
|
||||
"""
|
||||
try:
|
||||
subscription = self.create_subscription(
|
||||
msg_type,
|
||||
topic,
|
||||
callback,
|
||||
qos,
|
||||
callback_group=self.callback_group,
|
||||
)
|
||||
self._topic_subscribers[topic] = subscription
|
||||
str_msg_type = str(msg_type)[8:-2] if str(msg_type).startswith("<class") else str(msg_type)
|
||||
self.lab_logger().trace(f"订阅Topic: {topic}, 类型: {str_msg_type}, QoS: {qos}")
|
||||
except Exception as ex:
|
||||
self.lab_logger().error(f"创建订阅者 {topic} 失败,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}")
|
||||
|
||||
def get_real_function(self, instance, attr_name):
|
||||
if hasattr(instance.__class__, attr_name):
|
||||
obj = getattr(instance.__class__, attr_name)
|
||||
@@ -1243,28 +1142,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
plr_resource = await self.get_resource_with_dir(
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
queried_resources.append(plr_resource)
|
||||
|
||||
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
||||
|
||||
# 通过资源跟踪器获取本地实例
|
||||
final_resources = queried_resources if is_sequence else queried_resources[0]
|
||||
if not is_sequence:
|
||||
plr = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
|
||||
# 保留unilabos_extra
|
||||
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
||||
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
|
||||
final_resources = plr
|
||||
else:
|
||||
new_resources = []
|
||||
for res in queried_resources:
|
||||
plr = self.resource_tracker.figure_resource({"name": res.name}, try_mode=False)
|
||||
if hasattr(res, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
||||
plr.unilabos_extra = getattr(res, "unilabos_extra", {}).copy()
|
||||
new_resources.append(plr)
|
||||
final_resources = new_resources
|
||||
final_resources = (
|
||||
self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
|
||||
if not is_sequence
|
||||
else [
|
||||
self.resource_tracker.figure_resource({"name": res.name}, try_mode=False)
|
||||
for res in queried_resources
|
||||
]
|
||||
)
|
||||
action_kwargs[k] = final_resources
|
||||
|
||||
except Exception as e:
|
||||
@@ -1281,7 +1172,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if asyncio.iscoroutinefunction(ACTION):
|
||||
try:
|
||||
self.lab_logger().trace(f"异步执行动作 {ACTION}")
|
||||
|
||||
def _handle_future_exception(fut: Future):
|
||||
nonlocal execution_error, execution_success, action_return_value
|
||||
try:
|
||||
@@ -1378,11 +1268,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
seen = set()
|
||||
unique_resources = []
|
||||
for rs in akv: # todo: 这里目前只支持plr的类型
|
||||
if isinstance(rs, list):
|
||||
for r in rs:
|
||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||
else:
|
||||
res = self.resource_tracker.parent_resource(r)
|
||||
res = self.resource_tracker.parent_resource(rs) # 获取 resource 对象
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
@@ -1654,7 +1540,6 @@ class ROS2DeviceNode:
|
||||
这个类封装了设备类实例和ROS2节点的功能,提供ROS2接口。
|
||||
它不继承设备类,而是通过代理模式访问设备类的属性和方法。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def safe_task_wrapper(trace_callback, func, **kwargs):
|
||||
try:
|
||||
@@ -1677,9 +1562,7 @@ class ROS2DeviceNode:
|
||||
error(f"异步任务 {func.__name__} 获取结果失败")
|
||||
error(traceback.format_exc())
|
||||
|
||||
future = rclpy.get_global_executor().create_task(
|
||||
ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs)
|
||||
)
|
||||
future = rclpy.get_global_executor().create_task(ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs))
|
||||
if trace_error:
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
return future
|
||||
|
||||
@@ -706,20 +706,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
raise ValueError(f"ActionClient {action_id} not found.")
|
||||
|
||||
action_client: ActionClient = self._action_clients[action_id]
|
||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
||||
def assign_sample_id(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "sample_uuid" in obj:
|
||||
obj["sample_id"] = obj["sample_uuid"]
|
||||
obj.pop("sample_uuid")
|
||||
for k,v in obj.items():
|
||||
if k != "unilabos_extra":
|
||||
assign_sample_id(v)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
assign_sample_id(item)
|
||||
|
||||
assign_sample_id(action_kwargs)
|
||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
@@ -1159,19 +1146,23 @@ class HostNode(BaseROS2DeviceNode):
|
||||
def _resource_get_callback(self, request: SerialCommand.Request, response: SerialCommand.Response):
|
||||
"""
|
||||
获取资源回调
|
||||
|
||||
处理获取资源请求,从桥接器或本地查询资源数据
|
||||
|
||||
Args:
|
||||
request: 包含资源ID的请求对象
|
||||
response: 响应对象
|
||||
|
||||
Returns:
|
||||
响应对象,包含查询到的资源
|
||||
"""
|
||||
try:
|
||||
from unilabos.app.web import http_client
|
||||
data = json.loads(request.command)
|
||||
if "uuid" in data and data["uuid"] is not None:
|
||||
http_req = self.bridges[-1].resource_tree_get([data["uuid"]], data["with_children"])
|
||||
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
|
||||
elif "id" in data and data["id"].startswith("/"):
|
||||
http_req = self.bridges[-1].resource_get(data["id"], data["with_children"])
|
||||
http_req = http_client.resource_get(data["id"], data["with_children"])
|
||||
else:
|
||||
raise ValueError("没有使用正确的物料 id 或 uuid")
|
||||
response.response = json.dumps(http_req["data"])
|
||||
|
||||
@@ -203,9 +203,9 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
continue
|
||||
# 提取位置信息并转换单位
|
||||
position = {
|
||||
"x": float(resource_config['pose']['position']['x'])/1000,
|
||||
"y": float(resource_config['pose']['position']['y'])/1000,
|
||||
"z": float(resource_config['pose']['position']['z'])/1000
|
||||
"x": float(resource_config['position']['position']['x'])/1000,
|
||||
"y": float(resource_config['position']['position']['y'])/1000,
|
||||
"z": float(resource_config['position']['position']['z'])/1000
|
||||
}
|
||||
|
||||
rotation_dict = {
|
||||
@@ -214,8 +214,8 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
"z": 0
|
||||
}
|
||||
|
||||
if 'rotation' in resource_config['pose']:
|
||||
rotation_dict = resource_config['pose']['rotation']
|
||||
if 'rotation' in resource_config['position']:
|
||||
rotation_dict = resource_config['position']['rotation']
|
||||
|
||||
# 从欧拉角转换为四元数
|
||||
q = quaternion_from_euler(
|
||||
|
||||
@@ -66,8 +66,8 @@ class ResourceDict(BaseModel):
|
||||
klass: str = Field(alias="class", description="Resource class name")
|
||||
pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
||||
config: Dict[str, Any] = Field(description="Resource configuration")
|
||||
data: Dict[str, Any] = Field(description="Resource data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data")
|
||||
data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data, eg: slot index")
|
||||
|
||||
@field_serializer("parent_uuid")
|
||||
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
|
||||
@@ -146,20 +146,8 @@ class ResourceDictInstance(object):
|
||||
content["data"] = {}
|
||||
if not content.get("extra"): # MagicCode
|
||||
content["extra"] = {}
|
||||
if "position" in content:
|
||||
pose = content.get("pose",{})
|
||||
if "position" not in pose :
|
||||
if "position" in content["position"]:
|
||||
pose["position"] = content["position"]["position"]
|
||||
else:
|
||||
pose["position"] = {"x": 0, "y": 0, "z": 0}
|
||||
if "size" not in pose:
|
||||
pose["size"] = {
|
||||
"width": content["config"].get("size_x", 0),
|
||||
"height": content["config"].get("size_y", 0),
|
||||
"depth": content["config"].get("size_z", 0)
|
||||
}
|
||||
content["pose"] = pose
|
||||
if "pose" not in content:
|
||||
content["pose"] = content.pop("position", {})
|
||||
return ResourceDictInstance(ResourceDict.model_validate(content))
|
||||
|
||||
def get_plr_nested_dict(self) -> Dict[str, Any]:
|
||||
@@ -448,7 +436,7 @@ class ResourceTreeSet(object):
|
||||
from pylabrobot.utils.object_parsing import find_subclass
|
||||
|
||||
# 类型映射
|
||||
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"}
|
||||
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer"}
|
||||
|
||||
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
|
||||
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user