Compare commits
539 Commits
4189a2cfbe
...
workstatio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7443e74b | ||
|
|
8d16536973 | ||
|
|
015fee4171 | ||
|
|
9f8b5eaf71 | ||
|
|
143104e9e3 | ||
|
|
152d3a7563 | ||
|
|
ef14737839 | ||
|
|
5d5569121c | ||
|
|
d23e85ade4 | ||
|
|
02afafd423 | ||
|
|
6ac510dcd2 | ||
|
|
ed56c1eba2 | ||
|
|
16ee3de086 | ||
|
|
ced961050d | ||
|
|
11b2c99836 | ||
|
|
04024bc8a3 | ||
|
|
154048107d | ||
|
|
6413828c59 | ||
|
|
5072f00836 | ||
|
|
9dfbe3246e | ||
|
|
bef69db3b6 | ||
|
|
0b896870ba | ||
|
|
ee609e4aa2 | ||
|
|
a061bc2942 | ||
|
|
8c9e11c04f | ||
|
|
e4e3ec805a | ||
|
|
5551fbf360 | ||
|
|
e13b250632 | ||
|
|
b8278c5026 | ||
|
|
53e767a054 | ||
|
|
cf7032fa81 | ||
|
|
97681ba433 | ||
|
|
3fa81ab4f6 | ||
|
|
d634316bce | ||
|
|
9f4a69ddf5 | ||
|
|
05ae4e72df | ||
|
|
f5446c6480 | ||
|
|
a98d25c16d | ||
|
|
80b9589973 | ||
|
|
4d4bbcbae8 | ||
|
|
2870c04086 | ||
|
|
343e87df0d | ||
|
|
fa9b2a08f2 | ||
|
|
929d50f954 | ||
|
|
5d0807cba6 | ||
|
|
4875977d5f | ||
|
|
956b1c905b | ||
|
|
944911c52a | ||
|
|
a13b790926 | ||
|
|
9feadd68c6 | ||
|
|
c68d5246d0 | ||
|
|
e60bf29a7f | ||
|
|
49073f2c77 | ||
|
|
b2afc29f15 | ||
|
|
4061280f6b | ||
|
|
6a681e1d73 | ||
|
|
653e6e1ac3 | ||
|
|
2c774bcd1d | ||
|
|
2ba395b681 | ||
|
|
b6b3d59083 | ||
|
|
f40e3f521c | ||
|
|
7cc2fe036f | ||
|
|
2e17dee121 | ||
|
|
c03abb341a | ||
|
|
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 | ||
|
|
b97be6a5d4 | ||
|
|
44f830cf00 | ||
|
|
04b578a68b | ||
|
|
39a799cabd | ||
|
|
0d64563fb6 | ||
|
|
fbb9e0963d | ||
|
|
af411ddfe6 | ||
|
|
f5dbcb1bfc | ||
|
|
1ecf89ea27 | ||
|
|
6efdf6e5a6 | ||
|
|
e32dc55db0 | ||
|
|
acc45b716d | ||
|
|
017eaefb8d | ||
|
|
9e8c692702 | ||
|
|
beb90f20d2 | ||
|
|
7a284069d2 | ||
|
|
4a2d862333 | ||
|
|
538891fcbe | ||
|
|
a0e92b8e9b | ||
|
|
1d77225912 | ||
|
|
06e6ab0b7f | ||
|
|
5399c6c1cf | ||
|
|
f872d3ef56 | ||
|
|
85c6f4e688 | ||
|
|
442b759397 | ||
|
|
47ecb154c8 | ||
|
|
be429147c0 | ||
|
|
123c69e97a | ||
|
|
04004c9b6f | ||
|
|
45a778b928 | ||
|
|
c44ae32070 | ||
|
|
7af32b379b | ||
|
|
c35da65b15 | ||
|
|
48d429ae00 | ||
|
|
9bba4620b7 | ||
|
|
d7494ca458 | ||
|
|
85dc46cd38 | ||
|
|
5a0c2f9850 | ||
|
|
d897d70c3e | ||
|
|
d9dffc6bf8 | ||
|
|
30b202bea0 | ||
|
|
1b2c0dbcd7 | ||
|
|
0f341e9b4d | ||
|
|
4c3972820b | ||
|
|
a2a8ee9088 | ||
|
|
200105f647 | ||
|
|
8b5653d801 | ||
|
|
5f859917d4 | ||
|
|
af2fb7f34a | ||
|
|
baa107c230 | ||
|
|
83854a741d | ||
|
|
86c7880b5c | ||
|
|
6d934e354c | ||
|
|
bed453034f | ||
|
|
5331d7bfba | ||
|
|
38ab7d3e78 | ||
|
|
659cf05be6 | ||
|
|
3b8deb4d1d | ||
|
|
c796615f9f | ||
|
|
a5bad6074f | ||
|
|
1d3a07a736 | ||
|
|
cc2cd57cdf | ||
|
|
966b51042d | ||
|
|
d81638e20b | ||
|
|
3c583008aa | ||
|
|
9a85bfddcd | ||
|
|
d4e1286df7 | ||
|
|
765038a136 | ||
|
|
1d4e4c8377 | ||
|
|
54f749bcdb | ||
|
|
16ad4bbecc | ||
|
|
0ad2eaafea | ||
|
|
1477384c1a | ||
|
|
8149a175d9 | ||
|
|
bfd415279b | ||
|
|
0238a92e75 | ||
|
|
8009956326 | ||
|
|
68fc4dd61e | ||
|
|
cd12932788 | ||
|
|
f230028558 | ||
|
|
1c1a6b16c8 | ||
|
|
a2d6012080 | ||
|
|
10adc853a5 | ||
|
|
69ec034623 | ||
|
|
62d08aa954 | ||
|
|
4485907df8 | ||
|
|
b5b2358967 | ||
|
|
11f4f44bf9 | ||
|
|
f52fbd650e | ||
|
|
39bb7dc627 | ||
|
|
0fda155f55 | ||
|
|
6e3eacd2f0 | ||
|
|
e561c818b8 | ||
|
|
5cbd880e5a | ||
|
|
41e7251f62 | ||
|
|
727d2c2595 | ||
|
|
202a2667fd | ||
|
|
03745c5d08 | ||
|
|
062f1a2153 | ||
|
|
385a495e21 | ||
|
|
91513a5f4c | ||
|
|
a62896eda2 | ||
|
|
a82d1b7bdb | ||
|
|
6d7c39da9e | ||
|
|
d8e9ad4413 | ||
|
|
eb93b83415 | ||
|
|
6df93a5db7 | ||
|
|
2eb9986edb | ||
|
|
fe4e49e56d | ||
|
|
0fba4cf275 | ||
|
|
61e8d67800 | ||
|
|
ef9359776a | ||
|
|
954f1ee7b2 | ||
|
|
f58921ef82 | ||
|
|
95bdd39bf8 | ||
|
|
b3e28196c6 | ||
|
|
9fe8f4f28f | ||
|
|
39bc317bfc | ||
|
|
d0884cdbd8 | ||
|
|
545ea45024 | ||
|
|
a130c03ebd | ||
|
|
a97781c4eb | ||
|
|
c35edcece1 | ||
|
|
b9ddee8f2c | ||
|
|
a0c5095304 | ||
|
|
524e0f3053 | ||
|
|
66f483929d | ||
|
|
2d58576937 | ||
|
|
ff25e814de | ||
|
|
0163d16cbb | ||
|
|
3231d60646 | ||
|
|
d0279f63f0 | ||
|
|
ceef342860 | ||
|
|
42f7010134 | ||
|
|
190b2d2518 | ||
|
|
2901d72b4b | ||
|
|
6ad0157b50 | ||
|
|
55b678cd37 | ||
|
|
8101a22a0f | ||
|
|
667138baac | ||
|
|
01adf7ca92 | ||
|
|
f606062696 | ||
|
|
67d1c4acce | ||
|
|
7206e42bf1 | ||
|
|
e504505137 | ||
|
|
4d9d5701e9 | ||
|
|
6016c4b588 | ||
|
|
be02bef9c4 | ||
|
|
e62f0c2585 | ||
|
|
b6de0623e2 | ||
|
|
9d081e9fcd | ||
|
|
85a58e3464 | ||
|
|
85590672d8 | ||
|
|
1d4018196d | ||
|
|
5d34f742af | ||
|
|
e92d933968 | ||
|
|
f0ebcc60bb | ||
|
|
e2097f0b22 | ||
|
|
fd73731130 | ||
|
|
ab7f2081c9 | ||
|
|
9e850d8a81 | ||
|
|
5bef19e6d6 | ||
|
|
1af6ffafc6 | ||
|
|
35fc2f5ea6 | ||
|
|
d3d8ba6500 | ||
|
|
f816799753 | ||
|
|
a45d841769 | ||
|
|
5a7845d8ca | ||
|
|
7f0b33b3e3 | ||
|
|
9c4d0256cf | ||
|
|
de7c80c3c2 | ||
|
|
2006406a24 | ||
|
|
f94985632b | ||
|
|
12ba110569 | ||
|
|
97212be8b7 | ||
|
|
9bdd42f12f | ||
|
|
627140da03 | ||
|
|
5ceedb0565 | ||
|
|
8c77a20c43 | ||
|
|
3ff894feee | ||
|
|
fa5896ffdb | ||
|
|
eb504803ac | ||
|
|
8b0c845661 | ||
|
|
693873bfa9 | ||
|
|
e70c545ec8 | ||
|
|
2c2d1e5569 | ||
|
|
57da2d8da2 | ||
|
|
4638611fe7 | ||
|
|
37641c4389 | ||
|
|
8d1fd01259 | ||
|
|
388259e64b | ||
|
|
ab697ce973 | ||
|
|
d4724b8664 | ||
|
|
2f25063bf1 | ||
|
|
00b4b9cd87 | ||
|
|
d2352cc514 | ||
|
|
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.10
|
||||
version: 0.10.12
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
1
.gitignore
vendored
@@ -249,3 +249,4 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
test_config.py
|
||||
|
||||
|
||||
Uni-Lab-OS/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
recursive-include unilabos/test *
|
||||
recursive-include unilabos/registry *.yaml
|
||||
recursive-include unilabos/app/web/static *
|
||||
recursive-include unilabos/app/web/templates *
|
||||
|
||||
@@ -39,7 +39,9 @@ Uni-Lab-OS recommends using `mamba` for environment management. Choose the appro
|
||||
|
||||
```bash
|
||||
# Create new environment
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## Install Dev Uni-Lab-OS
|
||||
|
||||
@@ -41,7 +41,9 @@ Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适
|
||||
|
||||
```bash
|
||||
# 创建新环境
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. 安装开发版 Uni-Lab-OS:
|
||||
|
||||
14473
bioyond_yihua_YB.json
Normal file
2521
button_battery_station_resources_unilab.json
Normal file
726
docs/advanced_usage/configuration.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
本文档详细介绍 Uni-Lab 配置文件的结构、配置项、命令行覆盖和环境变量的使用方法。
|
||||
|
||||
## 配置文件概述
|
||||
|
||||
Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_data/local_config.py`。配置文件采用类属性的方式定义各种配置项,比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
## 获取实验室密钥
|
||||
|
||||
在配置文件或启动命令中,您需要提供实验室的访问密钥(ak)和私钥(sk)。
|
||||
|
||||
**获取方式:**
|
||||
|
||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
|
||||

|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 默认配置示例
|
||||
|
||||
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`:
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码
|
||||
sk = "" # 实验室网页给您提供的sk代码
|
||||
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
您可以根据需要添加更多配置选项:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
# 基础配置
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室访问密钥
|
||||
sk = "" # 实验室私钥
|
||||
working_dir = "" # 工作目录(通常自动设置)
|
||||
config_path = "" # 配置文件路径(自动设置)
|
||||
is_host_mode = True # 是否为主站模式
|
||||
slave_no_host = False # 从站模式下是否跳过等待主机服务
|
||||
upload_registry = False # 是否上传注册表
|
||||
machine_name = "undefined" # 机器名称(自动获取)
|
||||
vis_2d_enable = False # 是否启用2D可视化
|
||||
enable_resource_load = True # 是否启用资源加载
|
||||
communication_protocol = "websocket" # 通信协议
|
||||
log_level = "DEBUG" # 日志级别:TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
] # 需要加载的ROS模块
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
配置项的生效优先级从高到低为:
|
||||
|
||||
1. **命令行参数**:最高优先级
|
||||
2. **环境变量**:中等优先级
|
||||
3. **配置文件**:基础优先级
|
||||
|
||||
这意味着命令行参数会覆盖环境变量和配置文件,环境变量会覆盖配置文件。
|
||||
|
||||
## 推荐配置方式
|
||||
|
||||
根据参数特性,不同配置项有不同的推荐配置方式:
|
||||
|
||||
### 建议通过命令行指定的参数(不需要写入配置文件)
|
||||
|
||||
以下参数推荐通过命令行或环境变量指定,**一般不需要在配置文件中配置**:
|
||||
|
||||
| 参数 | 命令行参数 | 原因 |
|
||||
| ----------------- | ------------------- | ------------------------------------ |
|
||||
| `ak` / `sk` | `--ak` / `--sk` | **安全考虑**:避免敏感信息泄露 |
|
||||
| `working_dir` | `--working_dir` | **灵活性**:不同环境可能使用不同目录 |
|
||||
| `is_host_mode` | `--is_slave` | **运行模式**:由启动场景决定,不固定 |
|
||||
| `slave_no_host` | `--slave_no_host` | **运行模式**:从站特殊配置,按需使用 |
|
||||
| `upload_registry` | `--upload_registry` | **临时操作**:仅首次启动或更新时需要 |
|
||||
| `vis_2d_enable` | `--2d_vis` | **调试功能**:按需临时启用 |
|
||||
| `remote_addr` | `--addr` | **环境切换**:测试/生产环境快速切换 |
|
||||
|
||||
**推荐用法示例:**
|
||||
|
||||
```bash
|
||||
# 标准启动命令(所有必要参数通过命令行指定)
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 测试环境
|
||||
unilab --addr test --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 从站模式
|
||||
unilab --is_slave --ak your_ak --sk your_sk
|
||||
|
||||
# 首次启动上传注册表
|
||||
unilab --ak your_ak --sk your_sk -g graph.json --upload_registry
|
||||
```
|
||||
|
||||
### 适合在配置文件中配置的参数
|
||||
|
||||
以下参数适合在配置文件中配置,通常不会频繁更改:
|
||||
|
||||
| 参数 | 配置类 | 说明 |
|
||||
| ------------------------ | ----------- | ---------------------- |
|
||||
| `log_level` | BasicConfig | 日志级别配置 |
|
||||
| `reconnect_interval` | WSConfig | WebSocket 重连间隔 |
|
||||
| `max_reconnect_attempts` | WSConfig | WebSocket 最大重连次数 |
|
||||
| `ping_interval` | WSConfig | WebSocket 心跳间隔 |
|
||||
| `modules` | ROSConfig | ROS 模块列表 |
|
||||
|
||||
**配置文件示例(推荐最小配置):**
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
log_level = "INFO" # 生产环境建议 INFO,调试时用 DEBUG
|
||||
|
||||
# WebSocket配置,一般保持默认即可
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
**注意:** `ak` 和 `sk` 不建议写在配置文件中,始终通过命令行参数或环境变量传递。
|
||||
|
||||
## 命令行参数覆盖配置
|
||||
|
||||
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。
|
||||
|
||||
### 支持命令行覆盖的配置项
|
||||
|
||||
| 配置类 | 配置字段 | 命令行参数 | 说明 |
|
||||
| ------------- | ----------------- | ------------------- | -------------------------------- |
|
||||
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
|
||||
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
|
||||
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
|
||||
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
|
||||
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
|
||||
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
|
||||
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
|
||||
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
|
||||
|
||||
### 特殊命令行参数
|
||||
|
||||
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `--config` | 指定配置文件路径 |
|
||||
| `--port` | Web 服务端口(不影响配置文件) |
|
||||
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
|
||||
| `--visual` | 可视化工具选择(不影响配置文件) |
|
||||
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
|
||||
|
||||
### 命令行覆盖使用示例
|
||||
|
||||
```bash
|
||||
# 通过命令行覆盖认证信息
|
||||
unilab --ak "new_access_key" --sk "new_secret_key" -g graph.json
|
||||
|
||||
# 覆盖服务器地址
|
||||
unilab --ak ak --sk sk --addr "https://custom.server.com/api/v1" -g graph.json
|
||||
|
||||
# 启用从站模式并跳过等待主机
|
||||
unilab --is_slave --slave_no_host --ak ak --sk sk
|
||||
|
||||
# 启用上传注册表和2D可视化
|
||||
unilab --upload_registry --2d_vis --ak ak --sk sk -g graph.json
|
||||
|
||||
# 组合使用多个覆盖参数
|
||||
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g graph.json
|
||||
```
|
||||
|
||||
### 预设环境地址
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
## 配置选项详解
|
||||
|
||||
### 1. BasicConfig - 基础配置
|
||||
|
||||
基础配置包含了系统运行的核心参数:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------------- | ------------------------------------------ |
|
||||
| `ak` | str | `""` | 实验室访问密钥(必需) |
|
||||
| `sk` | str | `""` | 实验室私钥(必需) |
|
||||
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
|
||||
| `config_path` | str | `""` | 配置文件路径,自动设置 |
|
||||
| `is_host_mode` | bool | `True` | 是否为主站模式 |
|
||||
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
|
||||
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
|
||||
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
|
||||
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
|
||||
| `enable_resource_load` | bool | `True` | 是否启用资源加载 |
|
||||
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
|
||||
| `log_level` | str | `"DEBUG"` | 日志级别 |
|
||||
|
||||
#### 日志级别选项
|
||||
|
||||
- `TRACE` - 追踪级别(最详细)
|
||||
- `DEBUG` - 调试级别(默认)
|
||||
- `INFO` - 信息级别
|
||||
- `WARNING` - 警告级别
|
||||
- `ERROR` - 错误级别
|
||||
- `CRITICAL` - 严重错误级别(最简略)
|
||||
|
||||
#### 认证配置(ak / sk)
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
- **配置文件**:在 `BasicConfig` 类中设置(不推荐,安全风险)
|
||||
3. **安全注意**:请妥善保管您的密钥信息,不要提交到版本控制
|
||||
|
||||
**推荐做法**:
|
||||
|
||||
- **开发环境**:使用命令行参数或环境变量
|
||||
- **生产环境**:使用环境变量
|
||||
- **临时测试**:使用命令行参数
|
||||
|
||||
### 2. WSConfig - WebSocket 配置
|
||||
|
||||
WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------ | ------------------ |
|
||||
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
|
||||
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
|
||||
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
|
||||
|
||||
### 3. HTTPConfig - HTTP 配置
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### 4. ROSConfig - ROS 配置
|
||||
|
||||
配置 ROS 消息转换器需要加载的模块:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
| --------- | ---- | ---------- | ------------ |
|
||||
| `modules` | list | 见下方示例 | ROS 模块列表 |
|
||||
|
||||
**默认模块列表:**
|
||||
|
||||
```python
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg", # 标准消息类型
|
||||
"geometry_msgs.msg", # 几何消息类型
|
||||
"control_msgs.msg", # 控制消息类型
|
||||
"control_msgs.action", # 控制动作类型
|
||||
"nav2_msgs.action", # 导航动作类型
|
||||
"unilabos_msgs.msg", # UniLab 自定义消息类型
|
||||
"unilabos_msgs.action", # UniLab 自定义动作类型
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据实际使用的设备和功能添加其他 ROS 模块。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。
|
||||
|
||||
### 环境变量命名规则
|
||||
|
||||
```
|
||||
UNILABOS_<配置类名>_<配置项名>
|
||||
```
|
||||
|
||||
**注意:**
|
||||
|
||||
- 环境变量名不区分大小写
|
||||
- 配置类名和配置项名都会转换为大写进行匹配
|
||||
|
||||
### 设置环境变量
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
# 临时设置(当前终端)
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
|
||||
# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc)
|
||||
echo 'export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
#### Windows (cmd)
|
||||
|
||||
```cmd
|
||||
# 临时设置
|
||||
set UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
set UNILABOS_BASICCONFIG_AK=your_access_key
|
||||
|
||||
# 永久设置(系统环境变量)
|
||||
setx UNILABOS_BASICCONFIG_LOG_LEVEL INFO
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 临时设置
|
||||
$env:UNILABOS_BASICCONFIG_LOG_LEVEL="INFO"
|
||||
$env:UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
|
||||
# 永久设置
|
||||
[Environment]::SetEnvironmentVariable("UNILABOS_BASICCONFIG_LOG_LEVEL", "INFO", "User")
|
||||
```
|
||||
|
||||
### 环境变量类型转换
|
||||
|
||||
系统会根据配置项的原始类型自动转换环境变量值:
|
||||
|
||||
| 原始类型 | 转换规则 |
|
||||
| -------- | --------------------------------------- |
|
||||
| `bool` | "true", "1", "yes" → True;其他 → False |
|
||||
| `int` | 转换为整数 |
|
||||
| `float` | 转换为浮点数 |
|
||||
| `str` | 直接使用字符串值 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 布尔值
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE=true # 将设置为 True
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE=false # 将设置为 False
|
||||
|
||||
# 整数
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 # 将设置为 10
|
||||
|
||||
# 字符串
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO # 将设置为 "INFO"
|
||||
```
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```bash
|
||||
# 设置基础配置
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
|
||||
|
||||
# 设置WebSocket配置
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
## 配置文件使用方法
|
||||
|
||||
### 1. 使用默认配置文件(推荐)
|
||||
|
||||
系统会自动查找并加载配置文件:
|
||||
|
||||
```bash
|
||||
# 直接启动,使用默认的 unilabos_data/local_config.py
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
查找顺序:
|
||||
|
||||
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
|
||||
2. 工作目录下的 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 2. 指定配置文件启动
|
||||
|
||||
```bash
|
||||
# 使用指定配置文件启动
|
||||
unilab --config /path/to/your/config.py --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
### 3. 配置文件验证
|
||||
|
||||
系统启动时会自动验证配置文件:
|
||||
|
||||
- **语法检查**:确保 Python 语法正确
|
||||
- **类型检查**:验证配置项类型是否匹配
|
||||
- **加载确认**:控制台输出加载成功信息
|
||||
|
||||
## 常用配置场景
|
||||
|
||||
### 场景 1:调整日志级别
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class BasicConfig:
|
||||
log_level = "INFO" # 生产环境建议使用 INFO 或 WARNING
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
unilab --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
**命令行方式**(需要配置文件已包含):
|
||||
|
||||
```bash
|
||||
# 配置文件无直接命令行参数,需通过环境变量
|
||||
UNILABOS_BASICCONFIG_LOG_LEVEL=INFO unilab --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
### 场景 2:配置 WebSocket 重连
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class WSConfig:
|
||||
reconnect_interval = 10 # 增加重连间隔到 10 秒
|
||||
max_reconnect_attempts = 100 # 减少最大重连次数到 100 次
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
||||
```
|
||||
|
||||
### 场景 3:切换服务器环境
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
```bash
|
||||
unilab --addr test --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
### 场景 4:从站模式配置
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class BasicConfig:
|
||||
is_host_mode = False # 从站模式
|
||||
slave_no_host = True # 不等待主机服务
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
```bash
|
||||
unilab --is_slave --slave_no_host --ak your_ak --sk your_sk
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全配置
|
||||
|
||||
**不要在配置文件中存储敏感信息**
|
||||
|
||||
- ❌ **不推荐**:在配置文件中明文存储 ak/sk
|
||||
- ✅ **推荐**:使用环境变量或命令行参数
|
||||
|
||||
```bash
|
||||
# 生产环境 - 使用环境变量(推荐)
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
unilab -g graph.json
|
||||
|
||||
# 或使用命令行参数
|
||||
unilab --ak "your_access_key" --sk "your_secret_key" -g graph.json
|
||||
```
|
||||
|
||||
**其他安全建议:**
|
||||
|
||||
- 不要将包含密钥的配置文件提交到版本控制系统
|
||||
- 限制配置文件权限:`chmod 600 local_config.py`
|
||||
- 定期更换访问密钥
|
||||
- 使用 `.gitignore` 排除配置文件
|
||||
|
||||
### 2. 多环境配置
|
||||
|
||||
为不同环境创建不同的配置文件:
|
||||
|
||||
```
|
||||
configs/
|
||||
├── base_config.py # 基础配置(非敏感)
|
||||
├── dev_config.py # 开发环境
|
||||
├── test_config.py # 测试环境
|
||||
├── prod_config.py # 生产环境
|
||||
└── example_config.py # 示例配置
|
||||
```
|
||||
|
||||
**环境切换示例**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境
|
||||
unilab --config configs/dev_config.py --addr local --ak ak --sk sk -g graph.json
|
||||
|
||||
# 测试环境
|
||||
unilab --config configs/test_config.py --addr test --ak ak --sk sk --upload_registry -g graph.json
|
||||
|
||||
# 生产环境
|
||||
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -g graph.json
|
||||
```
|
||||
|
||||
### 3. 配置管理
|
||||
|
||||
**配置文件最佳实践:**
|
||||
|
||||
- 保持配置文件简洁,只包含需要修改的配置项
|
||||
- 为配置项添加注释说明其作用
|
||||
- 定期检查和更新配置文件
|
||||
- 版本控制仅保存示例配置,不包含实际密钥
|
||||
|
||||
**命令行参数优先使用场景:**
|
||||
|
||||
- 临时测试不同配置
|
||||
- CI/CD 流水线中的动态配置
|
||||
- 不同环境间快速切换
|
||||
- 敏感信息的安全传递
|
||||
|
||||
### 4. 灵活配置策略
|
||||
|
||||
**基础配置文件 + 命令行覆盖**的推荐方式:
|
||||
|
||||
```python
|
||||
# base_config.py - 基础配置(非敏感信息)
|
||||
class BasicConfig:
|
||||
# 非敏感配置写在文件中
|
||||
is_host_mode = True
|
||||
upload_registry = False
|
||||
vis_2d_enable = False
|
||||
log_level = "INFO"
|
||||
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动时通过命令行覆盖关键参数
|
||||
unilab --config base_config.py \
|
||||
--ak "$AK" \
|
||||
--sk "$SK" \
|
||||
--addr "test" \
|
||||
--upload_registry \
|
||||
--2d_vis \
|
||||
-g graph.json
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 配置文件加载失败
|
||||
|
||||
**错误信息**:`[ENV] 配置文件 xxx 不存在`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认配置文件路径正确
|
||||
- 检查文件权限是否可读
|
||||
- 确保配置文件是 `.py` 格式
|
||||
- 使用绝对路径或相对于当前目录的路径
|
||||
|
||||
### 2. 语法错误
|
||||
|
||||
**错误信息**:`[ENV] 加载配置文件 xxx 失败`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 检查 Python 语法是否正确
|
||||
- 确认类名和字段名拼写正确
|
||||
- 验证缩进是否正确(使用空格而非制表符)
|
||||
- 确保字符串使用引号包裹
|
||||
|
||||
### 3. 认证失败
|
||||
|
||||
**错误信息**:`后续运行必须拥有一个实验室`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认 `ak` 和 `sk` 已正确配置
|
||||
- 检查密钥是否有效(未过期或撤销)
|
||||
- 确认网络连接正常
|
||||
- 验证密钥是否来自正确的实验室
|
||||
|
||||
### 4. 环境变量不生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认环境变量名格式正确(`UNILABOS_<类名>_<字段名>`)
|
||||
- 检查环境变量是否已正确设置(`echo $VARIABLE_NAME`)
|
||||
- 重启终端或重新加载环境变量
|
||||
- 确认环境变量值的类型正确
|
||||
|
||||
### 5. 命令行参数不生效
|
||||
|
||||
**错误现象**:设置了命令行参数但配置没有生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`)
|
||||
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
|
||||
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
|
||||
- 查看启动日志确认参数是否被正确解析
|
||||
- 检查是否有配置文件或环境变量与之冲突
|
||||
|
||||
### 6. 配置优先级混淆
|
||||
|
||||
**错误现象**:不确定哪个配置生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 记住优先级:**命令行参数 > 环境变量 > 配置文件**
|
||||
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息:"传入了 ak 参数,优先采用传入参数!"
|
||||
- 检查启动日志中的配置加载信息
|
||||
- 临时移除低优先级配置来测试高优先级配置是否生效
|
||||
- 使用 `printenv | grep UNILABOS` 查看所有相关环境变量
|
||||
|
||||
## 配置验证
|
||||
|
||||
### 检查配置是否生效
|
||||
|
||||
启动 Uni-Lab 时,控制台会输出配置加载信息:
|
||||
|
||||
```
|
||||
[ENV] 配置文件 /path/to/config.py 加载成功
|
||||
[ENV] 设置 BasicConfig.log_level = INFO
|
||||
传入了ak参数,优先采用传入参数!
|
||||
传入了sk参数,优先采用传入参数!
|
||||
```
|
||||
|
||||
### 常见配置错误
|
||||
|
||||
1. **配置文件格式错误**
|
||||
|
||||
```
|
||||
[ENV] 加载配置文件 /path/to/config.py 失败
|
||||
```
|
||||
|
||||
**解决方案**:检查 Python 语法,确保配置类定义正确
|
||||
|
||||
2. **环境变量格式错误**
|
||||
|
||||
```
|
||||
[ENV] 环境变量格式不正确:UNILABOS_INVALID_VAR
|
||||
```
|
||||
|
||||
**解决方案**:确保环境变量遵循 `UNILABOS_<类名>_<字段名>` 格式
|
||||
|
||||
3. **类或字段不存在**
|
||||
```
|
||||
[ENV] 未找到类:UNKNOWNCONFIG
|
||||
[ENV] 类 BasicConfig 中未找到字段:UNKNOWN_FIELD
|
||||
```
|
||||
**解决方案**:检查配置类名和字段名是否正确
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [工作目录详解](working_directory.md)
|
||||
- [启动参数详解](../user_guide/launch.md)
|
||||
- [快速安装指南](../user_guide/quick_install_guide.md)
|
||||
BIN
docs/advanced_usage/image/copy_aksk.gif
Normal file
|
After Width: | Height: | Size: 526 KiB |
218
docs/advanced_usage/working_directory.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 工作目录详解
|
||||
|
||||
本文档详细介绍 Uni-Lab 工作目录(`working_dir`)的判断逻辑和详细用法。
|
||||
|
||||
## 什么是工作目录
|
||||
|
||||
工作目录是 Uni-Lab 存储配置文件、日志和运行数据的目录。默认情况下,工作目录为 `当前目录/unilabos_data`。
|
||||
|
||||
## 工作目录判断逻辑
|
||||
|
||||
系统按以下决策树自动确定工作目录:
|
||||
|
||||
### 第一步:初始判断
|
||||
|
||||
```python
|
||||
# 检查当前目录
|
||||
if 当前目录以 "unilabos_data" 结尾:
|
||||
working_dir = 当前目录的绝对路径
|
||||
else:
|
||||
working_dir = 当前目录/unilabos_data
|
||||
```
|
||||
|
||||
**解释:**
|
||||
- 如果您已经在 `unilabos_data` 目录内启动,系统直接使用当前目录
|
||||
- 否则,系统会在当前目录下创建或使用 `unilabos_data` 子目录
|
||||
|
||||
### 第二步:处理 `--working_dir` 参数
|
||||
|
||||
如果用户指定了 `--working_dir` 参数:
|
||||
|
||||
```python
|
||||
working_dir = 用户指定的路径
|
||||
```
|
||||
|
||||
此时还会检查配置文件:
|
||||
- 如果同时指定了 `--config` 但该文件不存在
|
||||
- 系统会尝试在 `working_dir/local_config.py` 查找
|
||||
- 如果仍未找到,报错退出
|
||||
|
||||
### 第三步:处理 `--config` 参数
|
||||
|
||||
如果用户指定了 `--config` 且文件存在:
|
||||
|
||||
```python
|
||||
# 工作目录改为配置文件所在目录
|
||||
working_dir = config_path 的父目录
|
||||
```
|
||||
|
||||
**重要:** 这意味着配置文件的位置会影响工作目录的判断。
|
||||
|
||||
## 使用场景示例
|
||||
|
||||
### 场景 1:默认场景(推荐)
|
||||
|
||||
```bash
|
||||
# 当前目录:/home/user/project
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /home/user/project/unilabos_data
|
||||
# config_path = /home/user/project/unilabos_data/local_config.py
|
||||
```
|
||||
|
||||
### 场景 2:在 unilabos_data 目录内启动
|
||||
|
||||
```bash
|
||||
cd /home/user/project/unilabos_data
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /home/user/project/unilabos_data
|
||||
# config_path = /home/user/project/unilabos_data/local_config.py
|
||||
```
|
||||
|
||||
### 场景 3:手动指定工作目录
|
||||
|
||||
```bash
|
||||
unilab --working_dir /custom/path --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /custom/path
|
||||
# config_path = /custom/path/local_config.py (如果存在)
|
||||
```
|
||||
|
||||
### 场景 4:通过配置文件路径推断工作目录
|
||||
|
||||
```bash
|
||||
unilab --config /data/lab_a/local_config.py --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /data/lab_a
|
||||
# config_path = /data/lab_a/local_config.py
|
||||
```
|
||||
|
||||
## 高级用法:管理多个实验室配置
|
||||
|
||||
### 方法 1:使用不同的工作目录
|
||||
|
||||
```bash
|
||||
# 实验室 A
|
||||
unilab --working_dir ~/labs/lab_a --ak ak_a --sk sk_a -g graph_a.json
|
||||
|
||||
# 实验室 B
|
||||
unilab --working_dir ~/labs/lab_b --ak ak_b --sk sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
### 方法 2:使用不同的配置文件
|
||||
|
||||
```bash
|
||||
# 实验室 A
|
||||
unilab --config ~/labs/lab_a/config.py --ak ak_a --sk sk_a -g graph_a.json
|
||||
|
||||
# 实验室 B
|
||||
unilab --config ~/labs/lab_b/config.py --ak ak_b --sk sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
### 方法 3:使用shell脚本管理
|
||||
|
||||
创建 `start_lab_a.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd ~/labs/lab_a
|
||||
unilab --ak your_ak_a --sk your_sk_a -g graph_a.json
|
||||
```
|
||||
|
||||
创建 `start_lab_b.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd ~/labs/lab_b
|
||||
unilab --ak your_ak_b --sk your_sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
## 完整决策流程图
|
||||
|
||||
```
|
||||
开始
|
||||
↓
|
||||
判断当前目录是否以 unilabos_data 结尾?
|
||||
├─ 是 → working_dir = 当前目录
|
||||
└─ 否 → working_dir = 当前目录/unilabos_data
|
||||
↓
|
||||
用户是否指定 --working_dir?
|
||||
└─ 是 → working_dir = 指定路径
|
||||
↓
|
||||
用户是否指定 --config 且文件存在?
|
||||
└─ 是 → working_dir = config 文件所在目录
|
||||
↓
|
||||
检查 working_dir/local_config.py 是否存在?
|
||||
├─ 是 → 加载配置文件 → 继续启动
|
||||
└─ 否 → 询问是否首次使用
|
||||
├─ 是 → 创建目录和配置文件 → 继续启动
|
||||
└─ 否 → 退出程序
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 如何查看当前使用的工作目录?
|
||||
|
||||
启动 Uni-Lab 时,系统会在控制台输出:
|
||||
|
||||
```
|
||||
当前工作目录为 /path/to/working_dir
|
||||
```
|
||||
|
||||
### 2. 可以在同一台机器上运行多个实验室吗?
|
||||
|
||||
可以。使用不同的工作目录或配置文件即可:
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
unilab --working_dir ~/lab1 --ak ak1 --sk sk1 -g graph1.json
|
||||
|
||||
# 终端 2
|
||||
unilab --working_dir ~/lab2 --ak ak2 --sk sk2 -g graph2.json
|
||||
```
|
||||
|
||||
### 3. 工作目录中存储了什么?
|
||||
|
||||
- `local_config.py` - 配置文件
|
||||
- 日志文件
|
||||
- 临时运行数据
|
||||
- 缓存文件
|
||||
|
||||
### 4. 可以删除工作目录吗?
|
||||
|
||||
可以,但会丢失:
|
||||
- 配置文件(需要重新创建)
|
||||
- 历史日志
|
||||
- 缓存数据
|
||||
|
||||
建议定期备份配置文件。
|
||||
|
||||
### 5. 如何迁移到新的工作目录?
|
||||
|
||||
```bash
|
||||
# 1. 复制旧的工作目录
|
||||
cp -r ~/old_path/unilabos_data ~/new_path/unilabos_data
|
||||
|
||||
# 2. 在新位置启动
|
||||
cd ~/new_path
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用默认工作目录**:对于单一实验室,使用默认的 `./unilabos_data` 即可
|
||||
2. **组织多实验室**:为每个实验室创建独立的目录结构
|
||||
3. **版本控制**:将配置文件纳入版本控制,但排除日志和缓存
|
||||
4. **备份配置**:定期备份 `local_config.py` 文件
|
||||
5. **使用脚本**:为不同实验室创建启动脚本,简化操作
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [配置文件指南](configuration.md)
|
||||
- [启动参数详解](../user_guide/launch.md)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(instructions)=
|
||||
# 设备抽象、指令集与通信中间件
|
||||
|
||||
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
Uni-Lab-OS的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
|
||||
## 设备间通信模式
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# 添加新设备
|
||||
# 添加设备:编写驱动
|
||||
|
||||
在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
|
||||
在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的这三种机制映射到 Uni-Lab 标准指令集上。
|
||||
|
||||
能被 Uni-Lab 添加的驱动程序类型有以下种类:
|
||||
> **💡 提示:** 本文档介绍如何使用已有的设备驱动(SDK)。若设备没有现成的驱动程序,需要自己开发驱动,请参考 {doc}`add_old_device`。
|
||||
|
||||
1. Python Class,如
|
||||
## 支持的驱动类型
|
||||
|
||||
Uni-Lab 支持以下两种驱动程序:
|
||||
|
||||
### 1. Python Class(推荐)
|
||||
|
||||
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,无需额外编译。
|
||||
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
class MockGripper:
|
||||
@@ -31,12 +39,11 @@ class MockGripper:
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
@status.setter
|
||||
def status(self, target):
|
||||
self._status = target
|
||||
|
||||
# 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
def push_to(self, position: float, torque: float, velocity: float = 0.0):
|
||||
self._status = "Running"
|
||||
current_pos = self.position
|
||||
@@ -53,9 +60,11 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
```
|
||||
|
||||
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。
|
||||
### 2. C# Class
|
||||
|
||||
2. C# Class,如
|
||||
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用(仅需一次)。
|
||||
|
||||
**示例:**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
@@ -84,7 +93,7 @@ public class MockGripper
|
||||
position = currentPos + (Position - currentPos) / 20 * (i + 1);
|
||||
torque = Torque / (20 - i);
|
||||
velocity = Velocity;
|
||||
await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds
|
||||
await Task.Delay((int)(moveTime * 1000 / 20));
|
||||
}
|
||||
torque = Torque;
|
||||
status = "Idle";
|
||||
@@ -92,12 +101,16 @@ public class MockGripper
|
||||
}
|
||||
```
|
||||
|
||||
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
|
||||
---
|
||||
|
||||
## 快速开始:使用注册表编辑器(推荐)
|
||||
## 快速开始:两种方式添加设备
|
||||
|
||||
### 方式 1:使用注册表编辑器(推荐)
|
||||
|
||||
推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置:
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 启动 Uni-Lab-OS
|
||||
2. 在浏览器中打开"注册表编辑器"页面
|
||||
3. 选择您的 Python 设备驱动文件
|
||||
@@ -106,13 +119,18 @@ C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能
|
||||
6. 点击"生成注册表",复制生成的内容
|
||||
7. 保存到 `devices/` 目录下
|
||||
|
||||
---
|
||||
**优点:**
|
||||
|
||||
## 手动编写注册表(简化版)
|
||||
- 自动识别设备属性和方法
|
||||
- 可视化界面,易于操作
|
||||
- 自动生成完整配置
|
||||
- 减少手动配置错误
|
||||
|
||||
### 方式 2:手动编写注册表(简化版)
|
||||
|
||||
如果需要手动编写,只需要提供两个必需字段,系统会自动补全其余内容:
|
||||
|
||||
### 最小配置示例
|
||||
**最小配置示例:**
|
||||
|
||||
```yaml
|
||||
my_device: # 设备唯一标识符
|
||||
@@ -121,22 +139,22 @@ my_device: # 设备唯一标识符
|
||||
type: python # 驱动类型
|
||||
```
|
||||
|
||||
### 注册表文件位置
|
||||
**注册表文件位置:**
|
||||
|
||||
- 默认路径:`unilabos/registry/devices`
|
||||
- 自定义路径:启动时使用 `--registry` 参数指定
|
||||
- 可将多个设备写在同一个 yaml 文件中
|
||||
- 自定义路径:启动时使用 `--registry_path` 参数指定
|
||||
- 可将多个设备写在同一个 YAML 文件中
|
||||
|
||||
### 系统自动生成的内容
|
||||
**系统自动生成的内容:**
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `get_*` 方法自动识别状态属性
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
|
||||
### 完整结构概览
|
||||
**完整结构概览:**
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
@@ -151,4 +169,848 @@ my_device:
|
||||
schema: {} # 自动生成
|
||||
```
|
||||
|
||||
详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 <add_yaml>`。
|
||||
> 💡 **提示:** 详细的注册表编写指南和高级配置,请参考 {doc}`03_add_device_registry`。
|
||||
|
||||
---
|
||||
|
||||
## Python 类结构要求
|
||||
|
||||
Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
说明设备的功能、连接方式等
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""初始化设备
|
||||
|
||||
Args:
|
||||
config: 配置字典,来自图文件或注册表
|
||||
"""
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
self.baudrate = config.get('baudrate', 9600)
|
||||
self._status = "idle"
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""设备状态(会自动广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
"""执行动作
|
||||
|
||||
Args:
|
||||
param: 参数说明
|
||||
|
||||
Returns:
|
||||
{"success": True, "result": ...}
|
||||
"""
|
||||
# 执行设备操作
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property)
|
||||
|
||||
状态属性会被自动识别并定期广播:
|
||||
|
||||
```python
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
**特点**:
|
||||
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
|
||||
```python
|
||||
def start_heating(self, target_temp: float, rate: float = 1.0) -> Dict[str, Any]:
|
||||
"""开始加热
|
||||
|
||||
Args:
|
||||
target_temp: 目标温度(°C)
|
||||
rate: 升温速率(°C/min)
|
||||
|
||||
Returns:
|
||||
{"success": bool, "message": str}
|
||||
"""
|
||||
self._status = "heating"
|
||||
self._target_temp = target_temp
|
||||
# 发送命令到硬件
|
||||
return {"success": True, "message": f"Heating to {target_temp}°C"}
|
||||
|
||||
async def async_operation(self, duration: float) -> Dict[str, Any]:
|
||||
"""异步操作(长时间运行)
|
||||
|
||||
Args:
|
||||
duration: 持续时间(秒)
|
||||
"""
|
||||
# 使用 self.sleep 而不是 asyncio.sleep(ROS2 异步机制)
|
||||
await self.sleep(duration)
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
**特点**:
|
||||
|
||||
- 普通方法或 async 方法
|
||||
- 返回 Dict 类型的结果
|
||||
- 自动注册为 ROS2 Action
|
||||
- 支持参数和返回值
|
||||
|
||||
### 返回值设计指南
|
||||
|
||||
> **⚠️ 重要:返回值会自动显示在前端**
|
||||
>
|
||||
> 动作方法的返回值(字典)会自动显示在 Web 界面的工作流执行结果中。因此,**强烈建议**设计结构化、可读的返回值字典。
|
||||
|
||||
**推荐的返回值结构:**
|
||||
|
||||
```python
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
"""执行操作"""
|
||||
try:
|
||||
# 执行操作...
|
||||
result = self._do_something(param)
|
||||
|
||||
return {
|
||||
"success": True, # 必需:操作是否成功
|
||||
"message": "操作完成", # 推荐:用户友好的消息
|
||||
"result": result, # 可选:具体结果数据
|
||||
"param_used": param, # 可选:记录使用的参数
|
||||
# 其他有用的信息...
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "操作失败"
|
||||
}
|
||||
```
|
||||
|
||||
**最佳实践示例(参考 `host_node.test_latency`):**
|
||||
|
||||
```python
|
||||
def test_latency(self) -> Dict[str, Any]:
|
||||
"""测试网络延迟
|
||||
|
||||
返回值会在前端显示,包含详细的测试结果
|
||||
"""
|
||||
# 执行测试...
|
||||
avg_rtt_ms = 25.5
|
||||
avg_time_diff_ms = 10.2
|
||||
test_count = 5
|
||||
|
||||
# 返回结构化的测试结果
|
||||
return {
|
||||
"status": "success", # 状态标识
|
||||
"avg_rtt_ms": avg_rtt_ms, # 平均往返时间
|
||||
"avg_time_diff_ms": avg_time_diff_ms, # 平均时间差
|
||||
"max_time_error_ms": 5.3, # 最大误差
|
||||
"task_delay_ms": 15.7, # 任务延迟
|
||||
"test_count": test_count, # 测试次数
|
||||
}
|
||||
```
|
||||
|
||||
**前端显示效果:**
|
||||
|
||||
当用户在 Web 界面执行工作流时,返回的字典会以 JSON 格式显示在结果面板中:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"avg_rtt_ms": 25.5,
|
||||
"avg_time_diff_ms": 10.2,
|
||||
"max_time_error_ms": 5.3,
|
||||
"task_delay_ms": 15.7,
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**返回值设计建议:**
|
||||
|
||||
1. **始终包含 `success` 字段**:布尔值,表示操作是否成功
|
||||
2. **包含 `message` 字段**:字符串,提供用户友好的描述
|
||||
3. **使用有意义的键名**:使用描述性的键名(如 `avg_rtt_ms` 而不是 `v1`)
|
||||
4. **包含单位**:在键名中包含单位(如 `_ms`、`_ml`、`_celsius`)
|
||||
5. **记录重要参数**:返回使用的关键参数值,便于追溯
|
||||
6. **错误信息详细**:失败时包含 `error` 字段和详细的错误描述
|
||||
7. **避免返回大数据**:不要返回大型数组或二进制数据,这会影响前端性能
|
||||
|
||||
**错误处理示例:**
|
||||
|
||||
```python
|
||||
def risky_operation(self, param: float) -> Dict[str, Any]:
|
||||
"""可能失败的操作"""
|
||||
if param < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "参数不能为负数",
|
||||
"message": f"无效参数: {param}",
|
||||
"param": param
|
||||
}
|
||||
|
||||
try:
|
||||
result = self._execute(param)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "操作成功",
|
||||
"result": result,
|
||||
"param": param
|
||||
}
|
||||
except IOError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "通信错误",
|
||||
"message": str(e),
|
||||
"device_status": self._status
|
||||
}
|
||||
```
|
||||
|
||||
## 特殊参数类型:ResourceSlot 和 DeviceSlot
|
||||
|
||||
Uni-Lab 提供特殊的参数类型,用于在方法中声明需要选择资源或设备。
|
||||
|
||||
### 导入类型
|
||||
|
||||
```python
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from typing import List
|
||||
```
|
||||
|
||||
### ResourceSlot - 资源选择
|
||||
|
||||
用于需要选择物料资源的场景:
|
||||
|
||||
```python
|
||||
def pipette_liquid(
|
||||
self,
|
||||
source: ResourceSlot, # 单个源容器
|
||||
target: ResourceSlot, # 单个目标容器
|
||||
volume: float
|
||||
) -> Dict[str, Any]:
|
||||
"""从源容器吸取液体到目标容器
|
||||
|
||||
Args:
|
||||
source: 源容器(前端会显示资源选择下拉框)
|
||||
target: 目标容器(前端会显示资源选择下拉框)
|
||||
volume: 体积(μL)
|
||||
"""
|
||||
print(f"Pipetting {volume}μL from {source.id} to {target.id}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
**多选示例**:
|
||||
|
||||
```python
|
||||
def mix_multiple(
|
||||
self,
|
||||
containers: List[ResourceSlot], # 多个容器选择
|
||||
speed: float
|
||||
) -> Dict[str, Any]:
|
||||
"""混合多个容器
|
||||
|
||||
Args:
|
||||
containers: 容器列表(前端会显示多选下拉框)
|
||||
speed: 混合速度
|
||||
"""
|
||||
for container in containers:
|
||||
print(f"Mixing {container.name}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
### DeviceSlot - 设备选择
|
||||
|
||||
用于需要选择其他设备的场景:
|
||||
|
||||
```python
|
||||
def coordinate_with_device(
|
||||
self,
|
||||
other_device: DeviceSlot, # 单个设备选择
|
||||
command: str
|
||||
) -> Dict[str, Any]:
|
||||
"""与另一个设备协同工作
|
||||
|
||||
Args:
|
||||
other_device: 协同设备(前端会显示设备选择下拉框)
|
||||
command: 命令
|
||||
"""
|
||||
print(f"Coordinating with {other_device.name}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
**多设备示例**:
|
||||
|
||||
```python
|
||||
def sync_devices(
|
||||
self,
|
||||
devices: List[DeviceSlot], # 多个设备选择
|
||||
sync_signal: str
|
||||
) -> Dict[str, Any]:
|
||||
"""同步多个设备
|
||||
|
||||
Args:
|
||||
devices: 设备列表(前端会显示多选下拉框)
|
||||
sync_signal: 同步信号
|
||||
"""
|
||||
for dev in devices:
|
||||
print(f"Syncing {dev.name}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
### 完整示例:液体处理工作站
|
||||
|
||||
```python
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from typing import List, Dict, Any
|
||||
|
||||
class LiquidHandler:
|
||||
"""液体处理工作站"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.simulation = config.get('simulation', False)
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
def transfer_liquid(
|
||||
self,
|
||||
source: ResourceSlot, # 源容器选择
|
||||
target: ResourceSlot, # 目标容器选择
|
||||
volume: float,
|
||||
tip: ResourceSlot = None # 可选的枪头选择
|
||||
) -> Dict[str, Any]:
|
||||
"""转移液体
|
||||
|
||||
前端效果:
|
||||
- source: 下拉框,列出所有可用容器
|
||||
- target: 下拉框,列出所有可用容器
|
||||
- volume: 数字输入框
|
||||
- tip: 下拉框(可选),列出所有枪头
|
||||
"""
|
||||
self._status = "transferring"
|
||||
|
||||
# source和target会被解析为实际的资源对象
|
||||
print(f"Transferring {volume}μL")
|
||||
print(f" From: {source.id} ({source.name})")
|
||||
print(f" To: {target.id} ({target.name})")
|
||||
|
||||
if tip:
|
||||
print(f" Using tip: {tip.id}")
|
||||
|
||||
# 执行实际的液体转移
|
||||
# ...
|
||||
|
||||
self._status = "idle"
|
||||
return {
|
||||
"success": True,
|
||||
"volume_transferred": volume,
|
||||
"source_id": source.id,
|
||||
"target_id": target.id
|
||||
}
|
||||
|
||||
def multi_dispense(
|
||||
self,
|
||||
source: ResourceSlot, # 单个源
|
||||
targets: List[ResourceSlot], # 多个目标
|
||||
volumes: List[float]
|
||||
) -> Dict[str, Any]:
|
||||
"""从一个源分配到多个目标
|
||||
|
||||
前端效果:
|
||||
- source: 单选下拉框
|
||||
- targets: 多选下拉框(可选择多个容器)
|
||||
- volumes: 数组输入(每个目标对应一个体积)
|
||||
"""
|
||||
results = []
|
||||
for target, vol in zip(targets, volumes):
|
||||
print(f"Dispensing {vol}μL to {target.name}")
|
||||
results.append({
|
||||
"target": target.id,
|
||||
"volume": vol
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"dispense_results": results
|
||||
}
|
||||
|
||||
def test_with_balance(
|
||||
self,
|
||||
target: ResourceSlot, # 容器
|
||||
balance: DeviceSlot # 天平设备
|
||||
) -> Dict[str, Any]:
|
||||
"""使用天平测量容器
|
||||
|
||||
前端效果:
|
||||
- target: 容器选择下拉框
|
||||
- balance: 设备选择下拉框(仅显示天平类型)
|
||||
"""
|
||||
print(f"Weighing {target.name} on {balance.name}")
|
||||
|
||||
# 可以调用balance的方法
|
||||
# weight = balance.get_weight()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"container": target.id,
|
||||
"balance_used": balance.id
|
||||
}
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
|
||||
#### 1. 类型识别
|
||||
|
||||
注册表扫描方法签名时:
|
||||
|
||||
```python
|
||||
def my_method(self, resource: ResourceSlot, device: DeviceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
系统识别到`ResourceSlot`和`DeviceSlot`类型。
|
||||
|
||||
#### 2. 自动添加 placeholder_keys
|
||||
|
||||
在注册表中自动生成:
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
class:
|
||||
action_value_mappings:
|
||||
my_method:
|
||||
goal:
|
||||
resource: resource
|
||||
device: device
|
||||
placeholder_keys:
|
||||
resource: unilabos_resources # 自动添加!
|
||||
device: unilabos_devices # 自动添加!
|
||||
```
|
||||
|
||||
#### 3. 前端 UI 生成
|
||||
|
||||
- `unilabos_resources`: 渲染为资源选择下拉框
|
||||
- `unilabos_devices`: 渲染为设备选择下拉框
|
||||
|
||||
#### 4. 运行时解析
|
||||
|
||||
用户选择资源/设备后,实际调用时会传入完整的资源/设备对象:
|
||||
|
||||
```python
|
||||
# 用户在前端选择了 plate_1
|
||||
# 运行时,source参数会收到完整的Resource对象
|
||||
source.id # "plate_1"
|
||||
source.name # "96孔板"
|
||||
source.type # "resource"
|
||||
source.class_ # "corning_96_wellplate_360ul_flat"
|
||||
```
|
||||
|
||||
## 支持的通信方式
|
||||
|
||||
### 1. 串口(Serial)
|
||||
|
||||
```python
|
||||
import serial
|
||||
|
||||
class SerialDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.port = config['port']
|
||||
self.baudrate = config.get('baudrate', 9600)
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1
|
||||
)
|
||||
|
||||
def send_command(self, cmd: str) -> str:
|
||||
"""发送命令并读取响应"""
|
||||
self.ser.write(f"{cmd}\r\n".encode())
|
||||
response = self.ser.readline().decode().strip()
|
||||
return response
|
||||
|
||||
def __del__(self):
|
||||
if hasattr(self, 'ser') and self.ser.is_open:
|
||||
self.ser.close()
|
||||
```
|
||||
|
||||
### 2. TCP/IP Socket
|
||||
|
||||
```python
|
||||
import socket
|
||||
|
||||
class TCPDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.host = config['host']
|
||||
self.port = config['port']
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.connect((self.host, self.port))
|
||||
|
||||
def send_command(self, cmd: str) -> str:
|
||||
self.sock.sendall(cmd.encode())
|
||||
response = self.sock.recv(1024).decode()
|
||||
return response
|
||||
```
|
||||
|
||||
### 3. Modbus
|
||||
|
||||
```python
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
class ModbusDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.host = config['host']
|
||||
self.port = config.get('port', 502)
|
||||
self.client = ModbusTcpClient(self.host, port=self.port)
|
||||
self.client.connect()
|
||||
|
||||
def read_register(self, address: int) -> int:
|
||||
result = self.client.read_holding_registers(address, 1)
|
||||
return result.registers[0]
|
||||
|
||||
def write_register(self, address: int, value: int):
|
||||
self.client.write_register(address, value)
|
||||
```
|
||||
|
||||
### 4. OPC UA
|
||||
|
||||
```python
|
||||
from opcua import Client
|
||||
|
||||
class OPCUADevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.url = config['url']
|
||||
self.client = Client(self.url)
|
||||
self.client.connect()
|
||||
|
||||
def read_node(self, node_id: str):
|
||||
node = self.client.get_node(node_id)
|
||||
return node.get_value()
|
||||
|
||||
def write_node(self, node_id: str, value):
|
||||
node = self.client.get_node(node_id)
|
||||
node.set_value(value)
|
||||
```
|
||||
|
||||
### 5. HTTP/RPC
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
class HTTPDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.base_url = config['url']
|
||||
self.auth_token = config.get('token')
|
||||
|
||||
def send_command(self, endpoint: str, data: Dict) -> Dict:
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
headers = {'Authorization': f'Bearer {self.auth_token}'}
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## 异步 vs 同步方法
|
||||
|
||||
### 同步方法(适合快速操作)
|
||||
|
||||
```python
|
||||
def quick_operation(self, param: float) -> Dict[str, Any]:
|
||||
"""快速操作,立即返回"""
|
||||
result = self._do_something(param)
|
||||
return {"success": True, "result": result}
|
||||
```
|
||||
|
||||
### 异步方法(适合耗时操作)
|
||||
|
||||
```python
|
||||
async def long_operation(self, duration: float) -> Dict[str, Any]:
|
||||
"""长时间运行的操作"""
|
||||
self._status = "running"
|
||||
|
||||
# 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep)
|
||||
await self.sleep(duration)
|
||||
|
||||
# 可以在过程中发送feedback
|
||||
# 需要配合ROS2 Action的feedback机制
|
||||
|
||||
self._status = "idle"
|
||||
return {"success": True, "duration": duration}
|
||||
```
|
||||
|
||||
> **⚠️ 重要提示:ROS2 异步机制 vs Python asyncio**
|
||||
>
|
||||
> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。
|
||||
>
|
||||
> **不能使用的 asyncio 功能:**
|
||||
>
|
||||
> - ❌ `asyncio.sleep()` - 会导致 ROS2 事件循环阻塞
|
||||
> - ❌ `asyncio.create_task()` - 任务不会被 ROS2 正确调度
|
||||
> - ❌ `asyncio.gather()` - 无法与 ROS2 集成
|
||||
> - ❌ 其他 asyncio 标准库函数
|
||||
>
|
||||
> **应该使用的方法(继承自 BaseROS2DeviceNode):**
|
||||
>
|
||||
> - ✅ `await self.sleep(seconds)` - ROS2 兼容的睡眠
|
||||
> - ✅ `await self.create_task(func, **kwargs)` - ROS2 兼容的任务创建
|
||||
> - ✅ ROS2 的 Action/Service 回调机制
|
||||
>
|
||||
> **示例:**
|
||||
>
|
||||
> ```python
|
||||
> async def complex_operation(self, duration: float) -> Dict[str, Any]:
|
||||
> """正确使用 ROS2 异步方法"""
|
||||
> self._status = "processing"
|
||||
>
|
||||
> # ✅ 正确:使用 self.sleep
|
||||
> await self.sleep(duration)
|
||||
>
|
||||
> # ✅ 正确:创建并发任务
|
||||
> task = await self.create_task(self._background_work)
|
||||
>
|
||||
> # ❌ 错误:不要使用 asyncio
|
||||
> # await asyncio.sleep(duration) # 这会导致问题!
|
||||
> # task = asyncio.create_task(...) # 这也不行!
|
||||
>
|
||||
> self._status = "idle"
|
||||
> return {"success": True}
|
||||
>
|
||||
> async def _background_work(self):
|
||||
> """后台任务"""
|
||||
> await self.sleep(1.0)
|
||||
> self.lab_logger().info("Background work completed")
|
||||
> ```
|
||||
>
|
||||
> **为什么不能混用?**
|
||||
>
|
||||
> ROS2 使用 `rclpy` 的事件循环来管理所有异步操作。如果使用 `asyncio` 的函数,这些操作会在不同的事件循环中运行,导致:
|
||||
>
|
||||
> - ROS2 回调无法正确执行
|
||||
> - 任务可能永远不会完成
|
||||
> - 程序可能死锁或崩溃
|
||||
>
|
||||
> **参考实现:**
|
||||
>
|
||||
> `BaseROS2DeviceNode` 提供的方法定义(`base_device_node.py:563-572`):
|
||||
>
|
||||
> ```python
|
||||
> async def sleep(self, rel_time: float, callback_group=None):
|
||||
> """ROS2 兼容的异步睡眠"""
|
||||
> if callback_group is None:
|
||||
> callback_group = self.callback_group
|
||||
> await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group)
|
||||
>
|
||||
> @classmethod
|
||||
> async def create_task(cls, func, trace_error=True, **kwargs) -> Task:
|
||||
> """ROS2 兼容的任务创建"""
|
||||
> return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs)
|
||||
> ```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 基本错误处理
|
||||
|
||||
```python
|
||||
def operation_with_error_handling(self, param: float) -> Dict[str, Any]:
|
||||
"""带错误处理的操作"""
|
||||
try:
|
||||
result = self._risky_operation(param)
|
||||
return {
|
||||
"success": True,
|
||||
"result": result
|
||||
}
|
||||
except ValueError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid parameter",
|
||||
"message": str(e)
|
||||
}
|
||||
except IOError as e:
|
||||
self._status = "error"
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Communication error",
|
||||
"message": str(e)
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义异常
|
||||
|
||||
```python
|
||||
class DeviceError(Exception):
|
||||
"""设备错误基类"""
|
||||
pass
|
||||
|
||||
class DeviceNotReadyError(DeviceError):
|
||||
"""设备未就绪"""
|
||||
pass
|
||||
|
||||
class DeviceTimeoutError(DeviceError):
|
||||
"""设备超时"""
|
||||
pass
|
||||
|
||||
class MyDevice:
|
||||
def operation(self) -> Dict[str, Any]:
|
||||
if self._status != "idle":
|
||||
raise DeviceNotReadyError(f"Device is {self._status}")
|
||||
|
||||
# 执行操作
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
def method(
|
||||
self,
|
||||
param1: float,
|
||||
param2: str,
|
||||
optional_param: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""完整的类型注解有助于自动生成注册表"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
"""方法简短描述
|
||||
|
||||
更详细的说明...
|
||||
|
||||
Args:
|
||||
param: 参数说明,包括单位和范围
|
||||
|
||||
Returns:
|
||||
Dict包含:
|
||||
- success (bool): 是否成功
|
||||
- result (Any): 结果数据
|
||||
|
||||
Raises:
|
||||
DeviceError: 错误情况说明
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 3. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
# 验证必需参数
|
||||
required = ['port', 'baudrate']
|
||||
for key in required:
|
||||
if key not in config:
|
||||
raise ValueError(f"Missing required config: {key}")
|
||||
|
||||
self.port = config['port']
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 4. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
"""析构函数,清理资源"""
|
||||
if hasattr(self, 'connection') and self.connection:
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 5. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def measure_temperature(self) -> Dict[str, Any]:
|
||||
"""测量温度
|
||||
|
||||
✅ 好的返回值设计:
|
||||
- 包含 success 状态
|
||||
- 使用描述性键名
|
||||
- 在键名中包含单位
|
||||
- 记录测量时间
|
||||
"""
|
||||
temp = self._read_temperature()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"temperature_celsius": temp, # 键名包含单位
|
||||
"timestamp": time.time(), # 记录时间
|
||||
"sensor_status": "normal", # 额外状态信息
|
||||
"message": f"温度测量完成: {temp}°C" # 用户友好的消息
|
||||
}
|
||||
|
||||
def bad_example(self) -> Dict[str, Any]:
|
||||
"""❌ 不好的返回值设计"""
|
||||
return {
|
||||
"s": True, # ❌ 键名不明确
|
||||
"v": 25.5, # ❌ 没有说明单位
|
||||
"t": 1234567890, # ❌ 不清楚是什么时间戳
|
||||
}
|
||||
```
|
||||
|
||||
**参考 `host_node.test_latency` 方法**(第 1216-1340 行),它返回详细的测试结果,在前端清晰显示:
|
||||
|
||||
```python
|
||||
return {
|
||||
"status": "success",
|
||||
"avg_rtt_ms": 25.5, # 有意义的键名 + 单位
|
||||
"avg_time_diff_ms": 10.2,
|
||||
"max_time_error_ms": 5.3,
|
||||
"task_delay_ms": 15.7,
|
||||
"test_count": 5, # 记录重要信息
|
||||
}
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
看完本文档后,建议继续阅读:
|
||||
|
||||
- {doc}`add_action` - 了解如何添加新的动作指令
|
||||
- {doc}`add_yaml` - 学习如何编写和完善 YAML 注册表
|
||||
|
||||
进阶主题:
|
||||
|
||||
- {doc}`03_add_device_registry` - 了解如何配置注册表
|
||||
- {doc}`04_add_device_testing` - 学习如何测试设备
|
||||
- {doc}`add_old_device` - 没有 SDK 时如何开发设备驱动
|
||||
|
||||
## 参考
|
||||
|
||||
- [Python 类型注解](https://docs.python.org/3/library/typing.html)
|
||||
- [ROS2 rclpy 异步编程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html) - Uni-Lab 使用 ROS2 的异步机制
|
||||
- [串口通信](https://pyserial.readthedocs.io/)
|
||||
|
||||
> **注意:** 虽然设备驱动使用 `async def` 语法,但请**不要参考** Python 标准的 [asyncio 文档](https://docs.python.org/3/library/asyncio.html)。Uni-Lab 使用的是 ROS2 的异步机制,两者不兼容。请使用 `self.sleep()` 和 `self.create_task()` 等 BaseROS2DeviceNode 提供的方法。
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 设备 Driver 开发
|
||||
# 设备 Driver 开发(无 SDK 设备)
|
||||
|
||||
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
|
||||
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。**仅当没有 SDK (Driver) 时,请参考本章进行驱动开发。**
|
||||
|
||||
> **💡 提示:** 本文档介绍如何为没有现成驱动的老设备开发驱动程序。如果您的设备已经有 SDK 或驱动,请直接参考 {doc}`add_device`。
|
||||
|
||||
## 有串口字符串指令集文档的设备:Python 串口通信(常见 RS485, RS232, USB)
|
||||
|
||||
@@ -12,13 +14,13 @@
|
||||
|
||||
Modbus 与 RS485、RS232 不一样的地方在于,会有更多直接寄存器的读写,以及涉及字节序转换(Big Endian, Little Endian)。
|
||||
|
||||
Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
Uni-Lab 开发团队在仓库中提供了 3 个样例:
|
||||
|
||||
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
* 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
|
||||
****
|
||||
---
|
||||
|
||||
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
||||
|
||||
@@ -26,32 +28,32 @@ Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
|
||||
## 没有接口的老设备老软件:使用 PyWinAuto
|
||||
|
||||
**pywinauto**是一个 Python 库,用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作:
|
||||
**pywinauto**是一个 Python 库,用于自动化 Windows GUI 操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI 自动化等场景。它支持通过两个后端进行操作:
|
||||
|
||||
* **win32**后端:适用于大多数Windows应用程序,使用native Win32 API。(pywinauto_recorder默认使用win32后端)
|
||||
* **uia**后端:基于Microsoft UI Automation,适用于较新的应用程序,特别是基于WPF或UWP的应用程序。(在win10上,会有更全的目录,有的窗口win32会识别不到)
|
||||
- **win32**后端:适用于大多数 Windows 应用程序,使用 native Win32 API。(pywinauto_recorder 默认使用 win32 后端)
|
||||
- **uia**后端:基于 Microsoft UI Automation,适用于较新的应用程序,特别是基于 WPF 或 UWP 的应用程序。(在 win10 上,会有更全的目录,有的窗口 win32 会识别不到)
|
||||
|
||||
### windows平台安装pywinauto和pywinauto_recorder
|
||||
### windows 平台安装 pywinauto 和 pywinauto_recorder
|
||||
|
||||
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
|
||||
|
||||
cd到对应目录,执行安装
|
||||
cd 到对应目录,执行安装
|
||||
|
||||
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
` pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple `
|
||||
|
||||

|
||||
|
||||
windows平台测试 python pywinauto_recorder.py,退出使用两次ctrl+alt+r取消选中,关闭命令提示符。
|
||||
windows 平台测试 python pywinauto_recorder.py,退出使用两次 ctrl+alt+r 取消选中,关闭命令提示符。
|
||||
|
||||
### 计算器例子
|
||||
|
||||
你可以先打开windows的计算器,然后在ilab的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的pywinauto用法:
|
||||
你可以先打开 windows 的计算器,然后在 ilab 的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的 pywinauto 用法:
|
||||
|
||||
* 连接到指定进程
|
||||
* 利用dump_tree查找需要的窗口
|
||||
* 获取某个位置的信息
|
||||
* 模拟点击
|
||||
* 模拟输入
|
||||
- 连接到指定进程
|
||||
- 利用 dump_tree 查找需要的窗口
|
||||
- 获取某个位置的信息
|
||||
- 模拟点击
|
||||
- 模拟输入
|
||||
|
||||
#### 代码学习
|
||||
|
||||
@@ -74,39 +76,39 @@ window.dump_tree(depth=3)
|
||||
Dialog - '计算器' (L-419, T773, R-73, B1287)
|
||||
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
|
||||
child_window(title="计算器", control_type="Window")
|
||||
|
|
||||
|
|
||||
| Dialog - '计算器' (L-269, T774, R-81, B806)
|
||||
| ['计算器Dialog2', 'Dialog2', '计算器2']
|
||||
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
|
||||
| |
|
||||
| |
|
||||
| | Menu - '系统' (L0, T0, R0, B0)
|
||||
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
|
||||
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
|
||||
| |
|
||||
| |
|
||||
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
|
||||
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
|
||||
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
|
||||
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
|
||||
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
|
||||
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
|
||||
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Dialog - '计算器' (L-411, T774, R-81, B1279)
|
||||
| ['计算器Dialog3', 'Dialog3', '计算器3']
|
||||
| child_window(title="计算器", control_type="Window")
|
||||
| |
|
||||
| |
|
||||
| | Static - '计算器' (L-363, T782, R-327, B798)
|
||||
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
|
||||
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Custom - '' (L-411, T806, R-81, B1279)
|
||||
| | ['Custom', '计算器Custom']
|
||||
| | child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
|
|
||||
| Pane - '' (L-411, T806, R-81, B1279)
|
||||
| ['Pane', '计算器Pane']
|
||||
"""
|
||||
@@ -122,58 +124,58 @@ target_window.dump_tree(depth=3)
|
||||
Custom - '' (L-411, T806, R-81, B1279)
|
||||
['标准Custom', 'Custom']
|
||||
child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
|
|
||||
| Button - '打开导航' (L-407, T812, R-367, B848)
|
||||
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
|
||||
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Static - '' (L0, T0, R0, B0)
|
||||
| | ['Static', 'Static0', 'Static1']
|
||||
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
||||
|
|
||||
|
|
||||
| GroupBox - '' (L-411, T814, R-81, B1275)
|
||||
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
|
||||
| |
|
||||
| |
|
||||
| | Static - '表达式为 ' (L0, T0, R0, B0)
|
||||
| | ['表达式为 ', 'Static2', '表达式为 Static']
|
||||
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Static - '显示为 0' (L-411, T875, R-81, B947)
|
||||
| | ['显示为 0Static', '显示为 0', 'Static3']
|
||||
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
|
||||
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
|
||||
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
|
||||
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
|
||||
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
|
||||
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
|
||||
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
|
||||
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
|
||||
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
|
||||
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
|
||||
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
|
||||
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
|
||||
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | Button - '正负' (L-407, T1228, R-328, B1275)
|
||||
| | ['Button32', '正负Button', '正负']
|
||||
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Static - '标准' (L-363, T815, R-322, B842)
|
||||
| ['标准', '标准Static', 'Static4']
|
||||
| child_window(title="标准", auto_id="Header", control_type="Text")
|
||||
|
|
||||
|
|
||||
| Button - '始终置顶' (L-312, T814, R-280, B846)
|
||||
| ['始终置顶Button', '始终置顶', 'Button33']
|
||||
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
|
||||
@@ -187,47 +189,47 @@ numpad.dump_tree(depth=2)
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '一' (L-334, T1450, R-255, B1498)
|
||||
| ['一Button', 'Button2', '一']
|
||||
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '二' (L-253, T1450, R-174, B1498)
|
||||
| ['Button3', '二', '二Button']
|
||||
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '三' (L-172, T1450, R-93, B1498)
|
||||
| ['Button4', '三', '三Button']
|
||||
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '四' (L-334, T1400, R-255, B1448)
|
||||
| ['四', 'Button5', '四Button']
|
||||
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '五' (L-253, T1400, R-174, B1448)
|
||||
| ['Button6', '五Button', '五']
|
||||
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '六' (L-172, T1400, R-93, B1448)
|
||||
| ['六Button', 'Button7', '六']
|
||||
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '七' (L-334, T1350, R-255, B1398)
|
||||
| ['Button8', '七Button', '七']
|
||||
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '八' (L-253, T1350, R-174, B1398)
|
||||
| ['八', 'Button9', '八Button']
|
||||
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '九' (L-172, T1350, R-93, B1398)
|
||||
| ['Button10', '九', '九Button']
|
||||
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
|
||||
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
|
||||
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
|
||||
@@ -262,13 +264,13 @@ r, g, b = pyautogui.pixel(point_x, point_y)
|
||||
|
||||
### pywinauto_recorder
|
||||
|
||||
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用pywinauto的一些查找UI步骤。
|
||||
pywinauto_recorder 是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用 DLL 的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用 pywinauto 的一些查找 UI 步骤。
|
||||
|
||||
#### 运行尝试
|
||||
|
||||
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
|
||||
请参照 上手尝试-环境创建-3 开启 pywinauto_recorder
|
||||
|
||||
例如我们这里先启动一个windows自带的计算器软件
|
||||
例如我们这里先启动一个 windows 自带的计算器软件
|
||||
|
||||

|
||||
|
||||
@@ -286,7 +288,7 @@ with UIPath(u"计算器||Window"):
|
||||
click(u"九||Button")
|
||||
```
|
||||
|
||||
执行该python脚本,可以观察到新开启的计算器被点击了数字9
|
||||
执行该 python 脚本,可以观察到新开启的计算器被点击了数字 9
|
||||
|
||||

|
||||
|
||||
@@ -308,23 +310,38 @@ window.dump_tree(depth=[int类型数字], filename=None)
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
"""
|
||||
```
|
||||
|
||||
这里以上面计算器的例子对dump_tree进行解读
|
||||
这里以上面计算器的例子对 dump_tree 进行解读
|
||||
|
||||
2~4行为当前对象的窗口
|
||||
2~4 行为当前对象的窗口
|
||||
|
||||
* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
* 第4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
- 第 2 行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
- 第 3 行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
- 第 4 行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
|
||||
6~8行为当前对象窗口所包含的子窗口信息,信息类型对应2~4行
|
||||
6~8 行为当前对象窗口所包含的子窗口信息,信息类型对应 2~4 行
|
||||
|
||||
### 窗口获取注意事项
|
||||
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
完成设备驱动开发后,建议继续阅读:
|
||||
|
||||
- {doc}`add_device` - 了解如何将驱动添加到 Uni-Lab 中
|
||||
- {doc}`add_action` - 学习如何添加新的动作指令
|
||||
- {doc}`add_yaml` - 编写和完善 YAML 注册表
|
||||
|
||||
进阶主题:
|
||||
|
||||
- {doc}`03_add_device_registry` - 详细的注册表配置
|
||||
- {doc}`04_add_device_testing` - 设备测试指南
|
||||
1139
docs/developer_guide/add_registry.md
Normal file
@@ -1,6 +1,17 @@
|
||||
# 电池装配工站接入(PLC)
|
||||
# 实例:电池装配工站接入(PLC 控制)
|
||||
|
||||
本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
|
||||
> **文档类型**:实际应用案例
|
||||
> **适用场景**:使用 PLC 控制的电池装配工站接入
|
||||
> **前置知识**:{doc}`../add_device` | {doc}`../add_registry`
|
||||
|
||||
本指南以电池装配工站为实际案例,引导你完成 PLC 控制设备的完整接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
|
||||
|
||||
## 案例概述
|
||||
|
||||
**设备类型**:电池装配工站
|
||||
**通信方式**:Modbus TCP (PLC)
|
||||
**工站基类**:`WorkstationBase`
|
||||
**主要功能**:电池组装、寄存器读写、数据采集
|
||||
|
||||
## 1. 新建工站文件
|
||||
|
||||
@@ -39,8 +50,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self.client = tcp.register_node_list(self.nodes)
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 2. 编写驱动与寄存器读写
|
||||
|
||||
### 2.1 寄存器示例
|
||||
@@ -84,49 +93,49 @@ def start_and_read_metrics(self):
|
||||
|
||||
完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。
|
||||
|
||||
|
||||
### 3.1 新增工站设备(或资源)首次生成注册表
|
||||
首先通过以下命令启动unilab。进入unilab系统状态检查页面
|
||||
|
||||
首先通过以下命令启动 unilab。进入 unilab 系统状态检查页面
|
||||
|
||||
```bash
|
||||
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK>
|
||||
```
|
||||
|
||||
点击注册表编辑,进入注册表编辑页面
|
||||

|
||||
|
||||

|
||||
|
||||
按照图示步骤填写自动生成注册表信息:
|
||||

|
||||
|
||||

|
||||
|
||||
步骤说明:
|
||||
|
||||
1. 选择新增的工站`coin_cell_assembly.py`文件
|
||||
2. 点击分析按钮,分析`coin_cell_assembly.py`文件
|
||||
3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`类
|
||||
4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`。
|
||||
4. 填写新增的工站.py 文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`。
|
||||
5. 此处填写新定义工站的类的名字(名称可以自拟)
|
||||
6. 填写新的工站注册表备注信息
|
||||
7. 生成注册表
|
||||
|
||||
以上操作步骤完成,则会生成的新的注册表ymal文件,如下图:
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
以上操作步骤完成,则会生成的新的注册表 YAML 文件,如下图:
|
||||
|
||||

|
||||
|
||||
### 3.2 添加新生成注册表
|
||||
在`unilabos\registry\devices`目录下新建一个yaml文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
|
||||
|
||||
在`unilabos\registry\devices`目录下新建一个 yaml 文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
|
||||
|
||||
在终端输入以下命令进行注册表补全操作。
|
||||
|
||||
```bash
|
||||
python unilabos\app\register.py --complete_registry
|
||||
```
|
||||
|
||||
|
||||
### 3.3 启动并上传注册表
|
||||
|
||||
新增设备之后,启动unilab需要增加`--upload_registry`参数,来上传注册表信息。
|
||||
新增设备之后,启动 unilab 需要增加`--upload_registry`参数,来上传注册表信息。
|
||||
|
||||
```bash
|
||||
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> --upload_registry
|
||||
@@ -134,14 +143,60 @@ python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> -
|
||||
|
||||
## 4. 注意事项
|
||||
|
||||
- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件:
|
||||
### 4.1 验证模块路径
|
||||
|
||||
```
|
||||
在新生成的 YAML 中,确认 `module` 指向新工站类。本例中需检查 `coincellassemblyworkstation_device.yaml` 文件中是否正确指向了 `CoinCellAssemblyWorkstation` 类:
|
||||
|
||||
```yaml
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
```
|
||||
|
||||
- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。
|
||||
### 4.2 首次接入流程
|
||||
|
||||
- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改,则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后,上传注册表即可。
|
||||
首次新增设备(或资源)需要完整流程:
|
||||
|
||||
1. ✅ 在网页端生成注册表信息
|
||||
2. ✅ 使用 `--complete_registry` 补全注册表
|
||||
3. ✅ 使用 `--upload_registry` 上传注册表信息
|
||||
|
||||
### 4.3 驱动更新流程
|
||||
|
||||
如果不是新增设备,仅修改了工站驱动的 `.py` 文件:
|
||||
|
||||
1. ✅ 运行 `--complete_registry` 补全注册表
|
||||
2. ✅ 运行 `--upload_registry` 上传注册表
|
||||
3. ❌ 不需要在网页端重新生成注册表
|
||||
|
||||
### 4.4 PLC 通信注意事项
|
||||
|
||||
- **握手机制**:若需参数下发,建议在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争
|
||||
- **字节序**:FLOAT32 等多字节数据类型需要正确指定字节序(如 `WorderOrder.LITTLE`)
|
||||
- **寄存器映射**:确保 CSV 文件中的寄存器地址与 PLC 实际配置一致
|
||||
- **连接稳定性**:在初始化时检查 PLC 连接状态,建议添加重连机制
|
||||
|
||||
## 5. 扩展阅读
|
||||
|
||||
### 相关文档
|
||||
|
||||
- {doc}`../add_device` - 设备驱动编写通用指南
|
||||
- {doc}`../add_registry` - 注册表配置完整指南
|
||||
- {doc}`../workstation_architecture` - 工站架构详解
|
||||
|
||||
### 技术要点
|
||||
|
||||
- **Modbus TCP 通信**:PLC 通信协议和寄存器读写
|
||||
- **WorkstationBase**:工站基类的继承和使用
|
||||
- **寄存器映射**:CSV 格式的寄存器配置
|
||||
- **注册表生成**:自动化工具使用
|
||||
|
||||
## 6. 总结
|
||||
|
||||
通过本案例,你应该掌握:
|
||||
|
||||
1. ✅ 如何创建 PLC 控制的工站驱动
|
||||
2. ✅ Modbus TCP 通信和寄存器读写
|
||||
3. ✅ 使用可视化编辑器生成注册表
|
||||
4. ✅ 注册表的补全和上传流程
|
||||
5. ✅ 新增设备与更新驱动的区别
|
||||
|
||||
这个案例展示了完整的 PLC 设备接入流程,可以作为其他类似设备接入的参考模板。
|
||||
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,4 +1,8 @@
|
||||
# 物料构建指南
|
||||
# 实例:物料构建指南
|
||||
|
||||
> **文档类型**:物料系统实战指南
|
||||
> **适用场景**:工作站物料系统构建、Deck/Warehouse/Carrier/Bottle 配置
|
||||
> **前置知识**:PyLabRobot 基础 | 资源管理概念
|
||||
|
||||
## 概述
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# 物料教程(Resource)
|
||||
# 实例:物料教程(Resource)
|
||||
|
||||
本教程面向 Uni-Lab-OS 的开发者,讲解“物料”的核心概念、3种物料格式(UniLab、PyLabRobot、奔耀Bioyond)及其相互转换方法,并说明4种 children 结构表现形式及使用场景。
|
||||
> **文档类型**:物料系统完整教程
|
||||
> **适用场景**:物料格式转换、多系统物料对接、资源结构理解
|
||||
> **前置知识**:Python 基础 | JSON 数据结构
|
||||
|
||||
本教程面向 Uni-Lab-OS 的开发者,讲解"物料"的核心概念、3种物料格式(UniLab、PyLabRobot、奔耀Bioyond)及其相互转换方法,并说明4种 children 结构表现形式及使用场景。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# 工作站模板架构设计与对接指南
|
||||
# 实例:工作站模板架构设计与对接指南
|
||||
|
||||
> **文档类型**:架构设计指南与实战案例
|
||||
> **适用场景**:大型工作站接入、子设备管理、物料系统集成
|
||||
> **前置知识**:{doc}`../add_device` | {doc}`../add_registry`
|
||||
|
||||
## 0. 问题简介
|
||||
|
||||
@@ -6,19 +10,19 @@
|
||||
|
||||
### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合;
|
||||
|
||||
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如2个泵共用1个串口、所有设备共同接入PLC等。
|
||||
2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json并管理执行、同时更改物料信息
|
||||
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如 2 个泵共用 1 个串口、所有设备共同接入 PLC 等。
|
||||
2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json 并管理执行、同时更改物料信息
|
||||
3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息
|
||||
|
||||
### 0.2 移液工作站:物料系统和工作流模板管理
|
||||
|
||||

|
||||

|
||||
|
||||
1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供
|
||||
2. 所有任务系统均由工作站本身实现并下发指令,有统一的抽象函数可实现(pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。
|
||||
@@ -26,12 +30,12 @@
|
||||
|
||||
### 0.3 厂家开发的定制大型工站
|
||||
|
||||

|
||||

|
||||
|
||||
由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信
|
||||
|
||||
1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取
|
||||
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC连续读写来配置工作站可用的工作流;
|
||||
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC 连续读写来配置工作站可用的工作流;
|
||||
3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接
|
||||
|
||||
## 1. 整体架构图
|
||||
@@ -45,7 +49,7 @@ graph TB
|
||||
RPN[ROS2WorkstationNode<br/>Protocol执行引擎]
|
||||
WB -.post_init关联.-> RPN
|
||||
end
|
||||
|
||||
|
||||
subgraph "物料管理系统"
|
||||
DECK[Deck<br/>PLR本地物料系统]
|
||||
RS[ResourceSynchronizer<br/>外部物料同步器]
|
||||
@@ -53,7 +57,7 @@ graph TB
|
||||
WB --> RS
|
||||
RS --> DECK
|
||||
end
|
||||
|
||||
|
||||
subgraph "通信与子设备管理"
|
||||
HW[hardware_interface<br/>硬件通信接口]
|
||||
SUBDEV[子设备集合<br/>pumps/grippers/sensors]
|
||||
@@ -61,7 +65,7 @@ graph TB
|
||||
RPN --> SUBDEV
|
||||
HW -.代理模式.-> RPN
|
||||
end
|
||||
|
||||
|
||||
subgraph "工作流任务系统"
|
||||
PROTO[Protocol定义<br/>LiquidHandling/PlateHandling]
|
||||
WORKFLOW[Workflow执行器<br/>步骤管理与编排]
|
||||
@@ -81,32 +85,32 @@ graph LR
|
||||
HW2[通信接口<br/>hardware_interface]
|
||||
HTTP[HTTP服务<br/>WorkstationHTTPService]
|
||||
end
|
||||
|
||||
|
||||
subgraph "外部物料系统"
|
||||
BIOYOND[Bioyond物料管理]
|
||||
LIMS[LIMS系统]
|
||||
WAREHOUSE[第三方仓储]
|
||||
end
|
||||
|
||||
|
||||
subgraph "外部硬件系统"
|
||||
PLC[PLC设备]
|
||||
SERIAL[串口设备]
|
||||
ROBOT[机械臂/机器人]
|
||||
end
|
||||
|
||||
|
||||
subgraph "云端系统"
|
||||
CLOUD[UniLab云端<br/>资源管理]
|
||||
MONITOR[监控与调度]
|
||||
end
|
||||
|
||||
|
||||
BIOYOND <-->|RPC双向同步| DECK2
|
||||
LIMS -->|HTTP报送| HTTP
|
||||
WAREHOUSE <-->|API对接| DECK2
|
||||
|
||||
|
||||
PLC <-->|Modbus TCP| HW2
|
||||
SERIAL <-->|串口通信| HW2
|
||||
ROBOT <-->|SDK/API| HW2
|
||||
|
||||
|
||||
WS -->|ROS消息| CLOUD
|
||||
CLOUD -->|任务下发| WS
|
||||
MONITOR -->|状态查询| WS
|
||||
@@ -119,40 +123,40 @@ graph TB
|
||||
subgraph "工作站基类"
|
||||
BASE[WorkstationBase<br/>抽象基类]
|
||||
end
|
||||
|
||||
|
||||
subgraph "Bioyond集成工作站"
|
||||
BW[BioyondWorkstation]
|
||||
BW_DECK[Deck + Warehouses]
|
||||
BW_SYNC[BioyondResourceSynchronizer]
|
||||
BW_HW[BioyondV1RPC]
|
||||
BW_HTTP[HTTP报送服务]
|
||||
|
||||
|
||||
BW --> BW_DECK
|
||||
BW --> BW_SYNC
|
||||
BW --> BW_HW
|
||||
BW --> BW_HTTP
|
||||
end
|
||||
|
||||
|
||||
subgraph "纯协议节点"
|
||||
PN[ProtocolNode]
|
||||
PN_SUB[子设备集合]
|
||||
PN_PROTO[Protocol工作流]
|
||||
|
||||
|
||||
PN --> PN_SUB
|
||||
PN --> PN_PROTO
|
||||
end
|
||||
|
||||
|
||||
subgraph "PLC控制工作站"
|
||||
PW[PLCWorkstation]
|
||||
PW_DECK[Deck物料系统]
|
||||
PW_PLC[Modbus PLC客户端]
|
||||
PW_WF[工作流定义]
|
||||
|
||||
|
||||
PW --> PW_DECK
|
||||
PW --> PW_PLC
|
||||
PW --> PW_WF
|
||||
end
|
||||
|
||||
|
||||
BASE -.继承.-> BW
|
||||
BASE -.继承.-> PN
|
||||
BASE -.继承.-> PW
|
||||
@@ -171,25 +175,25 @@ classDiagram
|
||||
+hardware_interface: Union[Any, str]
|
||||
+current_workflow_status: WorkflowStatus
|
||||
+supported_workflows: Dict[str, WorkflowInfo]
|
||||
|
||||
|
||||
+post_init(ros_node)*
|
||||
+set_hardware_interface(interface)
|
||||
+call_device_method(method, *args, **kwargs)
|
||||
+get_device_status()
|
||||
+is_device_available()
|
||||
|
||||
|
||||
+get_deck()
|
||||
+get_all_resources()
|
||||
+find_resource_by_name(name)
|
||||
+find_resources_by_type(type)
|
||||
+sync_with_external_system()
|
||||
|
||||
|
||||
+execute_workflow(name, params)
|
||||
+stop_workflow(emergency)
|
||||
+workflow_status
|
||||
+is_busy
|
||||
}
|
||||
|
||||
|
||||
class ROS2WorkstationNode {
|
||||
+device_id: str
|
||||
+children: Dict[str, Any]
|
||||
@@ -198,7 +202,7 @@ classDiagram
|
||||
+_action_clients: Dict
|
||||
+_action_servers: Dict
|
||||
+resource_tracker: DeviceNodeResourceTracker
|
||||
|
||||
|
||||
+initialize_device(device_id, config)
|
||||
+create_ros_action_server(action_name, mapping)
|
||||
+execute_single_action(device_id, action, kwargs)
|
||||
@@ -206,14 +210,14 @@ classDiagram
|
||||
+transfer_resource_to_another(resources, target, sites)
|
||||
+_setup_hardware_proxy(device, comm_device, read, write)
|
||||
}
|
||||
|
||||
|
||||
%% 物料管理相关类
|
||||
class Deck {
|
||||
+name: str
|
||||
+children: List
|
||||
+assign_child_resource()
|
||||
}
|
||||
|
||||
|
||||
class ResourceSynchronizer {
|
||||
<<abstract>>
|
||||
+workstation: WorkstationBase
|
||||
@@ -221,23 +225,23 @@ classDiagram
|
||||
+sync_to_external(plr_resource)*
|
||||
+handle_external_change(change_info)*
|
||||
}
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer {
|
||||
+bioyond_api_client: BioyondV1RPC
|
||||
+sync_interval: int
|
||||
+last_sync_time: float
|
||||
|
||||
|
||||
+initialize()
|
||||
+sync_from_external()
|
||||
+sync_to_external(resource)
|
||||
+handle_external_change(change_info)
|
||||
}
|
||||
|
||||
|
||||
%% 硬件接口相关类
|
||||
class HardwareInterface {
|
||||
<<interface>>
|
||||
}
|
||||
|
||||
|
||||
class BioyondV1RPC {
|
||||
+base_url: str
|
||||
+api_key: str
|
||||
@@ -245,7 +249,7 @@ classDiagram
|
||||
+add_material()
|
||||
+material_inbound()
|
||||
}
|
||||
|
||||
|
||||
%% 服务类
|
||||
class WorkstationHTTPService {
|
||||
+workstation: WorkstationBase
|
||||
@@ -253,7 +257,7 @@ classDiagram
|
||||
+port: int
|
||||
+server: HTTPServer
|
||||
+running: bool
|
||||
|
||||
|
||||
+start()
|
||||
+stop()
|
||||
+_handle_step_finish_report()
|
||||
@@ -262,13 +266,13 @@ classDiagram
|
||||
+_handle_material_change_report()
|
||||
+_handle_error_handling_report()
|
||||
}
|
||||
|
||||
|
||||
%% 具体实现类
|
||||
class BioyondWorkstation {
|
||||
+bioyond_config: Dict
|
||||
+workflow_mappings: Dict
|
||||
+workflow_sequence: List
|
||||
|
||||
|
||||
+post_init(ros_node)
|
||||
+transfer_resource_to_another()
|
||||
+resource_tree_add(resources)
|
||||
@@ -276,25 +280,25 @@ classDiagram
|
||||
+get_all_workflows()
|
||||
+get_bioyond_status()
|
||||
}
|
||||
|
||||
|
||||
class ProtocolNode {
|
||||
+post_init(ros_node)
|
||||
}
|
||||
|
||||
|
||||
%% 核心关系
|
||||
WorkstationBase o-- ROS2WorkstationNode : post_init关联
|
||||
WorkstationBase o-- WorkstationHTTPService : 可选服务
|
||||
|
||||
|
||||
%% 物料管理侧
|
||||
WorkstationBase *-- Deck : deck
|
||||
WorkstationBase *-- ResourceSynchronizer : 可选组合
|
||||
ResourceSynchronizer <|-- BioyondResourceSynchronizer
|
||||
|
||||
|
||||
%% 硬件接口侧
|
||||
WorkstationBase o-- HardwareInterface : hardware_interface
|
||||
HardwareInterface <|.. BioyondV1RPC : 实现
|
||||
BioyondResourceSynchronizer --> BioyondV1RPC : 使用
|
||||
|
||||
|
||||
%% 继承关系
|
||||
BioyondWorkstation --|> WorkstationBase
|
||||
ProtocolNode --|> WorkstationBase
|
||||
@@ -312,49 +316,49 @@ sequenceDiagram
|
||||
participant HW as HardwareInterface
|
||||
participant ROS as ROS2WorkstationNode
|
||||
participant HTTP as HTTPService
|
||||
|
||||
|
||||
APP->>WS: 创建工作站实例(__init__)
|
||||
WS->>DECK: 初始化PLR Deck
|
||||
DECK->>DECK: 创建Warehouse等子资源
|
||||
DECK-->>WS: Deck创建完成
|
||||
|
||||
|
||||
WS->>HW: 创建硬件接口(如BioyondV1RPC)
|
||||
HW->>HW: 建立连接(PLC/RPC/串口等)
|
||||
HW-->>WS: 硬件接口就绪
|
||||
|
||||
|
||||
WS->>SYNC: 创建ResourceSynchronizer(可选)
|
||||
SYNC->>HW: 使用hardware_interface
|
||||
SYNC->>SYNC: 初始化同步配置
|
||||
SYNC-->>WS: 同步器创建完成
|
||||
|
||||
|
||||
WS->>SYNC: sync_from_external()
|
||||
SYNC->>HW: 查询外部物料系统
|
||||
HW-->>SYNC: 返回物料数据
|
||||
SYNC->>DECK: 转换并添加到Deck
|
||||
SYNC-->>WS: 同步完成
|
||||
|
||||
|
||||
Note over WS: __init__完成,等待ROS节点
|
||||
|
||||
|
||||
APP->>ROS: 初始化ROS2WorkstationNode
|
||||
ROS->>ROS: 初始化子设备(children)
|
||||
ROS->>ROS: 创建Action客户端
|
||||
ROS->>ROS: 设置硬件接口代理
|
||||
ROS-->>APP: ROS节点就绪
|
||||
|
||||
|
||||
APP->>WS: post_init(ros_node)
|
||||
WS->>WS: self._ros_node = ros_node
|
||||
WS->>ROS: update_resource([deck])
|
||||
ROS->>ROS: 上传物料到云端
|
||||
ROS-->>WS: 上传完成
|
||||
|
||||
|
||||
WS->>HTTP: 创建WorkstationHTTPService(可选)
|
||||
HTTP->>HTTP: 启动HTTP服务器线程
|
||||
HTTP-->>WS: HTTP服务启动
|
||||
|
||||
|
||||
WS-->>APP: 工作站完全就绪
|
||||
```
|
||||
|
||||
## 4. 工作流执行时序图(Protocol模式)
|
||||
## 4. 工作流执行时序图(Protocol 模式)
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
@@ -365,15 +369,15 @@ sequenceDiagram
|
||||
participant DECK as PLR Deck
|
||||
participant CLOUD as 云端资源管理
|
||||
participant DEV as 子设备
|
||||
|
||||
|
||||
CLIENT->>ROS: 发送Protocol Action请求
|
||||
ROS->>ROS: execute_protocol回调
|
||||
ROS->>ROS: 从Goal提取参数
|
||||
ROS->>ROS: 调用protocol_steps_generator
|
||||
ROS->>ROS: 生成action步骤列表
|
||||
|
||||
|
||||
ROS->>WS: 更新workflow_status = RUNNING
|
||||
|
||||
|
||||
loop 执行每个步骤
|
||||
alt 调用子设备
|
||||
ROS->>ROS: execute_single_action(device_id, action, params)
|
||||
@@ -394,19 +398,19 @@ sequenceDiagram
|
||||
end
|
||||
WS-->>ROS: 返回结果
|
||||
end
|
||||
|
||||
|
||||
ROS->>DECK: 更新本地物料状态
|
||||
DECK->>DECK: 修改PLR资源属性
|
||||
end
|
||||
|
||||
|
||||
ROS->>CLOUD: 同步物料到云端(可选)
|
||||
CLOUD-->>ROS: 同步完成
|
||||
|
||||
|
||||
ROS->>WS: 更新workflow_status = COMPLETED
|
||||
ROS-->>CLIENT: 返回Protocol Result
|
||||
```
|
||||
|
||||
## 5. HTTP报送处理时序图
|
||||
## 5. HTTP 报送处理时序图
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
@@ -416,25 +420,25 @@ sequenceDiagram
|
||||
participant DECK as PLR Deck
|
||||
participant SYNC as ResourceSynchronizer
|
||||
participant CLOUD as 云端
|
||||
|
||||
|
||||
EXT->>HTTP: POST /report/step_finish
|
||||
HTTP->>HTTP: 解析请求数据
|
||||
HTTP->>HTTP: 验证LIMS协议字段
|
||||
HTTP->>WS: process_step_finish_report(request)
|
||||
|
||||
|
||||
WS->>WS: 增加接收计数(_reports_received_count++)
|
||||
WS->>WS: 记录步骤完成事件
|
||||
WS->>DECK: 更新相关物料状态(可选)
|
||||
DECK->>DECK: 修改PLR资源状态
|
||||
|
||||
|
||||
WS->>WS: 保存报送记录到内存
|
||||
|
||||
|
||||
WS-->>HTTP: 返回处理结果
|
||||
HTTP->>HTTP: 构造HTTP响应
|
||||
HTTP-->>EXT: 200 OK + acknowledgment_id
|
||||
|
||||
|
||||
Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送
|
||||
|
||||
|
||||
alt 物料变更报送
|
||||
EXT->>HTTP: POST /report/material_change
|
||||
HTTP->>WS: process_material_change_report(data)
|
||||
@@ -459,7 +463,7 @@ sequenceDiagram
|
||||
participant HW as HardwareInterface
|
||||
participant HTTP as HTTPService
|
||||
participant LOG as 日志系统
|
||||
|
||||
|
||||
alt 设备错误(ROS Action失败)
|
||||
DEV->>ROS: Action返回失败结果
|
||||
ROS->>ROS: 记录错误信息
|
||||
@@ -471,7 +475,7 @@ sequenceDiagram
|
||||
WS->>WS: 记录错误历史
|
||||
WS->>LOG: 记录错误日志
|
||||
end
|
||||
|
||||
|
||||
alt 关键错误需要停止
|
||||
WS->>ROS: stop_workflow(emergency=True)
|
||||
ROS->>ROS: 取消所有进行中的Action
|
||||
@@ -483,44 +487,44 @@ sequenceDiagram
|
||||
WS->>ROS: 触发重试逻辑(可选)
|
||||
ROS->>DEV: 重新发送Action
|
||||
end
|
||||
|
||||
|
||||
WS-->>HTTP: 返回错误处理结果
|
||||
HTTP-->>DEV: 200 OK + 处理状态
|
||||
```
|
||||
|
||||
## 7. 典型工作站实现示例
|
||||
|
||||
### 7.1 Bioyond集成工作站实现
|
||||
### 7.1 Bioyond 集成工作站实现
|
||||
|
||||
```python
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs):
|
||||
# 初始化deck
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
|
||||
|
||||
# 设置硬件接口为Bioyond RPC客户端
|
||||
self.hardware_interface = BioyondV1RPC(bioyond_config)
|
||||
|
||||
|
||||
# 创建资源同步器
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
|
||||
|
||||
# 从Bioyond同步物料到本地deck
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
# 配置工作流
|
||||
self.workflow_mappings = bioyond_config.get("workflow_mappings", {})
|
||||
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
"""ROS节点就绪后的初始化"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
|
||||
# 上传deck(包括所有物料)到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource,
|
||||
True,
|
||||
self._ros_node.update_resource,
|
||||
True,
|
||||
resources=[self.deck]
|
||||
)
|
||||
|
||||
|
||||
def resource_tree_add(self, resources: List[ResourcePLR]):
|
||||
"""添加物料并同步到Bioyond"""
|
||||
for resource in resources:
|
||||
@@ -533,24 +537,24 @@ class BioyondWorkstation(WorkstationBase):
|
||||
```python
|
||||
class ProtocolNode(WorkstationBase):
|
||||
"""纯协议节点,不需要物料管理和外部通信"""
|
||||
|
||||
|
||||
def __init__(self, deck: Optional[Deck] = None, *args, **kwargs):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
# 不设置hardware_interface和resource_synchronizer
|
||||
# 所有功能通过子设备协同完成
|
||||
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
# 不需要上传物料或其他初始化
|
||||
```
|
||||
|
||||
### 7.3 PLC直接控制工作站
|
||||
### 7.3 PLC 直接控制工作站
|
||||
|
||||
```python
|
||||
class PLCWorkstation(WorkstationBase):
|
||||
def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
|
||||
|
||||
# 设置硬件接口为Modbus客户端
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
self.hardware_interface = ModbusTcpClient(
|
||||
@@ -558,7 +562,7 @@ class PLCWorkstation(WorkstationBase):
|
||||
port=plc_config["port"]
|
||||
)
|
||||
self.hardware_interface.connect()
|
||||
|
||||
|
||||
# 定义支持的工作流
|
||||
self.supported_workflows = {
|
||||
"battery_assembly": WorkflowInfo(
|
||||
@@ -570,49 +574,49 @@ class PLCWorkstation(WorkstationBase):
|
||||
parameters_schema={"quantity": int, "model": str}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def execute_workflow(self, workflow_name: str, parameters: Dict):
|
||||
"""通过PLC执行工作流"""
|
||||
workflow_id = self._get_workflow_id(workflow_name)
|
||||
|
||||
|
||||
# 写入PLC寄存器启动工作流
|
||||
self.hardware_interface.write_register(100, workflow_id)
|
||||
self.hardware_interface.write_register(101, parameters["quantity"])
|
||||
|
||||
|
||||
self.current_workflow_status = WorkflowStatus.RUNNING
|
||||
return True
|
||||
```
|
||||
|
||||
## 8. 核心接口说明
|
||||
|
||||
### 8.1 WorkstationBase核心属性
|
||||
### 8.1 WorkstationBase 核心属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
| --------------------------- | ----------------------- | ----------------------------- |
|
||||
| `_ros_node` | ROS2WorkstationNode | ROS节点引用,由post_init设置 |
|
||||
| `deck` | Deck | PyLabRobot Deck,本地物料系统 |
|
||||
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
|
||||
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
|
||||
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
|
||||
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
|
||||
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
|
||||
| 属性 | 类型 | 说明 |
|
||||
| ------------------------- | ----------------------- | ------------------------------- |
|
||||
| `_ros_node` | ROS2WorkstationNode | ROS 节点引用,由 post_init 设置 |
|
||||
| `deck` | Deck | PyLabRobot Deck,本地物料系统 |
|
||||
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
|
||||
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
|
||||
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
|
||||
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
|
||||
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
|
||||
|
||||
### 8.2 必须实现的方法
|
||||
|
||||
- `post_init(ros_node)`: ROS节点就绪后的初始化,必须实现
|
||||
- `post_init(ros_node)`: ROS 节点就绪后的初始化,必须实现
|
||||
|
||||
### 8.3 硬件接口相关方法
|
||||
|
||||
- `set_hardware_interface(interface)`: 设置硬件接口
|
||||
- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用
|
||||
- 支持直接模式: 直接调用hardware_interface的方法
|
||||
- 支持代理模式: hardware_interface="proxy:device_id"通过ROS转发
|
||||
- 支持直接模式: 直接调用 hardware_interface 的方法
|
||||
- 支持代理模式: hardware_interface="proxy:device_id"通过 ROS 转发
|
||||
- `get_device_status()`: 获取设备状态
|
||||
- `is_device_available()`: 检查设备可用性
|
||||
|
||||
### 8.4 物料管理方法
|
||||
|
||||
- `get_deck()`: 获取PLR Deck
|
||||
- `get_deck()`: 获取 PLR Deck
|
||||
- `get_all_resources()`: 获取所有物料
|
||||
- `find_resource_by_name(name)`: 按名称查找物料
|
||||
- `find_resources_by_type(type)`: 按类型查找物料
|
||||
@@ -626,7 +630,7 @@ class PLCWorkstation(WorkstationBase):
|
||||
- `is_busy`: 检查是否忙碌(属性)
|
||||
- `workflow_runtime`: 获取运行时间(属性)
|
||||
|
||||
### 8.6 可选的HTTP报送处理方法
|
||||
### 8.6 可选的 HTTP 报送处理方法
|
||||
|
||||
- `process_step_finish_report()`: 步骤完成处理
|
||||
- `process_sample_finish_report()`: 样本完成处理
|
||||
@@ -634,10 +638,10 @@ class PLCWorkstation(WorkstationBase):
|
||||
- `process_material_change_report()`: 物料变更处理
|
||||
- `handle_external_error()`: 错误处理
|
||||
|
||||
### 8.7 ROS2WorkstationNode核心方法
|
||||
### 8.7 ROS2WorkstationNode 核心方法
|
||||
|
||||
- `initialize_device(device_id, config)`: 初始化子设备
|
||||
- `create_ros_action_server(action_name, mapping)`: 创建Action服务器
|
||||
- `create_ros_action_server(action_name, mapping)`: 创建 Action 服务器
|
||||
- `execute_single_action(device_id, action, kwargs)`: 执行单个动作
|
||||
- `update_resource(resources)`: 同步物料到云端
|
||||
- `transfer_resource_to_another(...)`: 跨设备物料转移
|
||||
@@ -694,7 +698,7 @@ workstation = BioyondWorkstation(
|
||||
"config": {...}
|
||||
},
|
||||
"gripper_1": {
|
||||
"type": "device",
|
||||
"type": "device",
|
||||
"driver": "RobotiqGripperDriver",
|
||||
"communication": "io_modbus_1",
|
||||
"config": {...}
|
||||
@@ -716,7 +720,7 @@ workstation = BioyondWorkstation(
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 HTTP服务配置
|
||||
### 9.3 HTTP 服务配置
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
@@ -737,31 +741,31 @@ http_service.start()
|
||||
### 10.1 清晰的职责分离
|
||||
|
||||
- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理
|
||||
- **ROS2WorkstationNode**: 负责子设备管理、Protocol执行、云端物料同步
|
||||
- **ResourceSynchronizer**: 可选的外部物料系统同步(如Bioyond)
|
||||
- **WorkstationHTTPService**: 可选的HTTP报送接收服务
|
||||
- **ROS2WorkstationNode**: 负责子设备管理、Protocol 执行、云端物料同步
|
||||
- **ResourceSynchronizer**: 可选的外部物料系统同步(如 Bioyond)
|
||||
- **WorkstationHTTPService**: 可选的 HTTP 报送接收服务
|
||||
|
||||
### 10.2 灵活的硬件接口模式
|
||||
|
||||
1. **直接模式**: hardware_interface是具体对象(如BioyondV1RPC、ModbusClient)
|
||||
2. **代理模式**: hardware_interface="proxy:device_id",通过ROS节点转发到子设备
|
||||
1. **直接模式**: hardware_interface 是具体对象(如 BioyondV1RPC、ModbusClient)
|
||||
2. **代理模式**: hardware_interface="proxy:device_id",通过 ROS 节点转发到子设备
|
||||
3. **混合模式**: 工作站有自己的接口,同时管理多个子设备
|
||||
|
||||
### 10.3 统一的物料系统
|
||||
|
||||
- 基于PyLabRobot Deck的标准化物料表示
|
||||
- 通过ResourceSynchronizer实现与外部系统(如Bioyond、LIMS)的双向同步
|
||||
- 通过ROS2WorkstationNode实现与云端的物料状态同步
|
||||
- 基于 PyLabRobot Deck 的标准化物料表示
|
||||
- 通过 ResourceSynchronizer 实现与外部系统(如 Bioyond、LIMS)的双向同步
|
||||
- 通过 ROS2WorkstationNode 实现与云端的物料状态同步
|
||||
|
||||
### 10.4 Protocol驱动的工作流
|
||||
### 10.4 Protocol 驱动的工作流
|
||||
|
||||
- ROS2WorkstationNode负责Protocol的执行和步骤管理
|
||||
- 支持子设备协同(通过Action Client调用)
|
||||
- 支持工作站直接控制(通过hardware_interface)
|
||||
- ROS2WorkstationNode 负责 Protocol 的执行和步骤管理
|
||||
- 支持子设备协同(通过 Action Client 调用)
|
||||
- 支持工作站直接控制(通过 hardware_interface)
|
||||
|
||||
### 10.5 可选的HTTP报送服务
|
||||
### 10.5 可选的 HTTP 报送服务
|
||||
|
||||
- 基于LIMS协议规范的统一报送接口
|
||||
- 基于 LIMS 协议规范的统一报送接口
|
||||
- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型
|
||||
- 与工作站解耦,可独立启停
|
||||
|
||||
334
docs/developer_guide/http_api.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# HTTP API 指南
|
||||
|
||||
本文档介绍如何通过 HTTP API 与 Uni-Lab-OS 进行交互,包括查询设备、提交任务和获取结果。
|
||||
|
||||
## 概述
|
||||
|
||||
Uni-Lab-OS 提供 RESTful HTTP API,允许外部系统通过标准 HTTP 请求控制实验室设备。API 基于 FastAPI 构建,默认运行在 `http://localhost:8002`。
|
||||
|
||||
### 基础信息
|
||||
|
||||
- **Base URL**: `http://localhost:8002/api/v1`
|
||||
- **Content-Type**: `application/json`
|
||||
- **响应格式**: JSON
|
||||
|
||||
### 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------ | ------------------ |
|
||||
| `code` | int | 状态码,0 表示成功 |
|
||||
| `data` | object | 响应数据 |
|
||||
| `message` | string | 响应消息 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
以下是一个完整的工作流示例:查询设备 → 获取动作 → 提交任务 → 获取结果。
|
||||
|
||||
### 步骤 1: 获取在线设备
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/online-devices"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"online_devices": {
|
||||
"host_node": {
|
||||
"device_key": "/host_node",
|
||||
"namespace": "",
|
||||
"machine_name": "本地",
|
||||
"uuid": "xxx-xxx-xxx",
|
||||
"node_name": "host_node"
|
||||
}
|
||||
},
|
||||
"total_count": 1,
|
||||
"timestamp": 1732612345.123
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 获取设备可用动作
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/devices/host_node/actions"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"device_id": "host_node",
|
||||
"actions": {
|
||||
"test_latency": {
|
||||
"type_name": "unilabos_msgs.action._empty_in.EmptyIn",
|
||||
"type_name_convert": "unilabos_msgs/action/_empty_in/EmptyIn",
|
||||
"action_path": "/devices/host_node/test_latency",
|
||||
"goal_info": "{}",
|
||||
"is_busy": false,
|
||||
"current_job_id": null
|
||||
},
|
||||
"create_resource": {
|
||||
"type_name": "unilabos_msgs.action._resource_create_from_outer_easy.ResourceCreateFromOuterEasy",
|
||||
"action_path": "/devices/host_node/create_resource",
|
||||
"goal_info": "{res_id: '', device_id: '', class_name: '', ...}",
|
||||
"is_busy": false,
|
||||
"current_job_id": null
|
||||
}
|
||||
},
|
||||
"action_count": 5
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**动作状态字段说明**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `type_name` | 动作类型的完整名称 |
|
||||
| `action_path` | ROS2 动作路径 |
|
||||
| `goal_info` | 动作参数模板 |
|
||||
| `is_busy` | 动作是否正在执行 |
|
||||
| `current_job_id` | 当前执行的任务 ID(如果繁忙) |
|
||||
|
||||
### 步骤 3: 提交任务
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "host_node",
|
||||
"action": "test_latency",
|
||||
"action_args": {}
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------- | ------ | ---- | ---------------------------------- |
|
||||
| `device_id` | string | ✓ | 目标设备 ID |
|
||||
| `action` | string | ✓ | 动作名称 |
|
||||
| `action_args` | object | ✓ | 动作参数(根据动作类型不同而变化) |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 1,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**任务状态码**:
|
||||
|
||||
| 状态码 | 含义 | 说明 |
|
||||
| ------ | --------- | ------------------------------ |
|
||||
| 0 | UNKNOWN | 未知状态 |
|
||||
| 1 | ACCEPTED | 任务已接受,等待执行 |
|
||||
| 2 | EXECUTING | 任务执行中 |
|
||||
| 3 | CANCELING | 任务取消中 |
|
||||
| 4 | SUCCEEDED | 任务成功完成 |
|
||||
| 5 | CANCELED | 任务已取消 |
|
||||
| 6 | ABORTED | 任务中止(设备繁忙或执行失败) |
|
||||
|
||||
### 步骤 4: 查询任务状态和结果
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/job/b6acb586-733a-42ab-9f73-55c9a52aa8bd/status"
|
||||
```
|
||||
|
||||
**响应示例(执行中)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 2,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(执行完成)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 4,
|
||||
"result": {
|
||||
"error": "",
|
||||
"suc": true,
|
||||
"return_value": {
|
||||
"avg_rtt_ms": 103.99,
|
||||
"avg_time_diff_ms": 7181.55,
|
||||
"max_time_error_ms": 7210.57,
|
||||
"task_delay_ms": -1,
|
||||
"raw_delay_ms": 33.19,
|
||||
"test_count": 5,
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 任务结果在首次查询后会被自动删除,请确保保存返回的结果数据。
|
||||
|
||||
## API 端点列表
|
||||
|
||||
### 设备相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ---------------------------------------------------------- | ---- | ---------------------- |
|
||||
| `/api/v1/online-devices` | GET | 获取在线设备列表 |
|
||||
| `/api/v1/devices` | GET | 获取设备配置 |
|
||||
| `/api/v1/devices/{device_id}/actions` | GET | 获取指定设备的可用动作 |
|
||||
| `/api/v1/devices/{device_id}/actions/{action_name}/schema` | GET | 获取动作参数 Schema |
|
||||
| `/api/v1/actions` | GET | 获取所有设备的可用动作 |
|
||||
|
||||
### 任务相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ----------------------------- | ---- | ------------------ |
|
||||
| `/api/v1/job/add` | POST | 提交新任务 |
|
||||
| `/api/v1/job/{job_id}/status` | GET | 查询任务状态和结果 |
|
||||
|
||||
### 资源相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ------------------- | ---- | ------------ |
|
||||
| `/api/v1/resources` | GET | 获取资源列表 |
|
||||
|
||||
## 常见动作示例
|
||||
|
||||
### test_latency - 延迟测试
|
||||
|
||||
测试系统延迟,无需参数。
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
|
||||
```
|
||||
|
||||
### create_resource - 创建资源
|
||||
|
||||
在设备上创建新资源。
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"device_id": "host_node",
|
||||
"action": "create_resource",
|
||||
"action_args": {
|
||||
"res_id": "my_plate",
|
||||
"device_id": "host_node",
|
||||
"class_name": "Plate",
|
||||
"parent": "deck",
|
||||
"bind_locations": {"x": 0, "y": 0, "z": 0}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 设备繁忙
|
||||
|
||||
当设备正在执行其他任务时,提交新任务会返回 `status: 6`(ABORTED):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "xxx",
|
||||
"status": 6,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
此时应等待当前任务完成后重试,或使用 `/devices/{device_id}/actions` 检查动作的 `is_busy` 状态。
|
||||
|
||||
### 参数错误
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 2002,
|
||||
"data": { ... },
|
||||
"message": "device_id is required"
|
||||
}
|
||||
```
|
||||
|
||||
## 轮询策略
|
||||
|
||||
推荐的任务状态轮询策略:
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
def wait_for_job(job_id, timeout=60, interval=0.5):
|
||||
"""等待任务完成并返回结果"""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
response = requests.get(f"http://localhost:8002/api/v1/job/{job_id}/status")
|
||||
data = response.json()["data"]
|
||||
|
||||
status = data["status"]
|
||||
if status in (4, 5, 6): # SUCCEEDED, CANCELED, ABORTED
|
||||
return data
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
|
||||
|
||||
# 使用示例
|
||||
response = requests.post(
|
||||
"http://localhost:8002/api/v1/job/add",
|
||||
json={"device_id": "host_node", "action": "test_latency", "action_args": {}}
|
||||
)
|
||||
job_id = response.json()["data"]["jobId"]
|
||||
result = wait_for_job(job_id)
|
||||
print(result)
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [设备注册指南](add_device.md)
|
||||
- [动作定义指南](add_action.md)
|
||||
- [网络架构概述](networking_overview.md)
|
||||
594
docs/developer_guide/networking_overview.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# 组网部署与主从模式配置
|
||||
|
||||
本文档介绍 Uni-Lab-OS 的组网架构、部署方式和主从模式的详细配置。
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览](#架构概览)
|
||||
- [节点类型](#节点类型)
|
||||
- [通信机制](#通信机制)
|
||||
- [典型拓扑](#典型拓扑)
|
||||
- [主从模式配置](#主从模式配置)
|
||||
- [网络配置](#网络配置)
|
||||
- [示例:多房间部署](#示例多房间部署)
|
||||
- [故障处理](#故障处理)
|
||||
- [监控和维护](#监控和维护)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
Uni-Lab-OS 支持多种部署模式:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Cloud Platform/Self-hosted Platform │
|
||||
│ uni-lab.bohrium.com │
|
||||
│ (Resource Management, Task Scheduling, │
|
||||
│ Monitoring) │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
│ WebSocket / HTTP
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
┌────▼─────┐ ┌────▼─────┐
|
||||
│ Master │◄──ROS2──►│ Slave │
|
||||
│ Node │ │ Node │
|
||||
│ (Host) │ │ (Slave) │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
┌────┴────┐ ┌────┴────┐
|
||||
│ Device A│ │ Device B│
|
||||
│ Device C│ │ Device D│
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 节点类型
|
||||
|
||||
### 主节点(Host Node)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 创建和管理全局资源
|
||||
- 提供 host_node 服务
|
||||
- 连接云端平台
|
||||
- 协调多个从节点
|
||||
- 提供 Web 管理界面
|
||||
|
||||
**启动命令**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g host_devices.json
|
||||
```
|
||||
|
||||
### 从节点(Slave Node)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 管理本地设备
|
||||
- 不连接云端(可选)
|
||||
- 向主节点注册
|
||||
- 执行分配的任务
|
||||
|
||||
**启动命令**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave_devices.json --is_slave
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通信机制
|
||||
|
||||
### ROS2 通信
|
||||
|
||||
**用途**: 节点间实时通信
|
||||
|
||||
**通信方式**:
|
||||
|
||||
- **Topic**: 状态广播(设备状态、传感器数据)
|
||||
- **Service**: 同步请求(资源查询、配置获取)
|
||||
- **Action**: 异步任务(设备操作、长时间运行)
|
||||
|
||||
**示例**:
|
||||
|
||||
```bash
|
||||
# 查看ROS2节点
|
||||
ros2 node list
|
||||
|
||||
# 查看topic
|
||||
ros2 topic list
|
||||
|
||||
# 查看action
|
||||
ros2 action list
|
||||
```
|
||||
|
||||
### WebSocket 通信
|
||||
|
||||
**用途**: 主节点与云端通信
|
||||
|
||||
**特点**:
|
||||
|
||||
- 实时双向通信
|
||||
- 自动重连
|
||||
- 心跳保持
|
||||
|
||||
**配置**:
|
||||
|
||||
```python
|
||||
# local_config.py
|
||||
BasicConfig.ak = "your_ak"
|
||||
BasicConfig.sk = "your_sk"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型拓扑
|
||||
|
||||
### 单节点模式
|
||||
|
||||
**适用场景**: 小型实验室、开发测试
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Uni-Lab Node │
|
||||
│ ┌────────────┐ │
|
||||
│ │ Device A │ │
|
||||
│ │ Device B │ │
|
||||
│ │ Device C │ │
|
||||
│ └────────────┘ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 配置简单
|
||||
- 无网络延迟
|
||||
- 适合快速原型
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g all_devices.json
|
||||
```
|
||||
|
||||
### 主从模式
|
||||
|
||||
**适用场景**: 多房间、分布式设备
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ Master Node │◄────►│ Slave Node 1 │
|
||||
│ Coordinator │ │ Liquid │
|
||||
│ Web UI │ │ Handling │
|
||||
└──────┬──────┘ └──────────────┘
|
||||
│
|
||||
│ ┌──────────────┐
|
||||
└────────────►│ Slave Node 2 │
|
||||
│ Analytical │
|
||||
│ (NMR/GC) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 物理分隔
|
||||
- 独立故障域
|
||||
- 易于扩展
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 设备物理位置分散
|
||||
- 不同房间的设备
|
||||
- 需要独立故障域
|
||||
- 分阶段扩展系统
|
||||
|
||||
**主节点**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g host.json
|
||||
```
|
||||
|
||||
**从节点**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
|
||||
unilab --ak your_ak --sk your_sk -g slave2.json --is_slave --port 8003
|
||||
```
|
||||
|
||||
### 云端集成模式
|
||||
|
||||
**适用场景**: 远程监控、多实验室协作
|
||||
|
||||
```
|
||||
Cloud Platform
|
||||
│
|
||||
┌───────┴────────┐
|
||||
│ │
|
||||
Laboratory A Laboratory B
|
||||
(Master Node) (Master Node)
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 远程访问
|
||||
- 数据同步
|
||||
- 任务调度
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主从模式配置
|
||||
|
||||
### 主节点配置
|
||||
|
||||
#### 1. 创建主节点设备图
|
||||
|
||||
`host.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 启动主节点
|
||||
|
||||
```bash
|
||||
# 基本启动
|
||||
unilab --ak your_ak --sk your_sk -g host.json
|
||||
|
||||
# 带云端集成
|
||||
unilab --ak your_ak --sk your_sk -g host.json --upload_registry
|
||||
|
||||
# 指定端口
|
||||
unilab --ak your_ak --sk your_sk -g host.json --port 8002
|
||||
```
|
||||
|
||||
#### 3. 验证主节点
|
||||
|
||||
```bash
|
||||
# 检查ROS2节点
|
||||
ros2 node list
|
||||
# 应该看到 /host_node
|
||||
|
||||
# 检查服务
|
||||
ros2 service list | grep host_node
|
||||
|
||||
# Web界面
|
||||
# 访问 http://localhost:8002
|
||||
```
|
||||
|
||||
### 从节点配置
|
||||
|
||||
#### 1. 创建从节点设备图
|
||||
|
||||
`slave1.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "liquid_handler_1",
|
||||
"name": "液体处理工作站",
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {
|
||||
"simulation": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 启动从节点
|
||||
|
||||
```bash
|
||||
# 基本从节点启动
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
|
||||
|
||||
# 指定不同端口(如果多个从节点在同一台机器)
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --port 8003
|
||||
|
||||
# 跳过等待主节点(独立测试)
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --slave_no_host
|
||||
```
|
||||
|
||||
#### 3. 验证从节点
|
||||
|
||||
```bash
|
||||
# 检查节点连接
|
||||
ros2 node list
|
||||
|
||||
# 检查设备状态
|
||||
ros2 topic echo /liquid_handler_1/status
|
||||
```
|
||||
|
||||
### 跨节点通信
|
||||
|
||||
#### 资源访问
|
||||
|
||||
主节点可以访问从节点的资源:
|
||||
|
||||
```bash
|
||||
# 在主节点或其他节点调用从节点设备
|
||||
ros2 action send_goal /liquid_handler_1/transfer_liquid \
|
||||
unilabos_msgs/action/TransferLiquid \
|
||||
"{source: {...}, target: {...}, volume: 100.0}"
|
||||
```
|
||||
|
||||
#### 状态监控
|
||||
|
||||
主节点监控所有从节点状态:
|
||||
|
||||
```bash
|
||||
# 订阅从节点状态
|
||||
ros2 topic echo /liquid_handler_1/status
|
||||
|
||||
# 查看所有设备状态
|
||||
ros2 topic list | grep status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 网络配置
|
||||
|
||||
### ROS2 DDS 配置
|
||||
|
||||
确保主从节点在同一网络:
|
||||
|
||||
```bash
|
||||
# 检查网络可达性
|
||||
ping <slave_node_ip>
|
||||
|
||||
# 设置ROS_DOMAIN_ID(可选,用于隔离)
|
||||
export ROS_DOMAIN_ID=42
|
||||
```
|
||||
|
||||
### 防火墙配置
|
||||
|
||||
**建议做法**:
|
||||
|
||||
为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。
|
||||
|
||||
**Linux**:
|
||||
|
||||
```bash
|
||||
# 关闭防火墙
|
||||
sudo ufw disable
|
||||
|
||||
# 或者临时停止防火墙
|
||||
sudo systemctl stop ufw
|
||||
```
|
||||
|
||||
**Windows**:
|
||||
|
||||
```powershell
|
||||
# 在Windows安全中心关闭防火墙
|
||||
# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭Windows Defender防火墙
|
||||
```
|
||||
|
||||
### 验证网络连通性
|
||||
|
||||
在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常:
|
||||
|
||||
**在主节点机器上**(激活 unilab 环境后):
|
||||
|
||||
```bash
|
||||
# 启动talker
|
||||
ros2 run demo_nodes_cpp talker
|
||||
|
||||
# 同时在另一个终端启动listener
|
||||
ros2 run demo_nodes_cpp listener
|
||||
```
|
||||
|
||||
**在从节点机器上**(激活 unilab 环境后):
|
||||
|
||||
```bash
|
||||
# 启动talker
|
||||
ros2 run demo_nodes_cpp talker
|
||||
|
||||
# 同时在另一个终端启动listener
|
||||
ros2 run demo_nodes_cpp listener
|
||||
```
|
||||
|
||||
**注意**:必须在两台机器上**互相启动** talker 和 listener,否则可能出现只能收不能发的单向通信问题。
|
||||
|
||||
**预期结果**:
|
||||
|
||||
- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息
|
||||
- 如果只能看到本地消息,说明网络配置有问题
|
||||
- 如果两台机器都能互相收发消息,则组网配置正确
|
||||
|
||||
### 本地网络要求
|
||||
|
||||
**ROS2 通信**:
|
||||
|
||||
- 同一局域网或 VPN
|
||||
- 端口:默认 DDS 端口(7400-7500)
|
||||
- 组播支持(或配置 unicast)
|
||||
|
||||
**检查连通性**:
|
||||
|
||||
```bash
|
||||
# Ping测试
|
||||
ping <target_ip>
|
||||
|
||||
# ROS2节点发现
|
||||
ros2 node list
|
||||
ros2 daemon stop && ros2 daemon start
|
||||
```
|
||||
|
||||
### 云端连接
|
||||
|
||||
**要求**:
|
||||
|
||||
- HTTPS (443)
|
||||
- WebSocket 支持
|
||||
- 稳定的互联网连接
|
||||
|
||||
**测试连接**:
|
||||
|
||||
```bash
|
||||
# 测试云端连接
|
||||
curl https://uni-lab.bohrium.com/api/v1/health
|
||||
|
||||
# 测试WebSocket
|
||||
# 启动Uni-Lab后查看日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例:多房间部署
|
||||
|
||||
### 场景描述
|
||||
|
||||
- **房间 A**: 主控室,有 Web 界面
|
||||
- **房间 B**: 液体处理室
|
||||
- **房间 C**: 分析仪器室
|
||||
|
||||
### 房间 A - 主节点
|
||||
|
||||
```bash
|
||||
# host.json
|
||||
unilab --ak your_ak --sk your_sk -g host.json --port 8002
|
||||
```
|
||||
|
||||
### 房间 B - 从节点 1
|
||||
|
||||
```bash
|
||||
# liquid_handler.json
|
||||
unilab --ak your_ak --sk your_sk -g liquid_handler.json --is_slave --port 8003
|
||||
```
|
||||
|
||||
### 房间 C - 从节点 2
|
||||
|
||||
```bash
|
||||
# analytical.json
|
||||
unilab --ak your_ak --sk your_sk -g analytical.json --is_slave --port 8004
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障处理
|
||||
|
||||
### 节点离线
|
||||
|
||||
**检测**:
|
||||
|
||||
```bash
|
||||
ros2 node list # 查看在线节点
|
||||
```
|
||||
|
||||
**处理**:
|
||||
|
||||
1. 检查网络连接
|
||||
2. 重启节点
|
||||
3. 检查日志
|
||||
|
||||
### 从节点无法连接主节点
|
||||
|
||||
1. 检查网络:
|
||||
|
||||
```bash
|
||||
ping <host_ip>
|
||||
```
|
||||
|
||||
2. 检查 ROS_DOMAIN_ID:
|
||||
|
||||
```bash
|
||||
echo $ROS_DOMAIN_ID
|
||||
```
|
||||
|
||||
3. 使用`--slave_no_host`测试:
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave.json --is_slave --slave_no_host
|
||||
```
|
||||
|
||||
### 通信延迟
|
||||
|
||||
**排查**:
|
||||
|
||||
```bash
|
||||
# 网络延迟
|
||||
ping <node_ip>
|
||||
|
||||
# ROS2话题延迟
|
||||
ros2 topic hz /device_status
|
||||
ros2 topic bw /device_status
|
||||
```
|
||||
|
||||
**优化**:
|
||||
|
||||
- 减少发布频率
|
||||
- 使用 QoS 配置
|
||||
- 优化网络带宽
|
||||
|
||||
### 数据同步失败
|
||||
|
||||
**检查**:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f unilabos_data/logs/unilab.log | grep sync
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- 检查云端连接
|
||||
- 验证 AK/SK
|
||||
- 手动触发同步
|
||||
|
||||
### 资源不可见
|
||||
|
||||
检查资源注册:
|
||||
|
||||
```bash
|
||||
ros2 service call /host_node/resource_list \
|
||||
unilabos_msgs/srv/ResourceList
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 节点状态监控
|
||||
|
||||
```bash
|
||||
# 查看所有节点
|
||||
ros2 node list
|
||||
|
||||
# 查看话题
|
||||
ros2 topic list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [最佳实践指南](../user_guide/best_practice.md) - 完整的实验室搭建流程
|
||||
- [安装指南](../user_guide/installation.md) - 环境安装步骤
|
||||
- [启动参数详解](../user_guide/launch.md) - 启动参数说明
|
||||
- [添加设备驱动](add_device.md) - 自定义设备开发
|
||||
- [工作站架构](workstation_architecture.md) - 复杂工作站搭建
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [ROS2 网络配置](https://docs.ros.org/en/humble/Tutorials/Advanced/Networking.html)
|
||||
- [DDS 配置](https://fast-dds.docs.eprosima.com/)
|
||||
- Uni-Lab 云平台文档
|
||||
@@ -1,9 +1,23 @@
|
||||
# Uni-Lab 项目文档
|
||||
# Uni-Lab-OS 项目文档
|
||||
|
||||
欢迎来到项目文档的首页!
|
||||
Uni-Lab-OS 是一个开源的实验室自动化操作系统,提供统一的设备接口、工作流管理和分布式部署能力。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 3
|
||||
|
||||
intro.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/http_api.md
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/action_includes.md
|
||||
```
|
||||
|
||||
@@ -10,35 +10,51 @@ concepts/01-communication-instruction.md
|
||||
concepts/02-topology-and-chemputer-compile.md
|
||||
```
|
||||
|
||||
## **用户指南**
|
||||
## 用户指南
|
||||
|
||||
本指南将带你了解如何使用项目的功能。
|
||||
快速上手、系统配置与使用说明。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
user_guide/best_practice.md
|
||||
user_guide/installation.md
|
||||
user_guide/configuration.md
|
||||
user_guide/launch.md
|
||||
user_guide/graph_files.md
|
||||
boot_examples/index.md
|
||||
```
|
||||
|
||||
## 进阶配置
|
||||
|
||||
高级配置和系统管理。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
advanced_usage/configuration.md
|
||||
advanced_usage/working_directory.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
```{toctree}
|
||||
设备开发、系统扩展与架构说明。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/device_driver
|
||||
developer_guide/add_device
|
||||
developer_guide/add_action
|
||||
developer_guide/actions
|
||||
developer_guide/workstation_architecture
|
||||
developer_guide/add_protocol
|
||||
developer_guide/add_batteryPLC
|
||||
developer_guide/materials_tutorial
|
||||
developer_guide/materials_construction_guide
|
||||
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_old_device.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/actions.md
|
||||
developer_guide/action_includes.md
|
||||
developer_guide/add_protocol.md
|
||||
developer_guide/examples/workstation_architecture.md
|
||||
developer_guide/examples/materials_construction_guide.md
|
||||
developer_guide/examples/materials_tutorial.md
|
||||
developer_guide/examples/battery_plc_workstation.md
|
||||
```
|
||||
|
||||
## 接口文档
|
||||
|
||||
BIN
docs/logo.png
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 262 KiB |
1837
docs/user_guide/best_practice.md
Normal file
@@ -1,442 +0,0 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
Uni-Lab 支持通过 Python 配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的 Uni-Lab 环境。
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
Uni-Lab 支持 Python 格式的配置文件,它比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
### 默认配置示例
|
||||
|
||||
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`:
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
|
||||
sk = "" # 实验室网页给您提供的sk代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
|
||||
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
您可以进入实验室,点击左下角的头像在实验室详情中获取所在实验室的ak sk
|
||||

|
||||
|
||||
### 完整配置示例
|
||||
|
||||
您可以根据需要添加更多配置选项:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
# 基础配置
|
||||
class BasicConfig:
|
||||
ak = "your_access_key" # 实验室访问密钥
|
||||
sk = "your_secret_key" # 实验室私钥
|
||||
working_dir = "" # 工作目录(通常自动设置)
|
||||
config_path = "" # 配置文件路径(自动设置)
|
||||
is_host_mode = True # 是否为主站模式
|
||||
slave_no_host = False # 从站模式下是否跳过等待主机服务
|
||||
upload_registry = False # 是否上传注册表
|
||||
machine_name = "undefined" # 机器名称(自动获取)
|
||||
vis_2d_enable = False # 是否启用2D可视化
|
||||
enable_resource_load = True # 是否启用资源加载
|
||||
communication_protocol = "websocket" # 通信协议
|
||||
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = "" # API主机地址
|
||||
authorization = "" # 授权信息
|
||||
init_endpoint = "" # 初始化端点
|
||||
complete_endpoint = "" # 完成端点
|
||||
max_retries = 3 # 最大重试次数
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1" # 远程地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
] # 需要加载的ROS模块
|
||||
```
|
||||
|
||||
## 命令行参数覆盖配置
|
||||
|
||||
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。命令行参数的优先级高于配置文件。
|
||||
|
||||
### 支持命令行覆盖的配置项
|
||||
|
||||
以下配置项可以通过命令行参数进行覆盖:
|
||||
|
||||
| 配置类 | 配置字段 | 命令行参数 | 说明 |
|
||||
| ------------- | ----------------- | ------------------- | -------------------------------- |
|
||||
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
|
||||
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
|
||||
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
|
||||
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
|
||||
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
|
||||
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
|
||||
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
|
||||
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
|
||||
|
||||
### 特殊命令行参数
|
||||
|
||||
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `--config` | 指定配置文件路径 |
|
||||
| `--port` | Web 服务端口(不影响配置文件) |
|
||||
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
|
||||
| `--visual` | 可视化工具选择(不影响配置文件) |
|
||||
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
|
||||
|
||||
### 配置优先级
|
||||
|
||||
配置项的生效优先级从高到低为:
|
||||
|
||||
1. **命令行参数**:最高优先级
|
||||
2. **环境变量**:中等优先级
|
||||
3. **配置文件**:基础优先级
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 通过命令行覆盖认证信息
|
||||
unilab --ak "new_access_key" --sk "new_secret_key"
|
||||
|
||||
# 覆盖服务器地址
|
||||
unilab --addr "https://custom.server.com/api/v1"
|
||||
|
||||
# 启用从站模式并跳过等待主机
|
||||
unilab --is_slave --slave_no_host
|
||||
|
||||
# 启用上传注册表和2D可视化
|
||||
unilab --upload_registry --2d_vis
|
||||
|
||||
# 组合使用多个覆盖参数
|
||||
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis
|
||||
```
|
||||
|
||||
### 预设环境地址
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
## 配置选项详解
|
||||
|
||||
### 基础配置 (BasicConfig)
|
||||
|
||||
基础配置包含了系统运行的核心参数:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------------- | ------------------------------------------ |
|
||||
| `ak` | str | `""` | 实验室访问密钥(必需) |
|
||||
| `sk` | str | `""` | 实验室私钥(必需) |
|
||||
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
|
||||
| `is_host_mode` | bool | `True` | 是否为主站模式 |
|
||||
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
|
||||
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
|
||||
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
|
||||
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
|
||||
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
|
||||
|
||||
#### 认证配置
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级)
|
||||
- **配置文件**:在 `BasicConfig` 类中设置
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
3. **优先级顺序**:命令行参数 > 环境变量 > 配置文件
|
||||
4. **安全注意**:请妥善保管您的密钥信息
|
||||
|
||||
**推荐做法**:
|
||||
|
||||
- 开发环境:使用配置文件
|
||||
- 生产环境:使用环境变量或命令行参数
|
||||
- 临时测试:使用命令行参数
|
||||
|
||||
### WebSocket 配置 (WSConfig)
|
||||
|
||||
WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------ | ------------------ |
|
||||
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
|
||||
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
|
||||
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
|
||||
|
||||
### HTTP 配置 (HTTPConfig)
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | --------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"http://127.0.0.1:48197/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### ROS 配置 (ROSConfig)
|
||||
|
||||
配置 ROS 消息转换器需要加载的模块:
|
||||
|
||||
```python
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg", # 标准消息类型
|
||||
"geometry_msgs.msg", # 几何消息类型
|
||||
"control_msgs.msg", # 控制消息类型
|
||||
"control_msgs.action", # 控制动作类型
|
||||
"nav2_msgs.action", # 导航动作类型
|
||||
"unilabos_msgs.msg", # UniLab 自定义消息类型
|
||||
"unilabos_msgs.action", # UniLab 自定义动作类型
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据实际使用的设备和功能添加其他 ROS 模块。
|
||||
|
||||
### OSS 上传配置 (OSSUploadConfig)
|
||||
|
||||
对象存储服务配置,用于文件上传功能:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------- | ---- | ------ | -------------------- |
|
||||
| `api_host` | str | `""` | OSS API 主机地址 |
|
||||
| `authorization` | str | `""` | 授权认证信息 |
|
||||
| `init_endpoint` | str | `""` | 上传初始化端点 |
|
||||
| `complete_endpoint` | str | `""` | 上传完成端点 |
|
||||
| `max_retries` | int | `3` | 上传失败最大重试次数 |
|
||||
|
||||
## 环境变量支持
|
||||
|
||||
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。环境变量格式为:
|
||||
|
||||
```
|
||||
UNILABOS_{配置类名}_{字段名}
|
||||
```
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```bash
|
||||
# 设置基础配置
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
|
||||
|
||||
# 设置WebSocket配置
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
### 环境变量类型转换
|
||||
|
||||
- **布尔值**:`"true"`, `"1"`, `"yes"` → `True`;其他 → `False`
|
||||
- **整数**:自动转换为 `int` 类型
|
||||
- **浮点数**:自动转换为 `float` 类型
|
||||
- **字符串**:保持原值
|
||||
|
||||
## 配置文件使用方法
|
||||
|
||||
### 1. 指定配置文件启动
|
||||
|
||||
```bash
|
||||
# 使用指定配置文件启动
|
||||
unilab --config /path/to/your/config.py
|
||||
```
|
||||
|
||||
### 2. 使用默认配置文件
|
||||
|
||||
如果不指定配置文件,系统会按以下顺序查找:
|
||||
|
||||
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
|
||||
2. 工作目录下的 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 3. 配置文件验证
|
||||
|
||||
系统启动时会自动验证配置文件:
|
||||
|
||||
- **语法检查**:确保 Python 语法正确
|
||||
- **类型检查**:验证配置项类型是否匹配
|
||||
- **必需项检查**:确保 `ak` 和 `sk` 已配置
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全配置
|
||||
|
||||
- 不要将包含密钥的配置文件提交到版本控制系统
|
||||
- 使用环境变量或命令行参数在生产环境中配置敏感信息
|
||||
- 定期更换访问密钥
|
||||
- **推荐配置方式**:
|
||||
|
||||
```bash
|
||||
# 生产环境 - 使用环境变量
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
unilab
|
||||
|
||||
# 或使用命令行参数
|
||||
unilab --ak "your_access_key" --sk "your_secret_key"
|
||||
```
|
||||
|
||||
### 2. 多环境配置
|
||||
|
||||
为不同环境创建不同的配置文件并结合命令行参数:
|
||||
|
||||
```
|
||||
configs/
|
||||
├── local_config.py # 本地开发
|
||||
├── test_config.py # 测试环境
|
||||
├── prod_config.py # 生产环境
|
||||
└── example_config.py # 示例配置
|
||||
```
|
||||
|
||||
**环境切换示例**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境
|
||||
unilab --config configs/local_config.py --addr local
|
||||
|
||||
# 测试环境
|
||||
unilab --config configs/test_config.py --addr test --upload_registry
|
||||
|
||||
# 生产环境
|
||||
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK"
|
||||
```
|
||||
|
||||
### 3. 配置管理
|
||||
|
||||
- 保持配置文件简洁,只包含需要修改的配置项
|
||||
- 为配置项添加注释说明其作用
|
||||
- 定期检查和更新配置文件
|
||||
- **命令行参数优先使用场景**:
|
||||
- 临时测试不同配置
|
||||
- CI/CD 流水线中的动态配置
|
||||
- 不同环境间快速切换
|
||||
- 敏感信息的安全传递
|
||||
|
||||
### 4. 灵活配置策略
|
||||
|
||||
**基础配置文件 + 命令行覆盖**的推荐方式:
|
||||
|
||||
```python
|
||||
# base_config.py - 基础配置
|
||||
class BasicConfig:
|
||||
# 非敏感配置写在文件中
|
||||
is_host_mode = True
|
||||
upload_registry = False
|
||||
vis_2d_enable = False
|
||||
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动时通过命令行覆盖关键参数
|
||||
unilab --config base_config.py \
|
||||
--ak "$AK" \
|
||||
--sk "$SK" \
|
||||
--addr "test" \
|
||||
--upload_registry \
|
||||
--2d_vis
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 配置文件加载失败
|
||||
|
||||
**错误信息**:`[ENV] 配置文件 xxx 不存在`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认配置文件路径正确
|
||||
- 检查文件权限是否可读
|
||||
- 确保配置文件是 `.py` 格式
|
||||
|
||||
### 2. 语法错误
|
||||
|
||||
**错误信息**:`[ENV] 加载配置文件 xxx 失败`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 检查 Python 语法是否正确
|
||||
- 确认类名和字段名拼写正确
|
||||
- 验证缩进是否正确(使用空格而非制表符)
|
||||
|
||||
### 3. 认证失败
|
||||
|
||||
**错误信息**:`后续运行必须拥有一个实验室`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认 `ak` 和 `sk` 已正确配置
|
||||
- 检查密钥是否有效
|
||||
- 确认网络连接正常
|
||||
|
||||
### 4. 环境变量不生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认环境变量名格式正确(`UNILABOS_CLASS_FIELD`)
|
||||
- 检查环境变量是否已正确设置
|
||||
- 重启系统或重新加载环境变量
|
||||
|
||||
### 5. 命令行参数不生效
|
||||
|
||||
**错误现象**:设置了命令行参数但配置没有生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`)
|
||||
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
|
||||
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
|
||||
- 查看启动日志确认参数是否被正确解析
|
||||
|
||||
### 6. 配置优先级混淆
|
||||
|
||||
**错误现象**:不确定哪个配置生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 记住优先级:命令行参数 > 环境变量 > 配置文件
|
||||
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息
|
||||
- 检查启动日志中的配置加载信息
|
||||
- 临时移除低优先级配置来测试高优先级配置是否生效
|
||||
860
docs/user_guide/graph_files.md
Normal file
@@ -0,0 +1,860 @@
|
||||
# 设备图文件说明
|
||||
|
||||
设备图文件定义了实验室中所有设备、资源及其连接关系。本文档说明如何创建和使用设备图文件。
|
||||
|
||||
## 概述
|
||||
|
||||
设备图文件采用 JSON 格式,节点定义基于 **`ResourceDict`** 标准模型(定义在 `unilabos.ros.nodes.resource_tracker`)。系统会自动处理旧格式并转换为标准格式,确保向后兼容性。
|
||||
|
||||
**核心概念**:
|
||||
|
||||
- **Nodes(节点)**: 代表设备或资源,通过 `parent` 字段建立层级关系
|
||||
- **Links(连接)**: 可选的连接关系定义,用于展示设备间的物理或通信连接
|
||||
- **UUID**: 全局唯一标识符,用于跨系统的资源追踪
|
||||
- **自动转换**: 旧格式会通过 `ResourceDictInstance.get_resource_instance_from_dict()` 自动转换
|
||||
|
||||
## 文件格式
|
||||
|
||||
Uni-Lab 支持两种格式的设备图文件:
|
||||
|
||||
### JSON 格式(推荐)
|
||||
|
||||
**优点**:
|
||||
|
||||
- 易于编辑和阅读
|
||||
- 支持注释(使用预处理)
|
||||
- 与 Web 界面完全兼容
|
||||
- 便于版本控制
|
||||
|
||||
**示例**: `workshop1.json`
|
||||
|
||||
### GraphML 格式
|
||||
|
||||
**优点**:
|
||||
|
||||
- 可用图形化工具编辑(如 yEd)
|
||||
- 适合复杂拓扑可视化
|
||||
|
||||
**示例**: `setup.graphml`
|
||||
|
||||
## JSON 文件结构
|
||||
|
||||
一个完整的 JSON 设备图文件包含两个主要部分:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
/* 设备和资源节点 */
|
||||
],
|
||||
"links": [
|
||||
/* 连接关系(可选)*/
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Nodes(节点)
|
||||
|
||||
每个节点代表一个设备或资源。节点的定义遵循 `ResourceDict` 标准模型:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "liquid_handler_1",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "液体处理工作站",
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {},
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200
|
||||
},
|
||||
"parent": null
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明(基于 ResourceDict 标准定义)**:
|
||||
|
||||
| 字段 | 必需 | 说明 | 示例 | 默认值 |
|
||||
| ------------- | ---- | ------------------------ | ---------------------------------------------------- | -------- |
|
||||
| `id` | ✓ | 唯一标识符 | `"pump_1"` | - |
|
||||
| `uuid` | | 全局唯一标识符 (UUID) | `"550e8400-e29b-41d4-a716-446655440000"` | 自动生成 |
|
||||
| `name` | ✓ | 显示名称 | `"主反应泵"` | - |
|
||||
| `type` | ✓ | 节点类型 | `"device"`, `"resource"`, `"container"`, `"deck"` 等 | - |
|
||||
| `class` | ✓ | 设备/资源类别 | `"liquid_handler"`, `"syringepump.runze"` | `""` |
|
||||
| `config` | | Python 类的初始化参数 | `{"port": "COM3"}` | `{}` |
|
||||
| `data` | | 资源的运行状态数据 | `{"status": "Idle", "position": 0.0}` | `{}` |
|
||||
| `position` | | 在图中的位置 | `{"x": 100, "y": 200}` 或完整的 pose 结构 | - |
|
||||
| `pose` | | 完整的 3D 位置信息 | 参见下文 | - |
|
||||
| `parent` | | 父节点 ID | `"deck_1"` | `null` |
|
||||
| `parent_uuid` | | 父节点 UUID | `"550e8400-..."` | `null` |
|
||||
| `children` | | 子节点 ID 列表(旧格式) | `["child1", "child2"]` | - |
|
||||
| `description` | | 资源描述 | `"用于精确控制试剂A的加料速率"` | `""` |
|
||||
| `schema` | | 资源 schema 定义 | `{}` | `{}` |
|
||||
| `model` | | 资源 3D 模型信息 | `{}` | `{}` |
|
||||
| `icon` | | 资源图标 | `"pump.webp"` | `""` |
|
||||
| `extra` | | 额外的自定义数据 | `{"custom_field": "value"}` | `{}` |
|
||||
|
||||
### Position 和 Pose(位置信息)
|
||||
|
||||
**简单格式(旧格式,兼容)**:
|
||||
|
||||
```json
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
}
|
||||
```
|
||||
|
||||
**完整格式(推荐)**:
|
||||
|
||||
```json
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 127.76,
|
||||
"height": 85.48,
|
||||
"depth": 10.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
},
|
||||
"layout": "x-y",
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
},
|
||||
"position3d": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
},
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"cross_section_type": "rectangle"
|
||||
}
|
||||
```
|
||||
|
||||
### Links(连接)
|
||||
|
||||
定义节点之间的连接关系(可选,主要用于物理连接或通信关系的可视化):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "reactor_1",
|
||||
"sourceHandle": "output",
|
||||
"targetHandle": "input",
|
||||
"type": "physical"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 必需 | 说明 | 示例 |
|
||||
| -------------- | ---- | ---------------- | ---------------------------------------- |
|
||||
| `source` | ✓ | 源节点 ID | `"pump_1"` |
|
||||
| `target` | ✓ | 目标节点 ID | `"reactor_1"` |
|
||||
| `sourceHandle` | | 源节点的连接点 | `"output"` |
|
||||
| `targetHandle` | | 目标节点的连接点 | `"input"` |
|
||||
| `type` | | 连接类型 | `"physical"`, `"communication"` |
|
||||
| `port` | | 端口映射信息 | `{"source": "port1", "target": "port2"}` |
|
||||
|
||||
**注意**: Links 主要用于图形化展示和文档说明,父子关系通过 `parent` 字段定义,不依赖 links。
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例 1:液体处理工作站(PRCXI9300)
|
||||
|
||||
这是一个真实的液体处理工作站配置,包含设备、工作台和多个板资源。
|
||||
|
||||
**文件位置**: `test/experiments/prcxi_9300.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PRCXI9300",
|
||||
"name": "PRCXI9300",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_child_name": "PRCXI_Deck_9300",
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
|
||||
},
|
||||
"host": "10.181.214.132",
|
||||
"port": 9999,
|
||||
"timeout": 10.0,
|
||||
"axis": "Left",
|
||||
"channel_num": 8,
|
||||
"setup": false,
|
||||
"debug": true,
|
||||
"simulator": true,
|
||||
"matrix_id": "71593"
|
||||
},
|
||||
"data": {},
|
||||
"children": ["PRCXI_Deck_9300"]
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck_9300",
|
||||
"name": "PRCXI_Deck_9300",
|
||||
"parent": "PRCXI9300",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 100,
|
||||
"size_y": 100,
|
||||
"size_z": 100,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck"
|
||||
},
|
||||
"data": {},
|
||||
"children": [
|
||||
"RackT1",
|
||||
"PlateT2",
|
||||
"trash",
|
||||
"PlateT4",
|
||||
"PlateT5",
|
||||
"PlateT6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RackT1",
|
||||
"name": "RackT1",
|
||||
"parent": "PRCXI_Deck_9300",
|
||||
"type": "tip_rack",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "TipRack",
|
||||
"size_x": 127.76,
|
||||
"size_y": 85.48,
|
||||
"size_z": 100
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- 使用 `parent` 字段建立层级关系(PRCXI9300 → Deck → Rack/Plate)
|
||||
- 使用 `children` 字段(旧格式)列出子节点
|
||||
- `config` 中包含设备特定的连接参数
|
||||
- `data` 存储运行时状态
|
||||
- `position` 使用简单的 x/y/z 坐标
|
||||
|
||||
### 示例 2:有机合成工作站(带 Links)
|
||||
|
||||
这是一个格林纳德反应的流动化学工作站配置,展示了完整的设备连接和通信关系。
|
||||
|
||||
**文件位置**: `test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "YugongStation",
|
||||
"name": "愚公常量合成工作站",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": [
|
||||
"PumpTransferProtocol",
|
||||
"CleanProtocol",
|
||||
"SeparateProtocol",
|
||||
"EvaporateProtocol"
|
||||
]
|
||||
},
|
||||
"data": {},
|
||||
"children": [
|
||||
"serial_pump",
|
||||
"pump_reagents",
|
||||
"flask_CH2Cl2",
|
||||
"reactor",
|
||||
"pump_workup",
|
||||
"separator_controller",
|
||||
"flask_separator",
|
||||
"rotavap",
|
||||
"column"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"name": "serial_pump",
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "serial",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "COM7",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "pump_reagents",
|
||||
"name": "pump_reagents",
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "1",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"name": "reactor",
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 430.4087301587302,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_workup": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- 多级设备层次:工作站包含多个子设备和容器
|
||||
- `links` 定义通信关系(泵通过串口连接)
|
||||
- `data` 字段存储设备状态(如泵的位置、速度等)
|
||||
- `class` 可以使用点号分层(如 `"syringepump.runze"`)
|
||||
- 容器的 `class` 可以为 `null`
|
||||
|
||||
## 格式兼容性和转换
|
||||
|
||||
### 旧格式自动转换
|
||||
|
||||
Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法自动处理旧格式的节点数据,确保向后兼容性。
|
||||
|
||||
**自动转换规则**:
|
||||
|
||||
1. **自动生成缺失字段**:
|
||||
|
||||
```python
|
||||
# 如果缺少 id,使用 name 作为 id
|
||||
if "id" not in content:
|
||||
content["id"] = content["name"]
|
||||
|
||||
# 如果缺少 uuid,自动生成
|
||||
if "uuid" not in content:
|
||||
content["uuid"] = str(uuid.uuid4())
|
||||
```
|
||||
|
||||
2. **Position 格式转换**:
|
||||
|
||||
```python
|
||||
# 旧格式:简单的 x/y 坐标
|
||||
"position": {"x": 100, "y": 200}
|
||||
|
||||
# 自动转换为新格式
|
||||
"position": {
|
||||
"position": {"x": 100, "y": 200}
|
||||
}
|
||||
```
|
||||
|
||||
3. **默认值填充**:
|
||||
|
||||
```python
|
||||
# 自动填充空字段
|
||||
if not content.get("class"):
|
||||
content["class"] = ""
|
||||
if not content.get("config"):
|
||||
content["config"] = {}
|
||||
if not content.get("data"):
|
||||
content["data"] = {}
|
||||
if not content.get("extra"):
|
||||
content["extra"] = {}
|
||||
```
|
||||
|
||||
4. **Pose 字段同步**:
|
||||
```python
|
||||
# 如果没有 pose,使用 position
|
||||
if "pose" not in content:
|
||||
content["pose"] = content.get("position", {})
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
|
||||
# 旧格式节点
|
||||
old_format_node = {
|
||||
"name": "pump_1",
|
||||
"type": "device",
|
||||
"class": "syringepump",
|
||||
"position": {"x": 100, "y": 200}
|
||||
}
|
||||
|
||||
# 自动转换为标准格式
|
||||
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
|
||||
|
||||
# 访问标准化后的数据
|
||||
print(instance.res_content.id) # "pump_1"
|
||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||
print(instance.res_content.config) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
```
|
||||
|
||||
### 格式迁移建议
|
||||
|
||||
虽然系统会自动处理旧格式,但建议在新文件中使用完整的标准格式:
|
||||
|
||||
| 字段 | 旧格式(兼容) | 新格式(推荐) |
|
||||
| ------ | ---------------------------------- | ------------------------------------------------ |
|
||||
| 标识符 | 仅 `id` 或仅 `name` | `id` + `uuid` |
|
||||
| 位置 | `"position": {"x": 100, "y": 200}` | 完整的 `pose` 结构 |
|
||||
| 父节点 | `"parent": "parent_id"` | `"parent": "parent_id"` + `"parent_uuid": "..."` |
|
||||
| 配置 | 可省略 | 显式设置为 `{}` |
|
||||
| 数据 | 可省略 | 显式设置为 `{}` |
|
||||
|
||||
## 节点类型详解
|
||||
|
||||
### Device 节点
|
||||
|
||||
设备节点代表实际的硬件设备:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "device_id",
|
||||
"name": "设备名称",
|
||||
"type": "device",
|
||||
"class": "设备类别",
|
||||
"parent": null,
|
||||
"config": {
|
||||
"port": "COM3"
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
**常见设备类别**:
|
||||
|
||||
- `liquid_handler`: 液体处理工作站
|
||||
- `liquid_handler.prcxi`: PRCXI 液体处理工作站
|
||||
- `syringepump`: 注射泵
|
||||
- `syringepump.runze`: 润泽注射泵
|
||||
- `heaterstirrer`: 加热搅拌器
|
||||
- `balance`: 天平
|
||||
- `reactor_vessel`: 反应釜
|
||||
- `serial`: 串口通信设备
|
||||
- `workstation`: 自动化工作站
|
||||
|
||||
### Resource 节点
|
||||
|
||||
资源节点代表物料容器、载具等:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "resource_id",
|
||||
"name": "资源名称",
|
||||
"type": "resource",
|
||||
"class": "资源类别",
|
||||
"parent": "父节点ID",
|
||||
"config": {
|
||||
"size_x": 127.76,
|
||||
"size_y": 85.48,
|
||||
"size_z": 100
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
**常见资源类型**:
|
||||
|
||||
- `deck`: 工作台/甲板
|
||||
- `plate`: 板(96 孔板等)
|
||||
- `tip_rack`: 枪头架
|
||||
- `tube`: 试管
|
||||
- `container`: 容器
|
||||
- `well`: 孔位
|
||||
- `bottle_carrier`: 瓶架
|
||||
|
||||
## Handle(连接点)
|
||||
|
||||
每个设备和资源可以有多个连接点(handles),用于定义可以连接的接口。
|
||||
|
||||
### 查看可用 handles
|
||||
|
||||
设备和资源的可用 handles 定义在注册表中:
|
||||
|
||||
```yaml
|
||||
# 设备注册表示例
|
||||
liquid_handler:
|
||||
handles:
|
||||
- handler_key: pipette
|
||||
io_type: source
|
||||
- handler_key: deck
|
||||
io_type: target
|
||||
```
|
||||
|
||||
### 常见 handles
|
||||
|
||||
| 设备类型 | Source Handles | Target Handles |
|
||||
| ---------- | -------------- | -------------- |
|
||||
| 泵 | output | input |
|
||||
| 反应釜 | output, vessel | input |
|
||||
| 液体处理器 | pipette | deck |
|
||||
| 板 | wells | access |
|
||||
|
||||
## 使用 Web 界面创建图文件
|
||||
|
||||
Uni-Lab 提供 Web 界面来可视化创建和编辑设备图:
|
||||
|
||||
### 1. 启动 Uni-Lab
|
||||
|
||||
```bash
|
||||
unilab
|
||||
```
|
||||
|
||||
### 2. 访问 Web 界面
|
||||
|
||||
打开浏览器访问 `http://localhost:8002`
|
||||
|
||||
### 3. 图形化编辑
|
||||
|
||||
- 拖拽添加设备和资源
|
||||
- 连线建立连接关系
|
||||
- 编辑节点属性
|
||||
- 保存为 JSON 文件
|
||||
|
||||
### 4. 导出图文件
|
||||
|
||||
点击"导出"按钮,下载 JSON 文件到本地。
|
||||
|
||||
## 从云端获取图文件
|
||||
|
||||
如果不指定`-g`参数,Uni-Lab 会自动从云端获取:
|
||||
|
||||
```bash
|
||||
# 使用云端配置
|
||||
unilab
|
||||
|
||||
# 日志会显示:
|
||||
# [INFO] 未指定设备加载文件路径,尝试从HTTP获取...
|
||||
# [INFO] 联网获取设备加载文件成功
|
||||
```
|
||||
|
||||
**云端图文件管理**:
|
||||
|
||||
1. 登录 https://uni-lab.bohrium.com
|
||||
2. 进入"设备配置"
|
||||
3. 创建或编辑配置
|
||||
4. 保存到云端
|
||||
|
||||
本地启动时会自动同步最新配置。
|
||||
|
||||
## 调试图文件
|
||||
|
||||
### 验证 JSON 格式
|
||||
|
||||
```bash
|
||||
# 使用Python验证
|
||||
python -c "import json; json.load(open('workshop1.json'))"
|
||||
|
||||
# 使用在线工具
|
||||
# https://jsonlint.com/
|
||||
```
|
||||
|
||||
### 检查节点引用
|
||||
|
||||
确保:
|
||||
|
||||
- 所有`links`中的`source`和`target`都存在于`nodes`中
|
||||
- `parent`字段指向的节点存在
|
||||
- `class`字段对应的设备/资源在注册表中存在
|
||||
|
||||
### 启动时验证
|
||||
|
||||
```bash
|
||||
# Uni-Lab启动时会验证图文件
|
||||
unilab -g workshop1.json
|
||||
|
||||
# 查看日志中的错误或警告
|
||||
# [ERROR] 节点 xxx 的source端点 yyy 不存在
|
||||
# [WARNING] 节点 zzz missing 'name', defaulting to ...
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 命名规范
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_reagent_1", // 小写+下划线,描述性
|
||||
"name": "试剂进料泵A", // 中文显示名称
|
||||
"class": "syringepump" // 使用注册表中的精确名称
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 层级组织
|
||||
|
||||
```
|
||||
host_node (主节点)
|
||||
└── liquid_handler_1 (设备)
|
||||
└── deck_1 (资源)
|
||||
├── tiprack_1 (资源)
|
||||
├── plate_1 (资源)
|
||||
└── reservoir_1 (资源)
|
||||
```
|
||||
|
||||
### 3. 配置分离
|
||||
|
||||
将设备特定配置放在`config`中:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_1",
|
||||
"class": "syringepump",
|
||||
"config": {
|
||||
"port": "COM3", // 设备特定
|
||||
"max_flow_rate": 10, // 设备特定
|
||||
"volume": 50 // 设备特定
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 版本控制
|
||||
|
||||
```bash
|
||||
# 使用Git管理图文件
|
||||
git add workshop1.json
|
||||
git commit -m "Add new liquid handler configuration"
|
||||
|
||||
# 使用有意义的文件名
|
||||
workshop_v1.json
|
||||
workshop_production.json
|
||||
workshop_test.json
|
||||
```
|
||||
|
||||
### 5. 注释(通过描述字段)
|
||||
|
||||
虽然 JSON 不支持注释,但可以使用`description`字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_1",
|
||||
"name": "进料泵",
|
||||
"description": "用于精确控制试剂A的加料速率,最大流速10mL/min",
|
||||
"class": "syringepump"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例文件位置
|
||||
|
||||
Uni-Lab 在安装时已预置了 **40+ 个真实的设备图文件示例**,位于 `unilabos/test/experiments/` 目录。这些都是真实项目中使用的配置文件,可以直接使用或作为参考。
|
||||
|
||||
### 📁 主要示例文件
|
||||
|
||||
```
|
||||
test/experiments/
|
||||
├── workshop.json # 综合工作台(推荐新手)
|
||||
├── empty_devices.json # 空设备配置(最小化)
|
||||
├── prcxi_9300.json # PRCXI液体处理工作站(本文示例1)
|
||||
├── prcxi_9320.json # PRCXI 9320工作站
|
||||
├── biomek.json # Biomek液体处理工作站
|
||||
├── Grignard_flow_batchreact_single_pumpvalve.json # 格林纳德反应工作站(本文示例2)
|
||||
├── dispensing_station_bioyond.json # Bioyond配液站
|
||||
├── reaction_station_bioyond.json # Bioyond反应站
|
||||
├── HPLC.json # HPLC分析系统
|
||||
├── plr_test.json # PyLabRobot测试配置
|
||||
├── lidocaine-graph.json # 利多卡因合成工作站
|
||||
├── opcua_example.json # OPC UA设备集成示例
|
||||
│
|
||||
├── mock_devices/ # 虚拟设备(用于离线测试)
|
||||
│ ├── mock_all.json # 完整虚拟设备集
|
||||
│ ├── mock_pump.json # 虚拟泵
|
||||
│ ├── mock_stirrer.json # 虚拟搅拌器
|
||||
│ ├── mock_heater.json # 虚拟加热器
|
||||
│ └── ... # 更多虚拟设备
|
||||
│
|
||||
├── Protocol_Test_Station/ # 协议测试工作站
|
||||
│ ├── pumptransfer_test_station.json # 泵转移协议测试
|
||||
│ ├── heatchill_protocol_test_station.json # 加热冷却协议测试
|
||||
│ ├── filter_protocol_test_station.json # 过滤协议测试
|
||||
│ └── ... # 更多协议测试
|
||||
│
|
||||
└── comprehensive_protocol/ # 综合协议示例
|
||||
├── comprehensive_station.json # 综合工作站
|
||||
└── comprehensive_slim.json # 精简版综合工作站
|
||||
```
|
||||
|
||||
### 🚀 快速使用
|
||||
|
||||
无需下载或创建,直接使用 `-g` 参数指定路径:
|
||||
|
||||
```bash
|
||||
# 使用简单工作台(推荐新手)
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json
|
||||
|
||||
# 使用虚拟设备(无需真实硬件)
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
|
||||
# 使用 PRCXI 液体处理工作站
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json
|
||||
|
||||
# 使用格林纳德反应工作站
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json
|
||||
```
|
||||
|
||||
### 📚 文件分类
|
||||
|
||||
| 类别 | 说明 | 文件数量 |
|
||||
| ------------ | ------------------------ | -------- |
|
||||
| **主工作站** | 完整的实验工作站配置 | 15+ |
|
||||
| **虚拟设备** | 用于开发测试的 mock 设备 | 10+ |
|
||||
| **协议测试** | 各种实验协议的测试配置 | 12+ |
|
||||
| **综合示例** | 包含多种协议的综合工作站 | 3+ |
|
||||
|
||||
这些文件展示了不同场景下的设备图配置,涵盖液体处理、有机合成、分析检测等多个领域,是学习和创建自己配置的绝佳参考。
|
||||
|
||||
## 快速参考:ResourceDict 完整字段列表
|
||||
|
||||
基于 `unilabos.ros.nodes.resource_tracker.ResourceDict` 的完整字段定义:
|
||||
|
||||
```python
|
||||
class ResourceDict(BaseModel):
|
||||
# === 基础标识 ===
|
||||
id: str # 资源ID(必需)
|
||||
uuid: str # 全局唯一标识符(自动生成)
|
||||
name: str # 显示名称(必需)
|
||||
|
||||
# === 类型和分类 ===
|
||||
type: Union[Literal["device"], str] # 节点类型(必需)
|
||||
klass: str # 资源类别(alias="class",必需)
|
||||
|
||||
# === 层级关系 ===
|
||||
parent: Optional[ResourceDict] # 父资源对象(不序列化)
|
||||
parent_uuid: Optional[str] # 父资源UUID
|
||||
|
||||
# === 位置和姿态 ===
|
||||
position: ResourceDictPosition # 位置信息
|
||||
pose: ResourceDictPosition # 姿态信息(推荐使用)
|
||||
|
||||
# === 配置和数据 ===
|
||||
config: Dict[str, Any] # 设备配置参数
|
||||
data: Dict[str, Any] # 运行时状态数据
|
||||
extra: Dict[str, Any] # 额外自定义数据
|
||||
|
||||
# === 元数据 ===
|
||||
description: str # 资源描述
|
||||
resource_schema: Dict[str, Any] # schema定义(alias="schema")
|
||||
model: Dict[str, Any] # 3D模型信息
|
||||
icon: str # 图标路径
|
||||
```
|
||||
|
||||
**Position/Pose 结构**:
|
||||
|
||||
```python
|
||||
class ResourceDictPosition(BaseModel):
|
||||
size: ResourceDictPositionSize # width, height, depth
|
||||
scale: ResourceDictPositionScale # x, y, z
|
||||
layout: Literal["2d", "x-y", "z-y", "x-z"]
|
||||
position: ResourceDictPositionObject # x, y, z
|
||||
position3d: ResourceDictPositionObject # x, y, z
|
||||
rotation: ResourceDictPositionObject # x, y, z
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- {doc}`../boot_examples/index` - 查看完整启动示例
|
||||
- {doc}`../developer_guide/add_device` - 了解如何添加新设备
|
||||
- {doc}`06_troubleshooting` - 图文件相关问题排查
|
||||
- 源码参考: `unilabos/ros/nodes/resource_tracker.py` - ResourceDict 标准定义
|
||||
|
||||
## 获取帮助
|
||||
|
||||
- 在 Web 界面中使用模板创建
|
||||
- 参考示例文件:`test/experiments/` 目录
|
||||
- 查看 ResourceDict 源码了解完整定义
|
||||
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
BIN
docs/user_guide/image/test_latency_result.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/user_guide/image/test_latency_running.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/user_guide/image/test_latency_select_device.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
@@ -1,43 +1,516 @@
|
||||
# **Uni-Lab 安装**
|
||||
# Uni-Lab-OS 安装指南
|
||||
|
||||
## 快速开始
|
||||
本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。
|
||||
|
||||
1. **配置 Conda 环境**
|
||||
## 系统要求
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境:
|
||||
- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+)
|
||||
- **内存**: 最小 4GB,推荐 8GB 以上
|
||||
- **磁盘空间**: 至少 10GB 可用空间
|
||||
- **网络**: 稳定的互联网连接(用于下载软件包)
|
||||
- **其他**:
|
||||
- 已安装 Conda/Miniconda/Miniforge/Mamba
|
||||
- 开发者需要 Git 和基本的 Python 开发知识
|
||||
- 自定义 msgs 需要 GitHub 账号
|
||||
|
||||
```shell
|
||||
## 安装方式选择
|
||||
|
||||
根据您的使用场景,选择合适的安装方式:
|
||||
|
||||
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||
|
||||
---
|
||||
|
||||
## 方式一:一键安装(推荐新用户)
|
||||
|
||||
使用预打包的 conda 环境,最快速的安装方法。
|
||||
|
||||
### 前置条件
|
||||
|
||||
确保已安装 Conda/Miniconda/Miniforge/Mamba。
|
||||
|
||||
### 安装步骤
|
||||
|
||||
#### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
|
||||
- Windows: `unilab-pack-win-64-{branch}.zip`
|
||||
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
|
||||
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
|
||||
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
|
||||
|
||||
#### 第二步:解压并运行安装脚本
|
||||
|
||||
**Windows**:
|
||||
|
||||
```batch
|
||||
REM 使用 Windows 资源管理器解压下载的 zip 文件
|
||||
REM 或使用命令行:
|
||||
tar -xzf unilab-pack-win-64-dev.zip
|
||||
|
||||
REM 进入解压后的目录
|
||||
cd unilab-pack-win-64-dev
|
||||
|
||||
REM 双击运行 install_unilab.bat
|
||||
REM 或在命令行中执行:
|
||||
install_unilab.bat
|
||||
```
|
||||
|
||||
**macOS**:
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-osx-arm64-dev
|
||||
|
||||
# 运行安装脚本
|
||||
bash install_unilab.sh
|
||||
```
|
||||
|
||||
**Linux**:
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-linux-64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-linux-64-dev
|
||||
|
||||
# 添加执行权限(如果需要)
|
||||
chmod +x install_unilab.sh
|
||||
|
||||
# 运行安装脚本
|
||||
./install_unilab.sh
|
||||
```
|
||||
|
||||
#### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。
|
||||
|
||||
---
|
||||
|
||||
## 方式二:手动安装(标准用户)
|
||||
|
||||
适合生产环境和需要灵活配置的用户。
|
||||
|
||||
### 第一步:安装 Mamba 环境管理器
|
||||
|
||||
Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。
|
||||
|
||||
#### Windows
|
||||
|
||||
下载并安装 Miniforge(包含 Mamba):
|
||||
|
||||
```powershell
|
||||
# 访问 https://github.com/conda-forge/miniforge/releases
|
||||
# 下载 Miniforge3-Windows-x86_64.exe
|
||||
# 运行安装程序
|
||||
|
||||
# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/
|
||||
# 下载 Miniforge3-Windows-x86_64.exe
|
||||
# 运行安装程序
|
||||
```
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 下载 Miniforge 安装脚本
|
||||
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
|
||||
|
||||
# 运行安装
|
||||
bash Miniforge3-$(uname)-$(uname -m).sh
|
||||
|
||||
# 按照提示完成安装,建议选择 yes 来初始化
|
||||
```
|
||||
|
||||
安装完成后,重新打开终端使 Mamba 生效。
|
||||
|
||||
### 第二步:创建 Uni-Lab 环境
|
||||
|
||||
使用以下命令创建 Uni-Lab 专用环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||
|
||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||
|
||||
```bash
|
||||
# 配置清华镜像源
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
|
||||
# 然后重新执行安装命令
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:开发者安装
|
||||
|
||||
适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 已安装 Git
|
||||
- 已安装 Mamba/Conda
|
||||
- 有 GitHub 账号(如需自定义 msgs)
|
||||
- 基本的 Python 开发知识
|
||||
|
||||
### 第一步:克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
如果您需要贡献代码,建议先 Fork 仓库:
|
||||
|
||||
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||
2. 点击右上角的 "Fork" 按钮
|
||||
3. Clone 您的 Fork 版本:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
### 第二步:安装基础环境
|
||||
|
||||
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||
|
||||
#### 选项 A:通过一键安装(推荐)
|
||||
|
||||
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
#### 选项 B:通过手动安装
|
||||
|
||||
参考上文"方式二:手动安装",创建并安装环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11
|
||||
conda activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
|
||||
|
||||
### 第三步:切换到开发版本
|
||||
|
||||
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||
pip uninstall unilabos -y
|
||||
|
||||
# 克隆 dev 分支(如果还未克隆)
|
||||
cd /path/to/your/workspace
|
||||
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# 或者如果已经克隆,切换到 dev 分支
|
||||
cd Uni-Lab-OS
|
||||
git checkout dev
|
||||
git pull
|
||||
|
||||
# 以可编辑模式安装开发版 unilabos
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||
- `-i`: 使用清华镜像源加速下载
|
||||
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||
|
||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||
|
||||
Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装,msgs 包已经自动安装。
|
||||
|
||||
#### 使用已安装的 msgs(大多数用户)
|
||||
|
||||
如果你不需要修改 msgs,可以跳过此步骤,直接使用已安装的 msgs 包。验证安装:
|
||||
|
||||
```bash
|
||||
# 列出所有 unilabos_msgs 接口
|
||||
ros2 interface list | grep unilabos_msgs
|
||||
|
||||
# 查看特定 action 定义
|
||||
ros2 interface show unilabos_msgs/action/DeviceCmd
|
||||
```
|
||||
|
||||
#### 自定义 msgs(高级用户)
|
||||
|
||||
如果你需要:
|
||||
|
||||
- 添加新的 ROS2 action 定义
|
||||
- 修改现有 msg/srv/action 接口
|
||||
- 为特定设备定制通信协议
|
||||
|
||||
请参考 **[添加新动作指令(Action)指南](../developer_guide/add_action.md)**,该指南详细介绍了如何:
|
||||
|
||||
- 编写新的 Action 定义
|
||||
- 在线构建 Action(通过 GitHub Actions)
|
||||
- 下载并安装自定义的 msgs 包
|
||||
- 测试和验证新的 Action
|
||||
|
||||
```bash
|
||||
# 安装自定义构建的 msgs 包
|
||||
mamba remove --force ros-humble-unilabos-msgs
|
||||
mamba config set safety_checks disabled # 关闭 md5 检查
|
||||
mamba install /path/to/ros-humble-unilabos-msgs-*.conda --offline
|
||||
```
|
||||
|
||||
### 第五步:验证开发环境
|
||||
|
||||
完成上述步骤后,验证开发环境是否正确配置:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 检查 ROS2 环境
|
||||
ros2 --version
|
||||
|
||||
# 检查 msgs 包
|
||||
ros2 interface list | grep unilabos_msgs
|
||||
|
||||
# 检查 Python 可以导入 unilabos
|
||||
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
|
||||
|
||||
# 检查 unilab 命令
|
||||
unilab --help
|
||||
```
|
||||
|
||||
如果所有命令都正常输出,说明开发环境配置成功!
|
||||
|
||||
---
|
||||
|
||||
## 验证安装
|
||||
|
||||
无论使用哪种安装方式,都应该验证安装是否成功。
|
||||
|
||||
### 基本验证
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab # 或 unilab-dev
|
||||
|
||||
# 检查 unilab 命令
|
||||
unilab --help
|
||||
```
|
||||
|
||||
您应该看到类似以下的输出:
|
||||
|
||||
```
|
||||
usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH]
|
||||
[--working_dir WORKING_DIR] [--backend {ros,simple,automancer}]
|
||||
...
|
||||
```
|
||||
|
||||
### 检查版本
|
||||
|
||||
```bash
|
||||
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
|
||||
```
|
||||
|
||||
### 使用验证脚本(方式一)
|
||||
|
||||
如果使用一键安装,可以运行预打包的验证脚本:
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab
|
||||
|
||||
# 运行验证脚本
|
||||
python verify_installation.py
|
||||
```
|
||||
|
||||
如果看到 "✓ All checks passed!",说明安装成功!
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1: 找不到 unilab 命令
|
||||
|
||||
**原因**: 环境未正确激活或 PATH 未设置
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保激活了正确的环境
|
||||
conda activate unilab
|
||||
|
||||
# 检查 unilab 是否在 PATH 中
|
||||
which unilab # Linux/macOS
|
||||
where unilab # Windows
|
||||
```
|
||||
|
||||
### 问题 2: 包冲突或依赖错误
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 删除旧环境重新创建
|
||||
conda deactivate
|
||||
conda env remove -n unilab
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. **安装开发版 Uni-Lab-OS**
|
||||
### 问题 3: 下载速度慢
|
||||
|
||||
```shell
|
||||
# 配置好conda环境后,克隆仓库
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
|
||||
cd Uni-Lab-OS
|
||||
**解决方案**: 使用国内镜像源(清华、中科大等)
|
||||
|
||||
# 安装 Uni-Lab-OS
|
||||
pip install -e .
|
||||
```bash
|
||||
# 查看当前 channel 配置
|
||||
conda config --show channels
|
||||
|
||||
# 添加清华镜像
|
||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
```
|
||||
|
||||
3. **安装开发版 ros-humble-unilabos-msgs**
|
||||
### 问题 4: 权限错误
|
||||
|
||||
**卸载老版本:**
|
||||
```shell
|
||||
**Windows 解决方案**: 以管理员身份运行命令提示符
|
||||
|
||||
**Linux/macOS 解决方案**:
|
||||
|
||||
```bash
|
||||
# 不要使用 sudo 安装 conda 包
|
||||
# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录
|
||||
```
|
||||
|
||||
### 问题 5: 安装脚本找不到 conda(方式一)
|
||||
|
||||
**解决方案**: 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置:
|
||||
|
||||
- **Windows**:
|
||||
|
||||
- `%USERPROFILE%\miniforge3`
|
||||
- `%USERPROFILE%\miniconda3`
|
||||
- `%USERPROFILE%\anaconda3`
|
||||
- `C:\ProgramData\miniforge3`
|
||||
|
||||
- **macOS/Linux**:
|
||||
- `~/miniforge3`
|
||||
- `~/miniconda3`
|
||||
- `~/anaconda3`
|
||||
- `/opt/conda`
|
||||
|
||||
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
|
||||
|
||||
### 问题 6: 安装后激活环境提示找不到?
|
||||
|
||||
**解决方案**: 尝试以下方法:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用 conda activate
|
||||
conda activate unilab
|
||||
conda remove --force ros-humble-unilabos-msgs
|
||||
```
|
||||
有时相同的安装包版本会由于dev构建得到的md5不一样,触发安全检查,可输入 `config set safety_checks disabled` 来关闭安全检查。
|
||||
|
||||
**安装新版本:**
|
||||
# 方法 2: 使用完整路径激活(Windows)
|
||||
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
|
||||
|
||||
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件)使用如下指令:
|
||||
```shell
|
||||
conda activate base
|
||||
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
|
||||
# 方法 2: 使用完整路径激活(Unix)
|
||||
source ~/miniforge3/envs/unilab/bin/activate
|
||||
```
|
||||
|
||||
4. **启动 Uni-Lab 系统**
|
||||
### 问题 7: conda-unpack 失败怎么办?(方式一)
|
||||
|
||||
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。
|
||||
**解决方案**: 尝试手动运行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
cd %CONDA_PREFIX%\envs\unilab
|
||||
.\Scripts\conda-unpack.exe
|
||||
|
||||
# macOS/Linux
|
||||
cd $CONDA_PREFIX/envs/unilab
|
||||
./bin/conda-unpack
|
||||
```
|
||||
|
||||
### 问题 8: 环境很大,有办法减小吗?
|
||||
|
||||
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||
|
||||
### 问题 9: 如何更新到最新版本?
|
||||
|
||||
**解决方案**:
|
||||
|
||||
**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
|
||||
|
||||
**方式二/三用户**: 在现有环境中更新:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
|
||||
# 更新 unilabos
|
||||
cd /path/to/Uni-Lab-OS
|
||||
git pull
|
||||
pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 更新 ros-humble-unilabos-msgs
|
||||
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
安装完成后,请继续:
|
||||
|
||||
- **快速启动**: 学习如何首次启动 Uni-Lab
|
||||
- **配置指南**: 配置您的实验室环境和设备
|
||||
- **运行示例**: 查看启动示例和最佳实践
|
||||
- **开发指南**:
|
||||
- 添加新设备驱动
|
||||
- 添加新物料资源
|
||||
- 了解工作站架构
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
- **故障排查**: 查看更详细的故障排查信息
|
||||
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
|
||||
---
|
||||
|
||||
**提示**:
|
||||
|
||||
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||
- 开发和测试推荐使用方式三(开发者安装)
|
||||
- 快速体验和演示推荐使用方式一(一键安装)
|
||||
|
||||
@@ -132,15 +132,14 @@ unilab --config path/to/your/config.py
|
||||
|
||||
使用 `-c` 传入控制逻辑配置。
|
||||
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`。
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`,只输入<your-registry-path>即可,支持多次--registry_path指定多个目录。
|
||||
|
||||
## 通信中间件 `--backend`
|
||||
|
||||
目前 Uni-Lab 支持以下通信中间件:
|
||||
|
||||
- **ros** (默认):基于 ROS2 的通信
|
||||
- **simple**:简化通信模式
|
||||
- **automancer**:Automancer 兼容模式
|
||||
- **automancer**:Automancer 兼容模式 (实验性)
|
||||
|
||||
## 端云桥接 `--app_bridges`
|
||||
|
||||
@@ -169,7 +168,7 @@ unilab --config path/to/your/config.py
|
||||
通过 `--visual` 参数选择:
|
||||
|
||||
- **rviz**:使用 RViz 进行 3D 可视化
|
||||
- **web**:使用 Web 界面进行可视化
|
||||
- **web**:使用 Web 界面进行可视化 (基于Pylabrobot)
|
||||
- **disable** (默认):禁用可视化
|
||||
|
||||
## 实验室管理
|
||||
@@ -245,78 +244,3 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
||||
- 检查图谱文件格式是否正确
|
||||
- 验证设备连接和端点配置
|
||||
- 确保注册表路径正确
|
||||
|
||||
## 页面操作
|
||||
|
||||
### 1. 启动成功
|
||||
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
|
||||

|
||||
|
||||
### 2. 根据需求创建设备和物料
|
||||
我们可以做一个简单的案例
|
||||
* 在容器1中加入水
|
||||
* 通过传输泵将容器1中的水转移到容器2中
|
||||
#### 2.1 添加所需的设备和物料
|
||||
仪器设备work_station中的workstation 数量x1
|
||||
仪器设备virtual_device中的virtual_transfer_pump 数量x1
|
||||
物料耗材container中的container 数量x2
|
||||
|
||||
#### 2.2 将设备和物料根据父子关系进行关联
|
||||
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
|
||||
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来,就像真实的设备操作一样
|
||||

|
||||
|
||||
### 3. 创建工作流
|
||||
进入工作流模块 → 点击"我创建的" → 新建工作流
|
||||

|
||||
|
||||
#### 3.1 新增工作流节点
|
||||
我们可以进入指定工作流,在空白处右键
|
||||
* 选择Laboratory→host_node中的creat_resource
|
||||
* 选择Laboratory→workstation中的PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||
#### 3.2 配置节点参数
|
||||
根据案例,工作流包含两个步骤:
|
||||
1. 使用creat_resource在容器中创建水
|
||||
2. 通过泵传输协议将水传输到另一个容器
|
||||
|
||||
我们点击creat_resource卡片上的编辑按钮来配置参数⭐️
|
||||
class_name :container
|
||||
device_id : workstation
|
||||
liquid_input_slot : 0或-1均可
|
||||
liquid_type : water
|
||||
liquid_volume : 根据需求填写即可,默认单位ml,这里举例50
|
||||
parent : workstation
|
||||
res_id : containe
|
||||
关联设备名称(原unilabos_device_id) : 这里就填写host_node
|
||||
**配置完成后点击底部保存按钮**
|
||||
|
||||
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐️
|
||||
event : transfer_liquid
|
||||
from_vessel : water
|
||||
to_vessel : container1
|
||||
volume : 根据需求填写即可,默认单位ml,这里举例50
|
||||
关联设备名称(原unilabos_device_id) : 这里就填写workstation
|
||||
**配置完成后点击底部保存按钮**
|
||||
|
||||
#### 3.3 运行工作流
|
||||
1. 连接两个节点卡片
|
||||
2. 点击底部保存按钮
|
||||
3. 点击运行按钮执行工作流
|
||||
|
||||

|
||||
|
||||
### 运行监控
|
||||
* 运行状态和消息实时显示在底部控制台
|
||||
* 如有报错,可点击查看详细信息
|
||||
|
||||
### 结果验证
|
||||
工作流完成后,返回仪器耗材模块:
|
||||
* 点击 container1卡片查看详情
|
||||
* 确认其中包含参数指定的水和容量
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# Uni-Lab-OS 一键安装快速指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南提供最快速的 Uni-Lab-OS 安装方法,使用预打包的 conda 环境,无需手动配置依赖。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- 已安装 Conda/Miniconda/Miniforge/Mamba
|
||||
- 至少 10GB 可用磁盘空间
|
||||
- Windows 10+, macOS 10.14+, 或 Linux (Ubuntu 20.04+)
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
|
||||
- Windows: `unilab-pack-win-64-{branch}.zip`
|
||||
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
|
||||
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
|
||||
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
|
||||
|
||||
### 第二步:解压并运行安装脚本
|
||||
|
||||
#### Windows
|
||||
|
||||
```batch
|
||||
REM 使用 Windows 资源管理器解压下载的 zip 文件
|
||||
REM 或使用命令行:
|
||||
tar -xzf unilab-pack-win-64-dev.zip
|
||||
|
||||
REM 进入解压后的目录
|
||||
cd unilab-pack-win-64-dev
|
||||
|
||||
REM 双击运行 install_unilab.bat
|
||||
REM 或在命令行中执行:
|
||||
install_unilab.bat
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-osx-arm64-dev
|
||||
|
||||
# 运行安装脚本
|
||||
bash install_unilab.sh
|
||||
```
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-linux-64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-linux-64-dev
|
||||
|
||||
# 添加执行权限(如果需要)
|
||||
chmod +x install_unilab.sh
|
||||
|
||||
# 运行安装脚本
|
||||
./install_unilab.sh
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
### 第四步:验证安装(推荐)
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab
|
||||
|
||||
# 运行验证脚本
|
||||
python verify_installation.py
|
||||
```
|
||||
|
||||
如果看到 "✓ All checks passed!",说明安装成功!
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 安装脚本找不到 conda?
|
||||
|
||||
**A:** 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置:
|
||||
|
||||
- **Windows**:
|
||||
|
||||
- `%USERPROFILE%\miniforge3`
|
||||
- `%USERPROFILE%\miniconda3`
|
||||
- `%USERPROFILE%\anaconda3`
|
||||
- `C:\ProgramData\miniforge3`
|
||||
|
||||
- **macOS/Linux**:
|
||||
- `~/miniforge3`
|
||||
- `~/miniconda3`
|
||||
- `~/anaconda3`
|
||||
- `/opt/conda`
|
||||
|
||||
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
|
||||
|
||||
### Q: 安装后激活环境提示找不到?
|
||||
|
||||
**A:** 尝试以下方法:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用 conda activate
|
||||
conda activate unilab
|
||||
|
||||
# 方法 2: 使用完整路径激活(Windows)
|
||||
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
|
||||
|
||||
# 方法 2: 使用完整路径激活(Unix)
|
||||
source ~/miniforge3/envs/unilab/bin/activate
|
||||
```
|
||||
|
||||
### Q: conda-unpack 失败怎么办?
|
||||
|
||||
**A:** 尝试手动运行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
cd %CONDA_PREFIX%\envs\unilab
|
||||
.\Scripts\conda-unpack.exe
|
||||
|
||||
# macOS/Linux
|
||||
cd $CONDA_PREFIX/envs/unilab
|
||||
./bin/conda-unpack
|
||||
```
|
||||
|
||||
### Q: 验证脚本报错?
|
||||
|
||||
**A:** 首先确认环境已激活:
|
||||
|
||||
```bash
|
||||
# 检查当前环境
|
||||
conda env list
|
||||
|
||||
# 应该看到 unilab 前面有 * 标记
|
||||
```
|
||||
|
||||
如果仍有问题,查看具体报错信息,可能需要:
|
||||
|
||||
- 重新运行安装脚本
|
||||
- 检查磁盘空间
|
||||
- 查看详细文档
|
||||
|
||||
### Q: 环境很大,有办法减小吗?
|
||||
|
||||
**A:** 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用手动安装方式,只安装需要的组件。
|
||||
|
||||
### Q: 如何更新到最新版本?
|
||||
|
||||
**A:** 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
|
||||
|
||||
或者在现有环境中更新:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
|
||||
# 更新 unilabos
|
||||
cd /path/to/Uni-Lab-OS
|
||||
git pull
|
||||
pip install -e . --upgrade
|
||||
|
||||
# 更新 ros-humble-unilabos-msgs
|
||||
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
安装完成后,你可以:
|
||||
|
||||
1. **查看启动指南**: {doc}`launch`
|
||||
2. **运行示例**: {doc}`../boot_examples/index`
|
||||
3. **配置设备**: 编辑 `unilabos_data/startup_config.json`
|
||||
4. **阅读开发文档**: {doc}`../developer_guide/workstation_architecture`
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
- **文档**: [docs/user_guide/installation.md](installation.md)
|
||||
- **问题反馈**: [GitHub Issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **开发版安装**: 参考 {doc}`installation` 的方式二
|
||||
|
||||
---
|
||||
|
||||
**提示**: 这个预打包环境包含了从指定分支(通常是 `dev`)构建的最新代码。如果需要稳定版本,请使用方式二手动安装 release 版本。
|
||||
54
new_cellconfig.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"parent": null,
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
"type": "device",
|
||||
"class":"coincellassemblyworkstation_device",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_YH_Deck",
|
||||
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
|
||||
}
|
||||
},
|
||||
"debug_mode": true,
|
||||
"protocol_type": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "YB_YH_Deck",
|
||||
"name": "YB_YH_Deck",
|
||||
"children": [],
|
||||
"parent": "BatteryStation",
|
||||
"type": "deck",
|
||||
"class": "CoincellDeck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "CoincellDeck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
101
new_cellconfig3c.json
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"name": "配液分液工站",
|
||||
"parent": null,
|
||||
"children": [
|
||||
"YB_Bioyond_Deck"
|
||||
],
|
||||
"type": "device",
|
||||
"class": "bioyond_cell",
|
||||
"config": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
||||
}
|
||||
},
|
||||
"protocol_type": []
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "YB_Bioyond_Deck",
|
||||
"name": "YB_Bioyond_Deck",
|
||||
"children": [],
|
||||
"parent": "bioyond_cell_workstation",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_YB_Deck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "BIOYOND_YB_Deck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"parent": null,
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
"type": "device",
|
||||
"class":"coincellassemblyworkstation_device",
|
||||
"config": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_YH_Deck",
|
||||
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
|
||||
}
|
||||
},
|
||||
"protocol_type": []
|
||||
},
|
||||
"position": {
|
||||
"size": {"height": 1450, "width": 1450, "depth": 2100},
|
||||
"position": {
|
||||
"x": -1500,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "YB_YH_Deck",
|
||||
"name": "YB_YH_Deck",
|
||||
"children": [],
|
||||
"parent": "BatteryStation",
|
||||
"type": "deck",
|
||||
"class": "CoincellDeck",
|
||||
"config": {
|
||||
"type": "CoincellDeck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.10
|
||||
version: 0.10.12
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.10"
|
||||
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
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.10',
|
||||
version='0.10.12',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
52
test/resources/YB_materials_info.json
Normal file
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "3a1d377b-299d-d0f2-ced9-48257f60dfad",
|
||||
"typeName": "加样头(大)",
|
||||
"code": "0005-00145",
|
||||
"barCode": "",
|
||||
"name": "LiDFOB",
|
||||
"quantity": 9999.0,
|
||||
"lockQuantity": 0.0,
|
||||
"unit": "个",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a19da56-1379-ff7c-1745-07e200b44ce2",
|
||||
"whid": "3a19da56-1378-613b-29f2-871e1a287aa5",
|
||||
"whName": "粉末加样头堆栈",
|
||||
"code": "0005-0001",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"id": "3a1d377b-6a81-6a7e-147c-f89f6463656d",
|
||||
"typeName": "液",
|
||||
"code": "0006-00141",
|
||||
"barCode": "",
|
||||
"name": "EMC",
|
||||
"quantity": 99999.0,
|
||||
"lockQuantity": 0.0,
|
||||
"unit": "g",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a1baa20-a7b1-c665-8b9c-d8099d07d2f6",
|
||||
"whid": "3a1baa20-a7b0-5c19-8844-5de8924d4e78",
|
||||
"whName": "4号手套箱内部堆栈",
|
||||
"code": "0015-0001",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
}
|
||||
]
|
||||
0
test/resources/__init__.py
Normal file
99
test/resources/test copy.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"typeId": "3a190c8b-3284-af78-d29f-9a69463ad047",
|
||||
"code": "",
|
||||
"barCode": "",
|
||||
"name": "test",
|
||||
"unit": "",
|
||||
"parameters": "{}",
|
||||
"quantity": "",
|
||||
"details": [
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)11",
|
||||
"quantity": "1",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)21",
|
||||
"quantity": "1",
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)12",
|
||||
"quantity": "1",
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)22",
|
||||
"quantity": "1",
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)13",
|
||||
"quantity": "1",
|
||||
"x": 1,
|
||||
"y": 3,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)23",
|
||||
"quantity": "1",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)14",
|
||||
"quantity": "1",
|
||||
"x": 1,
|
||||
"y": 4,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
|
||||
"code": "",
|
||||
"name": "配液瓶(小)24",
|
||||
"quantity": "1",
|
||||
"x": 2,
|
||||
"y": 4,
|
||||
"z": 1,
|
||||
"unit": "",
|
||||
"parameters": "{}"
|
||||
}
|
||||
]
|
||||
}
|
||||
148
test/resources/test.json
Normal file
@@ -0,0 +1,148 @@
|
||||
[
|
||||
{
|
||||
"id": "3a1d4c14-a9fb-d7dc-9e96-7a3ad6e50219",
|
||||
"typeName": "配液瓶(小)板",
|
||||
"code": "0001-00093",
|
||||
"barCode": "",
|
||||
"name": "test",
|
||||
"quantity": 2.0,
|
||||
"lockQuantity": 0.0,
|
||||
"unit": "块",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
"whid": "3a19deae-2c79-05a3-9c76-8e6760424841",
|
||||
"whName": "手动堆栈",
|
||||
"code": "1",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": [
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-1daa-71fa-146cb1ccb930",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-4f38-4c48-68486c391c42",
|
||||
"code": "0001-00093 - 05",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 1,
|
||||
"y": 3,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-3659-ea61-cd587da9e131",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-018f-93e5-c49343d37758",
|
||||
"code": "0001-00093 - 08",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 2,
|
||||
"y": 4,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-3f94-de83-979d2646e313",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-9987-c0ef-4b7cbad49e6b",
|
||||
"code": "0001-00093 - 01",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-8c35-6b25-913b11dbaf4e",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-9a83-865b-0c26ea5e8cc4",
|
||||
"code": "0001-00093 - 03",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-b41f-e968-64953bfddccd",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-daf7-9d64-e5ec8d3ae0e2",
|
||||
"code": "0001-00093 - 07",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 1,
|
||||
"y": 4,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-c20f-c26e-b1bb2cdc3bca",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-673b-ac83-aaaf71287f1f",
|
||||
"code": "0001-00093 - 06",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-cf21-059c-fde361d82b6f",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-25b1-e736-6b0d8dac0fae",
|
||||
"code": "0001-00093 - 02",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
},
|
||||
{
|
||||
"id": "3a1d4c14-a9fc-d732-2b93-9b2bd2bf581b",
|
||||
"detailMaterialId": "3a1d4c14-a9fc-7f5d-b6b6-8bcb2e15f320",
|
||||
"code": "0001-00093 - 04",
|
||||
"name": "配液瓶(小)",
|
||||
"quantity": "1",
|
||||
"lockQuantity": "0",
|
||||
"unit": "个",
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"associateId": null,
|
||||
"typeName": "配液瓶(小)",
|
||||
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
|
||||
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
|
||||
from unilabos.resources.bioyond.bottles import YB_Solid_Vial, YB_Solution_Beaker, YB_Reagent_Bottle
|
||||
|
||||
|
||||
def test_bottle_carrier() -> "BottleCarrier":
|
||||
@@ -16,9 +16,9 @@ def test_bottle_carrier() -> "BottleCarrier":
|
||||
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
|
||||
|
||||
# 创建瓶子和烧杯
|
||||
powder_bottle = BIOYOND_PolymerStation_Solid_Vial("powder_bottle_01")
|
||||
solution_beaker = BIOYOND_PolymerStation_Solution_Beaker("solution_beaker_01")
|
||||
reagent_bottle = BIOYOND_PolymerStation_Reagent_Bottle("reagent_bottle_01")
|
||||
powder_bottle = YB_Solid_Vial("powder_bottle_01")
|
||||
solution_beaker = YB_Solution_Beaker("solution_beaker_01")
|
||||
reagent_bottle = YB_Reagent_Bottle("reagent_bottle_01")
|
||||
|
||||
print(f"\n创建的物料:")
|
||||
print(f"粉末瓶: {powder_bottle.name} - {powder_bottle.diameter}mm x {powder_bottle.height}mm, {powder_bottle.max_volume}μL")
|
||||
|
||||
@@ -12,13 +12,13 @@ lab_registry.setup()
|
||||
|
||||
|
||||
type_mapping = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
"烧杯": ("YB_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("YB_1BottleCarrier", ""),
|
||||
"样品板": ("YB_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("YB_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("YB_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("YB_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("YB_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ast import If
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
@@ -8,18 +9,16 @@ from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
from unilabos.resources.bioyond.decks import YB_Deck
|
||||
|
||||
lab_registry.setup()
|
||||
|
||||
|
||||
type_mapping = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
"加样头(大)": ("YB_jia_yang_tou_da", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
"液": ("YB_1BottleCarrier", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
|
||||
"配液瓶(小)板": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"),
|
||||
"配液瓶(小)": ("YB_pei_ye_xiao_Bottler", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"),
|
||||
}
|
||||
|
||||
|
||||
@@ -57,12 +56,20 @@ def bioyond_materials_liquidhandling_2() -> list[dict]:
|
||||
"bioyond_materials_reaction",
|
||||
"bioyond_materials_liquidhandling_1",
|
||||
])
|
||||
def test_resourcetreeset_from_plr(materials_fixture, request) -> list[dict]:
|
||||
materials = request.getfixturevalue(materials_fixture)
|
||||
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
|
||||
def test_resourcetreeset_from_plr() -> list[dict]:
|
||||
# 直接加载 bioyond_materials_reaction.json 文件
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
json_path = os.path.join(current_dir, "test.json")
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
materials = json.load(f)
|
||||
deck = YB_Deck("test_deck")
|
||||
output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
|
||||
print(deck.summary())
|
||||
print(output)
|
||||
# print(deck.summary())
|
||||
|
||||
r = ResourceTreeSet.from_plr_resources([deck])
|
||||
print(r.dump())
|
||||
# json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_resourcetreeset_from_plr()
|
||||
|
||||
0
test/ros/__init__.py
Normal file
0
test/workflow/__init__.py
Normal file
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -9,86 +8,28 @@ if str(ROOT_DIR) not in sys.path:
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.workflow import build_protocol_graph, draw_protocol_graph, draw_protocol_graph_with_ports
|
||||
from unilabos.workflow.convert_from_json import (
|
||||
convert_from_json,
|
||||
normalize_steps as _normalize_steps,
|
||||
normalize_labware as _normalize_labware,
|
||||
)
|
||||
from unilabos.workflow.common import draw_protocol_graph_with_ports
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _normalize_steps(data):
|
||||
normalized = []
|
||||
for step in data:
|
||||
action = step.get("action") or step.get("operation")
|
||||
if not action:
|
||||
continue
|
||||
raw_params = step.get("parameters") or step.get("action_args") or {}
|
||||
params = dict(raw_params)
|
||||
|
||||
if "source" in raw_params and "sources" not in raw_params:
|
||||
params["sources"] = raw_params["source"]
|
||||
if "target" in raw_params and "targets" not in raw_params:
|
||||
params["targets"] = raw_params["target"]
|
||||
|
||||
description = step.get("description") or step.get("purpose")
|
||||
step_dict = {"action": action, "parameters": params}
|
||||
if description:
|
||||
step_dict["description"] = description
|
||||
normalized.append(step_dict)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_labware(data):
|
||||
labware = {}
|
||||
for item in data:
|
||||
reagent_name = item.get("reagent_name")
|
||||
key = reagent_name or item.get("material_name") or item.get("name")
|
||||
if not key:
|
||||
continue
|
||||
key = str(key)
|
||||
idx = 1
|
||||
original_key = key
|
||||
while key in labware:
|
||||
idx += 1
|
||||
key = f"{original_key}_{idx}"
|
||||
|
||||
labware[key] = {
|
||||
"slot": item.get("positions") or item.get("slot"),
|
||||
"labware": item.get("material_name") or item.get("labware"),
|
||||
"well": item.get("well", []),
|
||||
"type": item.get("type", "reagent"),
|
||||
"role": item.get("role", ""),
|
||||
"name": key,
|
||||
}
|
||||
return labware
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol_name", [
|
||||
"example_bio",
|
||||
# "bioyond_materials_liquidhandling_1",
|
||||
"example_prcxi",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"protocol_name",
|
||||
[
|
||||
"example_bio",
|
||||
# "bioyond_materials_liquidhandling_1",
|
||||
"example_prcxi",
|
||||
],
|
||||
)
|
||||
def test_build_protocol_graph(protocol_name):
|
||||
data_path = Path(__file__).with_name(f"{protocol_name}.json")
|
||||
with data_path.open("r", encoding="utf-8") as fp:
|
||||
d = json.load(fp)
|
||||
|
||||
if "workflow" in d and "reagent" in d:
|
||||
protocol_steps = d["workflow"]
|
||||
labware_info = d["reagent"]
|
||||
elif "steps_info" in d and "labware_info" in d:
|
||||
protocol_steps = _normalize_steps(d["steps_info"])
|
||||
labware_info = _normalize_labware(d["labware_info"])
|
||||
else:
|
||||
raise ValueError("Unsupported protocol format")
|
||||
graph = convert_from_json(data_path, workstation_name="PRCXi")
|
||||
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name="PRCXi",
|
||||
)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
|
||||
draw_protocol_graph_with_ports(graph, str(output_path))
|
||||
print(graph)
|
||||
print(graph)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.10"
|
||||
__version__ = "0.10.12"
|
||||
|
||||
@@ -141,7 +141,7 @@ class CommunicationClientFactory:
|
||||
"""
|
||||
if cls._client_cache is None:
|
||||
cls._client_cache = cls.create_client(protocol)
|
||||
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
|
||||
return cls._client_cache
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -41,7 +42,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
for i, arg in enumerate(sys.argv):
|
||||
for option_string in option_strings:
|
||||
if arg.startswith(option_string):
|
||||
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
|
||||
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
|
||||
sys.argv[i] = new_arg
|
||||
break
|
||||
|
||||
@@ -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(
|
||||
@@ -105,7 +108,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8002,
|
||||
default=None,
|
||||
help="Port for web service information page",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -153,21 +156,54 @@ 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
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
args = parse_args()
|
||||
convert_argv_dashes_to_underscores(args)
|
||||
args_dict = vars(args.parse_args())
|
||||
parser = parse_args()
|
||||
convert_argv_dashes_to_underscores(parser)
|
||||
args = parser.parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
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)
|
||||
@@ -218,19 +254,20 @@ def main():
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
configure_logger(loglevel=BasicConfig.log_level)
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
|
||||
if args_dict["addr"] == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args.addr == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args.addr == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args.addr
|
||||
|
||||
# 设置BasicConfig参数
|
||||
if args_dict.get("ak", ""):
|
||||
@@ -239,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
|
||||
|
||||
@@ -252,7 +292,8 @@ def main():
|
||||
else:
|
||||
print_status("远程资源不存在,本地将进行首次上报!", "info")
|
||||
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
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)
|
||||
@@ -281,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)
|
||||
@@ -291,7 +354,9 @@ def main():
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
request_startup_json = http_client.request_startup_json()
|
||||
if args_dict["graph"] is None:
|
||||
|
||||
file_path = args_dict.get("graph", BasicConfig.startup_json_path)
|
||||
if file_path is None:
|
||||
if not request_startup_json:
|
||||
print_status(
|
||||
"未指定设备加载文件路径,尝试从HTTP获取失败,请检查网络或者使用-g参数指定设备加载文件路径", "error"
|
||||
@@ -301,7 +366,38 @@ def main():
|
||||
print_status("联网获取设备加载文件成功", "info")
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
|
||||
else:
|
||||
file_path = args_dict["graph"]
|
||||
if not os.path.isfile(file_path):
|
||||
# 尝试从 main.py 向上两级目录查找
|
||||
temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path)))
|
||||
if os.path.isfile(temp_file_path):
|
||||
print_status(f"使用相对路径{temp_file_path}", "info")
|
||||
file_path = temp_file_path
|
||||
else:
|
||||
# 尝试在 working_dir 中查找
|
||||
working_dir_file_path = os.path.join(working_dir, file_path)
|
||||
if os.path.isfile(working_dir_file_path):
|
||||
print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info")
|
||||
file_path = working_dir_file_path
|
||||
else:
|
||||
# 尝试使用文件名在 working_dir 中查找
|
||||
file_name = os.path.basename(file_path)
|
||||
working_dir_file_path = os.path.join(working_dir, file_name)
|
||||
if os.path.isfile(working_dir_file_path):
|
||||
print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info")
|
||||
file_path = working_dir_file_path
|
||||
# 最终检查文件是否存在
|
||||
if not os.path.isfile(file_path):
|
||||
print_status(
|
||||
f"无法找到设备加载文件: {file_path}\n"
|
||||
f"已尝试在以下位置查找:\n"
|
||||
f" 1. 原始路径: {args_dict.get('graph', BasicConfig.startup_json_path)}\n"
|
||||
f" 2. 相对路径: {os.path.abspath(str(os.path.join(__file__, '..', '..', args_dict.get('graph', BasicConfig.startup_json_path) or '')))}\n"
|
||||
f" 3. 工作目录: {os.path.join(working_dir, args_dict.get('graph', BasicConfig.startup_json_path) or '')}\n"
|
||||
f" 4. 工作目录(仅文件名): {os.path.join(working_dir, os.path.basename(args_dict.get('graph', BasicConfig.startup_json_path) or ''))}\n"
|
||||
f"请使用 -g 参数指定正确的文件路径,或在工作目录 {working_dir} 中放置文件",
|
||||
"error"
|
||||
)
|
||||
os._exit(1)
|
||||
if file_path.endswith(".json"):
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
|
||||
else:
|
||||
@@ -354,20 +450,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:
|
||||
@@ -382,6 +464,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)
|
||||
@@ -413,8 +496,8 @@ def main():
|
||||
server_thread = threading.Thread(
|
||||
target=start_server,
|
||||
kwargs=dict(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
open_browser=not BasicConfig.disable_browser,
|
||||
port=BasicConfig.port,
|
||||
),
|
||||
)
|
||||
server_thread.start()
|
||||
@@ -423,16 +506,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
|
||||
@@ -442,13 +522,13 @@ def main():
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,21 +51,25 @@ class Resp(BaseModel):
|
||||
class JobAddReq(BaseModel):
|
||||
device_id: str = Field(examples=["Gripper"], description="device id")
|
||||
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
|
||||
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
|
||||
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid")
|
||||
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid", default="")
|
||||
server_info: dict = Field(
|
||||
examples=[{"send_timestamp": 1717000000.0}],
|
||||
description="server info (auto-generated if empty)",
|
||||
default_factory=dict,
|
||||
)
|
||||
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict)
|
||||
|
||||
|
||||
class JobStepFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -83,9 +87,7 @@ class JobStepFinishReq(BaseModel):
|
||||
|
||||
class JobPreintakeFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -102,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel):
|
||||
|
||||
class JobFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -133,6 +133,10 @@ class JobData(BaseModel):
|
||||
default=0,
|
||||
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
|
||||
)
|
||||
result: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)",
|
||||
)
|
||||
|
||||
|
||||
class JobStatusResp(Resp):
|
||||
|
||||
@@ -1,161 +1,158 @@
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
from unilabos.config.config import OSSUploadConfig
|
||||
from unilabos.app.web.client import http_client, HTTPClient
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default",
|
||||
expires_hours: int = 1) -> Tuple[bool, Dict]:
|
||||
def _get_oss_token(
|
||||
filename: str,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Tuple[bool, Dict]:
|
||||
"""
|
||||
初始化上传过程
|
||||
获取OSS上传Token
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
expires_hours: 链接过期小时数
|
||||
filename: 文件名
|
||||
driver_name: 驱动名称
|
||||
exp_type: 实验类型
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
(成功标志, 响应数据)
|
||||
(成功标志, Token数据字典包含token/path/host/expires)
|
||||
"""
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
# 使用提供的client或默认的http_client
|
||||
if client is None:
|
||||
client = http_client
|
||||
|
||||
# 构造初始化请求
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 构造scene参数: driver_name-exp_type
|
||||
sub_path = f"{driver_name}-{exp_type}"
|
||||
|
||||
payload = {
|
||||
"device_id": device_id,
|
||||
"process_key": process_key,
|
||||
"filename": filename,
|
||||
"path": oss_path,
|
||||
"expires_hours": expires_hours
|
||||
}
|
||||
# 构造请求URL,使用client的remote_addr(已包含/api/v1/)
|
||||
url = f"{client.remote_addr}/applications/token"
|
||||
params = {"sub_path": sub_path, "filename": filename, "scene": "job"}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True, result.get("data", {})
|
||||
logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}")
|
||||
response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10)
|
||||
|
||||
print(f"初始化上传失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
data = result.get("data", {})
|
||||
|
||||
# 转换expires时间戳为可读格式
|
||||
expires_timestamp = data.get("expires", 0)
|
||||
expires_datetime = datetime.fromtimestamp(expires_timestamp)
|
||||
expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
logger.info(f"[OSS] 获取预签名URL成功")
|
||||
logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}")
|
||||
logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})")
|
||||
|
||||
return True, data
|
||||
|
||||
logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}")
|
||||
return False, {}
|
||||
except Exception as e:
|
||||
print(f"初始化上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 获取预签名URL异常: {str(e)}")
|
||||
return False, {}
|
||||
|
||||
|
||||
def _put_upload(file_path: str, upload_url: str) -> bool:
|
||||
"""
|
||||
执行PUT上传
|
||||
使用预签名URL上传文件到OSS
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
upload_url: 上传URL
|
||||
upload_url: 完整的预签名上传URL
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[OSS] 开始上传文件: {file_path}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
response = requests.put(upload_url, data=f)
|
||||
# 使用预签名URL上传,不需要额外的认证header
|
||||
response = requests.put(upload_url, data=f, timeout=300)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[OSS] 文件上传成功")
|
||||
return True
|
||||
|
||||
print(f"PUT上传失败: {response.status_code}, {response.text}")
|
||||
logger.error(f"[OSS] 上传失败: {response.status_code}")
|
||||
logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"PUT上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _complete_upload(uuid: str) -> bool:
|
||||
"""
|
||||
完成上传过程
|
||||
|
||||
Args:
|
||||
uuid: 上传的UUID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"uuid": uuid
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True
|
||||
|
||||
print(f"完成上传失败: {response.status_code}, {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"完成上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default") -> bool:
|
||||
def oss_upload(
|
||||
file_path: Union[str, Path],
|
||||
filename: Optional[str] = None,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
max_retries: int = 3,
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
文件上传主函数,包含重试机制
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
driver_name: 驱动名称,用于构造scene
|
||||
exp_type: 实验类型,用于构造scene
|
||||
max_retries: 最大重试次数
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
是否成功上传
|
||||
Dict: {
|
||||
"success": bool, # 是否上传成功
|
||||
"original_path": str, # 原始文件路径
|
||||
"oss_path": str # OSS路径(成功时)或空字符串(失败时)
|
||||
}
|
||||
"""
|
||||
max_retries = OSSUploadConfig.max_retries
|
||||
file_path = Path(file_path)
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"[OSS] 文件不存在: {file_path}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": ""}
|
||||
|
||||
retry_count = 0
|
||||
oss_path = ""
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# 步骤1:初始化上传
|
||||
init_success, init_data = _init_upload(
|
||||
file_path=file_path,
|
||||
oss_path=oss_path,
|
||||
filename=filename,
|
||||
process_key=process_key,
|
||||
device_id=device_id
|
||||
# 步骤1:获取预签名URL
|
||||
token_success, token_data = _get_oss_token(
|
||||
filename=filename, driver_name=driver_name, exp_type=exp_type, client=client
|
||||
)
|
||||
|
||||
if not init_success:
|
||||
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
if not token_success:
|
||||
logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1) # 等待1秒后重试
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 获取UUID和上传URL
|
||||
uuid = init_data.get("uuid")
|
||||
upload_url = init_data.get("upload_url")
|
||||
# 获取预签名URL和OSS路径
|
||||
upload_url = token_data.get("url")
|
||||
oss_path = token_data.get("path", "")
|
||||
|
||||
if not uuid or not upload_url:
|
||||
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
|
||||
if not upload_url:
|
||||
logger.warning(f"[OSS] 无法获取上传URL,API未返回url字段")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
@@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
# 步骤2:PUT上传文件
|
||||
put_success = _put_upload(file_path, upload_url)
|
||||
if not put_success:
|
||||
print(f"PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 步骤3:完成上传
|
||||
complete_success = _complete_upload(uuid)
|
||||
if not complete_success:
|
||||
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
logger.warning(f"[OSS] PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 所有步骤都成功
|
||||
print(f"文件 {file_path} 上传成功")
|
||||
return True
|
||||
logger.info(f"[OSS] 文件 {file_path} 上传成功")
|
||||
return {"success": True, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
except Exception as e:
|
||||
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
|
||||
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return False
|
||||
logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \
|
||||
# --ak xxx --sk yyy --remote-addr http://xxx/api/v1
|
||||
# 命令行参数解析
|
||||
parser = argparse.ArgumentParser(description='文件上传测试工具')
|
||||
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
|
||||
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
|
||||
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
|
||||
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
|
||||
parser = argparse.ArgumentParser(description="文件上传测试工具")
|
||||
parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径")
|
||||
parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称")
|
||||
parser.add_argument("--type", "-t", type=str, default="default", help="实验类型")
|
||||
parser.add_argument("--ak", type=str, help="Access Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--sk", type=str, help="Secret Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1),如果提供则覆盖配置")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(args.file):
|
||||
print(f"错误:文件 {args.file} 不存在")
|
||||
logger.error(f"错误:文件 {args.file} 不存在")
|
||||
exit(1)
|
||||
|
||||
print("=" * 50)
|
||||
print(f"开始上传文件: {args.file}")
|
||||
print(f"目标路径: {args.path}")
|
||||
print(f"设备ID: {args.device}")
|
||||
print(f"处理键: {args.process}")
|
||||
print("=" * 50)
|
||||
# 如果提供了ak/sk/remote_addr,创建临时HTTPClient
|
||||
temp_client = None
|
||||
if args.ak and args.sk:
|
||||
import base64
|
||||
|
||||
auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8")
|
||||
remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr
|
||||
temp_client = HTTPClient(remote_addr=remote_addr, auth=auth)
|
||||
logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}")
|
||||
elif args.remote_addr:
|
||||
temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth)
|
||||
logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}")
|
||||
else:
|
||||
logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}")
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"开始上传文件: {args.file}")
|
||||
logger.info(f"驱动名称: {args.driver}")
|
||||
logger.info(f"实验类型: {args.type}")
|
||||
logger.info(f"Scene: {args.driver}-{args.type}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 执行上传
|
||||
success = oss_upload(
|
||||
result = oss_upload(
|
||||
file_path=args.file,
|
||||
oss_path=args.path,
|
||||
filename=None, # 使用默认文件名
|
||||
process_key=args.process,
|
||||
device_id=args.device
|
||||
driver_name=args.driver,
|
||||
exp_type=args.type,
|
||||
client=temp_client,
|
||||
)
|
||||
|
||||
# 输出结果
|
||||
if success:
|
||||
print("\n√ 文件上传成功!")
|
||||
if result["success"]:
|
||||
logger.info(f"\n√ 文件上传成功!")
|
||||
logger.info(f"原始路径: {result['original_path']}")
|
||||
logger.info(f"OSS路径: {result['oss_path']}")
|
||||
exit(0)
|
||||
else:
|
||||
print("\n× 文件上传失败!")
|
||||
logger.error(f"\n× 文件上传失败!")
|
||||
logger.error(f"原始路径: {result['original_path']}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from unilabos.app.web.controler import devices, job_add, job_info
|
||||
from unilabos.app.web.controller import (
|
||||
devices,
|
||||
job_add,
|
||||
job_info,
|
||||
get_online_devices,
|
||||
get_device_actions,
|
||||
get_action_schema,
|
||||
get_all_available_actions,
|
||||
)
|
||||
from unilabos.app.model import (
|
||||
Resp,
|
||||
RespCode,
|
||||
JobStatusResp,
|
||||
JobAddResp,
|
||||
JobAddReq,
|
||||
JobData,
|
||||
)
|
||||
from unilabos.app.web.utils.host_utils import get_host_node_info
|
||||
from unilabos.registry.registry import lab_registry
|
||||
@@ -1234,6 +1243,65 @@ def get_devices():
|
||||
return Resp(data=dict(data))
|
||||
|
||||
|
||||
@api.get("/online-devices", summary="Online devices list", response_model=Resp)
|
||||
def api_get_online_devices():
|
||||
"""获取在线设备列表
|
||||
|
||||
返回当前在线的设备列表,包含设备ID、命名空间、机器名等信息
|
||||
"""
|
||||
isok, data = get_online_devices()
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/devices/{device_id}/actions", summary="Device actions list", response_model=Resp)
|
||||
def api_get_device_actions(device_id: str):
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
返回指定设备的所有可用动作,包含动作名称、类型、是否繁忙等信息
|
||||
"""
|
||||
isok, data = get_device_actions(device_id)
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/devices/{device_id}/actions/{action_name}/schema", summary="Action schema", response_model=Resp)
|
||||
def api_get_action_schema(device_id: str, action_name: str):
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
返回动作的参数Schema、默认值、类型等详细信息
|
||||
"""
|
||||
isok, data = get_action_schema(device_id, action_name)
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/actions", summary="All available actions", response_model=Resp)
|
||||
def api_get_all_actions():
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
返回所有已注册设备的动作列表,包含设备信息和各动作的状态
|
||||
"""
|
||||
isok, data = get_all_available_actions()
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp)
|
||||
def job_status(id: str):
|
||||
"""获取任务状态"""
|
||||
@@ -1244,11 +1312,22 @@ def job_status(id: str):
|
||||
@api.post("/job/add", summary="Create job", response_model=JobAddResp)
|
||||
def post_job_add(req: JobAddReq):
|
||||
"""创建任务"""
|
||||
device_id = req.device_id
|
||||
if not req.data:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data")
|
||||
# 检查必要参数:device_id 和 action
|
||||
if not req.device_id:
|
||||
return JobAddResp(
|
||||
data=JobData(jobId="", status=6),
|
||||
code=RespCode.ErrorInvalidReq,
|
||||
message="device_id is required",
|
||||
)
|
||||
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
if not action_name:
|
||||
return JobAddResp(
|
||||
data=JobData(jobId="", status=6),
|
||||
code=RespCode.ErrorInvalidReq,
|
||||
message="action is required",
|
||||
)
|
||||
|
||||
req.device_id = device_id
|
||||
data = job_add(req)
|
||||
return JobAddResp(data=data)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -331,6 +332,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()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
|
||||
import json
|
||||
import traceback
|
||||
import uuid
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
|
||||
|
||||
def get_resources() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().resources_config
|
||||
|
||||
def devices() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().devices_config
|
||||
|
||||
def job_info(id: str):
|
||||
get_goal_status = HostNode.get_instance().get_goal_status(id)
|
||||
return JobData(jobId=id, status=get_goal_status)
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
if req.job_id is None:
|
||||
req.job_id = str(uuid.uuid4())
|
||||
action_name = req.data["action"]
|
||||
action_type = req.data.get("action_type", "LocalUnknown")
|
||||
action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除
|
||||
if action_args is None:
|
||||
action_args = req.data.get("action_args")
|
||||
else:
|
||||
if "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
# print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
|
||||
try:
|
||||
HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info)
|
||||
except Exception as e:
|
||||
for bridge in HostNode.get_instance().bridges:
|
||||
traceback.print_exc()
|
||||
if hasattr(bridge, "publish_job_status"):
|
||||
bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {}))
|
||||
return JobData(jobId=req.job_id)
|
||||
587
unilabos/app/web/controller.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
Web API Controller
|
||||
|
||||
提供Web API的控制器函数,处理设备、任务和动作相关的业务逻辑
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
"""任务结果数据"""
|
||||
|
||||
job_id: str
|
||||
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
|
||||
result: Dict[str, Any] = field(default_factory=dict)
|
||||
feedback: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class JobResultStore:
|
||||
"""任务结果存储(单例)"""
|
||||
|
||||
_instance: Optional["JobResultStore"] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, "_initialized"):
|
||||
self._results: Dict[str, JobResult] = {}
|
||||
self._results_lock = threading.RLock()
|
||||
self._initialized = True
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def store_result(
|
||||
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果"""
|
||||
with self._results_lock:
|
||||
self._results[job_id] = JobResult(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
result=result or {},
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
"""仅获取任务结果(不删除)"""
|
||||
with self._results_lock:
|
||||
return self._results.get(job_id)
|
||||
|
||||
def cleanup_old_results(self, max_age_seconds: float = 3600):
|
||||
"""清理过期的结果"""
|
||||
current_time = time.time()
|
||||
with self._results_lock:
|
||||
expired_jobs = [
|
||||
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
|
||||
]
|
||||
for job_id in expired_jobs:
|
||||
del self._results[job_id]
|
||||
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
|
||||
|
||||
|
||||
# 全局结果存储实例
|
||||
job_result_store = JobResultStore()
|
||||
|
||||
|
||||
def store_job_result(
|
||||
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果(供外部调用)
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
status: 状态字符串 ("success", "failed", "cancelled")
|
||||
result: 结果数据
|
||||
feedback: 反馈数据
|
||||
"""
|
||||
# 转换状态字符串为整数
|
||||
status_map = {
|
||||
"success": 4, # SUCCEEDED
|
||||
"failed": 6, # ABORTED
|
||||
"cancelled": 5, # CANCELED
|
||||
"running": 2, # EXECUTING
|
||||
}
|
||||
status_int = status_map.get(status, 0)
|
||||
|
||||
# 只存储最终状态
|
||||
if status_int in (4, 5, 6):
|
||||
job_result_store.store_result(job_id, status_int, result, feedback)
|
||||
|
||||
|
||||
def get_resources() -> Tuple[bool, Any]:
|
||||
"""获取资源配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.resources_config
|
||||
|
||||
|
||||
def devices() -> Tuple[bool, Any]:
|
||||
"""获取设备配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.devices_config
|
||||
|
||||
|
||||
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
|
||||
"""获取任务信息
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
remove_after_read: 是否在读取后删除结果(默认True)
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据
|
||||
"""
|
||||
# 首先检查结果存储中是否有已完成的结果
|
||||
if remove_after_read:
|
||||
stored_result = job_result_store.get_and_remove(job_id)
|
||||
else:
|
||||
stored_result = job_result_store.get_result(job_id)
|
||||
|
||||
if stored_result:
|
||||
# 有存储的结果,直接返回
|
||||
return JobData(
|
||||
jobId=job_id,
|
||||
status=stored_result.status,
|
||||
result=stored_result.result,
|
||||
)
|
||||
|
||||
# 没有存储的结果,从 HostNode 获取当前状态
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return JobData(jobId=job_id, status=0)
|
||||
|
||||
get_goal_status = host_node.get_goal_status(job_id)
|
||||
return JobData(jobId=job_id, status=get_goal_status)
|
||||
|
||||
|
||||
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
|
||||
"""检查设备动作是否正在执行(被占用)
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, None
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查 _device_action_status 中是否有正在执行的任务
|
||||
if device_action_key in host_node._device_action_status:
|
||||
status = host_node._device_action_status[device_action_key]
|
||||
if status.job_ids:
|
||||
# 返回第一个正在执行的job_id
|
||||
current_job_id = next(iter(status.job_ids.keys()), None)
|
||||
return True, current_job_id
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
|
||||
"""从注册表自动获取动作类型
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
动作类型字符串,未找到返回None
|
||||
"""
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
# 方法1: 从运行时注册设备获取
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
# 转换为字符串格式
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
# 方法2: 从lab_registry获取
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node and lab_registry:
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
|
||||
|
||||
Args:
|
||||
req: 任务添加请求
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据(包含状态)
|
||||
"""
|
||||
# 服务端自动生成 job_id 和 task_id
|
||||
job_id = str(uuid.uuid4())
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 服务端自动生成 server_info
|
||||
server_info = {"send_timestamp": time.time()}
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
|
||||
return JobData(jobId=job_id, status=6) # 6 = ABORTED
|
||||
|
||||
# 解析动作信息
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
|
||||
|
||||
if action_args is None:
|
||||
action_args = req.action_args or {}
|
||||
elif isinstance(action_args, dict) and "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
|
||||
# 自动获取 action_type
|
||||
action_type = _get_action_type(req.device_id, action_name)
|
||||
if action_type is None:
|
||||
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
# 检查设备动作是否繁忙
|
||||
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
|
||||
|
||||
if is_busy:
|
||||
logger.warning(
|
||||
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
|
||||
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
|
||||
)
|
||||
# 返回失败状态,status=6 表示 ABORTED
|
||||
return JobData(jobId=job_id, status=6)
|
||||
|
||||
# 设备空闲,提交任务执行
|
||||
try:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
queue_item = QueueItem(
|
||||
task_type="job_call_back_status",
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
device_action_key=device_action_key,
|
||||
)
|
||||
|
||||
host_node.send_goal(
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
|
||||
# 返回已接受状态,status=1 表示 ACCEPTED
|
||||
return JobData(jobId=job_id, status=1)
|
||||
|
||||
except ValueError as e:
|
||||
# ActionClient not found 等错误
|
||||
logger.error(f"[Controller] Action not available: {str(e)}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error submitting job: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
|
||||
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取在线设备列表
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 在线设备信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
online_devices = {}
|
||||
for device_key in host_node._online_devices:
|
||||
# device_key 格式: "namespace/device_id"
|
||||
parts = device_key.split("/")
|
||||
if len(parts) >= 2:
|
||||
device_id = parts[-1]
|
||||
else:
|
||||
device_id = device_key
|
||||
|
||||
# 获取设备详细信息
|
||||
device_info = registered_devices.get(device_id, {})
|
||||
machine_name = host_node.device_machine_names.get(device_id, "未知")
|
||||
|
||||
online_devices[device_id] = {
|
||||
"device_key": device_key,
|
||||
"namespace": host_node.devices_names.get(device_id, ""),
|
||||
"machine_name": machine_name,
|
||||
"uuid": device_info.get("uuid", "") if device_info else "",
|
||||
"node_name": device_info.get("node_name", "") if device_info else "",
|
||||
}
|
||||
|
||||
return True, {
|
||||
"online_devices": online_devices,
|
||||
"total_count": len(online_devices),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting online devices: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 动作列表信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
# 检查设备是否已注册
|
||||
if device_id not in registered_devices:
|
||||
return False, {"error": f"Device not found: {device_id}"}
|
||||
|
||||
device_info = registered_devices[device_id]
|
||||
actions = device_info.get("actions", {})
|
||||
|
||||
actions_list = {}
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
actions_list[action_name] = {
|
||||
**action_info,
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
|
||||
actions_list[action_name] = {
|
||||
"type_name": "unknown",
|
||||
"action_path": f"/devices/{device_id}/{action_name}",
|
||||
"is_busy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"device_id": device_id,
|
||||
"actions": actions_list,
|
||||
"action_count": len(actions_list),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting device actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, Schema信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
result = {
|
||||
"device_id": device_id,
|
||||
"action_name": action_name,
|
||||
"schema": None,
|
||||
"goal_default": None,
|
||||
"action_type": None,
|
||||
"is_busy": False,
|
||||
}
|
||||
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
result["is_busy"] = is_busy
|
||||
result["current_job_id"] = current_job[:8] if current_job else None
|
||||
|
||||
# 方法1: 从 registered_devices 获取运行时信息
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
if action_name in action_mappings:
|
||||
mapping = action_mappings[action_name]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
|
||||
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
|
||||
if result["schema"] is None and lab_registry:
|
||||
# 尝试查找设备类型
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
# 从配置中获取设备类型
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
mapping = action_mappings[key]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
result["handles"] = mapping.get("handles", {})
|
||||
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
|
||||
break
|
||||
|
||||
if result["schema"] is None:
|
||||
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
|
||||
|
||||
return True, result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting action schema: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
all_actions = {}
|
||||
total_action_count = 0
|
||||
|
||||
for device_id, device_info in registered_devices.items():
|
||||
actions = device_info.get("actions", {})
|
||||
device_actions = {}
|
||||
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
device_actions[action_name] = {
|
||||
"type_name": action_info.get("type_name", ""),
|
||||
"action_path": action_info.get("action_path", ""),
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
total_action_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
if device_actions:
|
||||
all_actions[device_id] = {
|
||||
"actions": device_actions,
|
||||
"action_count": len(device_actions),
|
||||
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"devices": all_actions,
|
||||
"device_count": len(all_actions),
|
||||
"total_action_count": total_action_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
@@ -359,6 +359,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = ""
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -388,7 +389,7 @@ class MessageProcessor:
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor")
|
||||
self.thread.start()
|
||||
logger.info("[MessageProcessor] Started")
|
||||
logger.trace("[MessageProcessor] Started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止消息处理线程"""
|
||||
@@ -420,21 +421,24 @@ class MessageProcessor:
|
||||
ssl_context = ssl_module.create_default_context()
|
||||
|
||||
ws_logger = logging.getLogger("websockets.client")
|
||||
ws_logger.setLevel(logging.INFO)
|
||||
# 日志级别已在 unilabos.utils.log 中统一配置为 WARNING
|
||||
|
||||
async with websockets.connect(
|
||||
self.websocket_url,
|
||||
ssl=ssl_context,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
additional_headers={"Authorization": f"Lab {BasicConfig.auth_secret()}"},
|
||||
additional_headers={
|
||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||
"EdgeSession": f"{self.session_id}",
|
||||
},
|
||||
logger=ws_logger,
|
||||
) as websocket:
|
||||
self.websocket = websocket
|
||||
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())
|
||||
@@ -499,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:
|
||||
@@ -572,6 +576,9 @@ class MessageProcessor:
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -932,7 +939,7 @@ class QueueProcessor:
|
||||
# 事件通知机制
|
||||
self.queue_update_event = threading.Event()
|
||||
|
||||
logger.info("[QueueProcessor] Initialized")
|
||||
logger.trace("[QueueProcessor] Initialized")
|
||||
|
||||
def set_websocket_client(self, websocket_client: "WebSocketClient"):
|
||||
"""设置WebSocket客户端引用"""
|
||||
@@ -947,7 +954,7 @@ class QueueProcessor:
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor")
|
||||
self.thread.start()
|
||||
logger.info("[QueueProcessor] Started")
|
||||
logger.trace("[QueueProcessor] Started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
@@ -958,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:
|
||||
@@ -1168,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:
|
||||
@@ -1181,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客户端"""
|
||||
@@ -1196,6 +1200,18 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
|
||||
logger.info("[WebSocketClient] Stopping connection")
|
||||
|
||||
# 发送 normal_exit 消息
|
||||
if self.is_connected():
|
||||
try:
|
||||
session_id = self.message_processor.session_id
|
||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||
# 给一点时间让消息发送出去
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||
|
||||
# 停止两个核心线程
|
||||
self.message_processor.stop()
|
||||
self.queue_processor.stop()
|
||||
@@ -1224,7 +1240,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.debug(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
|
||||
def publish_job_status(
|
||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||
@@ -1295,3 +1311,19 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully")
|
||||
else:
|
||||
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
||||
|
||||
def publish_host_ready(self) -> None:
|
||||
"""发布host_node ready信号"""
|
||||
if self.is_disabled or not self.is_connected():
|
||||
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
||||
return
|
||||
|
||||
message = {
|
||||
"action": "host_node_ready",
|
||||
"data": {
|
||||
"status": "ready",
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info("[WebSocketClient] Host node ready signal published")
|
||||
|
||||
@@ -18,7 +18,11 @@ class BasicConfig:
|
||||
vis_2d_enable = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@classmethod
|
||||
def auth_secret(cls):
|
||||
@@ -36,18 +40,9 @@ class WSConfig:
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = ""
|
||||
authorization = ""
|
||||
init_endpoint = ""
|
||||
complete_endpoint = ""
|
||||
max_retries = 3
|
||||
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
||||
|
||||
|
||||
# ROS配置
|
||||
@@ -71,13 +66,14 @@ 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():
|
||||
if not env_key.startswith(prefix):
|
||||
continue
|
||||
try:
|
||||
key_path = env_key[len(prefix):] # Remove UNILAB_ prefix
|
||||
key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix
|
||||
class_field = key_path.upper().split("_", 1)
|
||||
if len(class_field) != 2:
|
||||
logger.warning(f"[ENV] 环境变量格式不正确:{env_key}")
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "NEWARE_BATTERY_TEST_SYSTEM",
|
||||
"name": "Neware Battery Test System",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "neware_battery_test_system",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_id": 1,
|
||||
"devtype": "27",
|
||||
"timeout": 20,
|
||||
"size_x": 500.0,
|
||||
"size_y": 500.0,
|
||||
"size_z": 2000.0
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import serial
|
||||
import time
|
||||
import csv
|
||||
import threading
|
||||
import os
|
||||
from collections import deque
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
|
||||
class ElectrolysisWaterPlatform(WorkstationBase):
|
||||
"""
|
||||
电解水平台工作站
|
||||
基于 WorkstationBase 的电解水实验平台,支持串口通信和数据采集
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Deck,
|
||||
port: str = "COM10",
|
||||
baudrate: int = 115200,
|
||||
csv_path: Optional[str] = None,
|
||||
timeout: float = 0.2,
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(deck, **kwargs)
|
||||
|
||||
# ========== 配置 ==========
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
# 如果没有指定路径,默认保存在代码文件所在目录
|
||||
if csv_path is None:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.csv_path = os.path.join(current_dir, "stm32_data.csv")
|
||||
else:
|
||||
self.csv_path = csv_path
|
||||
self.ser_timeout = timeout
|
||||
self.chunk_read = 128
|
||||
|
||||
# 串口对象
|
||||
self.ser: Optional[serial.Serial] = None
|
||||
self.stop_flag = False
|
||||
|
||||
# 线程对象
|
||||
self.rx_thread: Optional[threading.Thread] = None
|
||||
self.tx_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ==== 接收(下位机->上位机):固定 1+13+1 = 15 字节 ====
|
||||
self.RX_HEAD = 0x3E
|
||||
self.RX_TAIL = 0x3E
|
||||
self.RX_FRAME_LEN = 1 + 13 + 1 # 15
|
||||
|
||||
# ==== 发送(上位机->下位机):固定 1+9+1 = 11 字节 ====
|
||||
self.TX_HEAD = 0x3E
|
||||
self.TX_TAIL = 0xE3 # 协议图中标注 E3 作为帧尾
|
||||
self.TX_FRAME_LEN = 1 + 9 + 1 # 11
|
||||
|
||||
def open_serial(self, port: Optional[str] = None, baudrate: Optional[int] = None, timeout: Optional[float] = None) -> Optional[serial.Serial]:
|
||||
"""打开串口"""
|
||||
port = port or self.port
|
||||
baudrate = baudrate or self.baudrate
|
||||
timeout = timeout or self.ser_timeout
|
||||
try:
|
||||
ser = serial.Serial(port, baudrate, timeout=timeout)
|
||||
print(f"[OK] 串口 {port} 已打开,波特率 {baudrate}")
|
||||
ser.reset_input_buffer()
|
||||
ser.reset_output_buffer()
|
||||
self.ser = ser
|
||||
return ser
|
||||
except serial.SerialException as e:
|
||||
print(f"[ERR] 无法打开串口 {port}: {e}")
|
||||
return None
|
||||
|
||||
def close_serial(self):
|
||||
"""关闭串口"""
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
print("[INFO] 串口已关闭")
|
||||
|
||||
@staticmethod
|
||||
def u16_be(h: int, l: int) -> int:
|
||||
"""将两个字节组合成16位无符号整数(大端序)"""
|
||||
return ((h & 0xFF) << 8) | (l & 0xFF)
|
||||
|
||||
@staticmethod
|
||||
def split_u16_be(val: int) -> tuple:
|
||||
"""返回 (高字节, 低字节),输入会夹到 0..65535"""
|
||||
v = int(max(0, min(65535, int(val))))
|
||||
return (v >> 8) & 0xFF, v & 0xFF
|
||||
|
||||
# ================== 接收:固定15字节 ==================
|
||||
def parse_rx_payload(self, dat13: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""解析 13 字节数据区(下位机发送到上位机)"""
|
||||
if len(dat13) != 13:
|
||||
return None
|
||||
current_mA = self.u16_be(dat13[0], dat13[1])
|
||||
voltage_mV = self.u16_be(dat13[2], dat13[3])
|
||||
temperature_raw = self.u16_be(dat13[4], dat13[5])
|
||||
tds_ppm = self.u16_be(dat13[6], dat13[7])
|
||||
gas_sccm = self.u16_be(dat13[8], dat13[9])
|
||||
liquid_mL = self.u16_be(dat13[10], dat13[11])
|
||||
ph_raw = dat13[12] & 0xFF
|
||||
|
||||
return {
|
||||
"Current_mA": current_mA,
|
||||
"Voltage_mV": voltage_mV,
|
||||
"Temperature_C": round(temperature_raw / 100.0, 2),
|
||||
"TDS_ppm": tds_ppm,
|
||||
"GasFlow_sccm": gas_sccm,
|
||||
"LiquidFlow_mL": liquid_mL,
|
||||
"pH": round(ph_raw / 10.0, 2)
|
||||
}
|
||||
|
||||
def try_parse_rx_frame(self, frame15: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""尝试解析接收帧"""
|
||||
if len(frame15) != self.RX_FRAME_LEN:
|
||||
return None
|
||||
if frame15[0] != self.RX_HEAD or frame15[-1] != self.RX_TAIL:
|
||||
return None
|
||||
return self.parse_rx_payload(frame15[1:-1])
|
||||
|
||||
def rx_thread_fn(self):
|
||||
"""接收线程函数"""
|
||||
headers = ["Timestamp", "Current_mA", "Voltage_mV",
|
||||
"Temperature_C", "TDS_ppm", "GasFlow_sccm", "LiquidFlow_mL", "pH"]
|
||||
|
||||
new_file = not os.path.exists(self.csv_path)
|
||||
f = open(self.csv_path, mode='a', newline='', encoding='utf-8')
|
||||
writer = csv.writer(f)
|
||||
if new_file:
|
||||
writer.writerow(headers)
|
||||
f.flush()
|
||||
|
||||
buf = deque(maxlen=8192)
|
||||
print(f"[RX] 开始接收(帧长 {self.RX_FRAME_LEN} 字节);写入:{self.csv_path}")
|
||||
|
||||
try:
|
||||
while not self.stop_flag and self.ser and self.ser.is_open:
|
||||
chunk = self.ser.read(self.chunk_read)
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
while True:
|
||||
# 找帧头
|
||||
try:
|
||||
start = next(i for i, b in enumerate(buf) if b == self.RX_HEAD)
|
||||
except StopIteration:
|
||||
buf.clear()
|
||||
break
|
||||
if start > 0:
|
||||
for _ in range(start):
|
||||
buf.popleft()
|
||||
if len(buf) < self.RX_FRAME_LEN:
|
||||
break
|
||||
candidate = bytes([buf[i] for i in range(self.RX_FRAME_LEN)])
|
||||
if candidate[-1] == self.RX_TAIL:
|
||||
parsed = self.try_parse_rx_frame(candidate)
|
||||
for _ in range(self.RX_FRAME_LEN):
|
||||
buf.popleft()
|
||||
if parsed:
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
row = [ts,
|
||||
parsed["Current_mA"], parsed["Voltage_mV"],
|
||||
parsed["Temperature_C"], parsed["TDS_ppm"],
|
||||
parsed["GasFlow_sccm"], parsed["LiquidFlow_mL"],
|
||||
parsed["pH"]]
|
||||
writer.writerow(row)
|
||||
f.flush()
|
||||
# 若不想打印可注释下一行
|
||||
# print(f"[{ts}] I={parsed['Current_mA']} mA, V={parsed['Voltage_mV']} mV, "
|
||||
# f"T={parsed['Temperature_C']} °C, TDS={parsed['TDS_ppm']}, "
|
||||
# f"Gas={parsed['GasFlow_sccm']} sccm, Liq={parsed['LiquidFlow_mL']} mL, pH={parsed['pH']}")
|
||||
else:
|
||||
# 头不变,尾不对,丢1字节继续对齐
|
||||
buf.popleft()
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
finally:
|
||||
f.close()
|
||||
print("[RX] 接收线程退出,CSV 已关闭")
|
||||
|
||||
# ================== 发送:固定11字节 ==================
|
||||
def build_tx_frame(self, mode: int, current_ma: int, voltage_mv: int, temp_c: float, ki: float, pump_percent: float) -> bytes:
|
||||
"""
|
||||
发送帧:HEAD + [mode, I_hi, I_lo, V_hi, V_lo, T_hi, T_lo, Ki_byte, Pump_byte] + TAIL
|
||||
- mode: 0=恒压, 1=恒流
|
||||
- current_ma: mA (0..65535)
|
||||
- voltage_mv: mV (0..65535)
|
||||
- temp_c: ℃,将 *100 后拆分为高/低字节
|
||||
- ki: 0.0..20.0 -> byte = round(ki * 10) 夹到 0..200
|
||||
- pump_percent: 0..100 -> byte = round(pump * 2) 夹到 0..200
|
||||
"""
|
||||
mode_b = 1 if int(mode) == 1 else 0
|
||||
|
||||
i_hi, i_lo = self.split_u16_be(current_ma)
|
||||
v_hi, v_lo = self.split_u16_be(voltage_mv)
|
||||
|
||||
t100 = int(round(float(temp_c) * 100.0))
|
||||
t_hi, t_lo = self.split_u16_be(t100)
|
||||
|
||||
ki_b = int(max(0, min(200, round(float(ki) * 10))))
|
||||
pump_b = int(max(0, min(200, round(float(pump_percent) * 2))))
|
||||
|
||||
return bytes((
|
||||
self.TX_HEAD,
|
||||
mode_b,
|
||||
i_hi, i_lo,
|
||||
v_hi, v_lo,
|
||||
t_hi, t_lo,
|
||||
ki_b,
|
||||
pump_b,
|
||||
self.TX_TAIL
|
||||
))
|
||||
|
||||
def tx_thread_fn(self):
|
||||
"""
|
||||
发送线程函数
|
||||
用户输入 6 个用逗号分隔的数值:
|
||||
mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent
|
||||
例如: 0,1000,500,0,0,50
|
||||
"""
|
||||
print("\n输入 6 个值(用英文逗号分隔),顺序为:")
|
||||
print("mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent")
|
||||
print("示例恒压:0,500,1000,25,0,100 (stop 结束)\n")
|
||||
print("示例恒流:1,1000,500,25,0,100 (stop 结束)\n")
|
||||
print("示例恒流:1,2000,500,25,0,100 (stop 结束)\n")
|
||||
# 1,2000,500,25,0,100
|
||||
|
||||
while not self.stop_flag and self.ser and self.ser.is_open:
|
||||
try:
|
||||
line = input(">>> ").strip()
|
||||
except EOFError:
|
||||
self.stop_flag = True
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() == "stop":
|
||||
self.stop_flag = True
|
||||
print("[SYS] 停止程序")
|
||||
break
|
||||
|
||||
try:
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
if len(parts) != 6:
|
||||
raise ValueError("需要 6 个逗号分隔的数值")
|
||||
mode = int(parts[0])
|
||||
i_ma = int(float(parts[1]))
|
||||
v_mv = int(float(parts[2]))
|
||||
t_c = float(parts[3])
|
||||
ki = float(parts[4])
|
||||
pump = float(parts[5])
|
||||
|
||||
frame = self.build_tx_frame(mode, i_ma, v_mv, t_c, ki, pump)
|
||||
self.ser.write(frame)
|
||||
print("[TX]", " ".join(f"{b:02X}" for b in frame))
|
||||
except Exception as e:
|
||||
print("[TX] 输入/打包失败:", e)
|
||||
print("格式:mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent")
|
||||
continue
|
||||
|
||||
def start(self):
|
||||
"""启动电解水平台"""
|
||||
self.ser = self.open_serial()
|
||||
if self.ser:
|
||||
try:
|
||||
self.rx_thread = threading.Thread(target=self.rx_thread_fn, daemon=True)
|
||||
self.tx_thread = threading.Thread(target=self.tx_thread_fn, daemon=True)
|
||||
self.rx_thread.start()
|
||||
self.tx_thread.start()
|
||||
print("[INFO] 电解水平台已启动")
|
||||
self.tx_thread.join() # 等待用户输入线程结束(输入 stop)
|
||||
finally:
|
||||
self.close_serial()
|
||||
|
||||
def stop(self):
|
||||
"""停止电解水平台"""
|
||||
self.stop_flag = True
|
||||
if self.rx_thread and self.rx_thread.is_alive():
|
||||
self.rx_thread.join(timeout=2.0)
|
||||
if self.tx_thread and self.tx_thread.is_alive():
|
||||
self.tx_thread.join(timeout=2.0)
|
||||
self.close_serial()
|
||||
print("[INFO] 电解水平台已停止")
|
||||
|
||||
|
||||
# ================== 主入口 ==================
|
||||
if __name__ == "__main__":
|
||||
# 创建一个简单的 Deck 用于测试
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
deck = Deck()
|
||||
platform = ElectrolysisWaterPlatform(deck)
|
||||
platform.start()
|
||||
@@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker):
|
||||
for i in range(self.driver._finished, temp):
|
||||
sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数
|
||||
pdf, txt = self.driver.get_data_file(i + 1)
|
||||
device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default"
|
||||
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id)
|
||||
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id)
|
||||
# 使用新的OSS上传接口,传入driver_name和exp_type
|
||||
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
|
||||
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
|
||||
|
||||
if pdf_result["success"]:
|
||||
print(f"PDF上传成功: {pdf_result['oss_path']}")
|
||||
else:
|
||||
print(f"PDF上传失败: {pdf_result['original_path']}")
|
||||
|
||||
if txt_result["success"]:
|
||||
print(f"TXT上传成功: {txt_result['oss_path']}")
|
||||
else:
|
||||
print(f"TXT上传失败: {txt_result['original_path']}")
|
||||
# self.driver.extract_data_from_txt()
|
||||
except Exception as ex:
|
||||
self.driver._finished = 0
|
||||
@@ -456,8 +466,12 @@ if __name__ == "__main__":
|
||||
}
|
||||
sample_id = obj._get_resource_sample_id("test", 0)
|
||||
pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6))
|
||||
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example")
|
||||
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result")
|
||||
# 使用新的OSS上传接口,传入driver_name和exp_type
|
||||
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
|
||||
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
|
||||
|
||||
print(f"PDF上传结果: {pdf_result}")
|
||||
print(f"TXT上传结果: {txt_result}")
|
||||
# driver = HPLCDriver()
|
||||
# for i in range(10000):
|
||||
# print({k: v for k, v in driver._device_status.items() if isinstance(v, str)})
|
||||
|
||||
0
unilabos/devices/laiyu_liquid_test/__init__.py
Normal file
0
unilabos/devices/liquid_handling/laiyu/__init__.py
Normal file
@@ -31,15 +31,17 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
class LiquidHandlerMiddleware(LiquidHandler):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, total_height: float = 310, **kwargs):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs):
|
||||
self._simulator = simulator
|
||||
self.channel_num = channel_num
|
||||
joint_config = kwargs.get("joint_config", None)
|
||||
if simulator:
|
||||
self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num,total_height, joint_config=joint_config, lh_device_id = deck.name)
|
||||
if joint_config:
|
||||
self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num, kwargs["total_height"],
|
||||
joint_config=joint_config, lh_device_id=deck.name)
|
||||
else:
|
||||
self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num)
|
||||
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
|
||||
if hasattr(backend, "total_height"):
|
||||
backend.total_height = total_height
|
||||
super().__init__(backend, deck)
|
||||
|
||||
async def setup(self, **backend_kwargs):
|
||||
@@ -145,6 +147,9 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
offsets: Optional[List[Coordinate]] = None,
|
||||
**backend_kwargs,
|
||||
):
|
||||
# 如果 use_channels 为 None,使用默认值(所有通道)
|
||||
if use_channels is None:
|
||||
use_channels = list(range(self.channel_num))
|
||||
if not offsets or (isinstance(offsets, list) and len(offsets) != len(use_channels)):
|
||||
offsets = [Coordinate.zero()] * len(use_channels)
|
||||
if self._simulator:
|
||||
@@ -544,51 +549,16 @@ 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,**backend_kwargs):
|
||||
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,total_height,**backend_kwargs)
|
||||
super().__init__(backend, deck, simulator, channel_num)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
@@ -792,7 +762,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
blow_out_air_volume=current_dis_blow_out_air_volume,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(current_targets)
|
||||
await self.discard_tips()
|
||||
@@ -866,17 +836,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None:
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.mix(
|
||||
targets=[targets[_]],
|
||||
mix_time=mix_time,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if delays is not None:
|
||||
# 只有在 mix_time 有效时才调用 mix
|
||||
if mix_time is not None and mix_time > 0:
|
||||
await self.mix(
|
||||
targets=[targets[_]],
|
||||
mix_time=mix_time,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(targets[_])
|
||||
await self.discard_tips()
|
||||
@@ -926,18 +898,20 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
blow_out_air_volume=current_dis_blow_out_air_volume,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
await self.mix(
|
||||
targets=current_targets,
|
||||
mix_time=mix_time,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if delays is not None:
|
||||
# 只有在 mix_time 有效时才调用 mix
|
||||
if mix_time is not None and mix_time > 0:
|
||||
await self.mix(
|
||||
targets=current_targets,
|
||||
mix_time=mix_time,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(current_targets)
|
||||
await self.discard_tips()
|
||||
@@ -975,60 +949,158 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
"""Transfer liquid from each *source* well/plate to the corresponding *target*.
|
||||
"""Transfer liquid with automatic mode detection.
|
||||
|
||||
Supports three transfer modes:
|
||||
1. One-to-many (1 source -> N targets): Distribute from one source to multiple targets
|
||||
2. One-to-one (N sources -> N targets): Standard transfer, each source to corresponding target
|
||||
3. Many-to-one (N sources -> 1 target): Combine multiple sources into one target
|
||||
|
||||
Parameters
|
||||
----------
|
||||
asp_vols, dis_vols
|
||||
Single volume (µL) or list matching the number of transfers.
|
||||
Single volume (µL) or list. Automatically expanded based on transfer mode.
|
||||
sources, targets
|
||||
Same‑length sequences of containers (wells or plates). In 96‑well mode
|
||||
each must contain exactly one plate.
|
||||
Containers (wells or plates). Length determines transfer mode:
|
||||
- len(sources) == 1, len(targets) > 1: One-to-many mode
|
||||
- len(sources) == len(targets): One-to-one mode
|
||||
- len(sources) > 1, len(targets) == 1: Many-to-one mode
|
||||
tip_racks
|
||||
One or more TipRacks providing fresh tips.
|
||||
is_96_well
|
||||
Set *True* to use the 96‑channel head.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# 确保 use_channels 有默认值
|
||||
if use_channels is None:
|
||||
use_channels = [0] if self.channel_num >= 1 else list(range(self.channel_num))
|
||||
|
||||
if is_96_well:
|
||||
pass # This mode is not verified.
|
||||
else:
|
||||
if len(asp_vols) != len(targets):
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
|
||||
# 转换体积参数为列表
|
||||
if isinstance(asp_vols, (int, float)):
|
||||
asp_vols = [float(asp_vols)]
|
||||
else:
|
||||
asp_vols = [float(v) for v in asp_vols]
|
||||
|
||||
if isinstance(dis_vols, (int, float)):
|
||||
dis_vols = [float(dis_vols)]
|
||||
else:
|
||||
dis_vols = [float(v) for v in dis_vols]
|
||||
|
||||
# 首先应该对任务分组,然后每次1个/8个进行操作处理
|
||||
if len(use_channels) == 1:
|
||||
for _ in range(len(targets)):
|
||||
tip = []
|
||||
for ___ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
# 统一混合次数为标量,防止数组/列表与 int 比较时报错
|
||||
if mix_times is not None and not isinstance(mix_times, (int, float)):
|
||||
try:
|
||||
mix_times = mix_times[0] if len(mix_times) > 0 else None
|
||||
except Exception:
|
||||
try:
|
||||
mix_times = next(iter(mix_times))
|
||||
except Exception:
|
||||
pass
|
||||
if mix_times is not None:
|
||||
mix_times = int(mix_times)
|
||||
|
||||
# 识别传输模式
|
||||
num_sources = len(sources)
|
||||
num_targets = len(targets)
|
||||
|
||||
if num_sources == 1 and num_targets > 1:
|
||||
# 模式1: 一对多 (1 source -> N targets)
|
||||
await self._transfer_one_to_many(
|
||||
sources[0], targets, tip_racks, use_channels,
|
||||
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
||||
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
||||
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
||||
mix_liquid_height, delays
|
||||
)
|
||||
elif num_sources > 1 and num_targets == 1:
|
||||
# 模式2: 多对一 (N sources -> 1 target)
|
||||
await self._transfer_many_to_one(
|
||||
sources, targets[0], tip_racks, use_channels,
|
||||
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
||||
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
||||
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
||||
mix_liquid_height, delays
|
||||
)
|
||||
elif num_sources == num_targets:
|
||||
# 模式3: 一对一 (N sources -> N targets) - 原有逻辑
|
||||
await self._transfer_one_to_one(
|
||||
sources, targets, tip_racks, use_channels,
|
||||
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
||||
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
||||
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
||||
mix_liquid_height, delays
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
||||
"Supported modes: 1->N, N->1, or N->N."
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
resources=[sources[_]],
|
||||
vols=[asp_vols[_]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[asp_flow_rates[0]] if asp_flow_rates else None,
|
||||
offsets=[offsets[0]] if offsets else None,
|
||||
liquid_height=[liquid_height[0]] if liquid_height else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
await self.dispense(
|
||||
resources=[targets[_]],
|
||||
vols=[dis_vols[_]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[dis_flow_rates[1]] if dis_flow_rates else None,
|
||||
offsets=[offsets[1]] if offsets else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None,
|
||||
liquid_height=[liquid_height[1]] if liquid_height else None,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
async def _transfer_one_to_one(
|
||||
self,
|
||||
sources: Sequence[Container],
|
||||
targets: Sequence[Container],
|
||||
tip_racks: Sequence[TipRack],
|
||||
use_channels: List[int],
|
||||
asp_vols: List[float],
|
||||
dis_vols: List[float],
|
||||
asp_flow_rates: Optional[List[Optional[float]]],
|
||||
dis_flow_rates: Optional[List[Optional[float]]],
|
||||
offsets: Optional[List[Coordinate]],
|
||||
touch_tip: bool,
|
||||
liquid_height: Optional[List[Optional[float]]],
|
||||
blow_out_air_volume: Optional[List[Optional[float]]],
|
||||
spread: Literal["wide", "tight", "custom"],
|
||||
mix_stage: Optional[Literal["none", "before", "after", "both"]],
|
||||
mix_times: Optional[int],
|
||||
mix_vol: Optional[int],
|
||||
mix_rate: Optional[int],
|
||||
mix_liquid_height: Optional[float],
|
||||
delays: Optional[List[int]],
|
||||
):
|
||||
"""一对一传输模式:N sources -> N targets"""
|
||||
# 验证参数长度
|
||||
if len(asp_vols) != len(targets):
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
|
||||
if len(dis_vols) != len(targets):
|
||||
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
|
||||
if len(sources) != len(targets):
|
||||
raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.")
|
||||
|
||||
if len(use_channels) == 1:
|
||||
for _ in range(len(targets)):
|
||||
tip = []
|
||||
for ___ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.aspirate(
|
||||
resources=[sources[_]],
|
||||
vols=[asp_vols[_]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[asp_flow_rates[_]] if asp_flow_rates and len(asp_flow_rates) > _ else None,
|
||||
offsets=[offsets[_]] if offsets and len(offsets) > _ else None,
|
||||
liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
await self.dispense(
|
||||
resources=[targets[_]],
|
||||
vols=[dis_vols[_]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[dis_flow_rates[_]] if dis_flow_rates and len(dis_flow_rates) > _ else None,
|
||||
offsets=[offsets[_]] if offsets and len(offsets) > _ else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None,
|
||||
liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[targets[_]],
|
||||
mix_time=mix_times,
|
||||
@@ -1037,63 +1109,60 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(targets[_])
|
||||
await self.discard_tips()
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(targets[_])
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
elif len(use_channels) == 8:
|
||||
# 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理
|
||||
if len(targets) % 8 != 0:
|
||||
raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.")
|
||||
elif len(use_channels) == 8:
|
||||
if len(targets) % 8 != 0:
|
||||
raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.")
|
||||
|
||||
# 8个8个来取任务序列
|
||||
for i in range(0, len(targets), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
current_targets = targets[i:i + 8]
|
||||
current_reagent_sources = sources[i:i + 8]
|
||||
current_asp_vols = asp_vols[i:i + 8]
|
||||
current_dis_vols = dis_vols[i:i + 8]
|
||||
current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None
|
||||
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
|
||||
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
|
||||
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
|
||||
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
|
||||
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
|
||||
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
|
||||
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
|
||||
|
||||
for i in range(0, len(targets), 8):
|
||||
# 取出8个任务
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
current_targets = targets[i:i + 8]
|
||||
current_reagent_sources = sources[i:i + 8]
|
||||
current_asp_vols = asp_vols[i:i + 8]
|
||||
current_dis_vols = dis_vols[i:i + 8]
|
||||
current_asp_flow_rates = asp_flow_rates[i:i + 8]
|
||||
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
|
||||
current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8
|
||||
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
|
||||
current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8
|
||||
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
|
||||
current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8
|
||||
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else [None] * 8
|
||||
await self.aspirate(
|
||||
resources=current_reagent_sources,
|
||||
vols=current_asp_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_asp_flow_rates,
|
||||
offsets=current_asp_offset,
|
||||
blow_out_air_volume=current_asp_blow_out_air_volume,
|
||||
liquid_height=current_asp_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
resources=current_reagent_sources,
|
||||
vols=current_asp_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_asp_flow_rates,
|
||||
offsets=current_asp_offset,
|
||||
blow_out_air_volume=current_asp_blow_out_air_volume,
|
||||
liquid_height=current_asp_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
await self.dispense(
|
||||
resources=current_targets,
|
||||
vols=current_dis_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_dis_flow_rates,
|
||||
offsets=current_dis_offset,
|
||||
blow_out_air_volume=current_dis_blow_out_air_volume,
|
||||
liquid_height=current_dis_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
await self.dispense(
|
||||
resources=current_targets,
|
||||
vols=current_dis_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_dis_flow_rates,
|
||||
offsets=current_dis_offset,
|
||||
blow_out_air_volume=current_dis_blow_out_air_volume,
|
||||
liquid_height=current_dis_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=current_targets,
|
||||
mix_time=mix_times,
|
||||
@@ -1102,10 +1171,363 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(current_targets)
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
async def _transfer_one_to_many(
|
||||
self,
|
||||
source: Container,
|
||||
targets: Sequence[Container],
|
||||
tip_racks: Sequence[TipRack],
|
||||
use_channels: List[int],
|
||||
asp_vols: List[float],
|
||||
dis_vols: List[float],
|
||||
asp_flow_rates: Optional[List[Optional[float]]],
|
||||
dis_flow_rates: Optional[List[Optional[float]]],
|
||||
offsets: Optional[List[Coordinate]],
|
||||
touch_tip: bool,
|
||||
liquid_height: Optional[List[Optional[float]]],
|
||||
blow_out_air_volume: Optional[List[Optional[float]]],
|
||||
spread: Literal["wide", "tight", "custom"],
|
||||
mix_stage: Optional[Literal["none", "before", "after", "both"]],
|
||||
mix_times: Optional[int],
|
||||
mix_vol: Optional[int],
|
||||
mix_rate: Optional[int],
|
||||
mix_liquid_height: Optional[float],
|
||||
delays: Optional[List[int]],
|
||||
):
|
||||
"""一对多传输模式:1 source -> N targets"""
|
||||
# 验证和扩展体积参数
|
||||
if len(asp_vols) == 1:
|
||||
# 如果只提供一个吸液体积,计算总吸液体积(所有分液体积之和)
|
||||
total_asp_vol = sum(dis_vols)
|
||||
asp_vol = asp_vols[0] if asp_vols[0] >= total_asp_vol else total_asp_vol
|
||||
else:
|
||||
raise ValueError("For one-to-many mode, `asp_vols` should be a single value or list with one element.")
|
||||
|
||||
if len(dis_vols) != len(targets):
|
||||
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
|
||||
|
||||
if len(use_channels) == 1:
|
||||
# 单通道模式:一次吸液,多次分液
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
# 从源容器吸液(总体积)
|
||||
await self.aspirate(
|
||||
resources=[source],
|
||||
vols=[asp_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
|
||||
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
||||
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
|
||||
# 分多次分液到不同的目标容器
|
||||
for idx, target in enumerate(targets):
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[dis_vols[idx]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[dis_flow_rates[idx]] if dis_flow_rates and len(dis_flow_rates) > idx else None,
|
||||
offsets=[offsets[idx]] if offsets and len(offsets) > idx else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
|
||||
liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None,
|
||||
spread=spread,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets[idx:idx+1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
elif len(use_channels) == 8:
|
||||
# 8通道模式:需要确保目标数量是8的倍数
|
||||
if len(targets) % 8 != 0:
|
||||
raise ValueError(f"For 8-channel mode, number of targets {len(targets)} must be a multiple of 8.")
|
||||
|
||||
# 每次处理8个目标
|
||||
for i in range(0, len(targets), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
current_targets = targets[i:i + 8]
|
||||
current_dis_vols = dis_vols[i:i + 8]
|
||||
|
||||
# 8个通道都从同一个源容器吸液,每个通道的吸液体积等于对应的分液体积
|
||||
current_asp_flow_rates = asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None
|
||||
current_asp_offset = offsets[0:1] * 8 if offsets and len(offsets) > 0 else [None] * 8
|
||||
current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8
|
||||
current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8
|
||||
|
||||
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
||||
await self.aspirate(
|
||||
resources=[source] * 8, # 8个通道都从同一个源
|
||||
vols=current_dis_vols, # 每个通道的吸液体积等于对应的分液体积
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_asp_flow_rates,
|
||||
offsets=current_asp_offset,
|
||||
liquid_height=current_asp_liquid_height,
|
||||
blow_out_air_volume=current_asp_blow_out_air_volume,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
|
||||
# 分液到8个目标
|
||||
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
|
||||
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
|
||||
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
|
||||
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
|
||||
|
||||
await self.dispense(
|
||||
resources=current_targets,
|
||||
vols=current_dis_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_dis_flow_rates,
|
||||
offsets=current_dis_offset,
|
||||
blow_out_air_volume=current_dis_blow_out_air_volume,
|
||||
liquid_height=current_dis_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=current_targets,
|
||||
mix_time=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip(current_targets)
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
async def _transfer_many_to_one(
|
||||
self,
|
||||
sources: Sequence[Container],
|
||||
target: Container,
|
||||
tip_racks: Sequence[TipRack],
|
||||
use_channels: List[int],
|
||||
asp_vols: List[float],
|
||||
dis_vols: List[float],
|
||||
asp_flow_rates: Optional[List[Optional[float]]],
|
||||
dis_flow_rates: Optional[List[Optional[float]]],
|
||||
offsets: Optional[List[Coordinate]],
|
||||
touch_tip: bool,
|
||||
liquid_height: Optional[List[Optional[float]]],
|
||||
blow_out_air_volume: Optional[List[Optional[float]]],
|
||||
spread: Literal["wide", "tight", "custom"],
|
||||
mix_stage: Optional[Literal["none", "before", "after", "both"]],
|
||||
mix_times: Optional[int],
|
||||
mix_vol: Optional[int],
|
||||
mix_rate: Optional[int],
|
||||
mix_liquid_height: Optional[float],
|
||||
delays: Optional[List[int]],
|
||||
):
|
||||
"""多对一传输模式:N sources -> 1 target(汇总/混合)"""
|
||||
# 验证和扩展体积参数
|
||||
if len(asp_vols) != len(sources):
|
||||
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.")
|
||||
|
||||
# 支持两种模式:
|
||||
# 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积
|
||||
# 2. dis_vols 长度等于 asp_vols:每个源按不同比例分液(按比例混合)
|
||||
if len(dis_vols) == 1:
|
||||
# 模式1:使用单个分液体积
|
||||
total_dis_vol = sum(asp_vols)
|
||||
dis_vol = dis_vols[0] if dis_vols[0] >= total_dis_vol else total_dis_vol
|
||||
use_proportional_mixing = False
|
||||
elif len(dis_vols) == len(asp_vols):
|
||||
# 模式2:按不同比例混合
|
||||
use_proportional_mixing = True
|
||||
else:
|
||||
raise ValueError(
|
||||
f"For many-to-one mode, `dis_vols` should be a single value or list with length {len(asp_vols)} "
|
||||
f"(matching `asp_vols`). Got length {len(dis_vols)}."
|
||||
)
|
||||
|
||||
if len(use_channels) == 1:
|
||||
# 单通道模式:多次吸液,一次分液
|
||||
# 先混合前(如果需要)
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
# 注意:在吸液前混合源容器通常不常见,这里跳过
|
||||
pass
|
||||
|
||||
# 从每个源容器吸液并分液到目标容器
|
||||
for idx, source in enumerate(sources):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.aspirate(
|
||||
resources=[source],
|
||||
vols=[asp_vols[idx]],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[asp_flow_rates[idx]] if asp_flow_rates and len(asp_flow_rates) > idx else None,
|
||||
offsets=[offsets[idx]] if offsets and len(offsets) > idx else None,
|
||||
liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None,
|
||||
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
|
||||
# 分液到目标容器
|
||||
if use_proportional_mixing:
|
||||
# 按不同比例混合:使用对应的 dis_vols
|
||||
dis_vol = dis_vols[idx]
|
||||
dis_flow_rate = dis_flow_rates[idx] if dis_flow_rates and len(dis_flow_rates) > idx else None
|
||||
dis_offset = offsets[idx] if offsets and len(offsets) > idx else None
|
||||
dis_liquid_height = liquid_height[idx] if liquid_height and len(liquid_height) > idx else None
|
||||
dis_blow_out = blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None
|
||||
else:
|
||||
# 标准模式:分液体积等于吸液体积
|
||||
dis_vol = asp_vols[idx]
|
||||
dis_flow_rate = dis_flow_rates[0] if dis_flow_rates and len(dis_flow_rates) > 0 else None
|
||||
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
|
||||
dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None
|
||||
dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[dis_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=[dis_flow_rate] if dis_flow_rate is not None else None,
|
||||
offsets=[dis_offset] if dis_offset is not None else None,
|
||||
blow_out_air_volume=[dis_blow_out] if dis_blow_out is not None else None,
|
||||
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
elif len(use_channels) == 8:
|
||||
# 8通道模式:需要确保源数量是8的倍数
|
||||
if len(sources) % 8 != 0:
|
||||
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
|
||||
|
||||
# 每次处理8个源
|
||||
for i in range(0, len(sources), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
current_sources = sources[i:i + 8]
|
||||
current_asp_vols = asp_vols[i:i + 8]
|
||||
current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None
|
||||
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
|
||||
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
|
||||
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
|
||||
|
||||
# 从8个源容器吸液
|
||||
await self.aspirate(
|
||||
resources=current_sources,
|
||||
vols=current_asp_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_asp_flow_rates,
|
||||
offsets=current_asp_offset,
|
||||
blow_out_air_volume=current_asp_blow_out_air_volume,
|
||||
liquid_height=current_asp_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
|
||||
# 分液到目标容器(每个通道分液到同一个目标)
|
||||
if use_proportional_mixing:
|
||||
# 按比例混合:使用对应的 dis_vols
|
||||
current_dis_vols = dis_vols[i:i + 8]
|
||||
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
|
||||
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
|
||||
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
|
||||
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
|
||||
else:
|
||||
# 标准模式:每个通道分液体积等于其吸液体积
|
||||
current_dis_vols = current_asp_vols
|
||||
current_dis_flow_rates = dis_flow_rates[0:1] * 8 if dis_flow_rates else None
|
||||
current_dis_offset = offsets[0:1] * 8 if offsets else [None] * 8
|
||||
current_dis_liquid_height = liquid_height[0:1] * 8 if liquid_height else [None] * 8
|
||||
current_dis_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8
|
||||
|
||||
await self.dispense(
|
||||
resources=[target] * 8, # 8个通道都分到同一个目标
|
||||
vols=current_dis_vols,
|
||||
use_channels=use_channels,
|
||||
flow_rates=current_dis_flow_rates,
|
||||
offsets=current_dis_offset,
|
||||
blow_out_air_volume=current_dis_blow_out_air_volume,
|
||||
liquid_height=current_dis_liquid_height,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
mix_vol=mix_vol,
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
# except Exception as e:
|
||||
# traceback.print_exc()
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 import (
|
||||
@@ -856,7 +857,30 @@ class PRCXI9300Api:
|
||||
|
||||
def _raw_request(self, payload: str) -> str:
|
||||
if self.debug:
|
||||
return " "
|
||||
# 调试/仿真模式下直接返回可解析的模拟 JSON,避免后续 json.loads 报错
|
||||
try:
|
||||
req = json.loads(payload)
|
||||
method = req.get("MethodName")
|
||||
except Exception:
|
||||
method = None
|
||||
|
||||
data: Any = True
|
||||
if method in {"AddSolution"}:
|
||||
data = str(uuid.uuid4())
|
||||
elif method in {"AddWorkTabletMatrix", "AddWorkTabletMatrix2"}:
|
||||
data = {"Success": True, "Message": "debug mock"}
|
||||
elif method in {"GetErrorCode"}:
|
||||
data = ""
|
||||
elif method in {"RemoveErrorCodet", "Reset", "Start", "LoadSolution", "Pause", "Resume", "Stop"}:
|
||||
data = True
|
||||
elif method in {"GetStepStateList", "GetStepStatus", "GetStepState"}:
|
||||
data = []
|
||||
elif method in {"GetLocation"}:
|
||||
data = {"X": 0, "Y": 0, "Z": 0}
|
||||
elif method in {"GetResetStatus"}:
|
||||
data = False
|
||||
|
||||
return json.dumps({"Success": True, "Msg": "debug mock", "Data": data})
|
||||
with contextlib.closing(socket.socket()) as sock:
|
||||
sock.settimeout(self.timeout)
|
||||
sock.connect((self.host, self.port))
|
||||
|
||||
8
unilabos/devices/neware_battery_test_system/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .neware_battery_test_system import NewareBatteryTestSystem
|
||||
from .neware_driver import build_start_command, start_test
|
||||
|
||||
__all__ = [
|
||||
"NewareBatteryTestSystem",
|
||||
"build_start_command",
|
||||
"start_test",
|
||||
]
|
||||
3
unilabos/devices/neware_battery_test_system/demo.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
Timestamp,Battery_Count,Assembly_Time,Open_Circuit_Voltage,Pole_Weight,Assembly_Pressure,Battery_Code,Electrolyte_Code,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʺ<EFBFBD><EFBFBD><EFBFBD>,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>mah/g,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ϵ,<EFBFBD>豸<EFBFBD><EFBFBD>,<EFBFBD>ź<EFBFBD>,ͨ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
2025/10/29 17:32,7,5,0.11299999803304672,18.049999237060547,3593,Li000595,Si-Gr001,9.2,0.954,469,SiGr_Li,1,1,2
|
||||
2025/10/30 17:49,2,5,0,13.109999895095825,4094,YS101224,NoRead88,5.2,0.92,190,SiGr_Li,2,1,1
|
||||
|
33
unilabos/devices/neware_battery_test_system/device.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "NEWARE_BATTERY_TEST_SYSTEM",
|
||||
"name": "Neware Battery Test System",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "neware_battery_test_system",
|
||||
"position": {
|
||||
"x": 620.0,
|
||||
"y": 200.0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_id": 1,
|
||||
"devtype": "27",
|
||||
"timeout": 20,
|
||||
"size_x": 500.0,
|
||||
"size_y": 500.0,
|
||||
"size_z": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能",
|
||||
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
|
||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
1100
unilabos/devices/neware_battery_test_system/generate_xml_content.py
Normal file
@@ -13,6 +13,8 @@
|
||||
- 状态类型: working/stop/finish/protect/pause/false/unknown
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import socket
|
||||
import xml.etree.ElementTree as ET
|
||||
import json
|
||||
@@ -21,7 +23,6 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from pylabrobot.resources import ResourceHolder, Coordinate, create_ordered_items_2d, Deck, Plate
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
|
||||
@@ -56,13 +57,6 @@ class BatteryTestPositionState(TypedDict):
|
||||
status: str # 通道状态
|
||||
color: str # 状态对应颜色
|
||||
|
||||
# 额外的inquire协议字段
|
||||
relativetime: float # 相对时间 (s)
|
||||
open_or_close: int # 0=关闭, 1=打开
|
||||
step_type: str # 步骤类型
|
||||
cycle_id: int # 循环ID
|
||||
step_id: int # 步骤ID
|
||||
log_code: str # 日志代码
|
||||
|
||||
|
||||
class BatteryTestPosition(ResourceHolder):
|
||||
@@ -142,9 +136,9 @@ class NewareBatteryTestSystem:
|
||||
devtype: str = None,
|
||||
timeout: int = None,
|
||||
|
||||
size_x: float = 500.0,
|
||||
size_y: float = 500.0,
|
||||
size_z: float = 2000.0,
|
||||
size_x: float = 50,
|
||||
size_y: float = 50,
|
||||
size_z: float = 20,
|
||||
):
|
||||
"""
|
||||
初始化新威电池测试系统
|
||||
@@ -162,6 +156,12 @@ class NewareBatteryTestSystem:
|
||||
self.machine_id = machine_id
|
||||
self.devtype = devtype or self.DEVTYPE
|
||||
self.timeout = timeout or self.TIMEOUT
|
||||
|
||||
# 存储设备物理尺寸
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
|
||||
self._last_status_update = None
|
||||
self._cached_status = {}
|
||||
self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置
|
||||
@@ -192,8 +192,9 @@ class NewareBatteryTestSystem:
|
||||
def _setup_material_management(self):
|
||||
"""设置物料管理系统"""
|
||||
# 第1盘:5行8列网格 (A1-E8) - 5行对应subdevid 1-5,8列对应chlid 1-8
|
||||
# 先给物料设置一个最大的Deck
|
||||
deck_main = Deck("ADeckName", 200, 200, 200)
|
||||
# 先给物料设置一个最大的Deck,并设置其在空间中的位置
|
||||
|
||||
deck_main = Deck("ADeckName", 2000, 1800, 100, origin=Coordinate(2000,2000,0))
|
||||
|
||||
plate1_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
|
||||
BatteryTestPosition,
|
||||
@@ -202,8 +203,8 @@ class NewareBatteryTestSystem:
|
||||
dx=10,
|
||||
dy=10,
|
||||
dz=0,
|
||||
item_dx=45,
|
||||
item_dy=45
|
||||
item_dx=65,
|
||||
item_dy=65
|
||||
)
|
||||
plate1 = Plate("P1", 400, 300, 50, ordered_items=plate1_resources)
|
||||
deck_main.assign_child_resource(plate1, location=Coordinate(0, 0, 0))
|
||||
@@ -232,11 +233,15 @@ class NewareBatteryTestSystem:
|
||||
num_items_y=5, # 5行(对应subdevid 6-10,即A-E)
|
||||
dx=10,
|
||||
dy=10,
|
||||
dz=100, # Z轴偏移100mm
|
||||
dz=0,
|
||||
item_dx=65,
|
||||
item_dy=65
|
||||
)
|
||||
|
||||
plate2 = Plate("P2", 400, 300, 50, ordered_items=plate2_resources)
|
||||
deck_main.assign_child_resource(plate2, location=Coordinate(0, 350, 0))
|
||||
|
||||
|
||||
# 为第2盘资源添加P2_前缀
|
||||
self.station_resources_plate2 = {}
|
||||
for name, resource in plate2_resources.items():
|
||||
@@ -306,55 +311,132 @@ class NewareBatteryTestSystem:
|
||||
|
||||
def _update_plate_resources(self, subunits: Dict):
|
||||
"""更新两盘电池资源的状态"""
|
||||
# 第1盘:subdevid 1-5 映射到 P1_A1-P1_E8 (5行8列)
|
||||
# 第1盘:subdevid 1-5 映射到 8列5行网格 (列0-7, 行0-4)
|
||||
for subdev_id in range(1, 6): # subdevid 1-5
|
||||
status_row = subunits.get(subdev_id, {})
|
||||
|
||||
for chl_id in range(1, 9): # chlid 1-8
|
||||
try:
|
||||
# 计算在5×8网格中的位置
|
||||
row_idx = (subdev_id - 1) # 0-4 (对应A-E)
|
||||
col_idx = (chl_id - 1) # 0-7 (对应1-8)
|
||||
resource_name = f"P1_{self.LETTERS[row_idx]}{col_idx + 1}"
|
||||
# 根据用户描述:第一个是(0,0),最后一个是(7,4)
|
||||
# 说明是8列5行,列从0开始,行从0开始
|
||||
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
|
||||
row_idx = (subdev_id - 1) # 0-4 (subdevid 1-5 -> 行0-4)
|
||||
|
||||
# 尝试多种可能的资源命名格式
|
||||
possible_names = [
|
||||
f"P1_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
|
||||
f"P1_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
|
||||
f"P1_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
|
||||
]
|
||||
|
||||
r = None
|
||||
resource_name = None
|
||||
for name in possible_names:
|
||||
if name in self.station_resources:
|
||||
r = self.station_resources[name]
|
||||
resource_name = name
|
||||
break
|
||||
|
||||
r = self.station_resources.get(resource_name)
|
||||
if r:
|
||||
status_channel = status_row.get(chl_id, {})
|
||||
metrics = status_channel.get("metrics", {})
|
||||
# 构建BatteryTestPosition状态数据(移除capacity和energy)
|
||||
channel_state = {
|
||||
# 基本测量数据
|
||||
"voltage": metrics.get("voltage_V", 0.0),
|
||||
"current": metrics.get("current_A", 0.0),
|
||||
"time": metrics.get("totaltime_s", 0.0),
|
||||
|
||||
# 状态信息
|
||||
"status": status_channel.get("state", "unknown"),
|
||||
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
|
||||
"voltage": status_channel.get("voltage_V", 0.0),
|
||||
"current": status_channel.get("current_A", 0.0),
|
||||
"time": status_channel.get("totaltime_s", 0.0),
|
||||
|
||||
# 通道名称标识
|
||||
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
|
||||
|
||||
}
|
||||
r.load_state(channel_state)
|
||||
except (KeyError, IndexError):
|
||||
|
||||
# 调试信息
|
||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||
self._ros_node.lab_logger().debug(
|
||||
f"更新P1资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
|
||||
f"状态:{channel_state['status']}"
|
||||
)
|
||||
else:
|
||||
# 如果找不到资源,记录调试信息
|
||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||
self._ros_node.lab_logger().debug(
|
||||
f"P1未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||
self._ros_node.lab_logger().debug(f"P1映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
|
||||
continue
|
||||
|
||||
# 第2盘:subdevid 6-10 映射到 P2_A1-P2_E8 (5行8列)
|
||||
# 第2盘:subdevid 6-10 映射到 8列5行网格 (列0-7, 行0-4)
|
||||
for subdev_id in range(6, 11): # subdevid 6-10
|
||||
status_row = subunits.get(subdev_id, {})
|
||||
|
||||
for chl_id in range(1, 9): # chlid 1-8
|
||||
try:
|
||||
# 计算在5×8网格中的位置
|
||||
row_idx = (subdev_id - 6) # 0-4 (subdevid 6->0, 7->1, ..., 10->4) (对应A-E)
|
||||
col_idx = (chl_id - 1) # 0-7 (对应1-8)
|
||||
resource_name = f"P2_{self.LETTERS[row_idx]}{col_idx + 1}"
|
||||
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
|
||||
row_idx = (subdev_id - 6) # 0-4 (subdevid 6-10 -> 行0-4)
|
||||
|
||||
# 尝试多种可能的资源命名格式
|
||||
possible_names = [
|
||||
f"P2_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
|
||||
f"P2_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
|
||||
f"P2_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
|
||||
]
|
||||
|
||||
r = None
|
||||
resource_name = None
|
||||
for name in possible_names:
|
||||
if name in self.station_resources:
|
||||
r = self.station_resources[name]
|
||||
resource_name = name
|
||||
break
|
||||
|
||||
r = self.station_resources.get(resource_name)
|
||||
if r:
|
||||
status_channel = status_row.get(chl_id, {})
|
||||
metrics = status_channel.get("metrics", {})
|
||||
# 构建BatteryTestPosition状态数据(移除capacity和energy)
|
||||
channel_state = {
|
||||
# 基本测量数据
|
||||
"voltage": metrics.get("voltage_V", 0.0),
|
||||
"current": metrics.get("current_A", 0.0),
|
||||
"time": metrics.get("totaltime_s", 0.0),
|
||||
|
||||
# 状态信息
|
||||
"status": status_channel.get("state", "unknown"),
|
||||
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
|
||||
"voltage": status_channel.get("voltage_V", 0.0),
|
||||
"current": status_channel.get("current_A", 0.0),
|
||||
"time": status_channel.get("totaltime_s", 0.0),
|
||||
|
||||
# 通道名称标识
|
||||
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
|
||||
|
||||
}
|
||||
r.load_state(channel_state)
|
||||
except (KeyError, IndexError):
|
||||
|
||||
# 调试信息
|
||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||
self._ros_node.lab_logger().debug(
|
||||
f"更新P2资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
|
||||
f"状态:{channel_state['status']}"
|
||||
)
|
||||
else:
|
||||
# 如果找不到资源,记录调试信息
|
||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||
self._ros_node.lab_logger().debug(
|
||||
f"P2未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
|
||||
self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
|
||||
continue
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": list(self.station_resources.values())
|
||||
})
|
||||
|
||||
@property
|
||||
def connection_info(self) -> Dict[str, str]:
|
||||
@@ -490,6 +572,45 @@ class NewareBatteryTestSystem:
|
||||
|
||||
|
||||
|
||||
def debug_resource_names(self) -> dict:
|
||||
"""
|
||||
调试方法:显示所有资源的实际名称(ROS2动作)
|
||||
|
||||
Returns:
|
||||
dict: ROS2动作结果格式,包含所有资源名称信息
|
||||
"""
|
||||
try:
|
||||
debug_info = {
|
||||
"total_resources": len(self.station_resources),
|
||||
"plate1_resources": len(self.station_resources_plate1),
|
||||
"plate2_resources": len(self.station_resources_plate2),
|
||||
"plate1_names": list(self.station_resources_plate1.keys())[:10], # 显示前10个
|
||||
"plate2_names": list(self.station_resources_plate2.keys())[:10], # 显示前10个
|
||||
"all_resource_names": list(self.station_resources.keys())[:20], # 显示前20个
|
||||
}
|
||||
|
||||
# 检查是否有用户提到的命名格式
|
||||
batterytestposition_names = [name for name in self.station_resources.keys()
|
||||
if "batterytestposition" in name]
|
||||
debug_info["batterytestposition_names"] = batterytestposition_names[:10]
|
||||
|
||||
success_msg = f"资源调试信息获取成功,共{debug_info['total_resources']}个资源"
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().info(success_msg)
|
||||
self._ros_node.lab_logger().info(f"调试信息: {debug_info}")
|
||||
|
||||
return {
|
||||
"return_info": success_msg,
|
||||
"success": True,
|
||||
"debug_data": debug_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"获取资源调试信息失败: {str(e)}"
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().error(error_msg)
|
||||
return {"return_info": error_msg, "success": False}
|
||||
|
||||
# ========================
|
||||
# 辅助方法
|
||||
# ========================
|
||||
@@ -538,6 +659,228 @@ class NewareBatteryTestSystem:
|
||||
except Exception as e:
|
||||
print(f" 获取状态失败: {e}")
|
||||
|
||||
# ========================
|
||||
# CSV批量提交功能(新增)
|
||||
# ========================
|
||||
|
||||
def _ensure_local_import_path(self):
|
||||
"""确保本地模块导入路径"""
|
||||
base_dir = os.path.dirname(__file__)
|
||||
if base_dir not in sys.path:
|
||||
sys.path.insert(0, base_dir)
|
||||
|
||||
def _canon(self, bs: str) -> str:
|
||||
"""规范化电池体系名称"""
|
||||
return str(bs).strip().replace('-', '_').upper()
|
||||
|
||||
def _compute_values(self, row):
|
||||
"""
|
||||
计算活性物质质量和容量
|
||||
|
||||
Args:
|
||||
row: DataFrame行数据
|
||||
|
||||
Returns:
|
||||
tuple: (活性物质质量mg, 容量mAh)
|
||||
"""
|
||||
pw = float(row['Pole_Weight'])
|
||||
cm = float(row['集流体质量'])
|
||||
am = row['活性物质含量']
|
||||
if isinstance(am, str) and am.endswith('%'):
|
||||
amv = float(am.rstrip('%')) / 100.0
|
||||
else:
|
||||
amv = float(am)
|
||||
act_mass = (pw - cm) * amv
|
||||
sc = float(row['克容量mah/g'])
|
||||
cap = act_mass * sc / 1000.0
|
||||
return round(act_mass, 2), round(cap, 3)
|
||||
|
||||
def _get_xml_builder(self, gen_mod, key: str):
|
||||
"""
|
||||
获取对应电池体系的XML生成函数
|
||||
|
||||
Args:
|
||||
gen_mod: generate_xml_content模块
|
||||
key: 电池体系标识
|
||||
|
||||
Returns:
|
||||
callable: XML生成函数
|
||||
"""
|
||||
fmap = {
|
||||
'LB6': gen_mod.xml_LB6,
|
||||
'GR_LI': gen_mod.xml_Gr_Li,
|
||||
'LFP_LI': gen_mod.xml_LFP_Li,
|
||||
'LFP_GR': gen_mod.xml_LFP_Gr,
|
||||
'811_LI_002': gen_mod.xml_811_Li_002,
|
||||
'811_LI_005': gen_mod.xml_811_Li_005,
|
||||
'SIGR_LI_STEP': gen_mod.xml_SiGr_Li_Step,
|
||||
'SIGR_LI': gen_mod.xml_SiGr_Li_Step,
|
||||
'811_SIGR': gen_mod.xml_811_SiGr,
|
||||
}
|
||||
if key not in fmap:
|
||||
raise ValueError(f"未定义电池体系映射: {key}")
|
||||
return fmap[key]
|
||||
|
||||
def _save_xml(self, xml: str, path: str):
|
||||
"""
|
||||
保存XML文件
|
||||
|
||||
Args:
|
||||
xml: XML内容
|
||||
path: 文件路径
|
||||
"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(xml)
|
||||
|
||||
def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict:
|
||||
"""
|
||||
从CSV文件批量提交Neware测试任务(设备动作)
|
||||
|
||||
Args:
|
||||
csv_path (str): 输入CSV文件路径
|
||||
output_dir (str): 输出目录,用于存储XML文件和备份,默认当前目录
|
||||
|
||||
Returns:
|
||||
dict: 执行结果 {"return_info": str, "success": bool, "submitted_count": int}
|
||||
"""
|
||||
try:
|
||||
# 确保可以导入本地模块
|
||||
self._ensure_local_import_path()
|
||||
import pandas as pd
|
||||
import generate_xml_content as gen_mod
|
||||
from neware_driver import start_test
|
||||
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().info(f"开始从CSV文件提交任务: {csv_path}")
|
||||
|
||||
# 读取CSV文件
|
||||
if not os.path.exists(csv_path):
|
||||
error_msg = f"CSV文件不存在: {csv_path}"
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().error(error_msg)
|
||||
return {"return_info": error_msg, "success": False, "submitted_count": 0}
|
||||
|
||||
df = pd.read_csv(csv_path, encoding='gbk')
|
||||
|
||||
# 验证必需列
|
||||
required = [
|
||||
'Battery_Code', 'Pole_Weight', '集流体质量', '活性物质含量',
|
||||
'克容量mah/g', '电池体系', '设备号', '排号', '通道号'
|
||||
]
|
||||
missing = [c for c in required if c not in df.columns]
|
||||
if missing:
|
||||
error_msg = f"CSV缺少必需列: {missing}"
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().error(error_msg)
|
||||
return {"return_info": error_msg, "success": False, "submitted_count": 0}
|
||||
|
||||
# 创建输出目录
|
||||
xml_dir = os.path.join(output_dir, 'xml_dir')
|
||||
backup_dir = os.path.join(output_dir, 'backup_dir')
|
||||
os.makedirs(xml_dir, exist_ok=True)
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().info(
|
||||
f"输出目录: XML={xml_dir}, 备份={backup_dir}"
|
||||
)
|
||||
|
||||
# 逐行处理CSV数据
|
||||
submitted_count = 0
|
||||
results = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
coin_id = str(row['Battery_Code'])
|
||||
|
||||
# 计算活性物质质量和容量
|
||||
act_mass, cap_mAh = self._compute_values(row)
|
||||
|
||||
if cap_mAh < 0:
|
||||
error_msg = (
|
||||
f"容量为负数: Battery_Code={coin_id}, "
|
||||
f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}"
|
||||
)
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().warning(error_msg)
|
||||
results.append(f"行{idx+1} 失败: {error_msg}")
|
||||
continue
|
||||
|
||||
# 获取电池体系对应的XML生成函数
|
||||
key = self._canon(row['电池体系'])
|
||||
builder = self._get_xml_builder(gen_mod, key)
|
||||
|
||||
# 生成XML内容
|
||||
xml_content = builder(act_mass, cap_mAh)
|
||||
|
||||
# 获取设备信息
|
||||
devid = int(row['设备号'])
|
||||
subdevid = int(row['排号'])
|
||||
chlid = int(row['通道号'])
|
||||
|
||||
# 保存XML文件
|
||||
recipe_path = os.path.join(
|
||||
xml_dir,
|
||||
f"{coin_id}_{devid}_{subdevid}_{chlid}.xml"
|
||||
)
|
||||
self._save_xml(xml_content, recipe_path)
|
||||
|
||||
# 提交测试任务
|
||||
resp = start_test(
|
||||
ip=self.ip,
|
||||
port=self.port,
|
||||
devid=devid,
|
||||
subdevid=subdevid,
|
||||
chlid=chlid,
|
||||
CoinID=coin_id,
|
||||
recipe_path=recipe_path,
|
||||
backup_dir=backup_dir
|
||||
)
|
||||
|
||||
submitted_count += 1
|
||||
results.append(f"行{idx+1} {coin_id}: {resp}")
|
||||
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().info(
|
||||
f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}): {resp}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"行{idx+1} 处理失败: {str(e)}"
|
||||
results.append(error_msg)
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().error(error_msg)
|
||||
|
||||
# 汇总结果
|
||||
success_msg = (
|
||||
f"批量提交完成: 成功{submitted_count}个,共{len(df)}行。"
|
||||
f"\n详细结果:\n" + "\n".join(results)
|
||||
)
|
||||
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().info(
|
||||
f"批量提交完成: 成功{submitted_count}/{len(df)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"return_info": success_msg,
|
||||
"success": True,
|
||||
"submitted_count": submitted_count,
|
||||
"total_count": len(df),
|
||||
"results": results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"批量提交失败: {str(e)}"
|
||||
if self._ros_node:
|
||||
self._ros_node.lab_logger().error(error_msg)
|
||||
return {
|
||||
"return_info": error_msg,
|
||||
"success": False,
|
||||
"submitted_count": 0
|
||||
}
|
||||
|
||||
|
||||
def get_device_summary(self) -> dict:
|
||||
"""
|
||||
获取设备级别的摘要统计(设备动作)
|
||||
49
unilabos/devices/neware_battery_test_system/neware_driver.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import socket
|
||||
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
|
||||
|
||||
def build_start_command(devid, subdevid, chlid, CoinID,
|
||||
ip_in_xml="127.0.0.1",
|
||||
devtype:int=27,
|
||||
recipe_path:str=f"D:\\HHM_test\\A001.xml",
|
||||
backup_dir:str=f"D:\\HHM_test\\backup") -> str:
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<bts version="1.0">',
|
||||
' <cmd>start</cmd>',
|
||||
' <list count="1">',
|
||||
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
|
||||
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="0" backupontime="1" backupontimeinterval="1" backupfree="0" />',
|
||||
' </list>',
|
||||
'</bts>',
|
||||
]
|
||||
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
|
||||
return "\r\n".join(lines) + "\r\n#\r\n"
|
||||
|
||||
def recv_until_marks(sock: socket.socket, timeout=60):
|
||||
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
|
||||
buf = bytearray()
|
||||
while True:
|
||||
chunk = sock.recv(8192)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# 读到结束标志就停,避免等对端断开
|
||||
for m in END_MARKS:
|
||||
if m in buf:
|
||||
return bytes(buf)
|
||||
# 保险:读到完整 XML 结束标签也停
|
||||
if b"</bts>" in buf:
|
||||
return bytes(buf)
|
||||
return bytes(buf)
|
||||
|
||||
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup"):
|
||||
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir)
|
||||
#print(xml_cmd)
|
||||
with socket.create_connection((ip, port), timeout=60) as s:
|
||||
s.sendall(xml_cmd.encode("utf-8"))
|
||||
data = recv_until_marks(s, timeout=60)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
if __name__ == "__main__":
|
||||
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
|
||||
print(resp)
|
||||
@@ -7,7 +7,7 @@ class VirtualMultiwayValve:
|
||||
"""
|
||||
虚拟九通阀门 - 0号位连接transfer pump,1-8号位连接其他设备 🔄
|
||||
"""
|
||||
def __init__(self, port: str = "VIRTUAL", positions: int = 8):
|
||||
def __init__(self, port: str = "VIRTUAL", positions: int = 8, **kwargs):
|
||||
self.port = port
|
||||
self.max_positions = positions # 1-8号位
|
||||
self.total_positions = positions + 1 # 0-8号位,共9个位置
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"name": "配液分液工站",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bioyond_cell",
|
||||
"config": {
|
||||
"protocol_type": [],
|
||||
"station_resource": {}
|
||||
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"name": "扣电组装工作站",
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
@@ -98,7 +113,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine_four",
|
||||
"type": "MagazineHolder_4",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -139,7 +154,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -234,7 +249,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -329,7 +344,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -424,7 +439,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -522,7 +537,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine_four",
|
||||
"type": "MagazineHolder_4",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -563,7 +578,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -658,7 +673,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -753,7 +768,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -848,7 +863,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -948,7 +963,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine",
|
||||
"type": "MagazineHolder_6",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -991,7 +1006,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1086,7 +1101,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1181,7 +1196,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1276,7 +1291,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1371,7 +1386,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1466,7 +1481,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1566,7 +1581,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine",
|
||||
"type": "MagazineHolder_6",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -1609,7 +1624,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1704,7 +1719,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1799,7 +1814,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1894,7 +1909,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -1989,7 +2004,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2084,7 +2099,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2184,7 +2199,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine",
|
||||
"type": "MagazineHolder_6",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -2227,7 +2242,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2322,7 +2337,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2417,7 +2432,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2512,7 +2527,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2607,7 +2622,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2702,7 +2717,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2802,7 +2817,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine",
|
||||
"type": "MagazineHolder_6",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -2845,7 +2860,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -2940,7 +2955,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3035,7 +3050,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3130,7 +3145,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3225,7 +3240,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3320,7 +3335,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3420,7 +3435,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine",
|
||||
"type": "MagazineHolder_6",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -3463,7 +3478,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3558,7 +3573,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3653,7 +3668,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3748,7 +3763,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3843,7 +3858,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -3938,7 +3953,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -4038,7 +4053,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazine",
|
||||
"type": "MagazineHolder_6",
|
||||
"size_x": 80,
|
||||
"size_y": 80,
|
||||
"size_z": 10,
|
||||
@@ -4081,7 +4096,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -4176,7 +4191,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -4271,7 +4286,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -4366,7 +4381,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -4461,7 +4476,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -4556,7 +4571,7 @@
|
||||
"z": 10
|
||||
},
|
||||
"config": {
|
||||
"type": "ClipMagazineHole",
|
||||
"type": "Magazine",
|
||||
"size_x": 14.0,
|
||||
"size_y": 14.0,
|
||||
"size_z": 10.0,
|
||||
@@ -0,0 +1,7 @@
|
||||
material_name
|
||||
LiPF6
|
||||
LiDFOB
|
||||
DTD
|
||||
LiFSI
|
||||
LiPO2F2
|
||||
|
||||
|
@@ -47,8 +47,8 @@ class BioyondV1RPC(BaseRequest):
|
||||
super().__init__()
|
||||
print("开始初始化 BioyondV1RPC")
|
||||
self.config = config
|
||||
self.api_key = config["api_key"]
|
||||
self.host = config["api_host"]
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.host = config.get("api_host", "") or config.get("base_url", "")
|
||||
self._logger = SimpleLogger()
|
||||
self.material_cache = {}
|
||||
self._load_material_cache()
|
||||
@@ -61,7 +61,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
:return: 当前时间的 ISO 8601 格式字符串
|
||||
"""
|
||||
current_time = datetime.now(timezone.utc).isoformat(
|
||||
current_time = datetime.now().isoformat(
|
||||
timespec='milliseconds'
|
||||
)
|
||||
# 替换时区部分为 'Z'
|
||||
@@ -212,14 +212,8 @@ class BioyondV1RPC(BaseRequest):
|
||||
})
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
if response:
|
||||
error_msg = response.get('message', '未知错误')
|
||||
print(f"[ERROR] 物料入库失败: code={response.get('code')}, message={error_msg}")
|
||||
else:
|
||||
print(f"[ERROR] 物料入库失败: API 无响应")
|
||||
return {}
|
||||
# 入库成功时,即使没有 data 字段,也返回成功标识
|
||||
return response.get("data") or {"success": True}
|
||||
return response.get("data", {})
|
||||
|
||||
def delete_material(self, material_id: str) -> dict:
|
||||
"""
|
||||
@@ -239,7 +233,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
return response.get("data", {})
|
||||
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
"""指定库位出库物料"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
|
||||
params = {
|
||||
@@ -257,36 +251,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
})
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return None
|
||||
return response
|
||||
|
||||
def material_outbound_by_id(self, material_id: str, location_id: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(直接使用location_id)
|
||||
|
||||
Args:
|
||||
material_id: 物料ID
|
||||
location_id: 库位ID(不是库位名称,是UUID)
|
||||
quantity: 数量
|
||||
|
||||
Returns:
|
||||
dict: API响应,失败返回None
|
||||
"""
|
||||
params = {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id,
|
||||
"quantity": quantity
|
||||
}
|
||||
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/storage/outbound',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": params
|
||||
})
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return None
|
||||
return {}
|
||||
return response
|
||||
|
||||
# ==================== 工作流查询相关接口 ====================
|
||||
@@ -507,7 +472,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
return {}
|
||||
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/order/project-order-report',
|
||||
url=f'{self.host}/api/lims/order/order-report',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
@@ -684,7 +649,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
def scheduler_pause(self) -> int:
|
||||
"""描述:暂停调度器"""
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/scheduler/pause',
|
||||
url=f'{self.host}/api/lims/scheduler/scheduler-pause',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
@@ -695,9 +660,8 @@ class BioyondV1RPC(BaseRequest):
|
||||
return response.get("code", 0)
|
||||
|
||||
def scheduler_continue(self) -> int:
|
||||
"""描述:继续调度器"""
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/scheduler/continue',
|
||||
url=f'{self.host}/api/lims/scheduler/scheduler-continue',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
@@ -710,7 +674,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
def scheduler_stop(self) -> int:
|
||||
"""描述:停止调度器"""
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/scheduler/stop',
|
||||
url=f'{self.host}/api/lims/scheduler/scheduler-stop',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
@@ -721,9 +685,9 @@ class BioyondV1RPC(BaseRequest):
|
||||
return response.get("code", 0)
|
||||
|
||||
def scheduler_reset(self) -> int:
|
||||
"""描述:复位调度器"""
|
||||
"""描述:重置调度器"""
|
||||
response = self.post(
|
||||
url=f'{self.host}/api/lims/scheduler/reset',
|
||||
url=f'{self.host}/api/lims/scheduler/scheduler-reset',
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
@@ -739,10 +703,10 @@ class BioyondV1RPC(BaseRequest):
|
||||
"""预加载材料列表到缓存中"""
|
||||
try:
|
||||
print("正在加载材料列表缓存...")
|
||||
|
||||
|
||||
# 加载所有类型的材料:耗材(0)、样品(1)、试剂(2)
|
||||
material_types = [0, 1, 2]
|
||||
|
||||
material_types = [1, 2]
|
||||
|
||||
for type_mode in material_types:
|
||||
print(f"正在加载类型 {type_mode} 的材料...")
|
||||
stock_query = f'{{"typeMode": {type_mode}, "includeDetail": true}}'
|
||||
@@ -759,7 +723,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
material_id = material.get("id")
|
||||
if material_name and material_id:
|
||||
self.material_cache[material_name] = material_id
|
||||
|
||||
|
||||
# 处理样品板等容器中的detail材料
|
||||
detail_materials = material.get("detail", [])
|
||||
for detail_material in detail_materials:
|
||||
@@ -795,4 +759,4 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
def get_available_materials(self):
|
||||
"""获取所有可用的材料名称列表"""
|
||||
return list(self.material_cache.keys())
|
||||
return list(self.material_cache.keys())
|
||||
@@ -2,141 +2,330 @@
|
||||
"""
|
||||
配置文件 - 包含所有配置信息和映射关系
|
||||
"""
|
||||
import os
|
||||
|
||||
# API配置
|
||||
# ==================== API 基础配置 ====================
|
||||
# BioyondCellWorkstation 默认配置(包含所有必需参数)
|
||||
API_CONFIG = {
|
||||
"api_key": "",
|
||||
"api_host": ""
|
||||
}
|
||||
|
||||
# 工作流映射配置
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "",
|
||||
"reactor_taken_in": "",
|
||||
"Solid_feeding_vials": "",
|
||||
"Liquid_feeding_vials(non-titration)": "",
|
||||
"Liquid_feeding_solvents": "",
|
||||
"Liquid_feeding(titration)": "",
|
||||
"liquid_feeding_beaker": "",
|
||||
"Drip_back": "",
|
||||
}
|
||||
|
||||
# 工作流名称到DisplaySectionName的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'reactor_taken_out': '反应器取出'
|
||||
# API 连接配置
|
||||
# "api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.1.143:44389"),#实机
|
||||
"api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.11.219:44388"),# 仿真机
|
||||
"api_key": os.getenv("BIOYOND_API_KEY", "8A819E5C"),
|
||||
"timeout": int(os.getenv("BIOYOND_TIMEOUT", "30")),
|
||||
|
||||
# 报送配置
|
||||
"report_token": os.getenv("BIOYOND_REPORT_TOKEN", "CHANGE_ME_TOKEN"),
|
||||
|
||||
# HTTP 服务配置
|
||||
"HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.6"), # HTTP服务监听地址,监听计算机飞连ip地址
|
||||
"HTTP_port": int(os.getenv("BIOYOND_HTTP_PORT", "8080")),
|
||||
"debug_mode": False,# 调试模式
|
||||
}
|
||||
|
||||
# 库位映射配置
|
||||
WAREHOUSE_MAPPING = {
|
||||
"粉末堆栈": {
|
||||
"粉末加样头堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
# 样品板
|
||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
# 分装板
|
||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
"A01": "3a19da56-1379-ff7c-1745-07e200b44ce2",
|
||||
"B01": "3a19da56-1379-2424-d751-fe6e94cef938",
|
||||
"C01": "3a19da56-1379-271c-03e3-6bdb590e395e",
|
||||
"D01": "3a19da56-1379-277f-2b1b-0d11f7cf92c6",
|
||||
"E01": "3a19da56-1379-2f1c-a15b-e01db90eb39a",
|
||||
"F01": "3a19da56-1379-3fa1-846b-088158ac0b3d",
|
||||
"G01": "3a19da56-1379-5aeb-d0cd-d3b4609d66e1",
|
||||
"H01": "3a19da56-1379-6077-8258-bdc036870b78",
|
||||
"I01": "3a19da56-1379-863b-a120-f606baf04617",
|
||||
"J01": "3a19da56-1379-8a74-74e5-35a9b41d4fd5",
|
||||
"K01": "3a19da56-1379-b270-b7af-f18773918abe",
|
||||
"L01": "3a19da56-1379-ba54-6d78-fd770a671ffc",
|
||||
"M01": "3a19da56-1379-c22d-c96f-0ceb5eb54a04",
|
||||
"N01": "3a19da56-1379-d64e-c6c5-c72ea4829888",
|
||||
"O01": "3a19da56-1379-d887-1a3c-6f9cce90f90e",
|
||||
"P01": "3a19da56-1379-e77d-0e65-7463b238a3b9",
|
||||
"Q01": "3a19da56-1379-edf6-1472-802ddb628774",
|
||||
"R01": "3a19da56-1379-f281-0273-e0ef78f0fd97",
|
||||
"S01": "3a19da56-1379-f924-7f68-df1fa51489f4",
|
||||
"T01": "3a19da56-1379-ff7c-1745-07e200b44ce2"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"配液站内试剂仓库": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f",
|
||||
"A02": "3a19da43-57b5-8441-db94-b4d3875a4b6c",
|
||||
"B02": "3a19da43-57b5-3e41-c181-5119dddaf50c",
|
||||
"C02": "3a19da43-57b5-269b-282d-fba61fe8ce96",
|
||||
"A03": "3a19da43-57b5-7c1e-d02e-c40e8c33f8a1",
|
||||
"B03": "3a19da43-57b5-659f-621f-1dcf3f640363",
|
||||
"C03": "3a19da43-57b5-855a-6e71-f398e376dee1",
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"试剂替换仓库": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
||||
"A01": "3a19da51-8f4e-30f3-ea08-4f8498e9b097",
|
||||
"B01": "3a19da51-8f4e-1da7-beb0-80a4a01e67a8",
|
||||
"C01": "3a19da51-8f4e-337d-2675-bfac46880b06",
|
||||
"D01": "3a19da51-8f4e-e514-b92c-9c44dc5e489d",
|
||||
"E01": "3a19da51-8f4e-22d1-dd5b-9774ddc80402",
|
||||
"F01": "3a19da51-8f4e-273a-4871-dff41c29bfd9",
|
||||
"G01": "3a19da51-8f4e-b32f-454f-74bc1a665653",
|
||||
"H01": "3a19da51-8f4e-8c93-68c9-0b4382320f59",
|
||||
"I01": "3a19da51-8f4e-360c-0149-291b47c6089b",
|
||||
"J01": "3a19da51-8f4e-4152-9bca-8d64df8c1af0"
|
||||
}
|
||||
},
|
||||
"自动堆栈-左": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
|
||||
"A02": "3a19debc-84b5-033b-b31f-6b87f7c2bf52",
|
||||
"B01": "3a19debc-84b5-3924-172f-719ab01b125c",
|
||||
"B02": "3a19debc-84b5-aad8-70c6-b8c6bb2d8750"
|
||||
}
|
||||
},
|
||||
"自动堆栈-右": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19debe-5200-7df2-1dd9-7d202f158864",
|
||||
"A02": "3a19debe-5200-573b-6120-8b51f50e1e50",
|
||||
"B01": "3a19debe-5200-7cd8-7666-851b0a97e309",
|
||||
"B02": "3a19debe-5200-e6d3-96a3-baa6e3d5e484"
|
||||
}
|
||||
},
|
||||
"手动堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
|
||||
"A03": "3a19deae-2c7a-5876-c454-6b7e224ca927",
|
||||
"B01": "3a19deae-2c7a-2426-6d71-e9de3cb158b1",
|
||||
"B02": "3a19deae-2c7a-79b0-5e44-efaafd1e4cf3",
|
||||
"B03": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
|
||||
"C01": "3a19deae-2c7a-32bc-768e-556647e292f3",
|
||||
"C02": "3a19deae-2c7a-e97a-8484-f5a4599447c4",
|
||||
"C03": "3a19deae-2c7a-3056-6504-10dc73fbc276",
|
||||
"D01": "3a19deae-2c7a-ffad-875e-8c4cda61d440",
|
||||
"D02": "3a19deae-2c7a-61be-601c-b6fb5610499a",
|
||||
"D03": "3a19deae-2c7a-c0f7-05a7-e3fe2491e560",
|
||||
"E01": "3a19deae-2c7a-a6f4-edd1-b436a7576363",
|
||||
"E02": "3a19deae-2c7a-4367-96dd-1ca2186f4910",
|
||||
"E03": "3a19deae-2c7a-b163-2219-23df15200311",
|
||||
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a",
|
||||
"F02": "3a19deae-2c7a-a194-ea63-8b342b8d8679",
|
||||
"F03": "3a19deae-2c7a-f7c4-12bd-425799425698",
|
||||
"G01": "3a19deae-2c7a-0b56-72f1-8ab86e53b955",
|
||||
"G02": "3a19deae-2c7a-204e-95ed-1f1950f28343",
|
||||
"G03": "3a19deae-2c7a-392b-62f1-4907c66343f8",
|
||||
"H01": "3a19deae-2c7a-5602-e876-d27aca4e3201",
|
||||
"H02": "3a19deae-2c7a-f15c-70e0-25b58a8c9702",
|
||||
"H03": "3a19deae-2c7a-780b-8965-2e1345f7e834",
|
||||
"I01": "3a19deae-2c7a-8849-e172-07de14ede928",
|
||||
"I02": "3a19deae-2c7a-4772-a37f-ff99270bafc0",
|
||||
"I03": "3a19deae-2c7a-cce7-6e4a-25ea4a2068c4",
|
||||
"J01": "3a19deae-2c7a-1848-de92-b5d5ed054cc6",
|
||||
"J02": "3a19deae-2c7a-1d45-b4f8-6f866530e205",
|
||||
"J03": "3a19deae-2c7a-f237-89d9-8fe19025dee9"
|
||||
}
|
||||
},
|
||||
"4号手套箱内部堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1baa20-a7b1-c665-8b9c-d8099d07d2f6",
|
||||
"A02": "3a1baa20-a7b1-93a7-c988-f9c8ad6c58c9",
|
||||
"A03": "3a1baa20-a7b1-00ee-f751-da9b20b6c464",
|
||||
"A04": "3a1baa20-a7b1-4712-c37b-0b5b658ef7b9",
|
||||
"B01": "3a1baa20-a7b1-9847-fc9c-96d604cd1a8e",
|
||||
"B02": "3a1baa20-a7b1-4ae9-e604-0601db06249c",
|
||||
"B03": "3a1baa20-a7b1-8329-ea75-81ca559d9ce1",
|
||||
"B04": "3a1baa20-a7b1-89c5-d96f-36e98a8f7268",
|
||||
"C01": "3a1baa20-a7b1-32ec-39e6-8044733839d6",
|
||||
"C02": "3a1baa20-a7b1-b573-e426-4c86040348b2",
|
||||
"C03": "3a1baa20-a7b1-cca7-781e-0522b729bf5d",
|
||||
"C04": "3a1baa20-a7b1-7c98-5fd9-5855355ae4b3"
|
||||
}
|
||||
},
|
||||
"大分液瓶堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19da3d-4f3d-bcac-2932-7542041e10e0",
|
||||
"A02": "3a19da3d-4f3d-4d75-38ac-fb58ad0687c3",
|
||||
"A03": "3a19da3d-4f3d-b25e-f2b1-85342a5b7eae",
|
||||
"B01": "3a19da3d-4f3d-fd3e-058a-2733a0925767",
|
||||
"B02": "3a19da3d-4f3d-37bd-a944-c391ad56857f",
|
||||
"B03": "3a19da3d-4f3d-e353-7862-c6d1d4bc667f",
|
||||
"C01": "3a19da3d-4f3d-9519-5da7-76179c958e70",
|
||||
"C02": "3a19da3d-4f3d-b586-d7ed-9ec244f6f937",
|
||||
"C03": "3a19da3d-4f3d-5061-249b-35dfef732811"
|
||||
}
|
||||
},
|
||||
"小分液瓶堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"C03": "3a19da40-55bf-8943-d20d-a8b3ea0d16c0"
|
||||
}
|
||||
},
|
||||
"站内Tip头盒堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a19deab-d5cc-be1e-5c37-4e9e5a664388",
|
||||
"A02": "3a19deab-d5cc-b394-8141-27cb3853e8ea",
|
||||
"B01": "3a19deab-d5cc-4dca-596e-ca7414d5f501",
|
||||
"B02": "3a19deab-d5cc-9bc0-442b-12d9d59aa62a",
|
||||
"C01": "3a19deab-d5cc-2eaf-b6a4-f0d54e4f1246",
|
||||
"C02": "3a19deab-d5cc-d9f4-25df-b8018c372bc7"
|
||||
}
|
||||
},
|
||||
"配液站内配液大板仓库(无需提前上料)": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1a21dc-06af-3915-9cb9-80a9dc42f386"
|
||||
}
|
||||
},
|
||||
"配液站内配液小板仓库(无需以前入料)": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1a21de-8e8b-7938-2d06-858b36c10e31"
|
||||
}
|
||||
},
|
||||
"移液站内大瓶板仓库(无需提前如料)": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1a224c-c727-fa62-1f2b-0037a84b9fca"
|
||||
}
|
||||
},
|
||||
"移液站内小瓶板仓库(无需提前入料)": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb"
|
||||
}
|
||||
},
|
||||
"适配器位仓库": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1abd46-18fe-1f56-6ced-a1f7fe08e36c"
|
||||
}
|
||||
},
|
||||
"1号2号手套箱交接堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1baa49-7f77-35aa-60b1-e55a45d065fa"
|
||||
}
|
||||
},
|
||||
"2号手套箱内部堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A01": "3a1baa4b-393e-9f86-3921-7a18b0a8e371",
|
||||
"A02": "3a1baa4b-393e-9425-928b-ee0f6f679d44",
|
||||
"A03": "3a1baa4b-393e-0baf-632b-59dfdc931a3a",
|
||||
"B01": "3a1baa4b-393e-f8aa-c8a9-956f3132f05c",
|
||||
"B02": "3a1baa4b-393e-ef05-42f6-53f4c6e89d70",
|
||||
"B03": "3a1baa4b-393e-c07b-a924-a9f0dfda9711",
|
||||
"C01": "3a1baa4b-393e-4c2b-821a-16a7fe025c48",
|
||||
"C02": "3a1baa4b-393e-2eaf-61a1-9063c832d5a2",
|
||||
"C03": "3a1baa4b-393e-034e-8e28-8626d934a85f"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# 物料类型配置
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
"100ml液体": ("YB_100ml_yeti", "d37166b3-ecaa-481e-bd84-3032b795ba07"),
|
||||
"液": ("YB_ye", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
|
||||
"高粘液": ("YB_gaonianye", "abe8df30-563d-43d2-85e0-cabec59ddc16"),
|
||||
"加样头(大)": ("YB_jia_yang_tou_da_Carrier", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "加样头(大)板": ("YB_jia_yang_tou_da", "a8e714ae-2a4e-4eb9-9614-e4c140ec3f16"),
|
||||
"5ml分液瓶板": ("YB_5ml_fenyepingban", "3a192fa4-007d-ec7b-456e-2a8be7a13f23"),
|
||||
"5ml分液瓶": ("YB_5ml_fenyeping", "3a192c2a-ebb7-58a1-480d-8b3863bf74f4"),
|
||||
"20ml分液瓶板": ("YB_20ml_fenyepingban", "3a192fa4-47db-3449-162a-eaf8aba57e27"),
|
||||
"20ml分液瓶": ("YB_20ml_fenyeping", "3a192c2b-19e8-f0a3-035e-041ca8ca1035"),
|
||||
"配液瓶(小)板": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"),
|
||||
"配液瓶(小)": ("YB_pei_ye_xiao_Bottle", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"),
|
||||
"配液瓶(大)板": ("YB_peiyepingdaban", "53e50377-32dc-4781-b3c0-5ce45bc7dc27"),
|
||||
"配液瓶(大)": ("YB_pei_ye_da_Bottle", "19c52ad1-51c5-494f-8854-576f4ca9c6ca"),
|
||||
"适配器块": ("YB_shi_pei_qi_kuai", "efc3bb32-d504-4890-91c0-b64ed3ac80cf"),
|
||||
"枪头盒": ("YB_qiang_tou_he", "3a192c2e-20f3-a44a-0334-c8301839d0b3"),
|
||||
"枪头": ("YB_qiang_tou", "b6196971-1050-46da-9927-333e8dea062d"),
|
||||
}
|
||||
|
||||
# 步骤参数配置(各工作流的步骤UUID)
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": ""
|
||||
SOLID_LIQUID_MAPPINGS = {
|
||||
# 固体
|
||||
"LiDFOB": {
|
||||
"typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
|
||||
"code": "",
|
||||
"barCode": "",
|
||||
"name": "LiDFOB",
|
||||
"unit": "g",
|
||||
"parameters": "",
|
||||
"quantity": "2",
|
||||
"warningQuantity": "1",
|
||||
"details": []
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
}
|
||||
# "LiPF6": {
|
||||
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
|
||||
# "code": "",
|
||||
# "barCode": "",
|
||||
# "name": "LiPF6",
|
||||
# "unit": "g",
|
||||
# "parameters": "",
|
||||
# "quantity": 2,
|
||||
# "warningQuantity": 1,
|
||||
# "details": []
|
||||
# },
|
||||
# "LiFSI": {
|
||||
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
|
||||
# "code": "",
|
||||
# "barCode": "",
|
||||
# "name": "LiFSI",
|
||||
# "unit": "g",
|
||||
# "parameters": "",
|
||||
# "quantity": 2,
|
||||
# "warningQuantity": 1,
|
||||
# "details": []
|
||||
# },
|
||||
# "DTC": {
|
||||
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
|
||||
# "code": "",
|
||||
# "barCode": "",
|
||||
# "name": "DTC",
|
||||
# "unit": "g",
|
||||
# "parameters": "",
|
||||
# "quantity": 2,
|
||||
# "warningQuantity": 1,
|
||||
# "details": []
|
||||
# },
|
||||
# "LiPO2F2": {
|
||||
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
|
||||
# "code": "",
|
||||
# "barCode": "",
|
||||
# "name": "LiPO2F2",
|
||||
# "unit": "g",
|
||||
# "parameters": "",
|
||||
# "quantity": 2,
|
||||
# "warningQuantity": 1,
|
||||
# "details": []
|
||||
# },
|
||||
# 液体
|
||||
# "SA": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "EC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "VC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "AND": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "HTCN": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "DENE": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "TMSP": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "TMSB": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "EP": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "DEC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "EMC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "SN": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "DMC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
# "FEC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
|
||||
}
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
WORKFLOW_MAPPINGS = {}
|
||||
|
||||
ACTION_NAMES = {}
|
||||
|
||||
HTTP_SERVICE_CONFIG = {}
|
||||
LOCATION_MAPPING = {}
|
||||
@@ -1,7 +1,5 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||
@@ -25,9 +23,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# self._logger = SimpleLogger()
|
||||
# self.is_running = False
|
||||
|
||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||
self.order_completion_status = {}
|
||||
|
||||
# 90%10%小瓶投料任务创建方法
|
||||
def create_90_10_vial_feeding_task(self,
|
||||
order_name: str = None,
|
||||
@@ -275,45 +270,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# 7. 调用create_order方法创建任务
|
||||
result = self.hardware_interface.create_order(json_str)
|
||||
self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}")
|
||||
|
||||
# 8. 解析结果获取order_id
|
||||
order_id = None
|
||||
if isinstance(result, str):
|
||||
# result 格式: "{'3a1d895c-4d39-d504-1398-18f5a40bac1e': [{'id': '...', ...}]}"
|
||||
# 第一个键就是order_id (UUID)
|
||||
try:
|
||||
# 尝试解析字符串为字典
|
||||
import ast
|
||||
result_dict = ast.literal_eval(result)
|
||||
# 获取第一个键作为order_id
|
||||
if result_dict and isinstance(result_dict, dict):
|
||||
first_key = list(result_dict.keys())[0]
|
||||
order_id = first_key
|
||||
self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}")
|
||||
else:
|
||||
self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}")
|
||||
except Exception as e:
|
||||
self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}, result类型={type(result)}")
|
||||
elif isinstance(result, dict):
|
||||
# 如果已经是字典
|
||||
if result:
|
||||
first_key = list(result.keys())[0]
|
||||
order_id = first_key
|
||||
self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}")
|
||||
|
||||
if not order_id:
|
||||
self.hardware_interface._logger.warning(
|
||||
f"⚠ 未能提取order_id,result={result[:100] if isinstance(result, str) else result}"
|
||||
)
|
||||
|
||||
# 返回成功结果和构建的JSON数据
|
||||
return json.dumps({
|
||||
"suc": True,
|
||||
"order_code": order_code,
|
||||
"order_id": order_id,
|
||||
"result": result,
|
||||
"order_params": order_data
|
||||
})
|
||||
return json.dumps({"suc": True})
|
||||
|
||||
except BioyondException:
|
||||
# 重新抛出BioyondException
|
||||
@@ -441,37 +398,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
result = self.hardware_interface.create_order(json_str)
|
||||
self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}")
|
||||
|
||||
# 8. 解析结果获取order_id
|
||||
order_id = None
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
import ast
|
||||
result_dict = ast.literal_eval(result)
|
||||
if result_dict and isinstance(result_dict, dict):
|
||||
first_key = list(result_dict.keys())[0]
|
||||
order_id = first_key
|
||||
self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}")
|
||||
else:
|
||||
self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}")
|
||||
except Exception as e:
|
||||
self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}")
|
||||
elif isinstance(result, dict):
|
||||
if result:
|
||||
first_key = list(result.keys())[0]
|
||||
order_id = first_key
|
||||
self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}")
|
||||
|
||||
if not order_id:
|
||||
self.hardware_interface._logger.warning(f"⚠ 未能提取order_id")
|
||||
|
||||
# 返回成功结果和构建的JSON数据
|
||||
return json.dumps({
|
||||
"suc": True,
|
||||
"order_code": order_code,
|
||||
"order_id": order_id,
|
||||
"result": result,
|
||||
"order_params": order_data
|
||||
})
|
||||
return json.dumps({"suc": True})
|
||||
|
||||
except BioyondException:
|
||||
# 重新抛出BioyondException
|
||||
@@ -572,24 +499,15 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
hold_m_name=hold_m_name
|
||||
)
|
||||
|
||||
# 解析返回结果以获取order_code和order_id
|
||||
result_data = json.loads(result) if isinstance(result, str) else result
|
||||
order_code = result_data.get("order_code")
|
||||
order_id = result_data.get("order_id")
|
||||
order_params = result_data.get("order_params", {})
|
||||
|
||||
results.append({
|
||||
"index": idx + 1,
|
||||
"name": name,
|
||||
"success": True,
|
||||
"order_code": order_code,
|
||||
"order_id": order_id,
|
||||
"hold_m_name": hold_m_name,
|
||||
"order_params": order_params
|
||||
"hold_m_name": hold_m_name
|
||||
})
|
||||
success_count += 1
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功创建二胺溶液配置任务: {name}, order_code={order_code}, order_id={order_id}"
|
||||
f"成功创建二胺溶液配置任务: {name}"
|
||||
)
|
||||
|
||||
except BioyondException as e:
|
||||
@@ -615,17 +533,11 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
f"创建第 {idx + 1} 个任务时发生未知错误: {str(e)}"
|
||||
)
|
||||
|
||||
# 提取所有成功任务的order_code和order_id
|
||||
order_codes = [r["order_code"] for r in results if r["success"]]
|
||||
order_ids = [r["order_id"] for r in results if r["success"]]
|
||||
|
||||
# 返回汇总结果
|
||||
summary = {
|
||||
"total": len(solutions),
|
||||
"success": success_count,
|
||||
"failed": failed_count,
|
||||
"order_codes": order_codes,
|
||||
"order_ids": order_ids,
|
||||
"details": results
|
||||
}
|
||||
|
||||
@@ -634,13 +546,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
f"成功={success_count}, 失败={failed_count}"
|
||||
)
|
||||
|
||||
# 构建返回结果
|
||||
summary["return_info"] = {
|
||||
"order_codes": order_codes,
|
||||
"order_ids": order_ids,
|
||||
}
|
||||
|
||||
return summary
|
||||
# 返回JSON字符串格式
|
||||
return json.dumps(summary, ensure_ascii=False)
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
@@ -706,15 +613,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
if not all([name, main_portion is not None, titration_portion is not None, titration_solvent is not None]):
|
||||
raise BioyondException("titration 数据缺少必要参数")
|
||||
|
||||
# 将main_portion平均分成3份作为90%物料(3个小瓶)
|
||||
portion_90 = main_portion / 3
|
||||
|
||||
# 调用单个任务创建方法
|
||||
result = self.create_90_10_vial_feeding_task(
|
||||
order_name=f"90%10%小瓶投料-{name}",
|
||||
speed=speed,
|
||||
temperature=temperature,
|
||||
delay_time=delay_time,
|
||||
# 90%物料 - 主称固体直接使用main_portion
|
||||
# 90%物料 - 主称固体平均分成3份
|
||||
percent_90_1_assign_material_name=name,
|
||||
percent_90_1_target_weigh=str(round(main_portion, 6)),
|
||||
percent_90_1_target_weigh=str(round(portion_90, 6)),
|
||||
percent_90_2_assign_material_name=name,
|
||||
percent_90_2_target_weigh=str(round(portion_90, 6)),
|
||||
percent_90_3_assign_material_name=name,
|
||||
percent_90_3_target_weigh=str(round(portion_90, 6)),
|
||||
# 10%物料 - 滴定固体 + 滴定溶剂(只使用第1个10%小瓶)
|
||||
percent_10_1_assign_material_name=name,
|
||||
percent_10_1_target_weigh=str(round(titration_portion, 6)),
|
||||
@@ -723,54 +637,29 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
hold_m_name=hold_m_name
|
||||
)
|
||||
|
||||
# 解析返回结果以获取order_code和order_id
|
||||
result_data = json.loads(result) if isinstance(result, str) else result
|
||||
order_code = result_data.get("order_code")
|
||||
order_id = result_data.get("order_id")
|
||||
order_params = result_data.get("order_params", {})
|
||||
|
||||
# 构建详细信息(保持原有结构)
|
||||
detail = {
|
||||
"index": 1,
|
||||
"name": name,
|
||||
summary = {
|
||||
"success": True,
|
||||
"order_code": order_code,
|
||||
"order_id": order_id,
|
||||
"hold_m_name": hold_m_name,
|
||||
"material_name": name,
|
||||
"90_vials": {
|
||||
"count": 1,
|
||||
"weight_per_vial": round(main_portion, 6),
|
||||
"count": 3,
|
||||
"weight_per_vial": round(portion_90, 6),
|
||||
"total_weight": round(main_portion, 6)
|
||||
},
|
||||
"10_vials": {
|
||||
"count": 1,
|
||||
"solid_weight": round(titration_portion, 6),
|
||||
"liquid_volume": round(titration_solvent, 6)
|
||||
},
|
||||
"order_params": order_params
|
||||
}
|
||||
|
||||
# 构建批量结果格式(与diamine_solution_tasks保持一致)
|
||||
summary = {
|
||||
"total": 1,
|
||||
"success": 1,
|
||||
"failed": 0,
|
||||
"order_codes": [order_code],
|
||||
"order_ids": [order_id],
|
||||
"details": [detail]
|
||||
}
|
||||
}
|
||||
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功创建90%10%小瓶投料任务: {name}, order_code={order_code}, order_id={order_id}"
|
||||
f"成功创建90%10%小瓶投料任务: {hold_m_name}, "
|
||||
f"90%物料={portion_90:.6f}g×3, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL"
|
||||
)
|
||||
|
||||
# 构建返回结果
|
||||
summary["return_info"] = {
|
||||
"order_codes": [order_code],
|
||||
"order_ids": [order_id],
|
||||
}
|
||||
|
||||
return summary
|
||||
# 返回JSON字符串格式
|
||||
return json.dumps(summary, ensure_ascii=False)
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
@@ -780,279 +669,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
|
||||
|
||||
def wait_for_multiple_orders_and_get_reports(self,
|
||||
batch_create_result: str = None,
|
||||
timeout: int = 7200,
|
||||
check_interval: int = 10) -> Dict[str, Any]:
|
||||
"""
|
||||
同时等待多个任务完成并获取实验报告
|
||||
|
||||
参数说明:
|
||||
- batch_create_result: 批量创建任务的返回结果JSON字符串,包含order_codes和order_ids数组
|
||||
- timeout: 超时时间(秒),默认7200秒(2小时)
|
||||
- check_interval: 检查间隔(秒),默认10秒
|
||||
|
||||
返回: 包含所有任务状态和报告的字典
|
||||
{
|
||||
"total": 2,
|
||||
"completed": 2,
|
||||
"timeout": 0,
|
||||
"elapsed_time": 120.5,
|
||||
"reports": [
|
||||
{
|
||||
"order_code": "task_vial_1",
|
||||
"order_id": "uuid1",
|
||||
"status": "completed",
|
||||
"completion_status": 30,
|
||||
"report": {...}
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
异常:
|
||||
- BioyondException: 所有任务都超时或发生错误
|
||||
"""
|
||||
try:
|
||||
# 参数类型转换
|
||||
timeout = int(timeout) if timeout else 7200
|
||||
check_interval = int(check_interval) if check_interval else 10
|
||||
|
||||
# 验证batch_create_result参数
|
||||
if not batch_create_result or batch_create_result == "":
|
||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||
|
||||
# 解析batch_create_result JSON对象
|
||||
try:
|
||||
# 清理可能存在的截断标记 [...]
|
||||
if isinstance(batch_create_result, str) and '[...]' in batch_create_result:
|
||||
batch_create_result = batch_create_result.replace('[...]', '[]')
|
||||
|
||||
result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result
|
||||
|
||||
# 兼容外层包装格式 {error, suc, return_value}
|
||||
if isinstance(result_obj, dict) and "return_value" in result_obj:
|
||||
inner = result_obj.get("return_value")
|
||||
if isinstance(inner, str):
|
||||
result_obj = json.loads(inner)
|
||||
elif isinstance(inner, dict):
|
||||
result_obj = inner
|
||||
|
||||
# 从summary对象中提取order_codes和order_ids
|
||||
order_codes = result_obj.get("order_codes", [])
|
||||
order_ids = result_obj.get("order_ids", [])
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise BioyondException(f"解析batch_create_result失败: {e}")
|
||||
except Exception as e:
|
||||
raise BioyondException(f"处理batch_create_result时出错: {e}")
|
||||
|
||||
# 验证提取的数据
|
||||
if not order_codes:
|
||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||
if not order_ids:
|
||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||
|
||||
# 确保order_codes和order_ids是列表类型
|
||||
if not isinstance(order_codes, list):
|
||||
order_codes = [order_codes] if order_codes else []
|
||||
if not isinstance(order_ids, list):
|
||||
order_ids = [order_ids] if order_ids else []
|
||||
|
||||
codes_list = order_codes
|
||||
ids_list = order_ids
|
||||
|
||||
if len(codes_list) != len(ids_list):
|
||||
raise BioyondException(
|
||||
f"order_codes数量({len(codes_list)})与order_ids数量({len(ids_list)})不匹配"
|
||||
)
|
||||
|
||||
if not codes_list or not ids_list:
|
||||
raise BioyondException("order_codes和order_ids不能为空")
|
||||
|
||||
# 初始化跟踪变量
|
||||
total = len(codes_list)
|
||||
pending_orders = {code: {"order_id": ids_list[i], "completed": False}
|
||||
for i, code in enumerate(codes_list)}
|
||||
reports = []
|
||||
|
||||
start_time = time.time()
|
||||
self.hardware_interface._logger.info(
|
||||
f"开始等待 {total} 个任务完成: {', '.join(codes_list)}"
|
||||
)
|
||||
|
||||
# 轮询检查任务状态
|
||||
while pending_orders:
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# 检查超时
|
||||
if elapsed_time > timeout:
|
||||
# 收集超时任务
|
||||
timeout_orders = list(pending_orders.keys())
|
||||
self.hardware_interface._logger.error(
|
||||
f"等待任务完成超时,剩余未完成任务: {', '.join(timeout_orders)}"
|
||||
)
|
||||
|
||||
# 为超时任务添加记录
|
||||
for order_code in timeout_orders:
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
"order_id": pending_orders[order_code]["order_id"],
|
||||
"status": "timeout",
|
||||
"completion_status": None,
|
||||
"report": None,
|
||||
"elapsed_time": elapsed_time
|
||||
})
|
||||
|
||||
break
|
||||
|
||||
# 检查每个待完成的任务
|
||||
completed_in_this_round = []
|
||||
for order_code in list(pending_orders.keys()):
|
||||
order_id = pending_orders[order_code]["order_id"]
|
||||
|
||||
# 检查任务是否完成
|
||||
if order_code in self.order_completion_status:
|
||||
completion_info = self.order_completion_status[order_code]
|
||||
self.hardware_interface._logger.info(
|
||||
f"检测到任务 {order_code} 已完成,状态: {completion_info.get('status')}"
|
||||
)
|
||||
|
||||
# 获取实验报告
|
||||
try:
|
||||
report_query = json.dumps({"order_id": order_id})
|
||||
report = self.hardware_interface.order_report(report_query)
|
||||
|
||||
if not report:
|
||||
self.hardware_interface._logger.warning(
|
||||
f"任务 {order_code} 已完成但无法获取报告"
|
||||
)
|
||||
report = {"error": "无法获取报告"}
|
||||
else:
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功获取任务 {order_code} 的实验报告"
|
||||
)
|
||||
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
"order_id": order_id,
|
||||
"status": "completed",
|
||||
"completion_status": completion_info.get('status'),
|
||||
"report": report,
|
||||
"elapsed_time": elapsed_time
|
||||
})
|
||||
|
||||
# 标记为已完成
|
||||
completed_in_this_round.append(order_code)
|
||||
|
||||
# 清理完成状态记录
|
||||
del self.order_completion_status[order_code]
|
||||
|
||||
except Exception as e:
|
||||
self.hardware_interface._logger.error(
|
||||
f"查询任务 {order_code} 报告失败: {str(e)}"
|
||||
)
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
"order_id": order_id,
|
||||
"status": "error",
|
||||
"completion_status": completion_info.get('status'),
|
||||
"report": None,
|
||||
"error": str(e),
|
||||
"elapsed_time": elapsed_time
|
||||
})
|
||||
completed_in_this_round.append(order_code)
|
||||
|
||||
# 从待完成列表中移除已完成的任务
|
||||
for order_code in completed_in_this_round:
|
||||
del pending_orders[order_code]
|
||||
|
||||
# 如果还有待完成的任务,等待后继续
|
||||
if pending_orders:
|
||||
time.sleep(check_interval)
|
||||
|
||||
# 每分钟记录一次等待状态
|
||||
new_elapsed_time = time.time() - start_time
|
||||
if int(new_elapsed_time) % 60 == 0 and new_elapsed_time > 0:
|
||||
self.hardware_interface._logger.info(
|
||||
f"批量等待任务中... 已完成 {len(reports)}/{total}, "
|
||||
f"待完成: {', '.join(pending_orders.keys())}, "
|
||||
f"已等待 {int(new_elapsed_time/60)} 分钟"
|
||||
)
|
||||
|
||||
# 统计结果
|
||||
completed_count = sum(1 for r in reports if r['status'] == 'completed')
|
||||
timeout_count = sum(1 for r in reports if r['status'] == 'timeout')
|
||||
error_count = sum(1 for r in reports if r['status'] == 'error')
|
||||
|
||||
final_elapsed_time = time.time() - start_time
|
||||
|
||||
summary = {
|
||||
"total": total,
|
||||
"completed": completed_count,
|
||||
"timeout": timeout_count,
|
||||
"error": error_count,
|
||||
"elapsed_time": round(final_elapsed_time, 2),
|
||||
"reports": reports
|
||||
}
|
||||
|
||||
self.hardware_interface._logger.info(
|
||||
f"批量等待任务完成: 总数={total}, 成功={completed_count}, "
|
||||
f"超时={timeout_count}, 错误={error_count}, 耗时={final_elapsed_time:.1f}秒"
|
||||
)
|
||||
|
||||
# 返回字典格式,在顶层包含统计信息
|
||||
return {
|
||||
"return_info": json.dumps(summary, ensure_ascii=False)
|
||||
}
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"批量等待任务完成时发生未预期的错误: {str(e)}"
|
||||
self.hardware_interface._logger.error(error_msg)
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""
|
||||
重写父类方法,处理任务完成报送并记录到 order_completion_status
|
||||
|
||||
Args:
|
||||
report_request: WorkstationReportRequest 对象,包含任务完成信息
|
||||
used_materials: 物料使用记录列表
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 处理结果
|
||||
"""
|
||||
try:
|
||||
# 调用父类方法
|
||||
result = super().process_order_finish_report(report_request, used_materials)
|
||||
|
||||
# 记录任务完成状态
|
||||
data = report_request.data
|
||||
order_code = data.get('orderCode')
|
||||
|
||||
if order_code:
|
||||
self.order_completion_status[order_code] = {
|
||||
'status': data.get('status'),
|
||||
'order_name': data.get('orderName'),
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'start_time': data.get('startTime'),
|
||||
'end_time': data.get('endTime')
|
||||
}
|
||||
|
||||
self.hardware_interface._logger.info(
|
||||
f"已记录任务完成状态: {order_code}, status={data.get('status')}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.hardware_interface._logger.error(f"处理任务完成报送失败: {e}")
|
||||
return {"processed": False, "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bioyond = BioyondDispensingStation(config={
|
||||
"api_key": "DE9BDDA0",
|
||||
@@ -1473,3 +1089,4 @@ if __name__ == "__main__":
|
||||
|
||||
# id = "3a1bce3c-4f31-c8f3-5525-f3b273bc34dc"
|
||||
# bioyond.sample_waste_removal(id)
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ class BioyondReactionStation(BioyondWorkstation):
|
||||
temperature: 温度设定(°C)
|
||||
"""
|
||||
# 处理 volume 参数:优先使用直接传入的 volume,否则从 solvents 中提取
|
||||
if not volume and solvents is not None:
|
||||
if volume is None and solvents is not None:
|
||||
# 参数类型转换:如果是字符串则解析为字典
|
||||
if isinstance(solvents, str):
|
||||
try:
|
||||
@@ -345,7 +345,7 @@ class BioyondReactionStation(BioyondWorkstation):
|
||||
|
||||
def liquid_feeding_beaker(
|
||||
self,
|
||||
volume: str = "350",
|
||||
volume: str = "35000",
|
||||
assign_material_name: str = "BAPP",
|
||||
time: str = "0",
|
||||
torque_variation: int = 1,
|
||||
@@ -355,7 +355,7 @@ class BioyondReactionStation(BioyondWorkstation):
|
||||
"""液体进料烧杯
|
||||
|
||||
Args:
|
||||
volume: 分液质量(g)
|
||||
volume: 分液量(μL)
|
||||
assign_material_name: 物料名称(试剂瓶位)
|
||||
time: 观察时间(分钟)
|
||||
torque_variation: 是否观察(int类型, 1=否, 2=是)
|
||||
@@ -580,14 +580,7 @@ class BioyondReactionStation(BioyondWorkstation):
|
||||
# print(f"\n✅ 任务创建成功: {result}")
|
||||
# print(f"\n✅ 任务创建成功")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# 返回结果,包含合并后的工作流数据和订单参数
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"result": result,
|
||||
"merged_workflow": merged_workflow,
|
||||
"order_params": order_params
|
||||
})
|
||||
return json.dumps({"success": True, "result": result})
|
||||
|
||||
def _build_workflows_with_parameters(self, workflows_result: list) -> list:
|
||||
"""
|
||||
@@ -787,4 +780,4 @@ class BioyondReactionStation(BioyondWorkstation):
|
||||
except Exception as e:
|
||||
print(f" ❌ 工作流ID验证失败: {e}")
|
||||
print(f" 💡 将重新合并工作流")
|
||||
return False
|
||||
return False
|
||||
@@ -0,0 +1,639 @@
|
||||
"""
|
||||
纽扣电池组装工作站物料类定义
|
||||
Button Battery Assembly Station Resource Classes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
from pylabrobot.resources.container import Container
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||
from pylabrobot.resources.resource import Resource
|
||||
from pylabrobot.resources.resource_stack import ResourceStack
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.trash import Trash
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||
|
||||
|
||||
|
||||
# TODO: 这个应该只能放一个极片
|
||||
class MaterialHoleState(TypedDict):
|
||||
diameter: int
|
||||
depth: int
|
||||
max_sheets: int
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialHole(Resource):
|
||||
"""料板洞位类"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "material_hole",
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||
diameter=20,
|
||||
depth=10,
|
||||
max_sheets=1,
|
||||
info=None
|
||||
)
|
||||
|
||||
def get_all_sheet_info(self):
|
||||
info_list = []
|
||||
for sheet in self.children:
|
||||
info_list.append(sheet._unilabos_state["info"])
|
||||
return info_list
|
||||
|
||||
#这个函数函数好像没用,一般不会集中赋值质量
|
||||
def set_all_sheet_mass(self):
|
||||
for sheet in self.children:
|
||||
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||
|
||||
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) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
#移动极片前先取出对象
|
||||
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||
for sheet in self.children:
|
||||
if sheet.name == name:
|
||||
return sheet
|
||||
return None
|
||||
|
||||
def has_electrode_sheet(self) -> bool:
|
||||
"""检查洞位是否有极片"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: ElectrodeSheet,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||
return self.children[index]
|
||||
|
||||
|
||||
class MaterialPlateState(TypedDict):
|
||||
hole_spacing_x: float
|
||||
hole_spacing_y: float
|
||||
hole_diameter: float
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||
|
||||
children: List[MaterialHole]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
fill: bool = False
|
||||
):
|
||||
"""初始化料板
|
||||
|
||||
Args:
|
||||
name: 料板名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing_x: X方向洞位间距 (mm)
|
||||
hole_spacing_y: Y方向洞位间距 (mm)
|
||||
number: 编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||
hole_spacing_x=24.0,
|
||||
hole_spacing_y=24.0,
|
||||
hole_diameter=20.0,
|
||||
info="",
|
||||
)
|
||||
# 创建4x4的洞位
|
||||
# TODO: 这里要改,对应不同形状
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 16,
|
||||
size_y = 16,
|
||||
size_z = 16,
|
||||
)
|
||||
if fill:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
else:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def update_locations(self):
|
||||
# TODO:调多次相加
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=self._size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 1,
|
||||
size_y = 1,
|
||||
size_z = 1,
|
||||
)
|
||||
for item, original_item in zip(holes.items(), self.children):
|
||||
original_item.location = item[1].location
|
||||
|
||||
|
||||
class PlateSlot(ResourceStack):
|
||||
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
max_plates: int = 8,
|
||||
category: str = "plate_slot",
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化板槽位
|
||||
|
||||
Args:
|
||||
name: 槽位名称
|
||||
max_plates: 最大板数量
|
||||
category: 类别
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
direction="z", # Z方向堆叠
|
||||
resources=[],
|
||||
)
|
||||
self.max_plates = max_plates
|
||||
self.category = category
|
||||
|
||||
def can_add_plate(self) -> bool:
|
||||
"""检查是否可以添加板"""
|
||||
return len(self.children) < self.max_plates
|
||||
|
||||
def add_plate(self, plate: MaterialPlate) -> None:
|
||||
"""添加料板"""
|
||||
if not self.can_add_plate():
|
||||
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||
self.assign_child_resource(plate)
|
||||
|
||||
def get_top_plate(self) -> MaterialPlate:
|
||||
"""获取最上方的板"""
|
||||
if len(self.children) == 0:
|
||||
raise ValueError(f"槽位 {self.name} 为空")
|
||||
return cast(MaterialPlate, self.get_top_item())
|
||||
|
||||
def take_top_plate(self) -> MaterialPlate:
|
||||
"""取出最上方的板"""
|
||||
top_plate = self.get_top_plate()
|
||||
self.unassign_child_resource(top_plate)
|
||||
return top_plate
|
||||
|
||||
def can_access_for_picking(self) -> bool:
|
||||
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"max_plates": self.max_plates,
|
||||
}
|
||||
|
||||
|
||||
#是一种类型注解,不用self
|
||||
class BatteryState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float
|
||||
height: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
electrolyte_name: str
|
||||
|
||||
class Battery(Resource):
|
||||
"""电池类 - 可容纳极片"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category: str = "battery",
|
||||
):
|
||||
"""初始化电池
|
||||
|
||||
Args:
|
||||
name: 电池名称
|
||||
diameter: 直径 (mm)
|
||||
height: 高度 (mm)
|
||||
max_volume: 最大容量 (μL)
|
||||
barcode: 二维码编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState(
|
||||
diameter = 1.0,
|
||||
height = 1.0,
|
||||
assembly_pressure = 1.0,
|
||||
electrolyte_volume = 1.0,
|
||||
electrolyte_name = "DP001"
|
||||
)
|
||||
|
||||
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||
if bottle.aspirate_electrolyte(10):
|
||||
if self.add_electrolyte(to_add_name, 10):
|
||||
pass
|
||||
else:
|
||||
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||
|
||||
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||
"""设置电解液信息"""
|
||||
self._unilabos_state["electrolyte_name"] = name
|
||||
self._unilabos_state["electrolyte_volume"] = volume
|
||||
#这个应该没用,不会有加了后再加的事情
|
||||
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||
"""添加电解液信息"""
|
||||
if name != self._unilabos_state["electrolyte_name"]:
|
||||
return False
|
||||
self._unilabos_state["electrolyte_volume"] += volume
|
||||
|
||||
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) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
# 电解液作为属性放进去
|
||||
|
||||
class BatteryPressSlotState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float =20.0
|
||||
depth: float = 4.0
|
||||
|
||||
class BatteryPressSlot(Resource):
|
||||
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||
children: List[Battery] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "BatteryPressSlot",
|
||||
category: str = "battery_press_slot",
|
||||
):
|
||||
"""初始化电池压制槽
|
||||
|
||||
Args:
|
||||
name: 压制槽名称
|
||||
diameter: 直径 (mm)
|
||||
depth: 深度 (mm)
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=10,
|
||||
size_y=12,
|
||||
size_z=13,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||
|
||||
def has_battery(self) -> bool:
|
||||
"""检查是否有电池"""
|
||||
return len(self.children) > 0
|
||||
|
||||
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) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Battery,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||
if self.has_battery():
|
||||
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_battery_info(self, index: int) -> Battery:
|
||||
return self.children[0]
|
||||
|
||||
|
||||
def TipBox64(
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
category: str = "tip_rack",
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""64孔枪头盒类"""
|
||||
from pylabrobot.resources.tip import Tip
|
||||
|
||||
# 创建12x8=96个枪头位
|
||||
def make_tip():
|
||||
return Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=20.0,
|
||||
maximal_volume=1000, # 1mL
|
||||
fitting_depth=8.0,
|
||||
)
|
||||
|
||||
tip_spots = create_ordered_items_2d(
|
||||
klass=TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=8.0,
|
||||
dy=8.0,
|
||||
dz=0.0,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=10,
|
||||
size_y=10,
|
||||
size_z=0.0,
|
||||
make_tip=make_tip,
|
||||
)
|
||||
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||
tip_rack = TipRack(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
# ordered_items=tip_spots_available,
|
||||
ordered_items=tip_spots,
|
||||
category=category,
|
||||
model=model,
|
||||
with_tips=False,
|
||||
)
|
||||
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||
return tip_rack
|
||||
|
||||
|
||||
class WasteTipBoxstate(TypedDict):
|
||||
""""废枪头盒状态字典"""
|
||||
max_tips: int = 100
|
||||
tip_count: int = 0
|
||||
|
||||
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||
class WasteTipBox(Trash):
|
||||
"""废枪头盒类 - 100个枪头容量"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
material_z_thickness=0,
|
||||
max_volume=float("inf"),
|
||||
category="trash",
|
||||
model=None,
|
||||
compute_volume_from_height=None,
|
||||
compute_height_from_volume=None,
|
||||
):
|
||||
"""初始化废枪头盒
|
||||
|
||||
Args:
|
||||
name: 废枪头盒名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
max_tips: 最大枪头容量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||
|
||||
def add_tip(self) -> None:
|
||||
"""添加废枪头"""
|
||||
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||
self._unilabos_state["tip_count"] += 1
|
||||
|
||||
def get_tip_count(self) -> int:
|
||||
"""获取枪头数量"""
|
||||
return self._unilabos_state["tip_count"]
|
||||
|
||||
def empty(self) -> None:
|
||||
"""清空废枪头盒"""
|
||||
self._unilabos_state["tip_count"] = 0
|
||||
|
||||
|
||||
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) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
|
||||
class CoincellDeck(Deck):
|
||||
"""纽扣电池组装工作站台面类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "coin_cell_deck",
|
||||
size_x: float = 1450.0, # 1m
|
||||
size_y: float = 1450.0, # 1m
|
||||
size_z: float = 100.0, # 0.9m
|
||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||
category: str = "coin_cell_deck",
|
||||
setup: bool = False, # 是否自动执行 setup
|
||||
):
|
||||
"""初始化纽扣电池组装工作站台面
|
||||
|
||||
Args:
|
||||
name: 台面名称
|
||||
size_x: 长度 (mm) - 1m
|
||||
size_y: 宽度 (mm) - 1m
|
||||
size_z: 高度 (mm) - 0.9m
|
||||
origin: 原点坐标
|
||||
category: 类别
|
||||
setup: 是否自动执行 setup 配置标准布局
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1450.0,
|
||||
size_y=1450.0,
|
||||
size_z=100.0,
|
||||
origin=origin,
|
||||
)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||
# ====================================== 子弹夹 ============================================
|
||||
|
||||
# 正极片(4个洞位,2x2布局)
|
||||
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||
|
||||
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||
|
||||
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||
|
||||
# 成品弹夹(6个洞位,3x2布局)
|
||||
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||
|
||||
# ====================================== 物料板 ============================================
|
||||
# 创建物料板(料盘carrier)- 4x4布局
|
||||
# 负极料盘
|
||||
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||
# for i in range(16):
|
||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||
|
||||
# 隔膜料盘
|
||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||
# for i in range(16):
|
||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||
|
||||
# ====================================== 瓶架、移液枪 ============================================
|
||||
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||
|
||||
# bottle_rack_3x4 = BottleRack(
|
||||
# name="bottle_rack_3x4",
|
||||
# size_x=210.0,
|
||||
# size_y=140.0,
|
||||
# size_z=100.0,
|
||||
# num_items_x=2,
|
||||
# num_items_y=4,
|
||||
# position_spacing=35.0,
|
||||
# orientation="vertical",
|
||||
# )
|
||||
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||
|
||||
# 电解液缓存位 - 6x2布局
|
||||
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||
# 电解液回收位6x2
|
||||
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||
|
||||
tip_box = TipBox64(name="tip_box_64")
|
||||
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deck = create_coin_cell_deck()
|
||||
print(deck)
|
||||
@@ -1,44 +1,133 @@
|
||||
import csv
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
from functools import wraps
|
||||
from pylabrobot.resources import Deck, Resource as PLRResource
|
||||
from unilabos_msgs.msg import Resource
|
||||
from unilabos.device_comms.modbus_plc.client import ModbusTcpClient
|
||||
from unilabos.devices.workstation.coin_cell_assembly.button_battery_station import MaterialHole, MaterialPlate
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusNode, PLCWorkflow, ModbusWorkflow, WorkflowAction, BaseClient
|
||||
from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNodeBase, DataType, WorderOrder
|
||||
from unilabos.devices.workstation.coin_cell_assembly.button_battery_station import *
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||
from unilabos.resources.graphio import convert_resources_to_type
|
||||
from unilabos.utils.log import logger
|
||||
|
||||
|
||||
def _ensure_modbus_slave_kw_alias(modbus_client):
|
||||
if modbus_client is None:
|
||||
return
|
||||
|
||||
method_names = [
|
||||
"read_coils",
|
||||
"write_coils",
|
||||
"write_coil",
|
||||
"read_discrete_inputs",
|
||||
"read_holding_registers",
|
||||
"write_register",
|
||||
"write_registers",
|
||||
]
|
||||
|
||||
def _wrap(func):
|
||||
signature = inspect.signature(func)
|
||||
has_var_kwargs = any(param.kind == param.VAR_KEYWORD for param in signature.parameters.values())
|
||||
accepts_unit = has_var_kwargs or "unit" in signature.parameters
|
||||
accepts_slave = has_var_kwargs or "slave" in signature.parameters
|
||||
|
||||
@wraps(func)
|
||||
def _wrapped(self, *args, **kwargs):
|
||||
if "slave" in kwargs and not accepts_slave:
|
||||
slave_value = kwargs.pop("slave")
|
||||
if accepts_unit and "unit" not in kwargs:
|
||||
kwargs["unit"] = slave_value
|
||||
if "unit" in kwargs and not accepts_unit:
|
||||
unit_value = kwargs.pop("unit")
|
||||
if accepts_slave and "slave" not in kwargs:
|
||||
kwargs["slave"] = unit_value
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
_wrapped._has_slave_alias = True
|
||||
return _wrapped
|
||||
|
||||
for name in method_names:
|
||||
if not hasattr(modbus_client, name):
|
||||
continue
|
||||
bound_method = getattr(modbus_client, name)
|
||||
func = getattr(bound_method, "__func__", None)
|
||||
if func is None:
|
||||
continue
|
||||
if getattr(func, "_has_slave_alias", False):
|
||||
continue
|
||||
wrapped = _wrap(func)
|
||||
setattr(modbus_client, name, types.MethodType(wrapped, modbus_client))
|
||||
|
||||
|
||||
def _coerce_deck_input(deck: Any) -> Optional[Deck]:
|
||||
if deck is None:
|
||||
return None
|
||||
|
||||
if isinstance(deck, Deck):
|
||||
return deck
|
||||
|
||||
if isinstance(deck, PLRResource):
|
||||
return deck if isinstance(deck, Deck) else None
|
||||
|
||||
candidates = None
|
||||
if isinstance(deck, dict):
|
||||
if "nodes" in deck and isinstance(deck["nodes"], list):
|
||||
candidates = deck["nodes"]
|
||||
else:
|
||||
candidates = [deck]
|
||||
elif isinstance(deck, list):
|
||||
candidates = deck
|
||||
|
||||
if candidates is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
converted = convert_resources_to_type(resources_list=candidates, resource_type=Deck)
|
||||
if isinstance(converted, Deck):
|
||||
return converted
|
||||
if isinstance(converted, list):
|
||||
for item in converted:
|
||||
if isinstance(item, Deck):
|
||||
return item
|
||||
except Exception as exc:
|
||||
logger.warning(f"deck 转换 Deck 失败: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
#构建物料系统
|
||||
|
||||
class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
def __init__(
|
||||
self,
|
||||
deck: CoincellDeck,
|
||||
address: str = "192.168.1.20",
|
||||
def __init__(self,
|
||||
config: dict = None,
|
||||
deck=None,
|
||||
address: str = "172.16.28.102",
|
||||
port: str = "502",
|
||||
debug_mode: bool = True,
|
||||
debug_mode: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
#桌子
|
||||
deck=deck,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
**kwargs):
|
||||
|
||||
if deck is None and config:
|
||||
deck = config.get('deck')
|
||||
if deck is None:
|
||||
logger.info("没有传入依华deck,检查启动json文件")
|
||||
super().__init__(deck=deck, *args, **kwargs,)
|
||||
self.debug_mode = debug_mode
|
||||
self.deck = deck
|
||||
|
||||
""" 连接初始化 """
|
||||
modbus_client = TCPClient(addr=address, port=port)
|
||||
print("modbus_client", modbus_client)
|
||||
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
|
||||
_ensure_modbus_slave_kw_alias(modbus_client.client)
|
||||
if not debug_mode:
|
||||
modbus_client.client.connect()
|
||||
count = 100
|
||||
@@ -49,27 +138,20 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
time.sleep(2)
|
||||
if not modbus_client.client.is_socket_open():
|
||||
raise ValueError('modbus tcp connection failed')
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_1105.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
else:
|
||||
print("测试模式,跳过连接")
|
||||
|
||||
self.nodes, self.client = None, None
|
||||
""" 工站的配置 """
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
|
||||
self.success = False
|
||||
self.allow_data_read = False #允许读取函数运行标志位
|
||||
self.csv_export_thread = None
|
||||
self.csv_export_running = False
|
||||
self.csv_export_file = None
|
||||
#创建一个物料台面,包含两个极片板
|
||||
#self.deck = create_a_coin_cell_deck()
|
||||
|
||||
#self._ros_node.update_resource(self.deck)
|
||||
|
||||
#ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
# "resources": [self.deck]
|
||||
#})
|
||||
self.coin_num_N = 0 #已组装电池数量
|
||||
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
#self.deck = create_a_coin_cell_deck()
|
||||
@@ -77,6 +159,27 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"resources": [self.deck]
|
||||
})
|
||||
|
||||
def sync_transfer_resources(self) -> Dict[str, Any]:
|
||||
"""
|
||||
供跨工站转运完成后调用,强制将当前台面资源同步到云端/前端。
|
||||
"""
|
||||
if not hasattr(self, "_ros_node") or self._ros_node is None:
|
||||
return {"status": "failed", "error": "ros_node_not_ready"}
|
||||
if self.deck is None:
|
||||
return {"status": "failed", "error": "deck_not_initialized"}
|
||||
try:
|
||||
future = ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource,
|
||||
True,
|
||||
resources=[self.deck],
|
||||
)
|
||||
if future:
|
||||
future.result()
|
||||
return {"status": "success"}
|
||||
except Exception as exc:
|
||||
logger.error(f"同步转运资源失败: {exc}", exc_info=True)
|
||||
return {"status": "failed", "error": str(exc)}
|
||||
|
||||
# 批量操作在这里写
|
||||
async def change_hole_sheet_to_2(self, hole: MaterialHole):
|
||||
hole._unilabos_state["max_sheets"] = 2
|
||||
@@ -491,11 +594,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
try:
|
||||
# 尝试不同的字节序读取
|
||||
code_little, read_err = self.client.use_node('REG_DATA_COIN_CELL_CODE').read(10, word_order=WorderOrder.LITTLE)
|
||||
print(code_little)
|
||||
# logger.debug(f"读取电池二维码原始数据: {code_little}")
|
||||
clean_code = code_little[-8:][::-1]
|
||||
return clean_code
|
||||
except Exception as e:
|
||||
print(f"读取电池二维码失败: {e}")
|
||||
logger.error(f"读取电池二维码失败: {e}")
|
||||
return "N/A"
|
||||
|
||||
|
||||
@@ -504,11 +607,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
try:
|
||||
# 尝试不同的字节序读取
|
||||
code_little, read_err = self.client.use_node('REG_DATA_ELECTROLYTE_CODE').read(10, word_order=WorderOrder.LITTLE)
|
||||
print(code_little)
|
||||
# logger.debug(f"读取电解液二维码原始数据: {code_little}")
|
||||
clean_code = code_little[-8:][::-1]
|
||||
return clean_code
|
||||
except Exception as e:
|
||||
print(f"读取电解液二维码失败: {e}")
|
||||
logger.error(f"读取电解液二维码失败: {e}")
|
||||
return "N/A"
|
||||
|
||||
# ===================== 环境监控区 ======================
|
||||
@@ -606,7 +709,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
print("waiting for start_cmd")
|
||||
time.sleep(1)
|
||||
|
||||
def func_pack_send_bottle_num(self, bottle_num: int):
|
||||
def func_pack_send_bottle_num(self, bottle_num):
|
||||
bottle_num = int(bottle_num)
|
||||
#发送电解液平台数
|
||||
print("启动")
|
||||
while (self._unilab_rece_electrolyte_bottle_num()) == False:
|
||||
@@ -654,16 +758,25 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
# self.success = True
|
||||
# return self.success
|
||||
|
||||
def func_pack_send_msg_cmd(self, elec_use_num) -> bool:
|
||||
def func_pack_send_msg_cmd(self, elec_use_num, elec_vol, assembly_type, assembly_pressure) -> bool:
|
||||
"""UNILAB写参数"""
|
||||
while (self.request_rec_msg_status) == False:
|
||||
print("wait for request_rec_msg_status to True")
|
||||
time.sleep(1)
|
||||
self.success = False
|
||||
#self._unilab_send_msg_electrolyte_num(elec_num)
|
||||
time.sleep(1)
|
||||
#设置平行样数目
|
||||
self._unilab_send_msg_electrolyte_use_num(elec_use_num)
|
||||
time.sleep(1)
|
||||
#发送电解液加注量
|
||||
self._unilab_send_msg_electrolyte_vol(elec_vol)
|
||||
time.sleep(1)
|
||||
#发送电解液组装类型
|
||||
self._unilab_send_msg_assembly_type(assembly_type)
|
||||
time.sleep(1)
|
||||
#发送电池压制力
|
||||
self._unilab_send_msg_assembly_pressure(assembly_pressure)
|
||||
time.sleep(1)
|
||||
self._unilab_send_msg_succ_cmd(True)
|
||||
time.sleep(1)
|
||||
while (self.request_rec_msg_status) == True:
|
||||
@@ -688,15 +801,32 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
data_coin_num = self.data_coin_num
|
||||
data_electrolyte_code = self.data_electrolyte_code
|
||||
data_coin_cell_code = self.data_coin_cell_code
|
||||
print("data_open_circuit_voltage", data_open_circuit_voltage)
|
||||
print("data_pole_weight", data_pole_weight)
|
||||
print("data_assembly_time", data_assembly_time)
|
||||
print("data_assembly_pressure", data_assembly_pressure)
|
||||
print("data_electrolyte_volume", data_electrolyte_volume)
|
||||
print("data_coin_num", data_coin_num)
|
||||
print("data_electrolyte_code", data_electrolyte_code)
|
||||
print("data_coin_cell_code", data_coin_cell_code)
|
||||
logger.debug(f"data_open_circuit_voltage: {data_open_circuit_voltage}")
|
||||
logger.debug(f"data_pole_weight: {data_pole_weight}")
|
||||
logger.debug(f"data_assembly_time: {data_assembly_time}")
|
||||
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
|
||||
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
|
||||
logger.debug(f"data_coin_num: {data_coin_num}")
|
||||
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
|
||||
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
|
||||
#接收完信息后,读取完毕标志位置True
|
||||
liaopan3 = self.deck.get_resource("成品弹夹")
|
||||
#把物料解绑后放到另一盘上
|
||||
battery = ElectrodeSheet(name=f"battery_{self.coin_num_N}", size_x=14, size_y=14, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
"electrolyte_name": data_coin_cell_code,
|
||||
"data_electrolyte_code": data_electrolyte_code,
|
||||
"open_circuit_voltage": data_open_circuit_voltage,
|
||||
"assembly_pressure": data_assembly_pressure,
|
||||
"electrolyte_volume": data_electrolyte_volume
|
||||
}
|
||||
liaopan3.children[self.coin_num_N].assign_child_resource(battery, location=None)
|
||||
#print(jipian2.parent)
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
|
||||
|
||||
self._unilab_rec_msg_succ_cmd(True)
|
||||
time.sleep(1)
|
||||
#等待允许读取标志位置False
|
||||
@@ -754,11 +884,25 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self.success = True
|
||||
return self.success
|
||||
|
||||
def qiming_coin_cell_code(self, fujipian_panshu:int, fujipian_juzhendianwei:int=0, gemopanshu:int=0, gemo_juzhendianwei:int=0, lvbodian:bool=True, battery_pressure_mode:bool=True, battery_pressure:int=4000, battery_clean_ignore:bool=False) -> bool:
|
||||
self.success = False
|
||||
self.client.use_node('REG_MSG_NE_PLATE_NUM').write(fujipian_panshu)
|
||||
self.client.use_node('REG_MSG_NE_PLATE_MATRIX').write(fujipian_juzhendianwei)
|
||||
self.client.use_node('REG_MSG_SEPARATOR_PLATE_NUM').write(gemopanshu)
|
||||
self.client.use_node('REG_MSG_SEPARATOR_PLATE_MATRIX').write(gemo_juzhendianwei)
|
||||
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||
self.client.use_node('REG_MSG_PRESS_MODE').write(not battery_pressure_mode)
|
||||
# self.client.use_node('REG_MSG_ASSEMBLY_PRESSURE').write(battery_pressure)
|
||||
self.client.use_node('REG_MSG_BATTERY_CLEAN_IGNORE').write(battery_clean_ignore)
|
||||
self.success = True
|
||||
|
||||
return self.success
|
||||
|
||||
|
||||
def func_allpack_cmd(self, elec_num, elec_use_num, file_path: str="D:\\coin_cell_data") -> bool:
|
||||
def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200, file_path: str="/Users/sml/work") -> bool:
|
||||
elec_num, elec_use_num, elec_vol, assembly_type, assembly_pressure = int(elec_num), int(elec_use_num), int(elec_vol), int(assembly_type), int(assembly_pressure)
|
||||
summary_csv_file = os.path.join(file_path, "duandian.csv")
|
||||
# 如果断点文件存在,先读取之前的进度
|
||||
|
||||
if os.path.exists(summary_csv_file):
|
||||
read_status_flag = True
|
||||
with open(summary_csv_file, 'r', newline='', encoding='utf-8') as csvfile:
|
||||
@@ -784,54 +928,38 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
elec_num_N = 0
|
||||
elec_use_num_N = 0
|
||||
coin_num_N = 0
|
||||
|
||||
print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}")
|
||||
|
||||
for i in range(20):
|
||||
print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}")
|
||||
print(f"剩余电解液瓶数: {type(elec_num)}, 已组装电池数: {type(elec_use_num)}")
|
||||
print(f"剩余电解液瓶数: {type(int(elec_num))}, 已组装电池数: {type(int(elec_use_num))}")
|
||||
|
||||
#如果是第一次运行,则进行初始化、切换自动、启动, 如果是断点重启则跳过。
|
||||
if read_status_flag == False:
|
||||
pass
|
||||
#初始化
|
||||
self.func_pack_device_init()
|
||||
#self.func_pack_device_init()
|
||||
#切换自动
|
||||
self.func_pack_device_auto()
|
||||
#self.func_pack_device_auto()
|
||||
#启动,小车收回
|
||||
self.func_pack_device_start()
|
||||
#self.func_pack_device_start()
|
||||
#发送电解液瓶数量,启动搬运,多搬运没事
|
||||
self.func_pack_send_bottle_num(elec_num)
|
||||
#self.func_pack_send_bottle_num(elec_num)
|
||||
last_i = elec_num_N
|
||||
last_j = elec_use_num_N
|
||||
for i in range(last_i, elec_num):
|
||||
print(f"开始第{last_i+i+1}瓶电解液的组装")
|
||||
#第一个循环从上次断点继续,后续循环从0开始
|
||||
j_start = last_j if i == last_i else 0
|
||||
self.func_pack_send_msg_cmd(elec_use_num-j_start)
|
||||
self.func_pack_send_msg_cmd(elec_use_num-j_start, elec_vol, assembly_type, assembly_pressure)
|
||||
|
||||
for j in range(j_start, elec_use_num):
|
||||
print(f"开始第{last_i+i+1}瓶电解液的第{j+j_start+1}个电池组装")
|
||||
#读取电池组装数据并存入csv
|
||||
self.func_pack_get_msg_cmd(file_path)
|
||||
time.sleep(1)
|
||||
|
||||
#这里定义物料系统
|
||||
# TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑
|
||||
liaopan1 = self.deck.get_resource("liaopan1")
|
||||
liaopan4 = self.deck.get_resource("liaopan4")
|
||||
jipian1 = liaopan1.children[coin_num_N].children[0]
|
||||
jipian4 = liaopan4.children[coin_num_N].children[0]
|
||||
#print(jipian1)
|
||||
#从料盘上去物料解绑后放到另一盘上
|
||||
jipian1.parent.unassign_child_resource(jipian1)
|
||||
jipian4.parent.unassign_child_resource(jipian4)
|
||||
|
||||
#print(jipian2.parent)
|
||||
battery = Battery(name = f"battery_{coin_num_N}")
|
||||
battery.assign_child_resource(jipian1, location=None)
|
||||
battery.assign_child_resource(jipian4, location=None)
|
||||
|
||||
zidanjia6 = self.deck.get_resource("zi_dan_jia6")
|
||||
|
||||
zidanjia6.children[0].assign_child_resource(battery, location=None)
|
||||
|
||||
|
||||
|
||||
# 生成断点文件
|
||||
# 生成包含elec_num_N、coin_num_N、timestamp的CSV文件
|
||||
@@ -842,6 +970,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
writer.writerow([elec_num, elec_use_num, elec_num_N, elec_use_num_N, coin_num_N, timestamp])
|
||||
csvfile.flush()
|
||||
coin_num_N += 1
|
||||
self.coin_num_N = coin_num_N
|
||||
elec_use_num_N += 1
|
||||
elec_num_N += 1
|
||||
elec_use_num_N = 0
|
||||
@@ -876,38 +1005,54 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
#self.success = True
|
||||
#return self.success
|
||||
|
||||
def run_packaging_workflow(self, workflow_config: Dict[str, Any]) -> "CoinCellAssemblyWorkstation":
|
||||
config = workflow_config or {}
|
||||
|
||||
qiming_params = config.get("qiming") or {}
|
||||
if qiming_params:
|
||||
self.qiming_coin_cell_code(**qiming_params)
|
||||
|
||||
if config.get("init", True):
|
||||
self.func_pack_device_init()
|
||||
if config.get("auto", True):
|
||||
self.func_pack_device_auto()
|
||||
if config.get("start", True):
|
||||
self.func_pack_device_start()
|
||||
|
||||
packaging_config = config.get("packaging") or {}
|
||||
bottle_num = packaging_config.get("bottle_num")
|
||||
if bottle_num is not None:
|
||||
self.func_pack_send_bottle_num(bottle_num)
|
||||
|
||||
allpack_params = packaging_config.get("command") or {}
|
||||
if allpack_params:
|
||||
self.func_allpack_cmd(**allpack_params)
|
||||
|
||||
return self
|
||||
|
||||
def fun_wuliao_test(self) -> bool:
|
||||
#找到data_init中构建的2个物料盘
|
||||
#liaopan1 = self.deck.get_resource("liaopan1")
|
||||
#liaopan4 = self.deck.get_resource("liaopan4")
|
||||
#for coin_num_N in range(16):
|
||||
# liaopan1 = self.deck.get_resource("liaopan1")
|
||||
# liaopan4 = self.deck.get_resource("liaopan4")
|
||||
# jipian1 = liaopan1.children[coin_num_N].children[0]
|
||||
# jipian4 = liaopan4.children[coin_num_N].children[0]
|
||||
# #print(jipian1)
|
||||
# #从料盘上去物料解绑后放到另一盘上
|
||||
# jipian1.parent.unassign_child_resource(jipian1)
|
||||
# jipian4.parent.unassign_child_resource(jipian4)
|
||||
#
|
||||
# #print(jipian2.parent)
|
||||
# battery = Battery(name = f"battery_{coin_num_N}")
|
||||
# battery.assign_child_resource(jipian1, location=None)
|
||||
# battery.assign_child_resource(jipian4, location=None)
|
||||
#
|
||||
# zidanjia6 = self.deck.get_resource("zi_dan_jia6")
|
||||
# zidanjia6.children[0].assign_child_resource(battery, location=None)
|
||||
# ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
# "resources": [self.deck]
|
||||
# })
|
||||
# time.sleep(2)
|
||||
for i in range(20):
|
||||
print(f"输出{i}")
|
||||
time.sleep(2)
|
||||
|
||||
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
for i in range(16):
|
||||
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
"diameter": 20.0,
|
||||
"height": 20.0,
|
||||
"assembly_pressure": i,
|
||||
"electrolyte_volume": 20.0,
|
||||
"electrolyte_name": f"DP{i}"
|
||||
}
|
||||
liaopan3.children[i].assign_child_resource(battery, location=None)
|
||||
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
# for i in range(40):
|
||||
# print(f"fun_wuliao_test 运行结束{i}")
|
||||
# time.sleep(1)
|
||||
# time.sleep(40)
|
||||
# 数据读取与输出
|
||||
def func_read_data_and_output(self, file_path: str="D:\\coin_cell_data"):
|
||||
def func_read_data_and_output(self, file_path: str="/Users/sml/work"):
|
||||
# 检查CSV导出是否正在运行,已运行则跳出,防止同时启动两个while循环
|
||||
if self.csv_export_running:
|
||||
return False, "读取已在运行中"
|
||||
@@ -1012,7 +1157,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
# else:
|
||||
# print("子弹夹洞位0没有极片")
|
||||
#
|
||||
# #把电解液从瓶中取到电池夹子中
|
||||
# # TODO:#把电解液从瓶中取到电池夹子中
|
||||
# battery_site = deck.get_resource("battery_press_1")
|
||||
# clip_magazine_battery = deck.get_resource("clip_magazine_battery")
|
||||
# if battery_site.has_battery():
|
||||
@@ -1098,45 +1243,95 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"""移液枪头库存 (数量, INT16)"""
|
||||
inventory, read_err = self.client.register_node_list(self.nodes).use_node('REG_DATA_TIPS_INVENTORY').read(1)
|
||||
return inventory
|
||||
|
||||
|
||||
'''
|
||||
|
||||
def run_coin_cell_assembly_workflow(
|
||||
self,
|
||||
workflow_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
config: Dict[str, Any]
|
||||
if workflow_config is None:
|
||||
config = {}
|
||||
elif isinstance(workflow_config, list):
|
||||
config = {"materials": workflow_config}
|
||||
else:
|
||||
config = workflow_config
|
||||
qiming_defaults = {
|
||||
"fujipian_panshu": 1,
|
||||
"fujipian_juzhendianwei": 0,
|
||||
"gemopanshu": 1,
|
||||
"gemo_juzhendianwei": 0,
|
||||
"lvbodian": True,
|
||||
"battery_pressure_mode": True,
|
||||
"battery_pressure": 4200,
|
||||
"battery_clean_ignore": False,
|
||||
}
|
||||
qiming_params = {**qiming_defaults, **(config.get("qiming") or {})}
|
||||
qiming_success = self.qiming_coin_cell_code(**qiming_params)
|
||||
|
||||
step_results: Dict[str, Any] = {}
|
||||
try:
|
||||
self.func_pack_device_init()
|
||||
step_results["init"] = True
|
||||
except Exception as exc:
|
||||
step_results["init"] = f"error: {exc}"
|
||||
|
||||
try:
|
||||
self.func_pack_device_auto()
|
||||
step_results["auto"] = True
|
||||
except Exception as exc:
|
||||
step_results["auto"] = f"error: {exc}"
|
||||
|
||||
try:
|
||||
self.func_pack_device_start()
|
||||
step_results["start"] = True
|
||||
except Exception as exc:
|
||||
step_results["start"] = f"error: {exc}"
|
||||
|
||||
packaging_cfg = config.get("packaging") or {}
|
||||
bottle_num = packaging_cfg.get("bottle_num", 1)
|
||||
try:
|
||||
self.func_pack_send_bottle_num(bottle_num)
|
||||
step_results["send_bottle_num"] = True
|
||||
except Exception as exc:
|
||||
step_results["send_bottle_num"] = f"error: {exc}"
|
||||
|
||||
command_defaults = {
|
||||
"elec_num": 1,
|
||||
"elec_use_num": 1,
|
||||
"elec_vol": 50,
|
||||
"assembly_type": 7,
|
||||
"assembly_pressure": 4200,
|
||||
"file_path": "/Users/sml/work",
|
||||
}
|
||||
command_params = {**command_defaults, **(packaging_cfg.get("command") or {})}
|
||||
packaging_result = self.func_allpack_cmd(**command_params)
|
||||
|
||||
finished_result = self.func_pack_send_finished_cmd()
|
||||
stop_result = self.func_pack_device_stop()
|
||||
|
||||
return {
|
||||
"qiming": {
|
||||
"params": qiming_params,
|
||||
"success": qiming_success,
|
||||
},
|
||||
"workflow_steps": step_results,
|
||||
"packaging": {
|
||||
"bottle_num": bottle_num,
|
||||
"command": command_params,
|
||||
"result": packaging_result,
|
||||
},
|
||||
"finish": {
|
||||
"send_finished": finished_result,
|
||||
"stop": stop_result,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pylabrobot.resources import Resource
|
||||
Coin_Cell = CoinCellAssemblyWorkstation(Resource("1", 1, 1, 1), debug_mode=True)
|
||||
#Coin_Cell.func_pack_device_init()
|
||||
#Coin_Cell.func_pack_device_auto()
|
||||
#Coin_Cell.func_pack_device_start()
|
||||
#Coin_Cell.func_pack_send_bottle_num(2)
|
||||
#Coin_Cell.func_pack_send_msg_cmd(2)
|
||||
#Coin_Cell.func_pack_get_msg_cmd()
|
||||
#Coin_Cell.func_pack_get_msg_cmd()
|
||||
#Coin_Cell.func_pack_send_finished_cmd()
|
||||
#
|
||||
#Coin_Cell.func_allpack_cmd(3, 2)
|
||||
#print(Coin_Cell.data_stack_vision_code)
|
||||
#print("success")
|
||||
#创建一个物料台面
|
||||
|
||||
#deck = create_a_coin_cell_deck()
|
||||
|
||||
##在台面上找到料盘和极片
|
||||
#liaopan1 = deck.get_resource("liaopan1")
|
||||
#liaopan2 = deck.get_resource("liaopan2")
|
||||
#jipian1 = liaopan1.children[1].children[0]
|
||||
#
|
||||
##print(jipian1)
|
||||
##把物料解绑后放到另一盘上
|
||||
#jipian1.parent.unassign_child_resource(jipian1)
|
||||
#liaopan2.children[1].assign_child_resource(jipian1, location=None)
|
||||
##print(jipian2.parent)
|
||||
from unilabos.resources.graphio import resource_ulab_to_plr, convert_resources_to_type
|
||||
|
||||
with open("./button_battery_decks_unilab.json", "r", encoding="utf-8") as f:
|
||||
bioyond_resources_unilab = json.load(f)
|
||||
print(f"成功读取 JSON 文件,包含 {len(bioyond_resources_unilab)} 个资源")
|
||||
ulab_resources = convert_resources_to_type(bioyond_resources_unilab, List[PLRResource])
|
||||
print(f"转换结果类型: {type(ulab_resources)}")
|
||||
print(ulab_resources)
|
||||
|
||||
deck = CoincellDeck(setup=True, name="coin_cell_deck")
|
||||
w = CoinCellAssemblyWorkstation(deck=deck, address="172.16.28.102", port="502", debug_mode=False)
|
||||
w.run_coin_cell_assembly_workflow()
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,9010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,9020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,9030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,9040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,9050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,9060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,9700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,9710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,9210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,9220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,9230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,9240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,9250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,9260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,9500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,9510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,17000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,17002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,17004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,17006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,17008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,16000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,16002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,16004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,16006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,16008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,16010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,16012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,16014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,16016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,16018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,16020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,16030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,18004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,16050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,16052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,16054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,9720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,9520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,17496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,16000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,9730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,9530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,16018,ASSEMBLY_TYPE7or8
|
||||
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,9340,
|
||||
REG_MSG_NE_PLATE_MATRIX,INT16,,负极片矩阵点位,,hold_register,17440,
|
||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,隔膜矩阵点位,,hold_register,17450,
|
||||
REG_MSG_TIP_BOX_MATRIX,INT16,,移液枪头矩阵点位,,hold_register,17480,
|
||||
REG_MSG_NE_PLATE_NUM,INT16,,负极片盘数,,hold_register,17443,
|
||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,隔膜盘数,,hold_register,17453,
|
||||
REG_MSG_PRESS_MODE,BOOL,,压制模式(false:压力检测模式,True:距离模式),,coil,9360,电池压制模式
|
||||
,,,,,,,
|
||||
,BOOL,,视觉对位(false:使用,true:忽略),,coil,9300,视觉对位
|
||||
,BOOL,,复检(false:使用,true:忽略),,coil,9310,视觉复检
|
||||
,BOOL,,手套箱_左仓(false:使用,true:忽略),,coil,9320,手套箱左仓
|
||||
,BOOL,,手套箱_右仓(false:使用,true:忽略),,coil,9420,手套箱右仓
|
||||
,BOOL,,真空检知(false:使用,true:忽略),,coil,9350,真空检知
|
||||
,BOOL,,电解液添加模式(false:单次滴液,true:二次滴液),,coil,9370,滴液模式
|
||||
,BOOL,,正极片称重(false:使用,true:忽略),,coil,9380,正极片称重
|
||||
,BOOL,,正负极片组装方式(false:正装,true:倒装),,coil,9390,正负极反装
|
||||
,BOOL,,压制清洁(false:使用,true:忽略),,coil,9400,压制清洁
|
||||
,BOOL,,物料盘摆盘方式(false:水平摆盘,true:堆叠摆盘),,coil,9410,负极片摆盘方式
|
||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,忽略电池清洁(false:使用,true:忽略),,coil,9460,
|
||||
|
@@ -0,0 +1,64 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||
REG_MSG_NE_PLATE_MATRIX,INT16,,负极片矩阵点位,,hold_register,440,
|
||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,隔膜矩阵点位,,hold_register,450,
|
||||
REG_MSG_TIP_BOX_MATRIX,INT16,,移液枪头矩阵点位,,hold_register,480,
|
||||
REG_MSG_NE_PLATE_NUM,INT16,,负极片盘数,,hold_register,443,
|
||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,隔膜盘数,,hold_register,453,
|
||||
REG_MSG_PRESS_MODE,BOOL,,压制模式(false:压力检测模式,True:距离模式),,coil,8360,电池压制模式
|
||||
,,,,,,,
|
||||
,BOOL,,视觉对位(false:使用,true:忽略),,coil,8300,视觉对位
|
||||
,BOOL,,复检(false:使用,true:忽略),,coil,8310,视觉复检
|
||||
,BOOL,,手套箱_左仓(false:使用,true:忽略),,coil,8320,手套箱左仓
|
||||
,BOOL,,手套箱_右仓(false:使用,true:忽略),,coil,8420,手套箱右仓
|
||||
,BOOL,,真空检知(false:使用,true:忽略),,coil,8350,真空检知
|
||||
,BOOL,,电解液添加模式(false:单次滴液,true:二次滴液),,coil,8370,滴液模式
|
||||
,BOOL,,正极片称重(false:使用,true:忽略),,coil,8380,正极片称重
|
||||
,BOOL,,正负极片组装方式(false:正装,true:倒装),,coil,8390,正负极反装
|
||||
,BOOL,,压制清洁(false:使用,true:忽略),,coil,8400,压制清洁
|
||||
,BOOL,,物料盘摆盘方式(false:水平摆盘,true:堆叠摆盘),,coil,8410,负极片摆盘方式
|
||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,忽略电池清洁(false:使用,true:忽略),,coil,8460,
|
||||
|
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"name": "配液分液工站",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bioyond_cell",
|
||||
"config": {
|
||||
"protocol_type": [],
|
||||
"station_resource": {}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "coincellassemblyworkstation_device",
|
||||
"position": {
|
||||
"x": -600,
|
||||
"y": -400,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"debug_mode": false,
|
||||
"protocol_type": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Workstation HTTP Service Module
|
||||
|
||||
统一的工作站报送接收服务,基于LIMS协议规范:
|
||||
1. 步骤完成报送 - POST /report/step_finish
|
||||
2. 通量完成报送 - POST /report/sample_finish
|
||||
2. 通量完成报送 - POST /report/sample_finish
|
||||
3. 任务完成报送 - POST /report/order_finish
|
||||
4. 批量更新报送 - POST /report/batch_update
|
||||
5. 物料变更报送 - POST /report/material_change
|
||||
@@ -54,18 +54,18 @@ class HttpResponse:
|
||||
|
||||
class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
"""工作站HTTP请求处理器"""
|
||||
|
||||
|
||||
def __init__(self, workstation_instance, *args, **kwargs):
|
||||
self.workstation = workstation_instance
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def do_POST(self):
|
||||
"""处理POST请求 - 统一的工作站报送接口"""
|
||||
try:
|
||||
# 解析请求路径
|
||||
parsed_path = urlparse(self.path)
|
||||
endpoint = parsed_path.path
|
||||
|
||||
|
||||
# 读取请求体
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
if content_length > 0:
|
||||
@@ -73,9 +73,9 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
request_data = json.loads(post_data.decode('utf-8'))
|
||||
else:
|
||||
request_data = {}
|
||||
|
||||
|
||||
logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}")
|
||||
|
||||
|
||||
# 统一的报送端点路由(基于LIMS协议规范)
|
||||
if endpoint == '/report/step_finish':
|
||||
response = self._handle_step_finish_report(request_data)
|
||||
@@ -102,18 +102,18 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"不支持的报送端点: {endpoint}",
|
||||
data={"supported_endpoints": [
|
||||
"/report/step_finish",
|
||||
"/report/sample_finish",
|
||||
"/report/step_finish",
|
||||
"/report/sample_finish",
|
||||
"/report/order_finish",
|
||||
"/report/batch_update",
|
||||
"/report/material_change",
|
||||
"/report/error_handling"
|
||||
]}
|
||||
)
|
||||
|
||||
|
||||
# 发送响应
|
||||
self._send_response(response)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理工作站报送失败: {e}\\n{traceback.format_exc()}")
|
||||
error_response = HttpResponse(
|
||||
@@ -121,13 +121,13 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
message=f"请求处理失败: {str(e)}"
|
||||
)
|
||||
self._send_response(error_response)
|
||||
|
||||
|
||||
def do_GET(self):
|
||||
"""处理GET请求 - 健康检查和状态查询"""
|
||||
try:
|
||||
parsed_path = urlparse(self.path)
|
||||
endpoint = parsed_path.path
|
||||
|
||||
|
||||
if endpoint == '/status':
|
||||
response = self._handle_status_check()
|
||||
elif endpoint == '/health':
|
||||
@@ -138,9 +138,9 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
message=f"不支持的查询端点: {endpoint}",
|
||||
data={"supported_endpoints": ["/status", "/health"]}
|
||||
)
|
||||
|
||||
|
||||
self._send_response(response)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GET请求处理失败: {e}")
|
||||
error_response = HttpResponse(
|
||||
@@ -148,7 +148,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
message=f"GET请求处理失败: {str(e)}"
|
||||
)
|
||||
self._send_response(error_response)
|
||||
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""处理OPTIONS请求 - CORS预检请求"""
|
||||
try:
|
||||
@@ -159,12 +159,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
self.send_header('Access-Control-Max-Age', '86400')
|
||||
self.end_headers()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OPTIONS请求处理失败: {e}")
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理步骤完成报送(统一LIMS协议规范)"""
|
||||
try:
|
||||
@@ -175,7 +175,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 验证data字段内容
|
||||
data = request_data['data']
|
||||
data_required_fields = ['orderCode', 'orderName', 'stepName', 'stepId', 'sampleId', 'startTime', 'endTime']
|
||||
@@ -184,31 +184,31 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 创建统一请求对象
|
||||
report_request = WorkstationReportRequest(
|
||||
token=request_data['token'],
|
||||
request_time=request_data['request_time'],
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
# 调用工作站处理方法
|
||||
result = self.workstation.process_step_finish_report(report_request)
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"步骤完成报送已处理: {data['stepName']} ({data['orderCode']})",
|
||||
acknowledgment_id=f"STEP_{int(time.time() * 1000)}_{data['stepId']}",
|
||||
data=result
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理步骤完成报送失败: {e}")
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"步骤完成报送处理失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理通量完成报送(统一LIMS协议规范)"""
|
||||
try:
|
||||
@@ -219,7 +219,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 验证data字段内容
|
||||
data = request_data['data']
|
||||
data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'status']
|
||||
@@ -228,37 +228,37 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 创建统一请求对象
|
||||
report_request = WorkstationReportRequest(
|
||||
token=request_data['token'],
|
||||
request_time=request_data['request_time'],
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
# 调用工作站处理方法
|
||||
result = self.workstation.process_sample_finish_report(report_request)
|
||||
|
||||
|
||||
status_names = {
|
||||
"0": "待生产", "2": "进样", "10": "开始",
|
||||
"0": "待生产", "2": "进样", "10": "开始",
|
||||
"20": "完成", "-2": "异常停止", "-3": "人工停止"
|
||||
}
|
||||
status_desc = status_names.get(str(data['status']), f"状态{data['status']}")
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"通量完成报送已处理: {data['sampleId']} ({data['orderCode']}) - {status_desc}",
|
||||
acknowledgment_id=f"SAMPLE_{int(time.time() * 1000)}_{data['sampleId']}",
|
||||
data=result
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理通量完成报送失败: {e}")
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"通量完成报送处理失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理任务完成报送(统一LIMS协议规范)"""
|
||||
try:
|
||||
@@ -269,7 +269,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 验证data字段内容
|
||||
data = request_data['data']
|
||||
data_required_fields = ['orderCode', 'orderName', 'startTime', 'endTime', 'status']
|
||||
@@ -278,7 +278,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 处理物料使用记录
|
||||
used_materials = []
|
||||
if 'usedMaterials' in data:
|
||||
@@ -290,41 +290,41 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
usedQuantity=material_data.get('usedQuantity', 0.0)
|
||||
)
|
||||
used_materials.append(material)
|
||||
|
||||
|
||||
# 创建统一请求对象
|
||||
report_request = WorkstationReportRequest(
|
||||
token=request_data['token'],
|
||||
request_time=request_data['request_time'],
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
# 调用工作站处理方法
|
||||
result = self.workstation.process_order_finish_report(report_request, used_materials)
|
||||
|
||||
|
||||
status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"}
|
||||
status_desc = status_names.get(str(data['status']), f"状态{data['status']}")
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"任务完成报送已处理: {data['orderName']} ({data['orderCode']}) - {status_desc}",
|
||||
acknowledgment_id=f"ORDER_{int(time.time() * 1000)}_{data['orderCode']}",
|
||||
data=result
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理任务完成报送失败: {e}")
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"任务完成报送处理失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理批量报送"""
|
||||
try:
|
||||
step_updates = request_data.get('step_updates', [])
|
||||
sample_updates = request_data.get('sample_updates', [])
|
||||
order_updates = request_data.get('order_updates', [])
|
||||
|
||||
|
||||
results = {
|
||||
'step_results': [],
|
||||
'sample_results': [],
|
||||
@@ -332,7 +332,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
'total_processed': 0,
|
||||
'total_failed': 0
|
||||
}
|
||||
|
||||
|
||||
# 处理批量步骤更新
|
||||
for step_data in step_updates:
|
||||
try:
|
||||
@@ -347,7 +347,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
results['step_results'].append(HttpResponse(success=False, message=str(e)))
|
||||
results['total_failed'] += 1
|
||||
|
||||
|
||||
# 处理批量通量更新
|
||||
for sample_data in sample_updates:
|
||||
try:
|
||||
@@ -362,7 +362,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
results['sample_results'].append(HttpResponse(success=False, message=str(e)))
|
||||
results['total_failed'] += 1
|
||||
|
||||
|
||||
# 处理批量任务更新
|
||||
for order_data in order_updates:
|
||||
try:
|
||||
@@ -377,21 +377,21 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
results['order_results'].append(HttpResponse(success=False, message=str(e)))
|
||||
results['total_failed'] += 1
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=results['total_failed'] == 0,
|
||||
message=f"批量报送处理完成: {results['total_processed']} 成功, {results['total_failed']} 失败",
|
||||
acknowledgment_id=f"BATCH_{int(time.time() * 1000)}",
|
||||
data=results
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理批量报送失败: {e}")
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"批量报送处理失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理物料变更报送"""
|
||||
try:
|
||||
@@ -417,24 +417,24 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 调用工作站的处理方法
|
||||
result = self.workstation.process_material_change_report(request_data)
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"物料变更报送已处理: {request_data['resource_id']} ({request_data['change_type']})",
|
||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{request_data['resource_id']}",
|
||||
data=result
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理物料变更报送失败: {e}")
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"物料变更报送处理失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse:
|
||||
"""处理错误处理报送"""
|
||||
try:
|
||||
@@ -446,13 +446,13 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message="奔曜格式缺少text字段"
|
||||
)
|
||||
|
||||
|
||||
error_data = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_data}")
|
||||
|
||||
|
||||
# 调用工作站的处理方法
|
||||
result = self.workstation.handle_external_error(error_data)
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: 任务{error_data.get('task', 'unknown')}, 错误代码{error_data.get('code', 'unknown')}",
|
||||
@@ -467,45 +467,38 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"缺少必要字段: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
|
||||
# 调用工作站的处理方法
|
||||
result = self.workstation.handle_external_error(request_data)
|
||||
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}",
|
||||
data=result
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理错误处理报送失败: {e}")
|
||||
return HttpResponse(
|
||||
success=False,
|
||||
message=f"错误处理报送处理失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_status_check(self) -> HttpResponse:
|
||||
"""处理状态查询"""
|
||||
try:
|
||||
# 安全地获取 device_id
|
||||
device_id = "unknown"
|
||||
if hasattr(self.workstation, 'device_id'):
|
||||
device_id = self.workstation.device_id
|
||||
elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'):
|
||||
device_id = self.workstation._ros_node.device_id
|
||||
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message="工作站报送服务正常运行",
|
||||
data={
|
||||
"workstation_id": device_id,
|
||||
"workstation_id": self.workstation.device_id,
|
||||
"service_type": "unified_reporting_service",
|
||||
"uptime": time.time() - getattr(self.workstation, '_start_time', time.time()),
|
||||
"reports_received": getattr(self.workstation, '_reports_received_count', 0),
|
||||
"supported_endpoints": [
|
||||
"POST /report/step_finish",
|
||||
"POST /report/sample_finish",
|
||||
"POST /report/sample_finish",
|
||||
"POST /report/order_finish",
|
||||
"POST /report/batch_update",
|
||||
"POST /report/material_change",
|
||||
@@ -521,28 +514,28 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
success=False,
|
||||
message=f"状态查询失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _send_response(self, response: HttpResponse):
|
||||
"""发送响应"""
|
||||
try:
|
||||
# 设置响应状态码
|
||||
status_code = 200 if response.success else 400
|
||||
self.send_response(status_code)
|
||||
|
||||
|
||||
# 设置响应头
|
||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.end_headers()
|
||||
|
||||
|
||||
# 发送响应体
|
||||
response_json = json.dumps(asdict(response), ensure_ascii=False, indent=2)
|
||||
self.wfile.write(response_json.encode('utf-8'))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送响应失败: {e}")
|
||||
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""重写日志方法"""
|
||||
logger.debug(f"HTTP请求: {format % args}")
|
||||
@@ -550,7 +543,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
|
||||
class WorkstationHTTPService:
|
||||
"""工作站HTTP服务"""
|
||||
|
||||
|
||||
def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 8080):
|
||||
self.workstation = workstation_instance
|
||||
self.host = host
|
||||
@@ -558,38 +551,31 @@ class WorkstationHTTPService:
|
||||
self.server = None
|
||||
self.server_thread = None
|
||||
self.running = False
|
||||
|
||||
|
||||
# 初始化统计信息
|
||||
self.workstation._start_time = time.time()
|
||||
self.workstation._reports_received_count = 0
|
||||
|
||||
|
||||
def start(self):
|
||||
"""启动HTTP服务"""
|
||||
try:
|
||||
# 创建处理器工厂函数
|
||||
def handler_factory(*args, **kwargs):
|
||||
return WorkstationHTTPHandler(self.workstation, *args, **kwargs)
|
||||
|
||||
|
||||
# 创建HTTP服务器
|
||||
self.server = HTTPServer((self.host, self.port), handler_factory)
|
||||
|
||||
# 安全地获取 device_id 用于线程命名
|
||||
device_id = "unknown"
|
||||
if hasattr(self.workstation, 'device_id'):
|
||||
device_id = self.workstation.device_id
|
||||
elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'):
|
||||
device_id = self.workstation._ros_node.device_id
|
||||
|
||||
|
||||
# 在单独线程中运行服务器
|
||||
self.server_thread = threading.Thread(
|
||||
target=self._run_server,
|
||||
daemon=True,
|
||||
name=f"WorkstationHTTP-{device_id}"
|
||||
name=f"WorkstationHTTP-{self.workstation.device_id}"
|
||||
)
|
||||
|
||||
|
||||
self.running = True
|
||||
self.server_thread.start()
|
||||
|
||||
|
||||
logger.info(f"工作站HTTP报送服务已启动: http://{self.host}:{self.port}")
|
||||
logger.info("统一的报送端点 (基于LIMS协议规范):")
|
||||
logger.info(" - POST /report/step_finish # 步骤完成报送")
|
||||
@@ -606,33 +592,33 @@ class WorkstationHTTPService:
|
||||
logger.info("服务端点:")
|
||||
logger.info(" - GET /status # 服务状态查询")
|
||||
logger.info(" - GET /health # 健康检查")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动HTTP服务失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def stop(self):
|
||||
"""停止HTTP服务"""
|
||||
try:
|
||||
if self.running and self.server:
|
||||
logger.info("正在停止工作站HTTP报送服务...")
|
||||
self.running = False
|
||||
|
||||
|
||||
# 停止serve_forever循环
|
||||
self.server.shutdown()
|
||||
|
||||
|
||||
# 等待服务器线程结束
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
self.server_thread.join(timeout=5.0)
|
||||
|
||||
|
||||
# 关闭服务器套接字
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
logger.info("工作站HTTP报送服务已停止")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"停止HTTP服务失败: {e}")
|
||||
|
||||
|
||||
def _run_server(self):
|
||||
"""运行HTTP服务器"""
|
||||
try:
|
||||
@@ -643,12 +629,12 @@ class WorkstationHTTPService:
|
||||
logger.error(f"HTTP服务运行错误: {e}")
|
||||
finally:
|
||||
logger.info("HTTP服务器线程已退出")
|
||||
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""检查服务是否正在运行"""
|
||||
return self.running and self.server_thread and self.server_thread.is_alive()
|
||||
|
||||
|
||||
@property
|
||||
def service_url(self) -> str:
|
||||
"""获取服务URL"""
|
||||
@@ -662,7 +648,7 @@ class MaterialChangeReport:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass
|
||||
class TaskExecutionReport:
|
||||
"""已废弃:任务执行报送,请使用统一的WorkstationReportRequest"""
|
||||
pass
|
||||
@@ -682,40 +668,40 @@ __all__ = [
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单测试HTTP服务
|
||||
class BioyondWorkstation:
|
||||
class DummyWorkstation:
|
||||
device_id = "WS-001"
|
||||
|
||||
|
||||
def process_step_finish_report(self, report_request):
|
||||
return {"processed": True}
|
||||
|
||||
|
||||
def process_sample_finish_report(self, report_request):
|
||||
return {"processed": True}
|
||||
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials):
|
||||
return {"processed": True}
|
||||
|
||||
|
||||
def process_material_change_report(self, report_data):
|
||||
return {"processed": True}
|
||||
|
||||
|
||||
def handle_external_error(self, error_data):
|
||||
return {"handled": True}
|
||||
|
||||
workstation = BioyondWorkstation()
|
||||
|
||||
workstation = DummyWorkstation()
|
||||
http_service = WorkstationHTTPService(workstation)
|
||||
|
||||
|
||||
try:
|
||||
http_service.start()
|
||||
print(f"测试服务器已启动: {http_service.service_url}")
|
||||
print("按 Ctrl+C 停止服务器")
|
||||
print("服务将持续运行,等待接收HTTP请求...")
|
||||
|
||||
|
||||
# 保持服务器运行 - 使用更好的等待机制
|
||||
try:
|
||||
while http_service.is_running:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n接收到停止信号...")
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n正在停止服务器...")
|
||||
http_service.stop()
|
||||
|
||||
@@ -83,6 +83,96 @@ workstation.bioyond_dispensing_station:
|
||||
title: batch_create_diamine_solution_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
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: {}
|
||||
@@ -112,6 +202,85 @@ workstation.bioyond_dispensing_station:
|
||||
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-wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -144,6 +313,31 @@ workstation.bioyond_dispensing_station:
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
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
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
|
||||
1285
unilabos/registry/devices/bioyond_cell.yaml
Normal file
@@ -5,6 +5,96 @@ 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: {}
|
||||
@@ -34,6 +124,81 @@ bioyond_dispensing_station:
|
||||
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-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:
|
||||
@@ -426,6 +591,56 @@ bioyond_dispensing_station:
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
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: 目标堆栈名称(手动输入,如"堆栈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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
camera.USB:
|
||||
camera:
|
||||
category:
|
||||
- camera
|
||||
class:
|
||||
@@ -61,6 +61,9 @@ camera.USB:
|
||||
device_id:
|
||||
default: video_publisher
|
||||
type: string
|
||||
device_uuid:
|
||||
default: ''
|
||||
type: string
|
||||
period:
|
||||
default: 0.1
|
||||
type: number
|
||||
|
||||
@@ -1,231 +1,3 @@
|
||||
hplc.agilent:
|
||||
category:
|
||||
- characterization_chromatic
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-check_status:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 检查安捷伦HPLC设备状态的函数。用于监控设备的运行状态、连接状态、错误信息等关键指标。该函数定期查询设备状态,确保系统稳定运行,及时发现和报告设备异常。适用于自动化流程中的设备监控、故障诊断、系统维护等场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: check_status参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-extract_data_from_txt:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
file_path: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 从文本文件中提取分析数据的函数。用于解析安捷伦HPLC生成的结果文件,提取峰面积、保留时间、浓度等关键分析数据。支持多种文件格式的自动识别和数据结构化处理,为后续数据分析和报告生成提供标准化的数据格式。适用于批量数据处理、结果验证、质量控制等分析工作流程。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
file_path:
|
||||
type: string
|
||||
required:
|
||||
- file_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: extract_data_from_txt参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_sequence:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
params: null
|
||||
resource: null
|
||||
wf_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 启动安捷伦HPLC分析序列的函数。用于执行预定义的分析方法序列,包括样品进样、色谱分离、检测等完整的分析流程。支持参数配置、资源分配、工作流程管理等功能,实现全自动的样品分析。适用于批量样品处理、标准化分析、质量检测等需要连续自动分析的应用场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
params:
|
||||
type: string
|
||||
resource:
|
||||
type: object
|
||||
wf_name:
|
||||
type: string
|
||||
required:
|
||||
- wf_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: start_sequence参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-try_close_sub_device:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
device_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 尝试关闭HPLC子设备的函数。用于安全地关闭泵、检测器、进样器等各个子模块,确保设备正常断开连接并保护硬件安全。该函数提供错误处理和状态确认机制,避免强制关闭可能造成的设备损坏。适用于设备维护、系统重启、紧急停机等需要安全关闭设备的场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
device_name:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: try_close_sub_device参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-try_open_sub_device:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
device_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 尝试打开HPLC子设备的函数。用于初始化和连接泵、检测器、进样器等各个子模块,建立设备通信并进行自检。该函数提供连接验证和错误恢复机制,确保子设备正常启动并准备就绪。适用于设备初始化、系统启动、设备重连等需要建立设备连接的场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
device_name:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: try_open_sub_device参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
execute_command_from_outer:
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
result:
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: SendCmd
|
||||
type: object
|
||||
type: SendCmd
|
||||
module: unilabos.devices.hplc.AgilentHPLC:HPLCDriver
|
||||
status_types:
|
||||
could_run: bool
|
||||
data_file: String
|
||||
device_status: str
|
||||
driver_init_ok: bool
|
||||
finish_status: str
|
||||
is_running: bool
|
||||
status_text: str
|
||||
success: bool
|
||||
type: python
|
||||
config_info: []
|
||||
description: 安捷伦高效液相色谱(HPLC)分析设备,用于复杂化合物的分离、检测和定量分析。该设备通过UI自动化技术控制安捷伦ChemStation软件,实现全自动的样品分析流程。具备序列启动、设备状态监控、数据文件提取、结果处理等功能。支持多样品批量处理和实时状态反馈,适用于药物分析、环境检测、食品安全、化学研究等需要高精度色谱分析的实验室应用。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
driver_debug:
|
||||
default: false
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
could_run:
|
||||
type: boolean
|
||||
data_file:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
device_status:
|
||||
type: string
|
||||
driver_init_ok:
|
||||
type: boolean
|
||||
finish_status:
|
||||
type: string
|
||||
is_running:
|
||||
type: boolean
|
||||
status_text:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- status_text
|
||||
- device_status
|
||||
- could_run
|
||||
- driver_init_ok
|
||||
- is_running
|
||||
- success
|
||||
- finish_status
|
||||
- data_file
|
||||
type: object
|
||||
version: 1.0.0
|
||||
hplc.agilent-zhida:
|
||||
category:
|
||||
- characterization_chromatic
|
||||
|
||||
@@ -1,194 +1 @@
|
||||
raman.home_made:
|
||||
category:
|
||||
- characterization_optic
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-ccd_time:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
int_time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 设置CCD检测器积分时间的函数。用于配置拉曼光谱仪的信号采集时间,控制光谱数据的质量和信噪比。较长的积分时间可获得更高的信号强度和更好的光谱质量,但会增加测量时间。该函数允许根据样品特性和测量要求动态调整检测参数,优化测量效果。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
int_time:
|
||||
type: string
|
||||
required:
|
||||
- int_time
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: ccd_time参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-laser_on_power:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
output_voltage_laser: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 设置激光器输出功率的函数。用于控制拉曼光谱仪激光器的功率输出,调节激光强度以适应不同样品的测量需求。适当的激光功率能够获得良好的拉曼信号同时避免样品损伤。该函数支持精确的功率控制,确保测量结果的稳定性和重现性。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
output_voltage_laser:
|
||||
type: string
|
||||
required:
|
||||
- output_voltage_laser
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: laser_on_power参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-raman_without_background:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
int_time: null
|
||||
laser_power: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 执行无背景扣除的拉曼光谱测量函数。用于直接采集样品的拉曼光谱信号,不进行背景校正处理。该函数配置积分时间和激光功率参数,获取原始光谱数据用于后续的数据处理分析。适用于对光谱数据质量要求较高或需要自定义背景处理流程的测量场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
int_time:
|
||||
type: string
|
||||
laser_power:
|
||||
type: string
|
||||
required:
|
||||
- int_time
|
||||
- laser_power
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: raman_without_background参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-raman_without_background_average:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
average: null
|
||||
int_time: null
|
||||
laser_power: null
|
||||
sample_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 执行多次平均的无背景拉曼光谱测量函数。通过多次测量取平均值来提高光谱数据的信噪比和测量精度,减少随机噪声影响。该函数支持自定义平均次数、积分时间、激光功率等参数,并可为样品指定名称便于数据管理。适用于对测量精度要求较高的定量分析和研究应用。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
average:
|
||||
type: string
|
||||
int_time:
|
||||
type: string
|
||||
laser_power:
|
||||
type: string
|
||||
sample_name:
|
||||
type: string
|
||||
required:
|
||||
- sample_name
|
||||
- int_time
|
||||
- laser_power
|
||||
- average
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: raman_without_background_average参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
raman_cmd:
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
result:
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: SendCmd
|
||||
type: object
|
||||
type: SendCmd
|
||||
module: unilabos.devices.raman_uv.home_made_raman:RamanObj
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
description: 拉曼光谱分析设备,用于物质的分子结构和化学成分表征。该设备集成激光器和CCD检测器,通过串口通信控制激光功率和光谱采集。具备背景扣除、多次平均、自动数据处理等功能,支持高精度的拉曼光谱测量。适用于材料表征、化学分析、质量控制、研究开发等需要分子指纹识别和结构分析的实验应用。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
baudrate_ccd:
|
||||
default: 921600
|
||||
type: string
|
||||
baudrate_laser:
|
||||
default: 9600
|
||||
type: string
|
||||
port_ccd:
|
||||
type: string
|
||||
port_laser:
|
||||
type: string
|
||||
required:
|
||||
- port_laser
|
||||
- port_ccd
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
{}
|
||||
|
||||