Compare commits
642 Commits
145fcaae65
...
prcix9320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be601177e | ||
|
|
ad05e8c73e | ||
|
|
940abc3664 | ||
|
|
6288e37464 | ||
|
|
3aed75bc8b | ||
|
|
acb2dc9359 | ||
|
|
f22c3f4c42 | ||
|
|
7df67ea9f3 | ||
|
|
4d3a41ed0d | ||
|
|
58997f0654 | ||
|
|
fbfc3e30fb | ||
|
|
1d1c1367df | ||
|
|
56d25b88bd | ||
|
|
95f3e0b291 | ||
|
|
c91b600e90 | ||
|
|
49b3c850f9 | ||
|
|
9b706236f6 | ||
|
|
9f60e65b6d | ||
|
|
59aa991988 | ||
|
|
aff340de84 | ||
|
|
25c94af755 | ||
|
|
2fd4270831 | ||
|
|
0d41d83ce5 | ||
|
|
9a6f744afd | ||
|
|
8164d990cc | ||
|
|
5c9c8a4ee9 | ||
|
|
68ef739f4a | ||
|
|
29a484f16f | ||
|
|
a48985720c | ||
|
|
14cf4ddc0d | ||
|
|
ad66fc1841 | ||
|
|
6b3f9756a0 | ||
|
|
afddc6e40c | ||
|
|
861a012747 | ||
|
|
edd67e4880 | ||
|
|
d13d3f7dfe | ||
|
|
ee63e95f50 | ||
|
|
1ab1ed69d4 | ||
|
|
ad2e5a1c04 | ||
|
|
71d35d31af | ||
|
|
7f4b57f589 | ||
|
|
0c667e68e6 | ||
|
|
9430be51a4 | ||
|
|
a187a57430 | ||
|
|
68029217de | ||
|
|
792504e08c | ||
|
|
dbf5df6e4d | ||
|
|
f10c0343ce | ||
|
|
8b6553bdd9 | ||
|
|
04c0564366 | ||
|
|
e7a4afd6b5 | ||
|
|
f18f6d82fc | ||
|
|
b7c726635c | ||
|
|
c809912fd3 | ||
|
|
d956b27e9f | ||
|
|
ff1e21fcd8 | ||
|
|
b9d9666003 | ||
|
|
d776550a4b | ||
|
|
3d8123849a | ||
|
|
9d65718f37 | ||
|
|
35bcf6765d | ||
|
|
d2f204c5b0 | ||
|
|
d8922884b1 | ||
|
|
427afe83d4 | ||
|
|
23c2e3b2f7 | ||
|
|
59c26265e9 | ||
|
|
4c2adea55a | ||
|
|
0f6264503a | ||
|
|
2c554182d3 | ||
|
|
cdbca70222 | ||
|
|
1a267729e4 | ||
|
|
b11f6eac55 | ||
|
|
d85ff540c4 | ||
|
|
6d319d91ff | ||
|
|
3155b2f97e | ||
|
|
e5e30a1c7d | ||
|
|
4e82f62327 | ||
|
|
95d3456214 | ||
|
|
38bf95b13c | ||
|
|
5f45a0b81b | ||
|
|
f2c0bec02c | ||
|
|
e0394bf414 | ||
|
|
975a56415a | ||
|
|
cadbe87e3f | ||
|
|
b993c1f590 | ||
|
|
e0fae94c10 | ||
|
|
b5cd181ac1 | ||
|
|
6bf9a319c7 | ||
|
|
5c047beb83 | ||
|
|
b40c087143 | ||
|
|
74f0d5ee65 | ||
|
|
7f1cc3b2a5 | ||
|
|
2596d48a2f | ||
|
|
3f160c2049 | ||
|
|
2ac1a3242a | ||
|
|
5d208c832b | ||
|
|
786498904d | ||
|
|
a9ea9f425d | ||
|
|
b3bc951cae | ||
|
|
01df4f1115 | ||
|
|
a54e7c0f23 | ||
|
|
e5015cd5e0 | ||
|
|
ca985f92ab | ||
|
|
514373c164 | ||
|
|
41be9e4e19 | ||
|
|
fcea02585a | ||
|
|
e1074f06d2 | ||
|
|
0dc273f366 | ||
|
|
2e5fac26b3 | ||
|
|
07cf690897 | ||
|
|
cfea27460a | ||
|
|
b7d3e980a9 | ||
|
|
5c2da9b793 | ||
|
|
45efbfcd12 | ||
|
|
8da6fdfd0b | ||
|
|
29ea9909a5 | ||
|
|
f9ed6cb3fb | ||
|
|
f38f3dfc89 | ||
|
|
699a0b3ce7 | ||
|
|
cf3a20ae79 | ||
|
|
ee6307a568 | ||
|
|
8a0116c852 | ||
|
|
cdf0652020 | ||
|
|
60073ff139 | ||
|
|
d3f59913b0 | ||
|
|
a9053b822f | ||
|
|
d238c2ab8b | ||
|
|
9a7d5c7c82 | ||
|
|
f6d46e669d | ||
|
|
abf5555e37 | ||
|
|
4f7d431c0b | ||
|
|
341a1b537c | ||
|
|
957fb41a6f | ||
|
|
26271bcab8 | ||
|
|
e4d915c59c | ||
|
|
11a38d4558 | ||
|
|
84a8223173 | ||
|
|
e8d1263488 | ||
|
|
380b39100d | ||
|
|
56eb7e2ab4 | ||
|
|
23ce145f74 | ||
|
|
b0da149252 | ||
|
|
07c9e6f0fe | ||
|
|
ccec6b9d77 | ||
|
|
dadfdf3d8d | ||
|
|
aeeb36d075 | ||
|
|
3478bfd7ed | ||
|
|
400bb073d4 | ||
|
|
3f63c36505 | ||
|
|
0ae94f7f3c | ||
|
|
7eacae6442 | ||
|
|
f7d2cb4b9e | ||
|
|
bf980d7248 | ||
|
|
27c0544bfc | ||
|
|
d48e77c9ae | ||
|
|
e70a5bea66 | ||
|
|
467d75dc03 | ||
|
|
9feeb0c430 | ||
|
|
b2f26ffb28 | ||
|
|
4b0d1553e9 | ||
|
|
67ddee2ab2 | ||
|
|
1bcdad9448 | ||
|
|
039c96fe01 | ||
|
|
e1555d10a0 | ||
|
|
f2a96b2041 | ||
|
|
329349639e | ||
|
|
e4cc111523 | ||
|
|
d245ceef1b | ||
|
|
6db7fbd721 | ||
|
|
ab05b858e1 | ||
|
|
43e4c71a8e | ||
|
|
d6910da57d | ||
|
|
2cf58ca452 | ||
|
|
fd73bb7dcb | ||
|
|
a02cecfd18 | ||
|
|
d6accc3f1c | ||
|
|
39dc443399 | ||
|
|
37b1fca962 | ||
|
|
216f19fb62 | ||
|
|
d5b4f07406 | ||
|
|
470d7283e4 | ||
|
|
03f7f44c77 | ||
|
|
ec7ca6a1fe | ||
|
|
4c8022ee95 | ||
|
|
6f600b4fc7 | ||
|
|
269ce440d1 | ||
|
|
be054589b5 | ||
|
|
ad21644db0 | ||
|
|
b045ab4e0a | ||
|
|
4595f86725 | ||
|
|
44a4c2362d | ||
|
|
9dfd58e9af | ||
|
|
31c9f9a172 | ||
|
|
1340bae838 | ||
|
|
ae75f07c8e | ||
|
|
02cd8de4c5 | ||
|
|
a66603ec1c | ||
|
|
ec015e16cd | ||
|
|
18d0ba7a46 | ||
|
|
de7fbe7ac8 | ||
|
|
965bf36e8d | ||
|
|
aacf3497e0 | ||
|
|
657f952e7a | ||
|
|
0165590290 | ||
|
|
daea1ab54d | ||
|
|
31e8d065c4 | ||
|
|
93cb307396 | ||
|
|
1c312772ae | ||
|
|
bad1db5094 | ||
|
|
f26eb69eca | ||
|
|
12c0770c92 | ||
|
|
3d2d428a96 | ||
|
|
78bf57f590 | ||
|
|
e227cddab3 | ||
|
|
f2b993643f | ||
|
|
2e14bf197c | ||
|
|
219a480c08 | ||
|
|
e9f1a7bb44 | ||
|
|
66c18c080a | ||
|
|
ead43b2bc1 | ||
|
|
cef86fd98d | ||
|
|
6993e97ae9 | ||
|
|
db396bcab3 | ||
|
|
1fed8de57d | ||
|
|
63eb0c0a4c | ||
|
|
888c6cf542 | ||
|
|
a1c34f138e | ||
|
|
cc248fc32c | ||
|
|
cfe64b023b | ||
|
|
ad1312cf26 | ||
|
|
75bb5ec553 | ||
|
|
bb95c89829 | ||
|
|
394c140830 | ||
|
|
799813f85b | ||
|
|
19c9d655d0 | ||
|
|
e6d8d41183 | ||
|
|
847a300af3 | ||
|
|
a201d7c307 | ||
|
|
3433766bc5 | ||
|
|
f9a9e35269 | ||
|
|
8cd306cd32 | ||
|
|
816a0d747b | ||
|
|
b0cff1a7a8 | ||
|
|
7e9e93b29c | ||
|
|
9e1e6da505 | ||
|
|
71d57c5631 | ||
|
|
546fb633ec | ||
|
|
a3c7fa9385 | ||
|
|
c6cf84def0 | ||
|
|
86512a0482 | ||
|
|
3ddbc1c9b7 | ||
|
|
abf1005241 | ||
|
|
c475eabb60 | ||
|
|
8a0f000bab | ||
|
|
2ffeb49acb | ||
|
|
5fec753fb9 | ||
|
|
acbaff7bb7 | ||
|
|
3ad20c85a5 | ||
|
|
706323dc3e | ||
|
|
b0804d939c | ||
|
|
97788b4e07 | ||
|
|
39cc280c91 | ||
|
|
44fc80c70f | ||
|
|
d0ac452405 | ||
|
|
152d3a7563 | ||
|
|
ef14737839 | ||
|
|
5d5569121c | ||
|
|
d23e85ade4 | ||
|
|
02afafd423 | ||
|
|
6ac510dcd2 | ||
|
|
8ba911bb55 | ||
|
|
896f287d92 | ||
|
|
0d150f7acd | ||
|
|
c27f7e42d6 | ||
|
|
cc56a68bc6 | ||
|
|
d7302c3b35 | ||
|
|
ed56c1eba2 | ||
|
|
16ee3de086 | ||
|
|
b46a51c40e | ||
|
|
c6780087b8 | ||
|
|
ced961050d | ||
|
|
1ef698dde6 | ||
|
|
91aadba4ef | ||
|
|
11b2c99836 | ||
|
|
04024bc8a3 | ||
|
|
154048107d | ||
|
|
0b896870ba | ||
|
|
ee609e4aa2 | ||
|
|
5551fbf360 | ||
|
|
e13b250632 | ||
|
|
b8278c5026 | ||
|
|
53e767a054 | ||
|
|
cf7032fa81 | ||
|
|
97681ba433 | ||
|
|
3fa81ab4f6 | ||
|
|
9f4a69ddf5 | ||
|
|
05ae4e72df | ||
|
|
2870c04086 | ||
|
|
343e87df0d | ||
|
|
5d0807cba6 | ||
|
|
4875977d5f | ||
|
|
956b1c905b | ||
|
|
944911c52a | ||
|
|
a13b790926 | ||
|
|
9feadd68c6 | ||
|
|
c68d5246d0 | ||
|
|
49073f2c77 | ||
|
|
b2afc29f15 | ||
|
|
4061280f6b | ||
|
|
6a681e1d73 | ||
|
|
653e6e1ac3 | ||
|
|
2c774bcd1d | ||
|
|
2ba395b681 | ||
|
|
b6b3d59083 | ||
|
|
f40e3f521c | ||
|
|
7cc2fe036f | ||
|
|
f81d20bb1d | ||
|
|
db1b5a869f | ||
|
|
0136630700 | ||
|
|
3c31811f9e | ||
|
|
64f02ff129 | ||
|
|
7d097b8222 | ||
|
|
d266d21104 | ||
|
|
b6d0bbcb17 | ||
|
|
31ebff8e37 | ||
|
|
2132895ba2 | ||
|
|
850eeae55a | ||
|
|
d869c14233 | ||
|
|
24101b3cec | ||
|
|
3bf8aad4d5 | ||
|
|
a599eb70e5 | ||
|
|
0bf6994f95 | ||
|
|
c36f53791c | ||
|
|
eb4d2d96c5 | ||
|
|
8233c41b1d | ||
|
|
0dfd4ce8a8 | ||
|
|
7953b3820e | ||
|
|
eed233fa76 | ||
|
|
0c55147ee4 | ||
|
|
ce6267b8e0 | ||
|
|
975e51cd96 | ||
|
|
c5056b381c | ||
|
|
c35da65b15 | ||
|
|
659cf05be6 | ||
|
|
3b8deb4d1d | ||
|
|
c796615f9f | ||
|
|
a5bad6074f | ||
|
|
1d3a07a736 | ||
|
|
cc2cd57cdf | ||
|
|
39bb7dc627 | ||
|
|
0fda155f55 | ||
|
|
6e3eacd2f0 | ||
|
|
062f1a2153 | ||
|
|
61e8d67800 | ||
|
|
d0884cdbd8 | ||
|
|
545ea45024 | ||
|
|
b9ddee8f2c | ||
|
|
a0c5095304 | ||
|
|
e504505137 | ||
|
|
4d9d5701e9 | ||
|
|
6016c4b588 | ||
|
|
be02bef9c4 | ||
|
|
e62f0c2585 | ||
|
|
b6de0623e2 | ||
|
|
9d081e9fcd | ||
|
|
85a58e3464 | ||
|
|
85590672d8 | ||
|
|
1d4018196d | ||
|
|
5d34f742af | ||
|
|
5bef19e6d6 | ||
|
|
f816799753 | ||
|
|
a45d841769 | ||
|
|
7f0b33b3e3 | ||
|
|
2006406a24 | ||
|
|
f94985632b | ||
|
|
12ba110569 | ||
|
|
97212be8b7 | ||
|
|
9bdd42f12f | ||
|
|
627140da03 | ||
|
|
5ceedb0565 | ||
|
|
8c77a20c43 | ||
|
|
3ff894feee | ||
|
|
fa5896ffdb | ||
|
|
eb504803ac | ||
|
|
8b0c845661 | ||
|
|
693873bfa9 | ||
|
|
57da2d8da2 | ||
|
|
8d1fd01259 | ||
|
|
388259e64b | ||
|
|
2c130e7f37 | ||
|
|
9f7c3f02f9 | ||
|
|
19dd80dcdb | ||
|
|
9d5ed627a2 | ||
|
|
2d0ff87bc8 | ||
|
|
d78475de9a | ||
|
|
88ae56806c | ||
|
|
95dd8beb81 | ||
|
|
4ab3fadbec | ||
|
|
229888f834 | ||
|
|
b443b39ebf | ||
|
|
0434bbc15b | ||
|
|
5791b81954 | ||
|
|
bd51c74fab | ||
|
|
ba81cbddf8 | ||
|
|
4e92a26057 | ||
|
|
c2895bb197 | ||
|
|
0423f4f452 | ||
|
|
41390fbef9 | ||
|
|
98bdb4e7e4 | ||
|
|
30037a077a | ||
|
|
6972680099 | ||
|
|
9d2c93807d | ||
|
|
e728007bc5 | ||
|
|
9c5ecda7cc | ||
|
|
2d26c3fac6 | ||
|
|
f5753afb7c | ||
|
|
398b2dde3f | ||
|
|
62c4135938 | ||
|
|
027b4269c4 | ||
|
|
3757bd9c58 | ||
|
|
c75b7d5aae | ||
|
|
dfc635189c | ||
|
|
d8f3ebac15 | ||
|
|
4a1e703a3a | ||
|
|
55d22a7c29 | ||
|
|
03a4e4ecba | ||
|
|
2316c34cb5 | ||
|
|
a8887161d3 | ||
|
|
25834f5ba0 | ||
|
|
a1e9332b51 | ||
|
|
357fc038ef | ||
|
|
fd58ef07f3 | ||
|
|
93dee2c1dc | ||
|
|
70fbf19009 | ||
|
|
9149155232 | ||
|
|
1ca1792e3c | ||
|
|
485e7e8dd2 | ||
|
|
4ddabdcb65 | ||
|
|
a5b0325301 | ||
|
|
50b44938c7 | ||
|
|
df0d2235b0 | ||
|
|
4e434eeb97 | ||
|
|
ca027bf0eb | ||
|
|
635a332b4e | ||
|
|
edf7a117ca | ||
|
|
70b2715996 | ||
|
|
7e8dfc2dc5 | ||
|
|
9b626489a8 | ||
|
|
03fe208743 | ||
|
|
e913e540a3 | ||
|
|
aed39b648d | ||
|
|
8c8359fab3 | ||
|
|
5d20be0762 | ||
|
|
09f745d300 | ||
|
|
bbcbcde9a4 | ||
|
|
42b437cdea | ||
|
|
ffd0f2d26a | ||
|
|
32422c0b3d | ||
|
|
c44e597dc0 | ||
|
|
4eef012a8e | ||
|
|
ac69452f3c | ||
|
|
57b30f627b | ||
|
|
2d2a4ca067 | ||
|
|
a2613aad4c | ||
|
|
54f75183ff | ||
|
|
735be067dc | ||
|
|
0fe62d64f0 | ||
|
|
2d4ecec1e1 | ||
|
|
0f976a1874 | ||
|
|
b263a7e679 | ||
|
|
7c7f1b31c5 | ||
|
|
00e668e140 | ||
|
|
4989f65a0b | ||
|
|
9fa3688196 | ||
|
|
40fb1ea49c | ||
|
|
18b0bb397e | ||
|
|
65abc5dbf7 | ||
|
|
2455ca15ba | ||
|
|
05a3ff607a | ||
|
|
ec882df36d | ||
|
|
43b992e3eb | ||
|
|
6422fa5a9a | ||
|
|
434b9e98e0 | ||
|
|
040073f430 | ||
|
|
3d95c9896a | ||
|
|
9aa97ed01e | ||
|
|
0b8bdf5e0a | ||
|
|
299f010754 | ||
|
|
15ce0d6883 | ||
|
|
dec474e1a7 | ||
|
|
5f187899fc | ||
|
|
c8d16c7024 | ||
|
|
25d46dc9d5 | ||
|
|
88c4d1a9d1 | ||
|
|
81fd8291c5 | ||
|
|
3a11eb90d4 | ||
|
|
387866b9c9 | ||
|
|
7f40f141f6 | ||
|
|
6fc7ed1b88 | ||
|
|
93f0e08d75 | ||
|
|
4b43734b55 | ||
|
|
174b1914d4 | ||
|
|
704e13f030 | ||
|
|
0c42d60cf2 | ||
|
|
df33e1a214 | ||
|
|
1f49924966 | ||
|
|
609b6006e8 | ||
|
|
67c01271b7 | ||
|
|
a1783f489e | ||
|
|
a8f6527de9 | ||
|
|
54cfaf15f3 | ||
|
|
5610c28b67 | ||
|
|
cfc1ee6e79 | ||
|
|
1c9d2ee98a | ||
|
|
3fe8f4ca44 | ||
|
|
2476821dcc | ||
|
|
7b426ed5ae | ||
|
|
9bbae96447 | ||
|
|
10aabb7592 | ||
|
|
709eb0d91c | ||
|
|
14b7d52825 | ||
|
|
a5397ffe12 | ||
|
|
c6c2da69ba | ||
|
|
622e579063 | ||
|
|
196e0f7e2b | ||
|
|
a632fd495e | ||
|
|
a8cc02a126 | ||
|
|
ad2e1432c6 | ||
|
|
c3b9583eac | ||
|
|
5c47cd0c8a | ||
|
|
63ab1af45d | ||
|
|
a8419dc0c3 | ||
|
|
34f05f2e25 | ||
|
|
0dc2488f02 | ||
|
|
f13156e792 | ||
|
|
13fd1ac572 | ||
|
|
f8ef6e0686 | ||
|
|
94a7b8aaca | ||
|
|
301bea639e | ||
|
|
4b5a83efa4 | ||
|
|
2889e9be2c | ||
|
|
304aebbba7 | ||
|
|
091c9fa247 | ||
|
|
67ca45a240 | ||
|
|
7aab2ea493 | ||
|
|
62f3a6d696 | ||
|
|
eb70ad0e18 | ||
|
|
768f43880e | ||
|
|
762c3c737c | ||
|
|
ace98a4472 | ||
|
|
41eaa88c6f | ||
|
|
a1a55a2c0a | ||
|
|
2eaa0ca729 | ||
|
|
6f8f070f40 | ||
|
|
da4bd927e0 | ||
|
|
01f8816597 | ||
|
|
e5006285df | ||
|
|
573c724a5c | ||
|
|
09549d2839 | ||
|
|
50c7777cea | ||
|
|
4888f02c09 | ||
|
|
779c9693d9 | ||
|
|
ffa841a41a | ||
|
|
fc669f09f8 | ||
|
|
2ca0311de6 | ||
|
|
94cdcbf24e | ||
|
|
1cd07915e7 | ||
|
|
b600fc666d | ||
|
|
9e214c56c1 | ||
|
|
bdf27a7e82 | ||
|
|
2493fb9f94 | ||
|
|
c7a0ff67a9 | ||
|
|
711a7c65fa | ||
|
|
cde7956896 | ||
|
|
95b6fd0451 | ||
|
|
513e848d89 | ||
|
|
58d1cc4720 | ||
|
|
5676dd6589 | ||
|
|
1ae274a833 | ||
|
|
22b88c8441 | ||
|
|
81bcc1907d | ||
|
|
8cffd3dc21 | ||
|
|
a722636938 | ||
|
|
f68340d932 | ||
|
|
361eae2f6d | ||
|
|
c25283ae04 | ||
|
|
961752fb0d | ||
|
|
55165024dd | ||
|
|
6ddceb8393 | ||
|
|
4e52c7d2f4 | ||
|
|
0b56efc89d | ||
|
|
a27b93396a | ||
|
|
2a60a6c27e | ||
|
|
5dda94044d | ||
|
|
0cfc6f45e3 | ||
|
|
831f4549f9 | ||
|
|
f4d4eb06d3 | ||
|
|
e3b8164f6b | ||
|
|
78c04acc2e | ||
|
|
cd0428ea78 | ||
|
|
bdddbd57ba | ||
|
|
a312de08a5 | ||
|
|
68513b5745 | ||
|
|
19027350fb | ||
|
|
bbbdb06bbc | ||
|
|
cd84e26126 | ||
|
|
ce5bab3af1 | ||
|
|
82d9ef6bf7 | ||
|
|
332b33c6f4 | ||
|
|
1ec642ee3a | ||
|
|
7d8e6d029b | ||
|
|
5ec8a57a1f | ||
|
|
ae3c1100ae | ||
|
|
14bc2e6cda | ||
|
|
9f823a4198 | ||
|
|
02c79363c1 | ||
|
|
227ff1284a | ||
|
|
4b7bde6be5 | ||
|
|
8a669ac35a | ||
|
|
a1538da39e | ||
|
|
0063df4cf3 | ||
|
|
e570ba4976 | ||
|
|
e8c1f76dbb | ||
|
|
f791c1a342 | ||
|
|
ea60cbe891 | ||
|
|
eac9b8ab3d | ||
|
|
573bcf1a6c | ||
|
|
50e93cb1af | ||
|
|
fe1a029a9b | ||
|
|
662c063f50 | ||
|
|
01cbbba0b3 | ||
|
|
e6c556cf19 | ||
|
|
0605f305ed | ||
|
|
37d8108ec4 | ||
|
|
6081dac561 | ||
|
|
5b2d066127 | ||
|
|
06e66765e7 | ||
|
|
98ce360088 | ||
|
|
5cd0f72fbd | ||
|
|
343f394203 | ||
|
|
46aa7a7bd2 | ||
|
|
a66369e2c3 |
62
.conda/base/recipe.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
# unilabos: Production package (depends on unilabos-env + pip unilabos)
|
||||
# For production deployment
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.11.1
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
target_directory: unilabos
|
||||
|
||||
build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
then:
|
||||
- copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR%
|
||||
- pip install %SRC_DIR%
|
||||
- if: unix
|
||||
then:
|
||||
- cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR
|
||||
- cp $RECIPE_DIR/../../setup.cfg $SRC_DIR
|
||||
- cp $RECIPE_DIR/../../setup.py $SRC_DIR
|
||||
- pip install $SRC_DIR
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ==3.11.14
|
||||
- pip
|
||||
- setuptools
|
||||
- zstd
|
||||
- zstandard
|
||||
run:
|
||||
- zstd
|
||||
- zstandard
|
||||
- networkx
|
||||
- typing_extensions
|
||||
- websockets
|
||||
- pint
|
||||
- fastapi
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- if: not osx
|
||||
then:
|
||||
- opcua
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.11.1
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS - Production package with minimal ROS2 dependencies"
|
||||
39
.conda/environment/recipe.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# unilabos-env: conda environment dependencies (ROS2 + conda packages)
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.11.1
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
requirements:
|
||||
run:
|
||||
# Python
|
||||
- zstd
|
||||
- zstandard
|
||||
- conda-forge::python ==3.11.14
|
||||
- conda-forge::opencv
|
||||
# ROS2 dependencies (from ci-check.yml)
|
||||
- robostack-staging::ros-humble-ros-core
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros-humble-control-msgs
|
||||
- robostack-staging::ros-humble-nav2-msgs
|
||||
- robostack-staging::ros-humble-cv-bridge
|
||||
- robostack-staging::ros-humble-vision-opencv
|
||||
- robostack-staging::ros-humble-tf-transformations
|
||||
- robostack-staging::ros-humble-moveit-msgs
|
||||
- robostack-staging::ros-humble-tf2-ros
|
||||
- robostack-staging::ros-humble-tf2-ros-py
|
||||
- conda-forge::transforms3d
|
||||
- conda-forge::uv
|
||||
|
||||
# UniLabOS custom messages
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Environment - ROS2 and conda dependencies"
|
||||
42
.conda/full/recipe.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# unilabos-full: Full package with all features
|
||||
# Depends on unilabos + complete ROS2 desktop + dev tools
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.11.1
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.11.1
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Web UI
|
||||
- gradio
|
||||
- flask
|
||||
# Interactive development
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
- colcon-common-extensions
|
||||
# ROS2 full desktop (includes rviz2, gazebo, etc.)
|
||||
- robostack-staging::ros-humble-desktop-full
|
||||
# Navigation and motion control
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# MoveIt motion planning
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
# Simulation
|
||||
- ros-humble-simulation
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Full - Complete package with ROS2 Desktop, MoveIt, Navigation2, Gazebo, Jupyter"
|
||||
@@ -1,92 +0,0 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.12
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
target_directory: unilabos
|
||||
|
||||
build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
then:
|
||||
- copy %RECIPE_DIR%\..\MANIFEST.in %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\setup.cfg %SRC_DIR%
|
||||
- copy %RECIPE_DIR%\..\setup.py %SRC_DIR%
|
||||
- call %PYTHON% -m pip install %SRC_DIR%
|
||||
- if: unix
|
||||
then:
|
||||
- cp $RECIPE_DIR/../MANIFEST.in $SRC_DIR
|
||||
- cp $RECIPE_DIR/../setup.cfg $SRC_DIR
|
||||
- cp $RECIPE_DIR/../setup.py $SRC_DIR
|
||||
- $PYTHON -m pip install $SRC_DIR
|
||||
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ==3.11.11
|
||||
- pip
|
||||
- setuptools
|
||||
- zstd
|
||||
- zstandard
|
||||
run:
|
||||
- conda-forge::python ==3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- zstd
|
||||
- zstandard
|
||||
- ninja
|
||||
- if: unix
|
||||
then:
|
||||
- make
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websockets
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
- colcon-common-extensions
|
||||
- robostack-staging::ros-humble-desktop-full
|
||||
- robostack-staging::ros-humble-control-msgs
|
||||
- robostack-staging::ros-humble-sensor-msgs
|
||||
- robostack-staging::ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
- ros-humble-rosbridge-server
|
||||
- ros-humble-cv-bridge
|
||||
- ros-humble-tf2
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
- ros-humble-simulation
|
||||
- ros-humble-tf-transformations
|
||||
- transforms3d
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
|
||||
about:
|
||||
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "Uni-Lab-OS"
|
||||
196
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: add-device
|
||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||
---
|
||||
|
||||
# 添加新设备到 Uni-Lab-OS
|
||||
|
||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
||||
|
||||
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
||||
|
||||
---
|
||||
|
||||
## 装饰器参考
|
||||
|
||||
### @device — 设备类装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
# 单设备
|
||||
@device(
|
||||
id="my_device.vendor", # 注册表唯一标识(必填)
|
||||
category=["temperature"], # 分类标签列表(必填)
|
||||
description="设备描述", # 设备描述
|
||||
display_name="显示名称", # UI 显示名称(默认用 id)
|
||||
icon="DeviceIcon.webp", # 图标文件名
|
||||
version="1.0.0", # 版本号
|
||||
device_type="python", # "python" 或 "ros2"
|
||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||
model={...}, # 3D 模型配置
|
||||
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
||||
)
|
||||
|
||||
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
||||
@device(
|
||||
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
||||
id_meta={
|
||||
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
||||
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
||||
},
|
||||
category=["pump_and_valve"],
|
||||
)
|
||||
```
|
||||
|
||||
### @action — 动作方法装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import action
|
||||
|
||||
@action # 无参:注册为 UniLabJsonCommand 动作
|
||||
@action() # 同上
|
||||
@action(description="执行操作") # 带描述
|
||||
@action(
|
||||
action_type=HeatChill, # 指定 ROS Action 消息类型
|
||||
goal={"temperature": "temp"}, # Goal 字段映射
|
||||
feedback={}, # Feedback 字段映射
|
||||
result={}, # Result 字段映射
|
||||
handles=[...], # 动作级别端口
|
||||
goal_default={"temp": 25.0}, # Goal 默认值
|
||||
placeholder_keys={...}, # 参数占位符
|
||||
always_free=True, # 不受排队限制
|
||||
auto_prefix=True, # 强制使用 auto- 前缀
|
||||
parent=True, # 从父类 MRO 获取参数签名
|
||||
)
|
||||
```
|
||||
|
||||
**自动识别规则:**
|
||||
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
||||
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
||||
- `_` 开头的方法 → 不扫描
|
||||
- `@not_action` 标记的方法 → 排除
|
||||
|
||||
### 参数文档 → JSON Schema 元数据
|
||||
|
||||
在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息:
|
||||
|
||||
```python
|
||||
"""
|
||||
Args:
|
||||
param[显示名称]: 参数说明,会写入 JSON Schema 的 description。
|
||||
"""
|
||||
```
|
||||
|
||||
- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。
|
||||
- `:` 后面的说明会写入 goal property 的 `description`。
|
||||
- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。
|
||||
- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。
|
||||
|
||||
### @topic_config — 状态属性配置
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config(
|
||||
period=5.0, # 发布周期(秒),默认 5.0
|
||||
print_publish=False, # 是否打印发布日志
|
||||
qos=10, # QoS 深度,默认 10
|
||||
name="custom_name", # 自定义发布名称(默认用属性名)
|
||||
)
|
||||
def temperature(self) -> float:
|
||||
return self.data.get("temperature", 0.0)
|
||||
```
|
||||
|
||||
### 辅助装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import not_action, always_free
|
||||
|
||||
@not_action # 标记为非动作(post_init、辅助方法等)
|
||||
@always_free # 标记为不受排队限制(查询类操作)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设备模板
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.registry.decorators import action, device, not_action, topic_config
|
||||
|
||||
@device(
|
||||
id="my_device",
|
||||
category=["my_category"],
|
||||
description="设备描述",
|
||||
display_name="设备显示名",
|
||||
)
|
||||
class MyDevice:
|
||||
"""设备类说明。"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
"""
|
||||
初始化设备。
|
||||
|
||||
Args:
|
||||
device_id[设备ID]: 设备实例 ID,默认使用 my_device。
|
||||
config[设备配置]: 设备启动配置。
|
||||
"""
|
||||
self.device_id = device_id or "my_device"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
||||
self._ros_node = ros_node
|
||||
|
||||
@action
|
||||
async def initialize(self) -> bool:
|
||||
self.data["status"] = "Ready"
|
||||
return True
|
||||
|
||||
@action
|
||||
async def cleanup(self) -> bool:
|
||||
self.data["status"] = "Offline"
|
||||
return True
|
||||
|
||||
@action(description="执行操作")
|
||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
带 @action 装饰器 → 注册为 'my_action' 动作。
|
||||
|
||||
Args:
|
||||
param[操作数值]: 操作使用的数值参数。
|
||||
name[操作名称]: 操作名称或备注。
|
||||
"""
|
||||
return {"success": True}
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
||||
return {"device_id": self.device_id}
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self.data.get("temperature", 0.0)
|
||||
```
|
||||
|
||||
### 要点
|
||||
|
||||
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
||||
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
||||
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
||||
- 运行时状态存储在 `self.data` 字典中
|
||||
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
||||
351
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||
---
|
||||
|
||||
# 添加新物料资源
|
||||
|
||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
||||
|
||||
---
|
||||
|
||||
## 资源类型
|
||||
|
||||
| 类型 | 基类 | 用途 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||
|
||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||
|
||||
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
||||
|
||||
---
|
||||
|
||||
## @resource 装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
@resource(
|
||||
id="my_resource_id", # 注册表唯一标识(必填)
|
||||
category=["bottles"], # 分类标签列表(必填)
|
||||
description="资源描述",
|
||||
icon="", # 图标
|
||||
version="1.0.0",
|
||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||
model={...}, # 3D 模型配置
|
||||
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 创建规范
|
||||
|
||||
### 命名规则
|
||||
|
||||
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
||||
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
||||
|
||||
### 子物料命名示例
|
||||
|
||||
```python
|
||||
# Carrier 内部的 sites 用 name 前缀
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
||||
|
||||
# Carrier 中放置 Bottle 时用 name 前缀
|
||||
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
||||
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
||||
|
||||
# create_homogeneous_resources 使用 name_prefix
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[...],
|
||||
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
||||
)
|
||||
|
||||
# Deck setup 中用仓库名称作为 name 传入
|
||||
self.warehouses = {
|
||||
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
||||
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
||||
}
|
||||
```
|
||||
|
||||
### 其他规范
|
||||
|
||||
- **max_volume 单位为 μL**:500mL = 500000
|
||||
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
||||
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
||||
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
||||
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
||||
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
||||
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
||||
|
||||
---
|
||||
|
||||
## 资源模板
|
||||
|
||||
### Bottle
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import resource
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
|
||||
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
||||
def My_Reagent_Bottle(
|
||||
name: str,
|
||||
diameter: float = 70.0,
|
||||
height: float = 120.0,
|
||||
max_volume: float = 500000.0,
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="My_Reagent_Bottle",
|
||||
)
|
||||
```
|
||||
|
||||
**Bottle 参数:**
|
||||
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
||||
- `diameter`: 瓶体直径 (mm)
|
||||
- `height`: 瓶体高度 (mm)
|
||||
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
||||
- `barcode`: 条形码(可选)
|
||||
|
||||
### BottleCarrier
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3, num_items_y=2,
|
||||
dx=10.0, dy=10.0, dz=5.0,
|
||||
item_dx=42.0, item_dy=35.0,
|
||||
size_x=20.0, size_y=20.0, size_z=50.0,
|
||||
)
|
||||
# 子 site 用 name 作为前缀
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
|
||||
sites=sites, model="My_6SlotCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 放置 Bottle 时用 name 作为前缀
|
||||
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
||||
for i in range(6):
|
||||
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
```
|
||||
|
||||
### WareHouse / Deck 放置位
|
||||
|
||||
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
||||
|
||||
#### WareHouse(使用 warehouse_factory)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import warehouse_factory
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
||||
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
||||
model="my_warehouse_4x4",
|
||||
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
||||
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
||||
)
|
||||
```
|
||||
|
||||
`warehouse_factory` 参数说明:
|
||||
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
||||
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
||||
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
||||
- `layout`:影响 slot 标签和坐标映射
|
||||
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
||||
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
||||
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
||||
|
||||
#### Deck 组装 WareHouse
|
||||
|
||||
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck, Coordinate
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
||||
class MyStation_Deck(Deck):
|
||||
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
||||
category="deck", setup=False, **kwargs) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
||||
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
||||
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
for wh_name, wh in self.warehouses.items():
|
||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||
```
|
||||
|
||||
#### Site 模式(前端定向放置)
|
||||
|
||||
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
||||
|
||||
```python
|
||||
import collections
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pylabrobot.resources import Deck, Resource, Coordinate
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
||||
class MyLabDeck(Deck):
|
||||
# 根据设备台面实测批量计算各 slot 坐标偏移
|
||||
_DEFAULT_SITE_POSITIONS = [
|
||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
if sites is not None:
|
||||
self.sites = [dict(s) for s in sites]
|
||||
else:
|
||||
self.sites = []
|
||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
||||
self.sites.append({
|
||||
"label": f"T{i + 1}", # 前端显示的槽位标签
|
||||
"visible": True, # 是否在前端可见
|
||||
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
||||
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
||||
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
||||
})
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
|
||||
def assign_child_resource(self, resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None):
|
||||
idx = spot
|
||||
if spot is None:
|
||||
for i, site in enumerate(self.sites):
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
for i in range(len(self.sites)):
|
||||
if self._get_site_resource(i) is None:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise ValueError(f"No available site for '{resource.name}'")
|
||||
loc = Coordinate(**self.sites[idx]["position"])
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
sites_out = []
|
||||
for i, site in enumerate(self.sites):
|
||||
occupied = self._get_site_resource(i)
|
||||
sites_out.append({
|
||||
"label": site["label"],
|
||||
"visible": site.get("visible", True),
|
||||
"occupied_by": occupied.name if occupied else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
```
|
||||
|
||||
**Site 字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
||||
| `visible` | bool | 是否在前端可见 |
|
||||
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
||||
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
||||
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
||||
|
||||
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
||||
|
||||
---
|
||||
|
||||
## 文件位置
|
||||
|
||||
```
|
||||
unilabos/resources/
|
||||
├── <project>/ # 按项目分组
|
||||
│ ├── bottles.py # Bottle 工厂函数
|
||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||
│ ├── warehouses.py # WareHouse 工厂函数
|
||||
│ └── decks.py # Deck 类定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 资源可导入
|
||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||
|
||||
# 启动测试(AST 自动扫描)
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
||||
|
||||
---
|
||||
|
||||
## 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 类继承体系
|
||||
|
||||
```
|
||||
PyLabRobot
|
||||
├── Resource (PLR 基类)
|
||||
│ ├── Well
|
||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||
│ ├── Deck
|
||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||
│ ├── ResourceHolder → 槽位占位符
|
||||
│ └── Container
|
||||
│ └── Battery (unilabos) → 组装好的电池
|
||||
│
|
||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||
│
|
||||
├── ItemizedResource (PLR)
|
||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||
│
|
||||
└── ResourceStack (PLR)
|
||||
└── Magazine (unilabos) → 子弹夹洞位
|
||||
```
|
||||
|
||||
### Bottle 类细节
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def __init__(self, name, diameter, height, max_volume,
|
||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||
barcode=None, category="container", model=None, **kwargs):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||
size_y=diameter,
|
||||
size_z=height, # PLR 用 height 作为 size_z
|
||||
max_volume=max_volume,
|
||||
category=category,
|
||||
model=model,
|
||||
bottom_type="flat",
|
||||
cross_section_type="circle"
|
||||
)
|
||||
```
|
||||
|
||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||
|
||||
### ItemizedCarrier 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||
| `capacity` | 总槽位数 |
|
||||
| `sites` | 所有槽位字典 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 序列化与反序列化
|
||||
|
||||
### PLR ↔ UniLab 转换
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||
|
||||
### `from_plr_resources` 流程
|
||||
|
||||
```
|
||||
PLR Resource
|
||||
↓ build_uuid_mapping (递归生成 UUID)
|
||||
↓ resource.serialize() → dict
|
||||
↓ resource.serialize_all_state() → states
|
||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||
ResourceTreeSet
|
||||
```
|
||||
|
||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||
|
||||
### `to_plr_resources` 流程
|
||||
|
||||
```
|
||||
ResourceTreeSet
|
||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||
PLR Resource
|
||||
```
|
||||
|
||||
### Bottle 序列化
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
return {**data, "diameter": self.diameter, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal=False):
|
||||
barcode_data = data.pop("barcode", None)
|
||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
if barcode_data and isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
return instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bioyond 物料同步
|
||||
|
||||
### 双向转换函数
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||
|
||||
### `resource_bioyond_to_plr` 流程
|
||||
|
||||
```
|
||||
Bioyond 物料列表
|
||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||
↓ 对每个物料:
|
||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||
initialize_resource({"name": unique_name, "class": model})
|
||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||
↓ 处理 detail (子物料/坐标)
|
||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||
PLR 资源列表
|
||||
```
|
||||
|
||||
### `resource_plr_to_bioyond` 流程
|
||||
|
||||
```
|
||||
PLR 资源列表
|
||||
↓ 遍历每个资源:
|
||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||
单瓶: 直接映射
|
||||
↓ type_mapping 查找 typeId
|
||||
↓ warehouse_mapping 查找位置 UUID
|
||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||
Bioyond 物料列表
|
||||
```
|
||||
|
||||
### BioyondResourceSynchronizer
|
||||
|
||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||
|
||||
```python
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
all_data = []
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_data,
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
deck=self.workstation.deck
|
||||
)
|
||||
# 更新 deck 上的资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 非瓶类资源
|
||||
|
||||
### ElectrodeSheet(极片)
|
||||
|
||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||
|
||||
```python
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||
_unilabos_state = {
|
||||
"diameter": 0.0,
|
||||
"thickness": 0.0,
|
||||
"mass": 0.0,
|
||||
"material_type": "",
|
||||
"color": "",
|
||||
"info": "",
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||
|
||||
### Battery(电池)
|
||||
|
||||
```python
|
||||
class Battery(Container):
|
||||
"""组装好的电池"""
|
||||
_unilabos_state = {
|
||||
"color": "",
|
||||
"electrolyte_name": "",
|
||||
"open_circuit_voltage": 0.0,
|
||||
}
|
||||
```
|
||||
|
||||
### Magazine / MagazineHolder(子弹夹)
|
||||
|
||||
```python
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||
# direction, max_sheets
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""多洞位子弹夹"""
|
||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||
```
|
||||
|
||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓库工厂模式参考
|
||||
|
||||
### 实际 warehouse 工厂函数示例
|
||||
|
||||
```python
|
||||
# 行优先 4x4 仓库
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||
)
|
||||
|
||||
# 右侧 4x4 仓库(列名偏移)
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
col_offset=4, # A05,A06,A07,A08
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# 竖向仓库(站内试剂存放)
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="vertical-col-major",
|
||||
)
|
||||
|
||||
# 行偏移(F 行开始)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||
row_offset=row_offset, # 0→A行起,5→F行起
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
### layout 类型说明
|
||||
|
||||
| layout | 命名顺序 | 适用场景 |
|
||||
|--------|---------|---------|
|
||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||
| 电池资源 | `unilabos/resources/battery/` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
626
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
name: add-workstation
|
||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 工作站接入指南
|
||||
|
||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
||||
|
||||
---
|
||||
|
||||
## 工作站类型
|
||||
|
||||
| 类型 | 基类 | 适用场景 |
|
||||
| ------------------- | ----------------- | ---------------------------------- |
|
||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
||||
|
||||
---
|
||||
|
||||
## @device 装饰器(工作站)
|
||||
|
||||
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
||||
|
||||
```python
|
||||
@device(
|
||||
id="my_workstation", # 注册表唯一标识(必填)
|
||||
category=["workstation"], # 分类标签
|
||||
description="我的工作站",
|
||||
)
|
||||
```
|
||||
|
||||
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
||||
|
||||
---
|
||||
|
||||
## 工作站驱动模板
|
||||
|
||||
### 模板 A:基于外部系统的工作站
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.registry.decorators import device, topic_config, not_action
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
||||
class MyWorkstation(WorkstationBase):
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger("MyWorkstation")
|
||||
self.api_host = self.config.get("api_host", "")
|
||||
self._status = "Idle"
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
||||
super().post_init(ros_node)
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||
"""注册为工作站动作"""
|
||||
return {"success": True}
|
||||
|
||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||
"""注册为工作站动作"""
|
||||
return {"success": True}
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def workflow_sequence(self) -> str:
|
||||
return "[]"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def material_info(self) -> str:
|
||||
return "{}"
|
||||
```
|
||||
|
||||
### 模板 B:Protocol 工作站
|
||||
|
||||
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||
```
|
||||
|
||||
在图文件中配置 `protocol_type` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 子设备访问(sub_devices)
|
||||
|
||||
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
||||
|
||||
```python
|
||||
# 在工站驱动类的方法中访问子设备
|
||||
sub = self._ros_node.sub_devices["pump_1"]
|
||||
|
||||
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
||||
sub.driver_instance.some_method(arg1, arg2)
|
||||
|
||||
# .ros_node_instance — 子设备的 ROS2 节点实例
|
||||
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
||||
```
|
||||
|
||||
**常见用法**:
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def my_protocol(self, **kwargs):
|
||||
# 获取子设备驱动实例
|
||||
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
||||
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
||||
|
||||
# 直接调用子设备方法
|
||||
pump.aspirate(volume=100)
|
||||
heater.set_temperature(80)
|
||||
```
|
||||
|
||||
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
||||
|
||||
---
|
||||
|
||||
## 硬件通信接口(hardware_interface)
|
||||
|
||||
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
||||
|
||||
### 工作原理
|
||||
|
||||
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
||||
|
||||
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
||||
|
||||
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
||||
|
||||
```
|
||||
hardware_interface = d.ros_node_instance._hardware_interface
|
||||
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
||||
```
|
||||
|
||||
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
||||
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
||||
2. 从通信设备获取真正的 `read`/`write` 方法
|
||||
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
||||
|
||||
因此:
|
||||
|
||||
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
||||
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
||||
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
||||
|
||||
### HardwareInterface 参数说明
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import HardwareInterface
|
||||
|
||||
HardwareInterface(
|
||||
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
||||
read="send_command", # 通信设备上暴露的读方法名
|
||||
write="send_command", # 通信设备上暴露的写方法名
|
||||
extra_info=["list_ports"], # 可选:额外暴露的方法
|
||||
)
|
||||
```
|
||||
|
||||
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
||||
|
||||
### 示例 1:泵(name="hardware_interface")
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, HardwareInterface
|
||||
|
||||
@device(
|
||||
id="my_pump",
|
||||
category=["pump_and_valve"],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class MyPump:
|
||||
def __init__(self, port=None, address="1", **kwargs):
|
||||
# name="hardware_interface" → 系统替换 self.hardware_interface
|
||||
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
||||
self.address = address
|
||||
|
||||
def send_command(self, command: str):
|
||||
full_command = f"/{self.address}{command}\r\n"
|
||||
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
||||
return self.hardware_interface.read_until(b"\n")
|
||||
```
|
||||
|
||||
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
||||
|
||||
```python
|
||||
@device(
|
||||
id="solenoid_valve",
|
||||
category=["pump_and_valve"],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
||||
read="read_io_coil",
|
||||
write="write_io_coil",
|
||||
),
|
||||
)
|
||||
class SolenoidValve:
|
||||
def __init__(self, io_device_port: str = None, **kwargs):
|
||||
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
||||
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
||||
```
|
||||
|
||||
### Serial 通信设备(class="serial")
|
||||
|
||||
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
||||
|
||||
```python
|
||||
from serial import Serial, SerialException
|
||||
from threading import Lock
|
||||
|
||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._hardware_interface = {
|
||||
"name": "hardware_interface",
|
||||
"write": "send_command",
|
||||
"read": "read_data",
|
||||
}
|
||||
self._query_lock = Lock()
|
||||
|
||||
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
||||
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self, driver_instance=self, registry_name=registry_name,
|
||||
device_id=device_id, status_types={}, action_value_mappings={},
|
||||
hardware_interface=self._hardware_interface, print_publish=False,
|
||||
)
|
||||
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
||||
|
||||
def send_command(self, command: str):
|
||||
with self._query_lock:
|
||||
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
||||
return self.hardware_interface.read_until(b"\n").decode()
|
||||
|
||||
def read_data(self):
|
||||
with self._query_lock:
|
||||
return self.hardware_interface.read_until(b"\n").decode()
|
||||
```
|
||||
|
||||
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"class": "serial",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "COM7", "baudrate": 9600 }
|
||||
}
|
||||
```
|
||||
|
||||
### 图文件配置
|
||||
|
||||
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"class": "workstation",
|
||||
"children": ["serial_pump", "pump_1", "pump_2"],
|
||||
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"class": "serial",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "COM7", "baudrate": 9600 }
|
||||
},
|
||||
{
|
||||
"id": "pump_1",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
||||
},
|
||||
{
|
||||
"id": "pump_2",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": { "pump_1": "port", "serial_pump": "port" }
|
||||
},
|
||||
{
|
||||
"source": "pump_2",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": { "pump_2": "port", "serial_pump": "port" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 通信协议速查
|
||||
|
||||
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
||||
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
||||
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
||||
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
||||
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
||||
|
||||
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||
|
||||
---
|
||||
|
||||
## Deck 与物料生命周期
|
||||
|
||||
### 1. Deck 入参与两种初始化模式
|
||||
|
||||
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
||||
|
||||
有两种初始化模式:
|
||||
|
||||
#### init 初始化(推荐)
|
||||
|
||||
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
||||
|
||||
```json
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### deserialize 初始化
|
||||
|
||||
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
||||
|
||||
```json
|
||||
"config": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
没有特殊需求时推荐 init 初始化。
|
||||
|
||||
#### config.deck 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
||||
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
||||
|
||||
#### 设备 __init__ 接收
|
||||
|
||||
```python
|
||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
# deck 已经是反序列化后的 Deck 实例
|
||||
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
||||
```
|
||||
|
||||
#### Deck 节点(图文件中)
|
||||
|
||||
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"children": [],
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542, "size_y": 374, "size_z": 0,
|
||||
"category": "deck",
|
||||
"sites": [...]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
||||
- `children` 初始为空时,由同步器或手动初始化填充
|
||||
- `config.type` 填 Deck 类名
|
||||
|
||||
### 2. Deck 为空时自行初始化
|
||||
|
||||
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
||||
|
||||
```python
|
||||
@not_action
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
if self.deck and not self.deck.children:
|
||||
self._initialize_default_deck()
|
||||
|
||||
def _initialize_default_deck(self):
|
||||
from my_labware import My_TipRack, My_Plate
|
||||
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
||||
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
||||
```
|
||||
|
||||
### 3. 物料双向同步
|
||||
|
||||
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统同步到 self.workstation.deck"""
|
||||
external_data = self._query_external_materials()
|
||||
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
||||
for item in external_data:
|
||||
cls = self._resolve_resource_class(item["type"])
|
||||
resource = cls(name=item["name"], **item["params"])
|
||||
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
||||
return True
|
||||
|
||||
def sync_to_external(self, resource) -> bool:
|
||||
"""将 UniLab 侧物料变更同步到外部系统"""
|
||||
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
||||
external_format = self._convert_to_external(resource)
|
||||
return self._push_to_external(external_format)
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统主动推送的变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
同步策略取决于业务场景:
|
||||
|
||||
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
||||
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
||||
|
||||
在工作站 `post_init` 中初始化同步器:
|
||||
|
||||
```python
|
||||
@not_action
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
```
|
||||
|
||||
### 4. 序列化与持久化(serialize / serialize_state)
|
||||
|
||||
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
||||
|
||||
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
||||
|
||||
```python
|
||||
class MyDeck(Deck):
|
||||
def __init__(self, name, size_x, size_y, size_z,
|
||||
sites=None, # serialize() 输出的字段
|
||||
rotation=None, # serialize() 输出的字段
|
||||
barcode=None, # serialize() 输出的字段
|
||||
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
# ...
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
data["sites"] = [...] # 自定义字段
|
||||
return data
|
||||
```
|
||||
|
||||
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
||||
|
||||
```python
|
||||
class MyPlate(Plate):
|
||||
def __init__(self, name, size_x, size_y, size_z,
|
||||
material_info=None, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def serialize_state(self) -> Dict[str, Any]:
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state)
|
||||
return data
|
||||
```
|
||||
|
||||
关键要点:
|
||||
|
||||
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
||||
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
||||
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
||||
|
||||
### 5. 子物料自动同步
|
||||
|
||||
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
||||
|
||||
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"type": "device",
|
||||
"class": "my_workstation",
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
||||
"_resource_child_name": "my_deck"
|
||||
},
|
||||
"host": "10.20.30.1",
|
||||
"port": 9999
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"children": [],
|
||||
"config": {
|
||||
"type": "MyLabDeck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"category": "deck",
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
```
|
||||
|
||||
Deck 节点要点:
|
||||
|
||||
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
||||
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
||||
- `children` 初始为空(由同步器或手动初始化填充)
|
||||
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
||||
|
||||
---
|
||||
|
||||
## 子设备
|
||||
|
||||
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
||||
|
||||
子设备约束:
|
||||
|
||||
- 图文件中 `parent` 指向工作站 ID
|
||||
- 在工作站 `children` 数组中列出
|
||||
|
||||
---
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
||||
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
||||
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
||||
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
||||
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
||||
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 模块可导入
|
||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||
|
||||
# 启动测试(AST 自动扫描)
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 现有工作站参考
|
||||
|
||||
| 工作站 | 驱动类 | 类型 |
|
||||
| -------------- | ----------------------------- | -------- |
|
||||
| Protocol 通用 | `ProtocolNode` | Protocol |
|
||||
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
||||
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||
|
||||
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||
Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 外部系统集成模式
|
||||
|
||||
### 1.1 RPC 客户端
|
||||
|
||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
|
||||
|
||||
class MySystemRPC(BaseRequest):
|
||||
"""外部系统 RPC 客户端"""
|
||||
|
||||
def __init__(self, host: str, api_key: str):
|
||||
super().__init__(host)
|
||||
self.api_key = api_key
|
||||
|
||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||
return self.post(
|
||||
url=f"{self.host}/api/{endpoint}",
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
|
||||
def query_status(self) -> dict:
|
||||
return self._request("status/query")
|
||||
|
||||
def create_order(self, order_data: dict) -> dict:
|
||||
return self._request("order/create", order_data)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||
|
||||
### 1.2 HTTP 回调服务
|
||||
|
||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, config=None, deck=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
http_cfg = self.config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_cfg.get("http_service_port", 8080),
|
||||
}
|
||||
self.http_service = None
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
```
|
||||
|
||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||
|
||||
| 端点 | 调用的工作站方法 |
|
||||
|------|-----------------|
|
||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||
|
||||
实现对应方法即可接收回调:
|
||||
|
||||
```python
|
||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||
"""处理步骤完成报告"""
|
||||
step_name = report_request.data.get("stepName")
|
||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""处理订单完成报告"""
|
||||
order_code = report_request.data.get("orderCode")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||
|
||||
### 1.3 连接监控
|
||||
|
||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||
|
||||
```python
|
||||
class ConnectionMonitor:
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 调用外部系统接口检测连接
|
||||
self.workstation.hardware_interface.ping()
|
||||
status = "online"
|
||||
except Exception:
|
||||
status = "offline"
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Config 结构模式
|
||||
|
||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||
|
||||
### 2.1 外部系统连接
|
||||
|
||||
```json
|
||||
{
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HTTP 回调服务
|
||||
|
||||
```json
|
||||
{
|
||||
"http_service_config": {
|
||||
"http_service_host": "127.0.0.1",
|
||||
"http_service_port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 物料类型映射
|
||||
|
||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"material_type_mappings": {
|
||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 仓库映射
|
||||
|
||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"仓库名": {
|
||||
"uuid": "warehouse-uuid",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-A01",
|
||||
"A02": "site-uuid-A02"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 工作流映射
|
||||
|
||||
将内部工作流名映射到外部系统的工作流 ID。
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_mappings": {
|
||||
"internal_workflow_name": "external-workflow-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 物料默认参数
|
||||
|
||||
```json
|
||||
{
|
||||
"material_default_parameters": {
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源同步机制
|
||||
|
||||
### 3.1 ResourceSynchronizer
|
||||
|
||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def __init__(self, workstation, api_client):
|
||||
super().__init__(workstation)
|
||||
self.api_client = api_client
|
||||
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统拉取物料到 deck"""
|
||||
external_materials = self.api_client.list_materials()
|
||||
for material in external_materials:
|
||||
plr_resource = self._convert_to_plr(material)
|
||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||
return True
|
||||
|
||||
def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将 deck 中的物料变更推送到外部系统"""
|
||||
external_data = self._convert_from_plr(plr_resource)
|
||||
self.api_client.update_material(external_data)
|
||||
return True
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统推送的物料变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.2 update_resource — 上传资源树到云端
|
||||
|
||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||
|
||||
```python
|
||||
# 在 post_init 中上传初始 deck
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 在动作方法中更新特定资源
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [updated_plate]}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流序列管理
|
||||
|
||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._workflow_sequence = []
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
"""返回 JSON 字符串,ROS 自动发布"""
|
||||
import json
|
||||
return json.dumps(self._workflow_sequence)
|
||||
|
||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||
"""添加工作流到队列"""
|
||||
self._workflow_sequence.append({
|
||||
"name": workflow_name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
})
|
||||
return {"success": True}
|
||||
|
||||
async def clear_workflows(self) -> Dict[str, Any]:
|
||||
"""清空工作流队列"""
|
||||
self._workflow_sequence = []
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 站间物料转移
|
||||
|
||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||
|
||||
```python
|
||||
async def transfer_materials_to_another_station(
|
||||
self,
|
||||
target_device_id: str,
|
||||
transfer_groups: list,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""将物料转移到另一个工作站"""
|
||||
target_node = self._children.get(target_device_id)
|
||||
if not target_node:
|
||||
# 通过 ROS 节点查找非子设备的目标站
|
||||
pass
|
||||
|
||||
for group in transfer_groups:
|
||||
resource = self.find_resource_by_name(group["resource_name"])
|
||||
# 从本站 deck 移除
|
||||
resource.unassign()
|
||||
# 调用目标站的接收方法
|
||||
# ...
|
||||
|
||||
return {"success": True, "transferred": len(transfer_groups)}
|
||||
```
|
||||
|
||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||
|
||||
---
|
||||
|
||||
## 6. post_init 完整模式
|
||||
|
||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||
|
||||
```python
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
|
||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||
self.rpc_client = MySystemRPC(
|
||||
host=self.config.get("api_host"),
|
||||
api_key=self.config.get("api_key"),
|
||||
)
|
||||
self.hardware_interface = self.rpc_client
|
||||
|
||||
# 2. 启动连接监控
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
|
||||
# 3. 启动 HTTP 回调服务
|
||||
if hasattr(self, '_http_service_config'):
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
|
||||
# 4. 上传 deck 到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 5. 初始化资源同步器(可选)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||
```
|
||||
261
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
name: batch-insert-reagent
|
||||
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
|
||||
---
|
||||
|
||||
# 批量录入试剂 Skill
|
||||
|
||||
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 录入试剂
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"cas": "<CAS号>",
|
||||
"name": "<试剂名称>",
|
||||
"molecular_formula": "<分子式>",
|
||||
"smiles": "<SMILES>",
|
||||
"stock_in_quantity": <入库数量>,
|
||||
"unit": "<单位字符串>",
|
||||
"supplier": "<供应商>",
|
||||
"production_date": "<生产日期 ISO 8601>",
|
||||
"expiry_date": "<过期日期 ISO 8601>"
|
||||
}'
|
||||
```
|
||||
|
||||
返回成功时包含试剂 UUID:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 试剂字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
|
||||
### unit 单位值
|
||||
|
||||
| 值 | 单位 |
|
||||
| ------ | ---- |
|
||||
| `"mL"` | 毫升 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"kg"` | 千克 |
|
||||
| `"瓶"` | 瓶 |
|
||||
|
||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||
|
||||
---
|
||||
|
||||
## 批量录入策略
|
||||
|
||||
### 方式一:用户提供 JSON 数组
|
||||
|
||||
用户一次性给出多条试剂数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"cas": "7732-18-3",
|
||||
"name": "水",
|
||||
"molecular_formula": "H2O",
|
||||
"smiles": "O",
|
||||
"stock_in_quantity": 10,
|
||||
"unit": "mL"
|
||||
},
|
||||
{
|
||||
"cas": "64-17-5",
|
||||
"name": "乙醇",
|
||||
"molecular_formula": "C2H6O",
|
||||
"smiles": "CCO",
|
||||
"stock_in_quantity": 5,
|
||||
"unit": "L"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。
|
||||
|
||||
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
|
||||
|
||||
### 方式二:用户逐个描述
|
||||
|
||||
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段:
|
||||
|
||||
1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断)
|
||||
2. 构建完整的请求体
|
||||
3. 向用户确认后提交
|
||||
|
||||
### 方式三:从 CSV/表格批量导入
|
||||
|
||||
用户提供 CSV 或表格文件路径,agent 读取并解析:
|
||||
|
||||
```bash
|
||||
# 期望的 CSV 格式(首行为表头)
|
||||
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
|
||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||
```
|
||||
|
||||
### 日期格式规则(重要)
|
||||
|
||||
所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。
|
||||
|
||||
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
|
||||
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
|
||||
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
|
||||
|
||||
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。
|
||||
|
||||
### 执行与汇报
|
||||
|
||||
每次 API 调用后:
|
||||
|
||||
1. 检查返回 `code`(0 = 成功)
|
||||
2. 记录成功/失败数量
|
||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||
4. 如有失败,列出失败的试剂名称和错误信息
|
||||
|
||||
---
|
||||
|
||||
## 常见试剂速查表
|
||||
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
|
||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件)
|
||||
- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等)
|
||||
- [ ] Step 6: 向用户确认待录入的试剂列表
|
||||
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid)
|
||||
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
|
||||
|
||||
Agent 构建的请求序列:
|
||||
|
||||
```json
|
||||
// 第 1 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
|
||||
// 第 2 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
|
||||
// 第 3 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
```
|
||||
360
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
name: batch-submit-experiment
|
||||
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
||||
---
|
||||
|
||||
# Uni-Lab 批量提交实验指南
|
||||
|
||||
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic")
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. req_device_registry_upload.json(设备注册表)
|
||||
|
||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||
|
||||
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||
|
||||
```
|
||||
Glob: **/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||
- `<workspace>/req_device_registry_upload.json`
|
||||
|
||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
|
||||
### 4. workflow_uuid(目标工作流)
|
||||
|
||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**)
|
||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 列出实验室项目(让用户选择项目)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"uuid": "1b3f249a-...",
|
||||
"name": "bt",
|
||||
"description": null,
|
||||
"status": "active",
|
||||
"created_at": "2026-04-09T14:31:28+08:00"
|
||||
},
|
||||
{
|
||||
"uuid": "b6366243-...",
|
||||
"name": "default",
|
||||
"description": "默认项目",
|
||||
"status": "active",
|
||||
"created_at": "2026-03-26T11:13:36+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
|
||||
|
||||
### 3. 列出可用 workflow
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
||||
|
||||
### 4. 获取 workflow 模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||
|
||||
- 每个 action 节点的 `node_uuid`
|
||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||
- 每个节点的动作名(`node_template_name`)
|
||||
- 每个节点的现有参数(`param`)
|
||||
|
||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
||||
|
||||
### 5. 提交实验(创建 notebook)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
||||
|
||||
### 6. 查询 notebook 状态
|
||||
|
||||
提交成功后,使用返回的 notebook UUID 查询执行状态:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
|
||||
|
||||
---
|
||||
|
||||
## Notebook 请求体详解
|
||||
|
||||
### node_params 结构
|
||||
|
||||
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
||||
|
||||
- 要跑 2 轮 → `node_params` 有 2 个元素
|
||||
- 要跑 N 轮 → `node_params` 有 N 个元素
|
||||
|
||||
### 每轮的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------- | ------------- | ----------------------------------------- |
|
||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
|
||||
### datas 中每个节点
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------ | -------------------------------------------- |
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||
|
||||
### sample_params 中每条
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------------- | ------ | ---------------------------------------------------- |
|
||||
| `container_uuid` | string | 容器 UUID |
|
||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||
|
||||
---
|
||||
|
||||
## 从本地注册表生成 param 模板
|
||||
|
||||
### 自动方式 — 运行脚本
|
||||
|
||||
```bash
|
||||
python scripts/gen_notebook_params.py \
|
||||
--auth <token> \
|
||||
--base <BASE_URL> \
|
||||
--workflow-uuid <workflow_uuid> \
|
||||
[--registry <path/to/req_device_registry_upload.json>] \
|
||||
[--rounds <轮次数>] \
|
||||
[--output <输出文件路径>]
|
||||
```
|
||||
|
||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||
|
||||
脚本会:
|
||||
|
||||
1. 调用 workflow detail API 获取所有 action 节点
|
||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||
3. 生成 `notebook_template.json`,包含:
|
||||
- 完整 `node_params` 骨架
|
||||
- 每个节点的 param 字段及类型说明
|
||||
- `_schema_info` 辅助信息(不提交,仅供参考)
|
||||
|
||||
### 手动方式
|
||||
|
||||
如果脚本不可用或注册表不存在:
|
||||
|
||||
1. 调用 API #4 获取 workflow 详情
|
||||
2. 找到每个 action 节点的 `node_uuid`
|
||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
||||
```
|
||||
resources[].id == <device_id>
|
||||
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
||||
```
|
||||
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
||||
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
||||
|
||||
### 注册表结构参考
|
||||
|
||||
```json
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "liquid_handler.prcxi",
|
||||
"class": {
|
||||
"module": "unilabos.devices.xxx:ClassName",
|
||||
"action_value_mappings": {
|
||||
"transfer_liquid": {
|
||||
"type": "LiquidHandlerTransfer",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"goal": {
|
||||
"properties": {
|
||||
"asp_vols": {
|
||||
"type": "array",
|
||||
"items": { "type": "number" }
|
||||
},
|
||||
"sources": { "type": "array" }
|
||||
},
|
||||
"required": ["asp_vols", "sources"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal_default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
|
||||
- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择)
|
||||
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
|
||||
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
|
||||
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||
- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||
- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交
|
||||
- [ ] Step 11: 检查返回结果,记录 notebook UUID
|
||||
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
||||
|
||||
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
||||
|
||||
### Q: 多轮实验的参数完全不同吗?
|
||||
|
||||
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
||||
|
||||
### Q: 如何获取 sample_uuids 和 container_uuid?
|
||||
|
||||
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
|
||||
|
||||
用法:
|
||||
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
|
||||
|
||||
选项:
|
||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||
--rounds <n> 实验轮次数(默认 1)
|
||||
--output <path> 输出模板文件路径(默认 notebook_template.json)
|
||||
--dump-response 打印 workflow detail API 的原始响应(调试用)
|
||||
|
||||
示例:
|
||||
python gen_notebook_params.py \\
|
||||
--auth YTFmZDlkNGUtxxxx \\
|
||||
--base https://leap-lab.test.bohrium.com \\
|
||||
--workflow-uuid abc-123-def \\
|
||||
--rounds 2
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||
|
||||
|
||||
def find_registry(explicit_path=None):
|
||||
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
|
||||
if explicit_path:
|
||||
if os.path.isfile(explicit_path):
|
||||
return explicit_path
|
||||
if os.path.isdir(explicit_path):
|
||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||
if os.path.isfile(fp):
|
||||
return fp
|
||||
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||
for c in candidates:
|
||||
path = os.path.join(workspace_root, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
cwd = os.getcwd()
|
||||
for _ in range(5):
|
||||
parent = os.path.dirname(cwd)
|
||||
if parent == cwd:
|
||||
break
|
||||
cwd = parent
|
||||
for c in candidates:
|
||||
path = os.path.join(cwd, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def load_registry(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def build_registry_index(registry_data):
|
||||
"""构建 device_id → action_value_mappings 的索引"""
|
||||
index = {}
|
||||
for res in registry_data.get("resources", []):
|
||||
rid = res.get("id", "")
|
||||
avm = res.get("class", {}).get("action_value_mappings", {})
|
||||
if rid and avm:
|
||||
index[rid] = avm
|
||||
return index
|
||||
|
||||
|
||||
def flatten_goal_schema(action_data):
|
||||
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
|
||||
schema = action_data.get("schema", {})
|
||||
goal_schema = schema.get("properties", {}).get("goal", {})
|
||||
return goal_schema if goal_schema else schema
|
||||
|
||||
|
||||
def build_param_template(goal_schema):
|
||||
"""根据 goal schema 生成 param 模板,含类型标注"""
|
||||
properties = goal_schema.get("properties", {})
|
||||
required = set(goal_schema.get("required", []))
|
||||
template = {}
|
||||
for field_name, field_def in properties.items():
|
||||
if field_name == "unilabos_device_id":
|
||||
continue
|
||||
ftype = field_def.get("type", "any")
|
||||
default = field_def.get("default")
|
||||
if default is not None:
|
||||
template[field_name] = default
|
||||
elif ftype == "string":
|
||||
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
|
||||
elif ftype == "number" or ftype == "integer":
|
||||
template[field_name] = 0
|
||||
elif ftype == "boolean":
|
||||
template[field_name] = False
|
||||
elif ftype == "array":
|
||||
template[field_name] = []
|
||||
elif ftype == "object":
|
||||
template[field_name] = {}
|
||||
else:
|
||||
template[field_name] = f"$TODO ({ftype})"
|
||||
return template
|
||||
|
||||
|
||||
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
|
||||
"""调用 workflow detail API"""
|
||||
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
|
||||
req = Request(url, method="GET")
|
||||
req.add_header("Authorization", f"Lab {auth_token}")
|
||||
try:
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"API 错误 {e.code}: {body}")
|
||||
return None
|
||||
except URLError as e:
|
||||
print(f"网络错误: {e.reason}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_nodes_from_response(response):
|
||||
"""
|
||||
从 workflow detail 响应中提取 action 节点列表。
|
||||
适配多种可能的响应格式。
|
||||
|
||||
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
|
||||
"""
|
||||
data = response.get("data", response)
|
||||
|
||||
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
|
||||
nodes_raw = None
|
||||
for key in search_keys:
|
||||
if key in data and isinstance(data[key], list):
|
||||
nodes_raw = data[key]
|
||||
break
|
||||
|
||||
if nodes_raw is None:
|
||||
if isinstance(data, list):
|
||||
nodes_raw = data
|
||||
else:
|
||||
for v in data.values():
|
||||
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
||||
nodes_raw = v
|
||||
break
|
||||
|
||||
if not nodes_raw:
|
||||
print("警告: 未能从响应中提取节点列表")
|
||||
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
||||
return []
|
||||
|
||||
result = []
|
||||
for node in nodes_raw:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
node_uuid = (
|
||||
node.get("uuid")
|
||||
or node.get("node_uuid")
|
||||
or node.get("id")
|
||||
or ""
|
||||
)
|
||||
resource_name = (
|
||||
node.get("resource_template_name")
|
||||
or node.get("device_id")
|
||||
or node.get("resource_name")
|
||||
or node.get("device_name")
|
||||
or ""
|
||||
)
|
||||
template_name = (
|
||||
node.get("node_template_name")
|
||||
or node.get("action_name")
|
||||
or node.get("template_name")
|
||||
or node.get("action")
|
||||
or node.get("name")
|
||||
or ""
|
||||
)
|
||||
existing_param = node.get("param", {}) or {}
|
||||
|
||||
if node_uuid:
|
||||
result.append((node_uuid, resource_name, template_name, existing_param))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_template(nodes, registry_index, rounds):
|
||||
"""生成 notebook 提交模板"""
|
||||
node_params = []
|
||||
schema_info = {}
|
||||
|
||||
datas_template = []
|
||||
for node_uuid, resource_name, template_name, existing_param in nodes:
|
||||
param_template = {}
|
||||
matched = False
|
||||
|
||||
if resource_name and template_name and resource_name in registry_index:
|
||||
avm = registry_index[resource_name]
|
||||
if template_name in avm:
|
||||
goal_schema = flatten_goal_schema(avm[template_name])
|
||||
param_template = build_param_template(goal_schema)
|
||||
goal_default = avm[template_name].get("goal_default", {})
|
||||
if goal_default:
|
||||
for k, v in goal_default.items():
|
||||
if k in param_template and v is not None:
|
||||
param_template[k] = v
|
||||
matched = True
|
||||
|
||||
schema_info[node_uuid] = {
|
||||
"device_id": resource_name,
|
||||
"action_name": template_name,
|
||||
"action_type": avm[template_name].get("type", ""),
|
||||
"schema_properties": list(goal_schema.get("properties", {}).keys()),
|
||||
"required": goal_schema.get("required", []),
|
||||
}
|
||||
|
||||
if not matched and existing_param:
|
||||
param_template = existing_param
|
||||
|
||||
if not matched and not existing_param:
|
||||
schema_info[node_uuid] = {
|
||||
"device_id": resource_name,
|
||||
"action_name": template_name,
|
||||
"warning": "未在本地注册表中找到匹配的 action schema",
|
||||
}
|
||||
|
||||
datas_template.append({
|
||||
"node_uuid": node_uuid,
|
||||
"param": param_template,
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "$TODO_CONTAINER_UUID",
|
||||
"sample_value": {
|
||||
"liquid_names": "$TODO_LIQUID_NAME",
|
||||
"volumes": 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
for i in range(rounds):
|
||||
node_params.append({
|
||||
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
|
||||
"datas": copy.deepcopy(datas_template),
|
||||
})
|
||||
|
||||
return {
|
||||
"lab_uuid": "$TODO_LAB_UUID",
|
||||
"project_uuid": "$TODO_PROJECT_UUID",
|
||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
||||
"name": "$TODO_EXPERIMENT_NAME",
|
||||
"node_params": node_params,
|
||||
"_schema_info(仅参考,提交时删除)": schema_info,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""简单的参数解析"""
|
||||
opts = {
|
||||
"auth": None,
|
||||
"base": None,
|
||||
"workflow_uuid": None,
|
||||
"registry": None,
|
||||
"rounds": 1,
|
||||
"output": "notebook_template.json",
|
||||
"dump_response": False,
|
||||
}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--auth" and i + 1 < len(argv):
|
||||
opts["auth"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--base" and i + 1 < len(argv):
|
||||
opts["base"] = argv[i + 1].rstrip("/")
|
||||
i += 2
|
||||
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
||||
opts["workflow_uuid"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--registry" and i + 1 < len(argv):
|
||||
opts["registry"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--rounds" and i + 1 < len(argv):
|
||||
opts["rounds"] = int(argv[i + 1])
|
||||
i += 2
|
||||
elif arg == "--output" and i + 1 < len(argv):
|
||||
opts["output"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--dump-response":
|
||||
opts["dump_response"] = True
|
||||
i += 1
|
||||
else:
|
||||
print(f"未知参数: {arg}")
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main():
|
||||
opts = parse_args(sys.argv[1:])
|
||||
|
||||
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
||||
print("用法:")
|
||||
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
||||
print()
|
||||
print("必需参数:")
|
||||
print(" --auth <token> Lab token(base64(ak:sk))")
|
||||
print(" --base <url> API 基础 URL")
|
||||
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
||||
print()
|
||||
print("可选参数:")
|
||||
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
||||
print(" --rounds <n> 实验轮次数(默认 1)")
|
||||
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
||||
print(" --dump-response 打印 API 原始响应")
|
||||
sys.exit(1)
|
||||
|
||||
# 1. 查找并加载本地注册表
|
||||
registry_path = find_registry(opts["registry"])
|
||||
registry_index = {}
|
||||
if registry_path:
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
||||
registry_data = load_registry(registry_path)
|
||||
registry_index = build_registry_index(registry_data)
|
||||
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
||||
else:
|
||||
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
||||
print(" 提交时需要手动填写各节点的 param 字段")
|
||||
|
||||
# 2. 获取 workflow 详情
|
||||
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
||||
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
||||
if not response:
|
||||
print("错误: 无法获取 workflow 详情")
|
||||
sys.exit(1)
|
||||
|
||||
if opts["dump_response"]:
|
||||
print("\n=== API 原始响应 ===")
|
||||
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
||||
print("=== 响应结束(截断至 5000 字符) ===\n")
|
||||
|
||||
# 3. 提取节点
|
||||
nodes = extract_nodes_from_response(response)
|
||||
if not nodes:
|
||||
print("错误: 未能从 workflow 中提取任何 action 节点")
|
||||
print("请使用 --dump-response 查看原始响应结构")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
||||
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
||||
print(" " + "-" * 110)
|
||||
for node_uuid, resource_name, template_name, _ in nodes:
|
||||
matched = "✓" if (resource_name in registry_index and
|
||||
template_name in registry_index.get(resource_name, {})) else "✗"
|
||||
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
||||
|
||||
# 4. 生成模板
|
||||
template = generate_template(nodes, registry_index, opts["rounds"])
|
||||
template["workflow_uuid"] = opts["workflow_uuid"]
|
||||
|
||||
output_path = opts["output"]
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n模板已写入: {output_path}")
|
||||
print(f" 轮次数: {opts['rounds']}")
|
||||
print(f" 节点数/轮: {len(nodes)}")
|
||||
print()
|
||||
print("下一步:")
|
||||
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
||||
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
||||
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
500
.cursor/skills/create-device-skill/SKILL.md
Normal file
@@ -0,0 +1,500 @@
|
||||
---
|
||||
name: create-device-skill
|
||||
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
||||
---
|
||||
|
||||
# 创建设备 Skill 指南
|
||||
|
||||
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
||||
|
||||
## 数据源
|
||||
|
||||
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
||||
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
||||
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
||||
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
||||
|
||||
## 创建流程
|
||||
|
||||
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
||||
|
||||
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
||||
|
||||
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
||||
|
||||
#### 必备项 ①:ak / sk(认证凭据)
|
||||
|
||||
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
||||
|
||||
获取后立即生成 AUTH token:
|
||||
|
||||
```bash
|
||||
python ./scripts/gen_auth.py <ak> <sk>
|
||||
# 或从 config.py 提取
|
||||
python ./scripts/gen_auth.py --config <config.py>
|
||||
```
|
||||
|
||||
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
#### 必备项 ②:--addr(目标环境)
|
||||
|
||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||
|
||||
| `--addr` 值 | BASE URL |
|
||||
| -------------- | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
|
||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||
|
||||
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
||||
|
||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||
|
||||
| 条件 | working_dir 取值 |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
|
||||
**按优先级搜索文件**:
|
||||
|
||||
```
|
||||
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
||||
<推断的 working_dir>/req_device_registry_upload.json
|
||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||
|
||||
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
||||
|
||||
#### 必备项 ④:目标设备
|
||||
|
||||
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
||||
|
||||
如果用户不确定,运行提取脚本列出所有设备供选择:
|
||||
|
||||
```bash
|
||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||
```
|
||||
|
||||
**四项全部就绪后才进入 Step 1。**
|
||||
|
||||
### Step 1 — 列出可用设备
|
||||
|
||||
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
||||
|
||||
```bash
|
||||
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
||||
python ./scripts/extract_device_actions.py
|
||||
|
||||
# 指定注册表文件路径
|
||||
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||
```
|
||||
|
||||
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
||||
|
||||
### Step 2 — 提取 Action Schema
|
||||
|
||||
用户选择设备后,运行提取脚本:
|
||||
|
||||
```bash
|
||||
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
||||
```
|
||||
|
||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||
|
||||
每个 action 生成一个 JSON 文件,包含:
|
||||
|
||||
- `type` — 作为 API 调用的 `action_type`
|
||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||
- `goal_default` — 默认值
|
||||
|
||||
### Step 3 — 写 action-index.md
|
||||
|
||||
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||
|
||||
```markdown
|
||||
### `<action_name>`
|
||||
|
||||
<用途描述(一句话)>
|
||||
|
||||
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||
- **可选参数**: `param3`, `param4`
|
||||
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
||||
```
|
||||
|
||||
描述规则:
|
||||
|
||||
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||
- 从 `schema.required` 区分核心/可选参数
|
||||
- 按功能分类(移液、枪头、外设等)
|
||||
- 标注 `placeholder_keys` 中的字段类型:
|
||||
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
- Session state 中的 `device_name`
|
||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||
|
||||
API 模板结构:
|
||||
|
||||
```markdown
|
||||
## 设备信息
|
||||
|
||||
- device_id, Python 源码路径, 设备类名
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## 请求约定
|
||||
|
||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||
|
||||
## Session State
|
||||
|
||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||
|
||||
## API Endpoints
|
||||
|
||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||
|
||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||
|
||||
# - #3 创建节点 POST /edge/workflow/node
|
||||
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
|
||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||
|
||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||
|
||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||
|
||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||
|
||||
# - #7 批量创建边 POST /lab/workflow/edges
|
||||
|
||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||
|
||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||
|
||||
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||
|
||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||
|
||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||
|
||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||
|
||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||
|
||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||
|
||||
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
|
||||
|
||||
# 返回 res_template_uuid,用于 #15 创建物料时的必填字段
|
||||
|
||||
# - #15 创建物料节点 POST /edge/material/node
|
||||
|
||||
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
|
||||
|
||||
# - #16 更新物料节点 PUT /edge/material/node
|
||||
|
||||
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
## 完整工作流 Checklist
|
||||
```
|
||||
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
|
||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||
- [ ] 描述能让 agent 判断该用哪个 action
|
||||
|
||||
## Action JSON 文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
||||
"goal": { // goal 字段映射
|
||||
"sources": "sources",
|
||||
"targets": "targets",
|
||||
"tip_racks": "tip_racks",
|
||||
"asp_vols": "asp_vols"
|
||||
},
|
||||
"schema": { // ← 直接是 goal 的 schema(已提升)
|
||||
"type": "object",
|
||||
"properties": { // 参数定义(即请求中 goal 的字段)
|
||||
"sources": { "type": "array", "items": { "type": "object" } },
|
||||
"targets": { "type": "array", "items": { "type": "object" } },
|
||||
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
||||
},
|
||||
"required": [...],
|
||||
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
||||
"sources": "unilabos_resources",
|
||||
"targets": "unilabos_resources",
|
||||
"tip_racks": "unilabos_resources"
|
||||
}
|
||||
},
|
||||
"goal_default": { ... }, // 默认值
|
||||
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
||||
"sources": "unilabos_resources", // ResourceSlot
|
||||
"targets": "unilabos_resources",
|
||||
"tip_racks": "unilabos_resources",
|
||||
"target_device_id": "unilabos_devices" // DeviceSlot
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
||||
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。
|
||||
|
||||
## Placeholder Slot 类型体系
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||
|
||||
### ResourceSlot(`unilabos_resources`)
|
||||
|
||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||
|
||||
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||
|
||||
### DeviceSlot / NodeSlot / ClassSlot
|
||||
|
||||
- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
|
||||
- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
|
||||
- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
|
||||
|
||||
### FormulationSlot(`unilabos_formulation`)
|
||||
|
||||
描述**液体配方**:向哪些容器中加入哪些液体及体积。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "bottle_A1",
|
||||
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||
|
||||
### 通过 API #12 获取资源树
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": [
|
||||
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
|
||||
],
|
||||
"edges": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`
|
||||
- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等)
|
||||
- `parent` 为父节点名称(空字符串表示顶级)
|
||||
- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点
|
||||
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
|
||||
|
||||
## 物料管理 API
|
||||
|
||||
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
|
||||
|
||||
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。
|
||||
|
||||
### API #14 — 按名称查询物料模板
|
||||
|
||||
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ---------- | ------ | -------------------------------- |
|
||||
| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) |
|
||||
| `name` | **是** | 物料模板名称(如 `"container"`) |
|
||||
|
||||
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。
|
||||
|
||||
模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
|
||||
|
||||
### API #15 — 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "my_custom_bottle",
|
||||
"display_name": "自定义瓶子",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"type": "",
|
||||
"init_param_data": {},
|
||||
"schema": {},
|
||||
"data": {
|
||||
"liquids": [["water", 1000, "uL"]],
|
||||
"max_volume": 50000
|
||||
},
|
||||
"plate_well_datas": {},
|
||||
"plate_reagent_datas": {},
|
||||
"pose": {},
|
||||
"model": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
|
||||
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
|
||||
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
|
||||
| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) |
|
||||
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
|
||||
| `type` | 否 | string | 从模板继承 | 节点类型 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
|
||||
| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 |
|
||||
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
|
||||
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
|
||||
| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 |
|
||||
| `pose` | 否 | object | 用户指定 | 位姿信息 |
|
||||
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
|
||||
|
||||
#### container 的 `data` 格式
|
||||
|
||||
> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
|
||||
"max_volume": 50000
|
||||
}
|
||||
```
|
||||
|
||||
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
|
||||
- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL
|
||||
|
||||
### API #16 — 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"display_name": "新显示名称",
|
||||
"description": "新描述",
|
||||
"init_param_data": {},
|
||||
"data": {},
|
||||
"pose": {},
|
||||
"schema": {},
|
||||
"extra": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
|
||||
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
|
||||
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
|
||||
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
|
||||
| `description` | 否 | string | 用户指定 | 更新描述 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
|
||||
| `data` | 否 | object | 用户指定 | 更新节点数据 |
|
||||
| `pose` | 否 | object | 用户指定 | 更新位姿 |
|
||||
| `schema` | 否 | object | 用户指定 | 更新 schema |
|
||||
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
|
||||
|
||||
> 只传需要更新的字段,未传的字段保持不变。
|
||||
|
||||
## 最终目录结构
|
||||
|
||||
```
|
||||
./<skill-name>/
|
||||
├── SKILL.md # API 端点 + 渐进加载指引
|
||||
├── action-index.md # 动作索引:描述/用途/核心参数
|
||||
└── actions/ # 每个 action 的完整 JSON Schema
|
||||
├── action1.json
|
||||
├── action2.json
|
||||
└── ...
|
||||
```
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
||||
|
||||
用法:
|
||||
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
||||
python extract_device_actions.py
|
||||
|
||||
# 指定注册表文件路径
|
||||
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||
|
||||
# 提取指定设备的 action 到目录
|
||||
python extract_device_actions.py <device_id> <output_dir>
|
||||
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
||||
|
||||
示例:
|
||||
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
||||
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||
|
||||
def find_registry(explicit_path=None):
|
||||
"""
|
||||
查找 req_device_registry_upload.json 文件。
|
||||
|
||||
搜索优先级:
|
||||
1. 用户通过 --registry 显式指定的路径
|
||||
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
||||
3. <cwd>/req_device_registry_upload.json
|
||||
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
||||
5. 向上逐级搜索父目录(最多 5 层)
|
||||
"""
|
||||
if explicit_path:
|
||||
if os.path.isfile(explicit_path):
|
||||
return explicit_path
|
||||
if os.path.isdir(explicit_path):
|
||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||
if os.path.isfile(fp):
|
||||
return fp
|
||||
print(f"警告: 指定的路径不存在: {explicit_path}")
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
]
|
||||
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||
for c in candidates:
|
||||
path = os.path.join(workspace_root, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
cwd = os.getcwd()
|
||||
for _ in range(5):
|
||||
parent = os.path.dirname(cwd)
|
||||
if parent == cwd:
|
||||
break
|
||||
cwd = parent
|
||||
for c in candidates:
|
||||
path = os.path.join(cwd, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def load_registry(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def list_devices(data):
|
||||
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
||||
resources = data.get('resources', [])
|
||||
devices = []
|
||||
for res in resources:
|
||||
rid = res.get('id', '')
|
||||
cls = res.get('class', {})
|
||||
avm = cls.get('action_value_mappings', {})
|
||||
module = cls.get('module', '')
|
||||
if avm:
|
||||
devices.append((rid, len(avm), module))
|
||||
return devices
|
||||
|
||||
def flatten_schema_to_goal(action_data):
|
||||
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
||||
schema = action_data.get('schema', {})
|
||||
goal_schema = schema.get('properties', {}).get('goal', {})
|
||||
if goal_schema:
|
||||
action_data = dict(action_data)
|
||||
action_data['schema'] = goal_schema
|
||||
return action_data
|
||||
|
||||
|
||||
def extract_actions(data, device_id, output_dir):
|
||||
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
||||
resources = data.get('resources', [])
|
||||
for res in resources:
|
||||
if res.get('id') == device_id:
|
||||
cls = res.get('class', {})
|
||||
module = cls.get('module', '')
|
||||
avm = cls.get('action_value_mappings', {})
|
||||
if not avm:
|
||||
print(f"设备 {device_id} 没有 action_value_mappings")
|
||||
return []
|
||||
|
||||
if module:
|
||||
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
||||
class_name = module.split(":")[-1] if ":" in module else ""
|
||||
print(f"Python 源码: {py_path}")
|
||||
if class_name:
|
||||
print(f"设备类: {class_name}")
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
written = []
|
||||
for action_name in sorted(avm.keys()):
|
||||
action_data = flatten_schema_to_goal(avm[action_name])
|
||||
filename = action_name.replace('-', '_') + '.json'
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
||||
written.append(filename)
|
||||
print(f" {filepath}")
|
||||
return written
|
||||
|
||||
print(f"设备 {device_id} 未找到")
|
||||
return []
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
explicit_registry = None
|
||||
|
||||
if "--registry" in args:
|
||||
idx = args.index("--registry")
|
||||
if idx + 1 < len(args):
|
||||
explicit_registry = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
else:
|
||||
print("错误: --registry 需要指定路径")
|
||||
sys.exit(1)
|
||||
|
||||
registry_path = find_registry(explicit_registry)
|
||||
if not registry_path:
|
||||
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
||||
print()
|
||||
print("解决方法:")
|
||||
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
||||
print(" 2. 用 --registry 指定文件路径:")
|
||||
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
||||
print()
|
||||
print("搜索过的路径:")
|
||||
for p in [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
||||
]:
|
||||
print(f" - {p}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"注册表: {registry_path}")
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
||||
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
||||
data = load_registry(registry_path)
|
||||
|
||||
if len(args) == 0:
|
||||
devices = list_devices(data)
|
||||
print(f"\n找到 {len(devices)} 个设备:")
|
||||
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
||||
print("-" * 120)
|
||||
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
||||
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
||||
print(f"{did:<50} {count:>7} {py_path}")
|
||||
|
||||
elif len(args) == 2:
|
||||
device_id = args[0]
|
||||
output_dir = args[1]
|
||||
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
||||
written = extract_actions(data, device_id, output_dir)
|
||||
if written:
|
||||
print(f"\n共写入 {len(written)} 个 action 文件")
|
||||
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
||||
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 ak/sk 生成 UniLab API Authorization header。
|
||||
|
||||
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
||||
|
||||
用法:
|
||||
python gen_auth.py <ak> <sk>
|
||||
python gen_auth.py --config <config.py>
|
||||
|
||||
示例:
|
||||
python gen_auth.py myak mysk
|
||||
python gen_auth.py --config experiments/config.py
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def gen_auth(ak: str, sk: str) -> str:
|
||||
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
||||
return token
|
||||
|
||||
|
||||
def extract_from_config(config_path: str) -> tuple:
|
||||
"""从 config.py 中提取 ak 和 sk"""
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
||||
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
||||
if not ak_match or not sk_match:
|
||||
return None, None
|
||||
return ak_match.group(1), sk_match.group(1)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 2 and args[0] == "--config":
|
||||
ak, sk = extract_from_config(args[1])
|
||||
if not ak or not sk:
|
||||
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
||||
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
||||
sys.exit(1)
|
||||
print(f"配置文件: {args[1]}")
|
||||
elif len(args) == 2:
|
||||
ak, sk = args
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python gen_auth.py <ak> <sk>")
|
||||
print(" python gen_auth.py --config <config.py>")
|
||||
sys.exit(1)
|
||||
|
||||
token = gen_auth(ak, sk)
|
||||
print(f"ak: {ak}")
|
||||
print(f"sk: {sk}")
|
||||
print()
|
||||
print(f"Authorization header:")
|
||||
print(f" Authorization: Lab {token}")
|
||||
print()
|
||||
print(f"curl 用法:")
|
||||
print(f' curl -H "Authorization: Lab {token}" ...')
|
||||
print()
|
||||
print(f"Shell 变量:")
|
||||
print(f' AUTH="Authorization: Lab {token}"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
284
.cursor/skills/submit-agent-result/SKILL.md
Normal file
@@ -0,0 +1,284 @@
|
||||
---
|
||||
name: submit-agent-result
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||
---
|
||||
|
||||
# Uni-Lab 提交历史实验记录指南
|
||||
|
||||
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. notebook_uuid(**必须询问用户**)
|
||||
|
||||
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
|
||||
|
||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||
|
||||
如果用户不记得,可提示:
|
||||
|
||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||
- 或通过平台页面查找对应的 notebook
|
||||
|
||||
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
|
||||
|
||||
### 4. 实验结果数据
|
||||
|
||||
用户需要提供实验结果数据,支持以下方式:
|
||||
|
||||
| 方式 | 说明 |
|
||||
| --------- | ----------------------------------------------- |
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**)
|
||||
- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 提交实验结果(agent_result)
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<notebook_uuid>",
|
||||
"agent_result": {
|
||||
"<key1>": "<value1>",
|
||||
"<key2>": 123,
|
||||
"<nested_key>": {"a": 1, "b": 2},
|
||||
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:HTTP 方法是 **PUT**(不是 POST)。
|
||||
|
||||
#### 必要字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------------- | ------------------------------------------- |
|
||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
|
||||
#### agent_result 内容格式
|
||||
|
||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||
|
||||
**简单键值对**:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**包含嵌套结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
||||
"measurements": [
|
||||
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
|
||||
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**从 CSV 文件导入**(脚本自动转换):
|
||||
|
||||
```json
|
||||
{
|
||||
"experiment_data": [
|
||||
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
|
||||
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 整合脚本
|
||||
|
||||
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
```
|
||||
|
||||
| 参数 | 必选 | 说明 |
|
||||
| ----------------- | ---------- | ----------------------------------------------- |
|
||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||
| `--base` | 提交时必选 | API base URL |
|
||||
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
||||
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
||||
|
||||
### 文件合并规则
|
||||
|
||||
| 文件类型 | 合并方式 |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||
|
||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 仅生成请求体文件(不提交)
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json measurements.csv
|
||||
|
||||
# 生成并直接提交
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json \
|
||||
--auth YTFmZDlkNGUt... \
|
||||
--base https://leap-lab.test.bohrium.com \
|
||||
--submit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手动构建方式
|
||||
|
||||
如果不使用脚本,也可手动构建请求体:
|
||||
|
||||
1. 将实验结果数据组装为 JSON 对象
|
||||
2. 写入临时文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<uuid>",
|
||||
"agent_result": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
3. 用 curl 提交:
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '@tmp_body.json'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过)
|
||||
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
|
||||
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
|
||||
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
|
||||
- [ ] Step 8: 检查返回结果,确认提交成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: notebook_uuid 从哪里获取?
|
||||
|
||||
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
|
||||
|
||||
### Q: agent_result 有固定的 schema 吗?
|
||||
|
||||
没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
|
||||
|
||||
### Q: 可以多次提交同一个 notebook 的结果吗?
|
||||
|
||||
可以,后续提交会覆盖之前的 agent_result。
|
||||
|
||||
### Q: 认证方式是 Lab 还是 Api?
|
||||
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。
|
||||
|
||||
用法:
|
||||
python prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <Lab token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
|
||||
支持的输入文件格式:
|
||||
- .json → 直接作为 dict 合并
|
||||
- .csv → 转为 {"filename": [row_dict, ...]} 格式
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def read_json_file(filepath: str) -> Dict[str, Any]:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
|
||||
rows = []
|
||||
with open(filepath, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
converted = {}
|
||||
for k, v in row.items():
|
||||
try:
|
||||
converted[k] = int(v)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
converted[k] = float(v)
|
||||
except (ValueError, TypeError):
|
||||
converted[k] = v
|
||||
rows.append(converted)
|
||||
return rows
|
||||
|
||||
|
||||
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
|
||||
"""将多个文件合并为一个 agent_result dict"""
|
||||
merged: Dict[str, Any] = {}
|
||||
for fp in filepaths:
|
||||
path = Path(fp)
|
||||
ext = path.suffix.lower()
|
||||
key = path.stem
|
||||
|
||||
if ext == ".json":
|
||||
data = read_json_file(fp)
|
||||
if isinstance(data, dict):
|
||||
merged.update(data)
|
||||
else:
|
||||
merged[key] = data
|
||||
elif ext == ".csv":
|
||||
merged[key] = read_csv_file(fp)
|
||||
else:
|
||||
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"notebook_uuid": notebook_uuid,
|
||||
"agent_result": agent_result,
|
||||
}
|
||||
|
||||
|
||||
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
url = f"{base}/api/v1/lab/notebook/agent-result"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Lab {auth}",
|
||||
}
|
||||
resp = requests.put(url, json=body, headers=headers, timeout=30)
|
||||
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
|
||||
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
|
||||
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)")
|
||||
parser.add_argument("--auth", help="Lab token(base64(ak:sk))")
|
||||
parser.add_argument("--base", help="API base URL")
|
||||
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
|
||||
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
for fp in args.files:
|
||||
if not os.path.exists(fp):
|
||||
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agent_result = merge_files(args.files)
|
||||
body = build_request_body(args.notebook_uuid, agent_result)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||
print(f"[完成] 请求体已保存: {args.output}")
|
||||
print(f" notebook_uuid: {args.notebook_uuid}")
|
||||
print(f" agent_result 字段数: {len(agent_result)}")
|
||||
print(f" 合并文件数: {len(args.files)}")
|
||||
|
||||
if args.submit:
|
||||
if not args.auth or not args.base:
|
||||
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
|
||||
result = submit(args.base, args.auth, body)
|
||||
print(f" HTTP {result['status_code']}")
|
||||
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
26
.cursorignore
Normal file
@@ -0,0 +1,26 @@
|
||||
.conda
|
||||
# .github
|
||||
.idea
|
||||
# .vscode
|
||||
output
|
||||
pylabrobot_repo
|
||||
recipes
|
||||
scripts
|
||||
service
|
||||
temp
|
||||
# unilabos/test
|
||||
# unilabos/app/web
|
||||
unilabos/device_mesh
|
||||
unilabos_data
|
||||
unilabos_msgs
|
||||
unilabos.egg-info
|
||||
CONTRIBUTORS
|
||||
# LICENSE
|
||||
MANIFEST.in
|
||||
pyrightconfig.json
|
||||
# README.md
|
||||
# README_zh.md
|
||||
setup.py
|
||||
setup.cfg
|
||||
.gitattrubutes
|
||||
**/__pycache__
|
||||
67
.github/workflows/ci-check.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: CI Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
registry-check:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
# Fix Unicode encoding issue on Windows runner (cp1252 -> utf-8)
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: 1
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: robostack-staging,conda-forge,uni-lab
|
||||
channel-priority: flexible
|
||||
activate-environment: check-env
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install ROS dependencies, uv and unilabos-msgs
|
||||
run: |
|
||||
echo Installing ROS dependencies...
|
||||
mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y
|
||||
|
||||
- name: Install pip dependencies and unilabos
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Installing pip dependencies...
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git
|
||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||
uv pip install .
|
||||
|
||||
- name: Run check mode (AST registry validation)
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Running check mode...
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
shell: bash
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "::error::检测到文件变化!请先在本地运行 'python -m unilabos --complete_registry' 并提交变更"
|
||||
echo "变化的文件:"
|
||||
git diff --name-only
|
||||
exit 1
|
||||
fi
|
||||
echo "检查通过:无文件变化"
|
||||
102
.github/workflows/conda-pack-build.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: Build Conda-Pack Environment
|
||||
|
||||
on:
|
||||
# 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack
|
||||
workflow_run:
|
||||
workflows: ["UniLabOS Conda Build"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -13,9 +17,24 @@ on:
|
||||
required: false
|
||||
default: 'win-64'
|
||||
type: string
|
||||
build_full:
|
||||
description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
build-conda-pack:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'workflow_run'
|
||||
)
|
||||
env:
|
||||
BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}
|
||||
PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -24,7 +43,7 @@ jobs:
|
||||
platform: linux-64
|
||||
env_file: unilabos-linux-64.yaml
|
||||
script_ext: sh
|
||||
- os: macos-13 # Intel
|
||||
- os: macos-15 # Intel (via Rosetta)
|
||||
platform: osx-64
|
||||
env_file: unilabos-osx-64.yaml
|
||||
script_ext: sh
|
||||
@@ -49,7 +68,9 @@ jobs:
|
||||
id: should_build
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${{ github.event.inputs.platforms }}" ]]; then
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
@@ -57,10 +78,10 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge (with mamba)
|
||||
@@ -69,8 +90,8 @@ jobs:
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.11'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
python-version: '3.11.14'
|
||||
channels: conda-forge,robostack-staging,uni-lab
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
auto-update-conda: false
|
||||
@@ -81,7 +102,14 @@ jobs:
|
||||
run: |
|
||||
echo Installing unilabos and dependencies to unilab environment...
|
||||
echo Using mamba for faster and more reliable dependency resolution...
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
echo Build full: ${{ env.BUILD_FULL }}
|
||||
if "${{ env.BUILD_FULL }}"=="true" (
|
||||
echo Installing unilabos-full ^(complete package^)...
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
||||
) else (
|
||||
echo Installing unilabos ^(minimal package^)...
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
||||
)
|
||||
|
||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||
@@ -89,7 +117,14 @@ jobs:
|
||||
run: |
|
||||
echo "Installing unilabos and dependencies to unilab environment..."
|
||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
echo "Build full: ${{ env.BUILD_FULL }}"
|
||||
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
||||
echo "Installing unilabos-full (complete package)..."
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
||||
else
|
||||
echo "Installing unilabos (minimal package)..."
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
||||
fi
|
||||
|
||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
@@ -115,27 +150,27 @@ jobs:
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
run: |
|
||||
echo Checking for available ros-humble-unilabos-msgs versions...
|
||||
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed
|
||||
mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed
|
||||
echo.
|
||||
echo Updating ros-humble-unilabos-msgs to latest version...
|
||||
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version
|
||||
mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version
|
||||
|
||||
- name: Check for newer ros-humble-unilabos-msgs (Unix)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Checking for available ros-humble-unilabos-msgs versions..."
|
||||
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed"
|
||||
mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed"
|
||||
echo ""
|
||||
echo "Updating ros-humble-unilabos-msgs to latest version..."
|
||||
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version"
|
||||
mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version"
|
||||
|
||||
- name: Install latest unilabos from source (Windows)
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
run: |
|
||||
echo Uninstalling existing unilabos...
|
||||
mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip
|
||||
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
|
||||
echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})...
|
||||
mamba run -n unilab pip install .
|
||||
echo Verifying installation...
|
||||
mamba run -n unilab pip show unilabos
|
||||
@@ -146,7 +181,7 @@ jobs:
|
||||
run: |
|
||||
echo "Uninstalling existing unilabos..."
|
||||
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
|
||||
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
|
||||
echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..."
|
||||
mamba run -n unilab pip install .
|
||||
echo "Verifying installation..."
|
||||
mamba run -n unilab pip show unilabos
|
||||
@@ -207,7 +242,9 @@ jobs:
|
||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||
run: |
|
||||
echo Packing unilab environment with conda-pack...
|
||||
mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||
for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i"
|
||||
echo Packing environment at: %UNILAB_PREFIX%
|
||||
mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||
echo Pack file created:
|
||||
dir unilab-env-${{ matrix.platform }}.tar.gz
|
||||
|
||||
@@ -216,8 +253,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Packing unilab environment with conda-pack..."
|
||||
mamba install conda-pack -c conda-forge -y
|
||||
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||
UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')"
|
||||
echo "Packing environment at: $UNILAB_PREFIX"
|
||||
mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||
echo "Pack file created:"
|
||||
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
|
||||
|
||||
@@ -248,7 +286,7 @@ jobs:
|
||||
|
||||
rem Create README using Python script
|
||||
echo Creating: README.txt
|
||||
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
|
||||
python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt
|
||||
|
||||
echo.
|
||||
echo Distribution package contents:
|
||||
@@ -284,7 +322,7 @@ jobs:
|
||||
|
||||
# Create README using Python script
|
||||
echo "Creating: README.txt"
|
||||
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
|
||||
python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt
|
||||
|
||||
echo ""
|
||||
echo "Distribution package contents:"
|
||||
@@ -293,9 +331,9 @@ jobs:
|
||||
|
||||
- name: Upload distribution package
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||
name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
|
||||
path: dist-package/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
@@ -307,13 +345,18 @@ jobs:
|
||||
echo Build Summary
|
||||
echo ==========================================
|
||||
echo Platform: ${{ matrix.platform }}
|
||||
echo Branch: ${{ github.event.inputs.branch }}
|
||||
echo Python version: 3.11.11
|
||||
echo Branch: ${{ env.PACKAGE_REF }}
|
||||
echo Python version: 3.11.14
|
||||
if "${{ env.BUILD_FULL }}"=="true" (
|
||||
echo Package: unilabos-full ^(complete^)
|
||||
) else (
|
||||
echo Package: unilabos ^(minimal^)
|
||||
)
|
||||
echo.
|
||||
echo Distribution package contents:
|
||||
dir dist-package
|
||||
echo.
|
||||
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
|
||||
echo.
|
||||
echo After download, extract the ZIP and run:
|
||||
echo install_unilab.bat
|
||||
@@ -327,13 +370,18 @@ jobs:
|
||||
echo "Build Summary"
|
||||
echo "=========================================="
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||
echo "Python version: 3.11.11"
|
||||
echo "Branch: ${{ env.PACKAGE_REF }}"
|
||||
echo "Python version: 3.11.14"
|
||||
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
||||
echo "Package: unilabos-full (complete)"
|
||||
else
|
||||
echo "Package: unilabos (minimal)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Distribution package contents:"
|
||||
ls -lh dist-package/
|
||||
echo ""
|
||||
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
|
||||
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}"
|
||||
echo ""
|
||||
echo "After download:"
|
||||
echo " install_unilab.sh"
|
||||
|
||||
41
.github/workflows/deploy-docs.yml
vendored
@@ -1,10 +1,12 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# 在 CI Check 成功后自动触发(仅 main 分支)
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -33,12 +35,19 @@ concurrency:
|
||||
jobs:
|
||||
# Build documentation
|
||||
build:
|
||||
# 只在以下情况运行:
|
||||
# 1. workflow_run 触发且 CI Check 成功
|
||||
# 2. 手动触发
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
# workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支
|
||||
ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniforge (with mamba)
|
||||
@@ -46,8 +55,8 @@ jobs:
|
||||
with:
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.11'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
python-version: '3.11.14'
|
||||
channels: conda-forge,robostack-staging,uni-lab
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
auto-update-conda: false
|
||||
@@ -57,7 +66,7 @@ jobs:
|
||||
run: |
|
||||
echo "Installing unilabos and dependencies to unilab environment..."
|
||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y
|
||||
|
||||
- name: Install latest unilabos from source
|
||||
run: |
|
||||
@@ -75,8 +84,10 @@ jobs:
|
||||
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v4
|
||||
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
uses: actions/configure-pages@v5
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
|
||||
- name: Build Sphinx documentation
|
||||
run: |
|
||||
@@ -94,14 +105,18 @@ jobs:
|
||||
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
with:
|
||||
path: docs/_build/html
|
||||
|
||||
# Deploy to GitHub Pages
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
if: |
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
68
.github/workflows/multi-platform-build.yml
vendored
@@ -1,11 +1,19 @@
|
||||
name: Multi-Platform Conda Build
|
||||
|
||||
on:
|
||||
# 在 CI Check 工作流完成后触发(仅限 main/dev 分支)
|
||||
workflow_run:
|
||||
workflows: ["CI Check"]
|
||||
types:
|
||||
- completed
|
||||
branches: [main, dev]
|
||||
# 支持 tag 推送(不依赖 CI Check)
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
# GitHub Release 发布时自动构建并上传
|
||||
release:
|
||||
types: [published]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
@@ -17,9 +25,37 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_ci_check:
|
||||
description: '跳过等待 CI Check (手动触发时可选)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check CI status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "CI Check passed, proceeding with build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-ci]
|
||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -27,7 +63,7 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-64
|
||||
env_file: unilabos-linux-64.yaml
|
||||
- os: macos-13 # Intel
|
||||
- os: macos-15 # Intel (via Rosetta)
|
||||
platform: osx-64
|
||||
env_file: unilabos-osx-64.yaml
|
||||
- os: macos-latest # ARM64
|
||||
@@ -44,8 +80,10 @@ jobs:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -61,22 +99,22 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Miniconda
|
||||
- name: Setup Miniforge
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniconda-version: 'latest'
|
||||
channels: conda-forge,robostack-staging,defaults
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: conda-forge,robostack-staging
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install rattler-build and anaconda-client
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
conda install -c conda-forge rattler-build anaconda-client
|
||||
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
||||
|
||||
- name: Show environment info
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -115,7 +153,7 @@ jobs:
|
||||
|
||||
- name: Upload conda package artifacts
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: conda-package-${{ matrix.platform }}
|
||||
path: conda-packages-temp
|
||||
@@ -123,7 +161,13 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload to Anaconda.org (unilab organization)
|
||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
(
|
||||
github.event_name == 'release' ||
|
||||
startsWith(github.ref, 'refs/tags/') ||
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
)
|
||||
run: |
|
||||
for package in $(find ./output -name "*.conda"); do
|
||||
echo "Uploading $package to unilab organization..."
|
||||
|
||||
136
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,32 +1,65 @@
|
||||
name: UniLabOS Conda Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
# 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发
|
||||
workflow_run:
|
||||
workflows: ["Multi-Platform Conda Build"]
|
||||
types: [completed]
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
||||
required: false
|
||||
default: 'linux-64'
|
||||
build_full:
|
||||
description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
upload_to_anaconda:
|
||||
description: '是否上传到Anaconda.org'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_ci_check:
|
||||
description: '跳过等待 CI Check (手动触发时可选)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发)
|
||||
wait-for-upstream:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run'
|
||||
outputs:
|
||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||
steps:
|
||||
- name: Check upstream workflow status
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then
|
||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||
echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build"
|
||||
else
|
||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||
echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [wait-for-upstream]
|
||||
# 运行条件:workflow_run 触发且上游成功,或者手动触发
|
||||
if: |
|
||||
always() &&
|
||||
(needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-64
|
||||
- os: macos-13 # Intel
|
||||
- os: macos-15 # Intel (via Rosetta)
|
||||
platform: osx-64
|
||||
- os: macos-latest # ARM64
|
||||
platform: osx-arm64
|
||||
@@ -40,8 +73,10 @@ jobs:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# 如果是 workflow_run 触发,使用上游 conda 包构建的 commit
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if platform should be built
|
||||
@@ -57,22 +92,22 @@ jobs:
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Miniconda
|
||||
- name: Setup Miniforge
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniconda-version: 'latest'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
channels: conda-forge,robostack-staging,uni-lab
|
||||
channel-priority: strict
|
||||
activate-environment: build-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install rattler-build and anaconda-client
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
conda install -c conda-forge rattler-build anaconda-client
|
||||
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
||||
|
||||
- name: Show environment info
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -81,12 +116,73 @@ jobs:
|
||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "OS: ${{ matrix.os }}"
|
||||
echo "Building UniLabOS package"
|
||||
echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
|
||||
echo "Building packages:"
|
||||
echo " - unilabos-env (environment dependencies)"
|
||||
echo " - unilabos (with pip package)"
|
||||
if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then
|
||||
echo " - unilabos-full (complete package)"
|
||||
fi
|
||||
|
||||
- name: Build conda package
|
||||
- name: Build unilabos-env (conda environment only, noarch)
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
rattler-build build -r .conda/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||
echo "Building unilabos-env (conda environment dependencies)..."
|
||||
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||
|
||||
- name: Upload unilabos-env to Anaconda.org (if enabled)
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
(
|
||||
github.event_name == 'workflow_run' ||
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
)
|
||||
run: |
|
||||
echo "Uploading unilabos-env to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-env*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: Build unilabos (with pip package)
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
run: |
|
||||
echo "Building unilabos package..."
|
||||
# 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取
|
||||
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||
|
||||
- name: Upload unilabos to Anaconda.org (if enabled)
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
(
|
||||
github.event_name == 'workflow_run' ||
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
)
|
||||
run: |
|
||||
echo "Uploading unilabos to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: Build unilabos-full - Only when explicitly requested
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.build_full == 'true'
|
||||
run: |
|
||||
echo "Building unilabos-full package on ${{ matrix.platform }}..."
|
||||
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||
|
||||
- name: Upload unilabos-full to Anaconda.org (if enabled)
|
||||
if: |
|
||||
steps.should_build.outputs.should_build == 'true' &&
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.build_full == 'true' &&
|
||||
github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
echo "Uploading unilabos-full to uni-lab organization..."
|
||||
for package in $(find ./output -name "unilabos-full*.conda"); do
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
- name: List built packages
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
@@ -108,17 +204,9 @@ jobs:
|
||||
|
||||
- name: Upload conda package artifacts
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: conda-package-unilabos-${{ matrix.platform }}
|
||||
path: conda-packages-temp
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload to Anaconda.org (uni-lab organization)
|
||||
if: github.event.inputs.upload_to_anaconda == 'true'
|
||||
run: |
|
||||
for package in $(find ./output -name "*.conda"); do
|
||||
echo "Uploading $package to uni-lab organization..."
|
||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||
done
|
||||
|
||||
5
.gitignore
vendored
@@ -1,8 +1,11 @@
|
||||
cursor_docs/
|
||||
configs/
|
||||
temp/
|
||||
output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
device_package*/
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -249,3 +252,5 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
test_config.py
|
||||
|
||||
|
||||
/.claude
|
||||
/.cursor
|
||||
|
||||
170
AGENTS.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
# Install in editable mode (requires mamba env with python 3.11)
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Run with a device graph
|
||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||
|
||||
# Common CLI flags
|
||||
unilab --app_bridges websocket fastapi # communication bridges
|
||||
unilab --test_mode # simulate hardware, no real execution
|
||||
unilab --check_mode # CI validation of registry imports
|
||||
unilab --skip_env_check # skip auto-install of dependencies
|
||||
unilab --visual rviz|web|disable # visualization mode
|
||||
unilab --is_slave # run as slave node
|
||||
|
||||
# Workflow upload subcommand(P6.1 新增 --target_device;P6.1.1 新增 --target_model)
|
||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||
unilab workflow_upload -f <workflow.json> --target_device prcxi # P6.1 默认;同上 P6 行为
|
||||
unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320 # P6.1.1:型号粒度
|
||||
unilab workflow_upload -f <workflow.json> --target_device beckman # 未来支持,需在 YAML 中声明 target_devices.beckman
|
||||
|
||||
# Tests
|
||||
pytest tests/ # all tests
|
||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Startup Flow
|
||||
|
||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||
|
||||
### Core Layers
|
||||
|
||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
||||
|
||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
||||
|
||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
||||
|
||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
||||
|
||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
||||
|
||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
||||
|
||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||
|
||||
### Test Data
|
||||
|
||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||
|
||||
### Labware Mapping Table (`labware_mapping.yaml`) — P6 + P6.1 + P6.1.1
|
||||
|
||||
Opentrons → 目标仪器(PRCXI / Beckman / Tecan ...)的「槽位重映射 + labware 归类 +
|
||||
class_name 选择」全部外化到项目根的
|
||||
[`labware_mapping.yaml`](./labware_mapping.yaml)(与 `pyproject.toml` 同级,最显眼的位置)。
|
||||
要新增 SKU、新厂商、新型号、或调整 tip 量程档时,**只改 YAML,不改 Python**。
|
||||
|
||||
- **YAML 两段顶层语义**(P6.1.1 起 `slot_remap` 已下沉到 `target_devices` 内):
|
||||
- `kinds` — 顺序敏感的 regex;把 labware 字符串归到 `trash / tip_rack / tube_rack / plate`。**全局段**,与目标仪器无关。
|
||||
- `target_devices.<name>` — 按目标仪器组织的规则段,内含三个字段:
|
||||
- `slot_remap` — 替代历史 `_map_deck_slot`(例:`4 → 13`、`8 → 14`、`12+trash → 16`)。
|
||||
- `rules` — 顺序敏感的「`kind + hole_count + volume_min/volume_max` → `class_name`」规则,首个命中胜出。
|
||||
- `models.<model_name>` — 可选的型号粒度覆盖(slot_remap / rules);缺失字段自动继承厂商级。
|
||||
- **`target_devices` 内段名约定**:
|
||||
- `default` — **固定段名**,兜底物料集 + 兜底 `slot_remap`。caller 传入的 `target_device` 在 `target_devices`
|
||||
下未声明时,自动 fallback 到此段(loader 单次 warning,下游消费方零感知)。
|
||||
**第一版按 prcxi 内容拷贝填充**(值仍是 `PRCXI_*`),但与 prcxi 段在 YAML 中
|
||||
各自独立,可独立演进。**`default` 不支持 `models` 子段**——型号粒度差异必须落到具体仪器段。
|
||||
- `prcxi` / `beckman` / `tecan` / ... — 具体仪器段(厂商粒度);caller 显式
|
||||
`--target_device <name>` 时命中。可在 `models.<model>` 下声明同厂商不同型号的差异。
|
||||
- **4 段 fallback 链**(`slot_remap` / `rules` 共用):
|
||||
1. `target_devices.<device>.models.<model>.<field>`(caller 同时传 device + model)
|
||||
2. `target_devices.<device>.<field>`(厂商级;步骤 1 缺字段时静默 fallback)
|
||||
3. `target_devices.default.<field>`(caller 传未声明 device,或步骤 2 缺字段;打 warning)
|
||||
4. `_BUILTIN_DEFAULT.target_devices.default.<field>`(YAML 误删 default 段时的最后兜底)
|
||||
- **CLI 用法**:
|
||||
- P6.1:`unilab workflow_upload -f <workflow.json> --target_device prcxi`
|
||||
(`--target_device` snake-case,默认 `prcxi`;未声明的名字自动 fallback 到 `default` 段)。
|
||||
- P6.1.1:可加 `--target_model <name>`(snake,可省略,默认 `None`)。
|
||||
例:`unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320`。
|
||||
- **入口代码**:`unilabos/workflow/labware_mapping.py` 暴露 `remap_slot` / `infer_kind` /
|
||||
`resolve_target_class` / `reload_mapping`。
|
||||
API 签名(P6.1.1):
|
||||
- `remap_slot(raw_slot, object_type="", *, target_device="prcxi", target_model=None)`
|
||||
- `resolve_target_class(target_device, kind, hole_count=None, volume=None, *, target_model=None)`
|
||||
`workflow/common.py` 中 `_map_deck_slot` / `_infer_reagent_kind` /
|
||||
`_apply_tip_rack_class_from_transfer_volumes` / `_apply_target_labware_class_auto_match` /
|
||||
`_reconcile_slot_carrier_target_class` 都已转调 YAML 并透传 `target_device` / `target_model`;
|
||||
YAML 未命中(孔数 / 体积超出 default 段覆盖范围)时 fallback 到
|
||||
`prcxi_labware.get_prcxi_labware_template_specs` 的模板打分匹配,并打 warning 提示「请补到映射表」。
|
||||
- **`labware_info` 字段重命名**:P6 的 `prcxi_class_name` → P6.1 的 `target_class_name`,
|
||||
13 处全部同步刷新;旧 schema(顶层 `vendors` / `slot_remap` 或任一 rule 内 `prcxi_class`)
|
||||
会触发 loader warning 并整段 fallback 到 builtin 默认表。
|
||||
- **测试**:
|
||||
- `pytest tests/workflow/test_labware_mapping.py` —— 45 项单元测试(含 P6.1 + P6.1.1 用例:
|
||||
`test_remap_slot_model_level_overrides_device_level`、
|
||||
`test_remap_slot_model_inherits_device_when_field_missing`、
|
||||
`test_legacy_top_level_slot_remap_rejected`、
|
||||
`test_default_section_models_subsection_warns` 等)。
|
||||
- `pytest tests/workflow/test_build_protocol_graph_target_device.py` —— 6 项集成
|
||||
测试(默认 / 显式 prcxi / unknown 段 fallback / per-device tip class / 字段重命名 /
|
||||
P6.1.1 model-level slot_remap)。
|
||||
- **设计文档**:[`product_designs/protocol_convert/06-labware-mapping-table.md`](../product_designs/protocol_convert/06-labware-mapping-table.md)
|
||||
(§11.7 = P6.1 多目标仪器选择,§11.8 = P6.1.1 槽位映射按厂商+型号分叉)。
|
||||
|
||||
### P2 跨 slot transfer_liquid 合并(v2,已落地)
|
||||
|
||||
当一次 phase 中存在「单源吸取 → 跨多个 plate 分发」(典型 `steps/51b9a5.json` 9 plate × 12 well = 108 条 1:1 dispense),Stage 2 + Stage 3 现在能把它折叠成 **1 个 merged set_liquid_from_plate + 1 个 transfer_liquid** 节点。
|
||||
|
||||
- **Stage 2**([`Protocols/protocol_converter/change_to_transfer_group.py`](../Protocols/protocol_converter/change_to_transfer_group.py)):
|
||||
- `_pair_mergeable` 只要求源 slot / tip 量程档 / use_channels 一致;不再要求 `_target_slot` 相同。
|
||||
- `_merge_two_transfer_actions` 维护 `_target_slots: list[int]`(与 `_target_wells` 平行,每次 dispense 一条)。
|
||||
- `export_transfer_actions` 通过 `_register_target_reagent_key` 统一注册 reagent_key:跨 slot 时按 `_target_slots` 顺序拼出 `action_args.targets: list[str]`(同板退化为 `str`)。
|
||||
- 末尾 `pop` 全部 `_` 前缀字段(包括新增的 `_target_slots`)。
|
||||
- **Stage 3**([`Uni-Lab-OS/unilabos/workflow/common.py`](unilabos/workflow/common.py)):
|
||||
- 新增 `_emit_merged_set_liquid(...)`:对 `params.targets: list[str]` 的 transfer_liquid 节点,在其上游插入一个 **merged `set_liquid_from_plate`** 跨板聚合器;其 `param.wells` 是按 dispense 顺序通过 cursor 走 `reagent[key].well` 得出的有序跨板 well refs;多入边(每 plate 一条 `create_resource.labware → wells_identifier`),单出边(`output_wells → transfer_liquid.targets_identifier`)。
|
||||
- 把 `params["targets"]` 改写为 synthetic str `_merged_targets_<idx>` 并注册 `resource_last_writer`,保证 INPUT_PORT_MAPPING 走 P3 既有的单边路径。
|
||||
- `OUTPUT_PORT_MAPPING` 在原始 `step.param.targets` 为 `list[str]` 时为每个 reagent_key 分别注册 transfer_liquid 的下游 writer。
|
||||
- **PRCXI runtime**([`prcxi/prcxi.py`](unilabos/devices/liquid_handling/prcxi/prcxi.py)):`change_slots` 改为遍历所有 source / target 的 parent plate 并按 plate name 去重(跨板 4 个 plate 都能 `update_pipetting_position`)。
|
||||
- **`liquid_handler_abstract.transfer_liquid`**:**完全不改动**,主循环 `i % num_targets` 与单边 + 单 list 完全兼容。
|
||||
|
||||
CLI 行为不变:现有 `unilab workflow_upload -f <workflow.json> ...` 一切照旧;跨 slot 协议自动走 v2 路径。
|
||||
|
||||
测试:
|
||||
- `pytest Protocols/protocol_converter/tests/test_cross_slot_merge.py` — Stage 2 单测 10 项。
|
||||
- `pytest tests/workflow/test_common_cross_slot_v2.py` — Stage 3 集成测试 6 项。
|
||||
- `pytest tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py` — device 跨板单测 6 项(pylabrobot 不全时优雅 skip)。
|
||||
|
||||
设计文档:[`product_designs/protocol_convert/02-cross-slot-merge.md`](../product_designs/protocol_convert/02-cross-slot-merge.md)(§9 v2 设计 + §11 落地记录)。
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Code comments and log messages in simplified Chinese
|
||||
- Python 3.11+, type hints expected
|
||||
- Pydantic models for data validation (`resource_tracker.py`)
|
||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||
- CLI argument dashes auto-converted to underscores for consistency
|
||||
|
||||
## Licensing
|
||||
|
||||
- Framework code: GPL-3.0
|
||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||
@@ -1,4 +1,5 @@
|
||||
recursive-include unilabos/test *
|
||||
recursive-include unilabos/utils *
|
||||
recursive-include unilabos/registry *.yaml
|
||||
recursive-include unilabos/app/web/static *
|
||||
recursive-include unilabos/app/web/templates *
|
||||
|
||||
17
NOTICE
Normal file
@@ -0,0 +1,17 @@
|
||||
# Uni-Lab-OS Licensing Notice
|
||||
|
||||
This project uses a dual licensing structure:
|
||||
|
||||
## 1. Main Framework - GPL-3.0
|
||||
|
||||
- unilabos/ (except unilabos/devices/)
|
||||
- docs/
|
||||
- tests/
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
|
||||
## 2. Device Drivers - DP Technology Proprietary License
|
||||
|
||||
- unilabos/devices/
|
||||
|
||||
See [unilabos/devices/LICENSE](unilabos/devices/LICENSE) for details.
|
||||
90
README.md
@@ -8,17 +8,13 @@
|
||||
|
||||
**English** | [中文](README_zh.md)
|
||||
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
||||
|
||||
Uni-Lab-OS is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows.
|
||||
|
||||
## 🏆 Competition
|
||||
|
||||
Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.dp.tech/competitions/1451645258) to explore automated synthesis with Uni-Lab-OS!
|
||||
|
||||
## Key Features
|
||||
|
||||
- Multi-device integration management
|
||||
@@ -31,39 +27,89 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
|
||||
|
||||
Detailed documentation can be found at:
|
||||
|
||||
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||
- [Online Documentation](https://deepmodeling.github.io/Uni-Lab-OS/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
|
||||
### 1. Setup Conda Environment
|
||||
|
||||
Uni-Lab-OS recommends using `mamba` for environment management. Choose the package that fits your needs:
|
||||
|
||||
| Package | Use Case | Contents |
|
||||
|---------|----------|----------|
|
||||
| `unilabos` | **Recommended for most users** | Complete package, ready to use |
|
||||
| `unilabos-env` | Developers (editable install) | Environment only, install unilabos via pip |
|
||||
| `unilabos-full` | Simulation/Visualization | unilabos + ROS2 Desktop + Gazebo + MoveIt |
|
||||
|
||||
```bash
|
||||
# Create new environment
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba activate unilab
|
||||
|
||||
# Option A: Standard installation (recommended for most users)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# Option B: For developers (editable mode development)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# Then install unilabos and dependencies:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Option C: Full installation (simulation/visualization)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## Install Dev Uni-Lab-OS
|
||||
**When to use which?**
|
||||
- **unilabos**: Standard installation for production deployment and general usage (recommended)
|
||||
- **unilabos-env**: For developers who need `pip install -e .` editable mode, modify source code
|
||||
- **unilabos-full**: For simulation (Gazebo), visualization (rviz2), and Jupyter notebooks
|
||||
|
||||
### 2. Clone Repository (Optional, for developers)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# Clone the repository (only needed for development or examples)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# Install Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. Start Uni-Lab System:
|
||||
3. Start Uni-Lab System
|
||||
|
||||
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
|
||||
Please refer to [Documentation - Boot Examples](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
||||
|
||||
4. Best Practice
|
||||
|
||||
See [Best Practice Guide](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html)
|
||||
|
||||
## Message Format
|
||||
|
||||
Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) page.
|
||||
Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) page.
|
||||
|
||||
## Citation
|
||||
|
||||
If you use [Uni-Lab-OS](https://arxiv.org/abs/2512.21766) in academic research, please cite:
|
||||
|
||||
```bibtex
|
||||
@article{gao2025unilabos,
|
||||
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
|
||||
doi = {10.48550/arXiv.2512.21766},
|
||||
publisher = {arXiv},
|
||||
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and
|
||||
Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and
|
||||
Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and
|
||||
Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
|
||||
year = {2025}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
This project uses a dual licensing structure:
|
||||
|
||||
- **Main Framework**: GPL-3.0 - see [LICENSE](LICENSE)
|
||||
- **Device Drivers** (`unilabos/devices/`): DP Technology Proprietary License
|
||||
|
||||
See [NOTICE](NOTICE) for complete licensing details.
|
||||
|
||||
## Project Statistics
|
||||
|
||||
@@ -75,4 +121,4 @@ This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for det
|
||||
|
||||
## Contact Us
|
||||
|
||||
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
|
||||
90
README_zh.md
@@ -8,17 +8,13 @@
|
||||
|
||||
[English](README.md) | **中文**
|
||||
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
||||
|
||||
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
||||
|
||||
## 🏆 比赛
|
||||
|
||||
欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成!
|
||||
|
||||
## 核心特点
|
||||
|
||||
- 多设备集成管理
|
||||
@@ -31,41 +27,89 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
|
||||
|
||||
详细文档可在以下位置找到:
|
||||
|
||||
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||
- [在线文档](https://deepmodeling.github.io/Uni-Lab-OS/)
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 配置 Conda 环境
|
||||
### 1. 配置 Conda 环境
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包:
|
||||
|
||||
| 安装包 | 适用场景 | 包含内容 |
|
||||
|--------|----------|----------|
|
||||
| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt |
|
||||
|
||||
```bash
|
||||
# 创建新环境
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
mamba create -n unilab python=3.11.14
|
||||
mamba activate unilab
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后安装 unilabos 和依赖:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 方案 C:完整安装(仿真/可视化)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. 安装开发版 Uni-Lab-OS:
|
||||
**如何选择?**
|
||||
- **unilabos**:标准安装,适用于生产部署和日常使用(推荐)
|
||||
- **unilabos-env**:开发者使用,支持 `pip install -e .` 可编辑模式,可修改源代码
|
||||
- **unilabos-full**:需要仿真(Gazebo)、可视化(rviz2)或 Jupyter Notebook
|
||||
|
||||
### 2. 克隆仓库(可选,供开发者使用)
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# 克隆仓库(仅开发或查看示例时需要)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 安装 Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. 启动 Uni-Lab 系统:
|
||||
3. 启动 Uni-Lab 系统
|
||||
|
||||
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
|
||||
请见[文档-启动样例](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
||||
|
||||
4. 最佳实践
|
||||
|
||||
请见[最佳实践指南](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html)
|
||||
|
||||
## 消息格式
|
||||
|
||||
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
||||
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
||||
|
||||
## 引用
|
||||
|
||||
如果您在学术研究中使用 [Uni-Lab-OS](https://arxiv.org/abs/2512.21766),请引用:
|
||||
|
||||
```bibtex
|
||||
@article{gao2025unilabos,
|
||||
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
|
||||
doi = {10.48550/arXiv.2512.21766},
|
||||
publisher = {arXiv},
|
||||
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and
|
||||
Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and
|
||||
Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and
|
||||
Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
|
||||
year = {2025}
|
||||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。
|
||||
本项目采用双许可证结构:
|
||||
|
||||
- **主框架**:GPL-3.0 - 详见 [LICENSE](LICENSE)
|
||||
- **设备驱动** (`unilabos/devices/`):深势科技专有许可证
|
||||
|
||||
完整许可证说明请参阅 [NOTICE](NOTICE)。
|
||||
|
||||
## 项目统计
|
||||
|
||||
@@ -77,4 +121,4 @@ Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在
|
||||
|
||||
## 联系我们
|
||||
|
||||
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
|
||||
@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
|
||||
|
||||
**获取方式:**
|
||||
|
||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
|
||||

|
||||
|
||||
@@ -69,7 +69,7 @@ class WSConfig:
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `test` → `https://leap-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://leap-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | --------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://leap-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`
|
||||
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
|
||||
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### 4. ROSConfig - ROS 配置
|
||||
@@ -401,7 +401,7 @@ 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"
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
## 配置文件使用方法
|
||||
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
||||
|
||||
```python
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
@@ -24,7 +24,7 @@ extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||
"sphinx_rtd_theme",
|
||||
"sphinxcontrib.mermaid"
|
||||
"sphinxcontrib.mermaid",
|
||||
]
|
||||
|
||||
source_suffix = {
|
||||
@@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# sphinx-book-theme 主题选项
|
||||
html_theme_options = {
|
||||
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||
"repository_url": "https://github.com/deepmodeling/Uni-Lab-OS",
|
||||
"use_repository_button": True,
|
||||
"use_issues_button": True,
|
||||
"use_edit_page_button": True,
|
||||
|
||||
@@ -15,6 +15,9 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
||||
class MockGripper:
|
||||
def __init__(self):
|
||||
self._position: float = 0.0
|
||||
@@ -23,19 +26,23 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
|
||||
@property
|
||||
@topic_config() # 添加 @topic_config 才会定时广播
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def velocity(self) -> float:
|
||||
return self._velocity
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -149,7 +156,7 @@ my_device: # 设备唯一标识符
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
@@ -179,7 +186,9 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="my_device", category=["general"], description="My Device")
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
@@ -198,8 +207,9 @@ class MyDevice:
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加 @topic_config 才会广播
|
||||
def status(self) -> str:
|
||||
"""设备状态(会自动广播)"""
|
||||
"""设备状态(通过 @topic_config 广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
@@ -217,34 +227,61 @@ class MyDevice:
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property)
|
||||
### 状态属性(@property + @topic_config)
|
||||
|
||||
状态属性会被自动识别并定期广播:
|
||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加,否则不会广播
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config(name="ready") # 可自定义发布名称
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
||||
|
||||
```python
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
||||
return {"temp": self._temp, "humidity": self._humidity}
|
||||
```
|
||||
|
||||
**`@topic_config` 参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `period` | float | 5.0 | 发布周期(秒) |
|
||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
||||
| `qos` | int | 10 | QoS 深度 |
|
||||
| `name` | str | None | 自定义发布名称 |
|
||||
|
||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
||||
|
||||
**特点**:
|
||||
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 必须使用 `@topic_config` 装饰器
|
||||
- 支持 `@property` 和普通方法
|
||||
- 添加到注册表的 `status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
@@ -497,6 +534,7 @@ class LiquidHandler:
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -886,7 +924,52 @@ class MyDevice:
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 类型注解
|
||||
### 1. 使用 `@device` 装饰器标识设备类
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
- `id`:设备唯一标识符,用于注册表匹配
|
||||
- `category`:分类列表,前端用于分组显示
|
||||
- `description`:设备描述
|
||||
- `icon`:图标文件名(可选)
|
||||
|
||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ @property + @topic_config → 会广播
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
return {"temp": self._temp}
|
||||
|
||||
# ✓ 使用 name 参数自定义发布名称
|
||||
@property
|
||||
@topic_config(name="ready")
|
||||
def is_ready(self) -> bool:
|
||||
return self._status == "idle"
|
||||
|
||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
||||
@property
|
||||
def internal_state(self) -> str:
|
||||
return self._state
|
||||
```
|
||||
|
||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
||||
|
||||
### 3. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -901,7 +984,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. 文档字符串
|
||||
### 4. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
@@ -923,7 +1006,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
||||
pass
|
||||
```
|
||||
|
||||
### 3. 配置验证
|
||||
### 5. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
@@ -937,7 +1020,7 @@ def __init__(self, config: Dict[str, Any]):
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 4. 资源清理
|
||||
### 6. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
@@ -946,7 +1029,7 @@ def __del__(self):
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 5. 设计前端友好的返回值
|
||||
### 7. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
|
||||
@@ -422,18 +422,20 @@ placeholder_keys:
|
||||
|
||||
### status_types
|
||||
|
||||
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||
status: str # 从 get_status() 或 @property status
|
||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
||||
is_heating: bool
|
||||
status: str
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
|
||||
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||
|
||||
@@ -537,11 +539,13 @@ class AdvancedLiquidHandler:
|
||||
self._temperature = 25.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
"""设备状态"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._temperature
|
||||
@@ -809,21 +813,23 @@ my_temperature_controller:
|
||||
你的设备类需要符合以下要求:
|
||||
|
||||
```python
|
||||
from unilabos.common.device_base import DeviceBase
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
class MyDevice(DeviceBase):
|
||||
@device(id="my_device", category=["temperature"], description="My Device")
|
||||
class MyDevice:
|
||||
def __init__(self, config):
|
||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||
super().__init__(config)
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
|
||||
# 状态方法(会自动生成到 status_types)
|
||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self):
|
||||
"""返回设备状态"""
|
||||
return "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self):
|
||||
"""返回当前温度"""
|
||||
return 25.0
|
||||
@@ -1039,7 +1045,34 @@ resource.type # "resource"
|
||||
|
||||
### 代码规范
|
||||
|
||||
1. **始终使用类型注解**
|
||||
1. **使用 `@device` 装饰器标识设备类**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Device")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
2. **使用 `@topic_config` 声明广播属性**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ 需要广播的状态属性
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✗ 仅有 @property 不会广播
|
||||
@property
|
||||
def internal_counter(self) -> int:
|
||||
return self._counter
|
||||
```
|
||||
|
||||
3. **始终使用类型注解**
|
||||
|
||||
```python
|
||||
# ✓ 好
|
||||
@@ -1051,7 +1084,7 @@ def method(self, resource, device):
|
||||
pass
|
||||
```
|
||||
|
||||
2. **提供有意义的参数名**
|
||||
4. **提供有意义的参数名**
|
||||
|
||||
```python
|
||||
# ✓ 好 - 清晰的参数名
|
||||
@@ -1063,7 +1096,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
3. **使用 Optional 表示可选参数**
|
||||
5. **使用 Optional 表示可选参数**
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
@@ -1076,7 +1109,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
4. **添加详细的文档字符串**
|
||||
6. **添加详细的文档字符串**
|
||||
|
||||
```python
|
||||
def method(
|
||||
@@ -1096,13 +1129,13 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
5. **方法命名规范**
|
||||
7. **方法命名规范**
|
||||
|
||||
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
||||
- 动作方法使用动词开头
|
||||
- 保持命名清晰、一致
|
||||
|
||||
6. **完善的错误处理**
|
||||
8. **完善的错误处理**
|
||||
- 实现完善的错误处理
|
||||
- 添加日志记录
|
||||
- 提供有意义的错误信息
|
||||
|
||||
@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Cloud Platform/Self-hosted Platform │
|
||||
│ uni-lab.bohrium.com │
|
||||
│ leap-lab.bohrium.com │
|
||||
│ (Resource Management, Task Scheduling, │
|
||||
│ Monitoring) │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
```
|
||||
|
||||
---
|
||||
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
|
||||
|
||||
```bash
|
||||
# 测试云端连接
|
||||
curl https://uni-lab.bohrium.com/api/v1/health
|
||||
curl https://leap-lab.bohrium.com/api/v1/health
|
||||
|
||||
# 测试WebSocket
|
||||
# 启动Uni-Lab后查看日志
|
||||
|
||||
1331
docs/moveit2_integration_summary.md
Normal file
@@ -12,3 +12,7 @@ sphinx-copybutton>=0.5.0
|
||||
|
||||
# 用于自动摘要生成
|
||||
sphinx-autobuild>=2024.2.4
|
||||
|
||||
# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX)
|
||||
rinohtype>=0.5.4
|
||||
sphinx-simplepdf>=1.6.0
|
||||
@@ -31,6 +31,14 @@
|
||||
|
||||
详细的安装步骤请参考 [安装指南](installation.md)。
|
||||
|
||||
**选择合适的安装包:**
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 |
|
||||
| --------------- | ---------------------------- | --------------------------------------------- |
|
||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||
|
||||
**关键步骤:**
|
||||
|
||||
```bash
|
||||
@@ -38,15 +46,31 @@
|
||||
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
||||
|
||||
# 2. 创建 Conda 环境
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba create -n unilab python=3.11.14
|
||||
|
||||
# 3. 激活环境
|
||||
mamba activate unilab
|
||||
|
||||
# 4. 安装 Uni-Lab-OS
|
||||
# 4. 安装 Uni-Lab-OS(选择其一)
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
pip install -e /path/to/Uni-Lab-OS # 可编辑安装
|
||||
uv pip install -r unilabos/utils/requirements.txt # 安装 pip 依赖
|
||||
|
||||
# 方案 C:完整版(仿真/可视化)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**选择建议:**
|
||||
|
||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||
|
||||
#### 1.2 验证安装
|
||||
|
||||
```bash
|
||||
@@ -65,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
|
||||
|
||||
#### 2.1 注册实验室账号
|
||||
|
||||
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||
2. 注册账号并登录
|
||||
3. 创建新实验室
|
||||
|
||||
@@ -274,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
|
||||
#### 5.2 访问 Web 界面
|
||||
|
||||
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||
|
||||
#### 5.3 添加设备和物料
|
||||
|
||||
@@ -283,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**示例场景:** 创建一个简单的液体转移实验
|
||||
|
||||
1. **添加工作站(必需):**
|
||||
|
||||
- 在"仪器设备"中找到 `work_station`
|
||||
- 添加 `workstation` x1
|
||||
|
||||
2. **添加虚拟转移泵:**
|
||||
|
||||
- 在"仪器设备"中找到 `virtual_device`
|
||||
- 添加 `virtual_transfer_pump` x1
|
||||
|
||||
@@ -416,6 +438,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||
2. 在"仪器设备"区域找到并添加上述设备
|
||||
3. 在"物料耗材"区域找到并添加容器
|
||||
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -426,8 +451,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**操作步骤:**
|
||||
|
||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
3. 在画布上连接它们(建立父子关系)
|
||||
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
4. 在画布上连接它们(建立父子关系)
|
||||
|
||||

|
||||
|
||||
@@ -768,7 +794,44 @@ Waiting for host service...
|
||||
|
||||
详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。
|
||||
|
||||
#### 9.1 为什么需要自定义设备?
|
||||
#### 9.1 开发环境准备
|
||||
|
||||
**推荐使用 `unilabos-env` + `pip install -e .` + `uv pip install`** 进行设备开发:
|
||||
|
||||
```bash
|
||||
# 1. 创建环境并安装 unilabos-env(ROS2 + conda 依赖 + uv)
|
||||
mamba create -n unilab python=3.11.14
|
||||
conda activate unilab
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
|
||||
# 2. 克隆代码
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 3. 以可编辑模式安装(推荐使用脚本,自动检测中文环境)
|
||||
python scripts/dev_install.py
|
||||
|
||||
# 或手动安装:
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
|
||||
**为什么使用这种方式?**
|
||||
|
||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||
- 使用 `uv` 替代 `pip`,安装速度更快
|
||||
- 可编辑模式:代码修改**立即生效**,无需重新安装
|
||||
|
||||
**如果安装失败或速度太慢**,可以手动执行(使用清华镜像):
|
||||
|
||||
```bash
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
#### 9.2 为什么需要自定义设备?
|
||||
|
||||
Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
||||
|
||||
@@ -777,7 +840,7 @@ Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要
|
||||
- 特殊的实验流程
|
||||
- 第三方设备集成
|
||||
|
||||
#### 9.2 创建 Python 包
|
||||
#### 9.3 创建 Python 包
|
||||
|
||||
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
||||
|
||||
@@ -814,7 +877,7 @@ touch my_lab_devices/my_lab_devices/__init__.py
|
||||
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
||||
```
|
||||
|
||||
#### 9.3 创建 setup.py
|
||||
#### 9.4 创建 setup.py
|
||||
|
||||
```python
|
||||
# my_lab_devices/setup.py
|
||||
@@ -845,7 +908,7 @@ setup(
|
||||
)
|
||||
```
|
||||
|
||||
#### 9.4 开发安装
|
||||
#### 9.5 开发安装
|
||||
|
||||
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
||||
|
||||
@@ -860,7 +923,7 @@ pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
- 方便调试和测试
|
||||
- 支持版本控制(git)
|
||||
|
||||
#### 9.5 编写设备驱动
|
||||
#### 9.6 编写设备驱动
|
||||
|
||||
创建设备驱动文件:
|
||||
|
||||
@@ -1001,7 +1064,7 @@ class MyPump:
|
||||
- **返回 Dict**:所有动作方法返回字典类型
|
||||
- **文档字符串**:详细说明参数和功能
|
||||
|
||||
#### 9.6 测试设备驱动
|
||||
#### 9.7 测试设备驱动
|
||||
|
||||
创建简单的测试脚本:
|
||||
|
||||
@@ -1733,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
||||
**详细步骤:**
|
||||
|
||||
1. **需求分析**:
|
||||
|
||||
- 明确实验流程
|
||||
- 列出所需设备和物料
|
||||
- 设计工作流程图
|
||||
|
||||
2. **环境搭建**:
|
||||
|
||||
- 安装 Uni-Lab-OS
|
||||
- 创建实验室账号
|
||||
- 准备开发工具(IDE、Git)
|
||||
|
||||
3. **原型验证**:
|
||||
|
||||
- 使用虚拟设备测试流程
|
||||
- 验证工作流逻辑
|
||||
- 调整参数
|
||||
|
||||
4. **迭代开发**:
|
||||
|
||||
- 实现自定义设备驱动(同时撰写单点函数测试)
|
||||
- 编写注册表
|
||||
- 单元测试
|
||||
- 集成测试
|
||||
|
||||
5. **测试部署**:
|
||||
|
||||
- 连接真实硬件
|
||||
- 空跑测试
|
||||
- 小规模试验
|
||||
@@ -1807,8 +1865,8 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
||||
|
||||
#### 14.5 社区支持
|
||||
|
||||
- **GitHub Issues**:[https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -463,7 +463,7 @@ Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
||||
|
||||
# 旧格式节点
|
||||
old_format_node = {
|
||||
@@ -477,10 +477,10 @@ old_format_node = {
|
||||
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.id) # "pump_1"
|
||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||
print(instance.res_content.config) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
```
|
||||
|
||||
### 格式迁移建议
|
||||
@@ -626,7 +626,7 @@ unilab
|
||||
|
||||
**云端图文件管理**:
|
||||
|
||||
1. 登录 https://uni-lab.bohrium.com
|
||||
1. 登录 https://leap-lab.bohrium.com
|
||||
2. 进入"设备配置"
|
||||
3. 创建或编辑配置
|
||||
4. 保存到云端
|
||||
@@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel):
|
||||
- 在 Web 界面中使用模板创建
|
||||
- 参考示例文件:`test/experiments/` 目录
|
||||
- 查看 ResourceDict 源码了解完整定义
|
||||
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
- [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
||||
|
||||
BIN
docs/user_guide/image/add_protocol.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 415 KiB |
@@ -13,15 +13,26 @@
|
||||
- 开发者需要 Git 和基本的 Python 开发知识
|
||||
- 自定义 msgs 需要 GitHub 账号
|
||||
|
||||
## 安装包选择
|
||||
|
||||
Uni-Lab-OS 提供三个安装包版本,根据您的需求选择:
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 | 磁盘占用 |
|
||||
|--------|----------|----------|----------|
|
||||
| **unilabos** | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | ~2-3 GB |
|
||||
| **unilabos-env** | 开发者环境(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | ~2 GB |
|
||||
| **unilabos-full** | 仿真可视化、完整功能体验 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | ~8-10 GB |
|
||||
|
||||
## 安装方式选择
|
||||
|
||||
根据您的使用场景,选择合适的安装方式:
|
||||
|
||||
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||
| 安装方式 | 适用人群 | 推荐安装包 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ----------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 快速体验、演示 | 预打包环境 | 离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 |
|
||||
| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +48,7 @@
|
||||
|
||||
#### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
@@ -144,17 +155,38 @@ bash Miniforge3-$(uname)-$(uname -m).sh
|
||||
使用以下命令创建 Uni-Lab 专用环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||
mamba create -n unilab python=3.11.14 # 目前ros2组件依赖版本大多为3.11.14
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 选择安装包(三选一):
|
||||
|
||||
# 方案 A:标准安装(推荐大多数用户)
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
|
||||
# 方案 B:开发者环境(可编辑模式开发)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后安装 unilabos 和 pip 依赖:
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 方案 C:完整版(含仿真和可视化工具)
|
||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||
- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐)
|
||||
- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .`
|
||||
- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等)
|
||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||
|
||||
**包选择建议**:
|
||||
- **日常使用/生产部署**:安装 `unilabos`(推荐,完整功能,开箱即用)
|
||||
- **开发者**:安装 `unilabos-env`,然后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖,再 `pip install -e .` 进行可编辑安装
|
||||
- **仿真/可视化**:安装 `unilabos-full`(Gazebo、rviz2、MoveIt)
|
||||
|
||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||
|
||||
```bash
|
||||
@@ -163,8 +195,14 @@ mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/m
|
||||
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
|
||||
|
||||
# 或完整版(仿真/可视化)
|
||||
mamba create -n unilab uni-lab::unilabos-full -c robostack-staging
|
||||
|
||||
# pip 安装时使用清华镜像(开发者安装时使用)
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
@@ -189,13 +227,13 @@ conda activate unilab
|
||||
### 第一步:克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
如果您需要贡献代码,建议先 Fork 仓库:
|
||||
|
||||
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||
1. 访问 https://github.com/deepmodeling/Uni-Lab-OS
|
||||
2. 点击右上角的 "Fork" 按钮
|
||||
3. Clone 您的 Fork 版本:
|
||||
```bash
|
||||
@@ -203,58 +241,87 @@ cd Uni-Lab-OS
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
### 第二步:安装基础环境
|
||||
### 第二步:安装开发环境(unilabos-env)
|
||||
|
||||
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||
|
||||
#### 选项 A:通过一键安装(推荐)
|
||||
|
||||
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||
**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计:
|
||||
- 包含 ROS2 核心组件和消息包(ros-humble-ros-core、std-msgs、geometry-msgs 等)
|
||||
- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖
|
||||
- 包含 `uv` 工具,用于快速安装 pip 依赖
|
||||
- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装)
|
||||
|
||||
```bash
|
||||
# 创建并激活环境
|
||||
mamba create -n unilab python=3.11.14
|
||||
conda activate unilab
|
||||
|
||||
# 安装开发者环境包(ROS2 + conda 依赖 + uv)
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
#### 选项 B:通过手动安装
|
||||
### 第三步:安装 pip 依赖和可编辑模式安装
|
||||
|
||||
参考上文"方式二:手动安装",创建并安装环境:
|
||||
|
||||
```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 分支
|
||||
# 克隆仓库(如果还未克隆)
|
||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 切换到 dev 分支(可选)
|
||||
git checkout dev
|
||||
git pull
|
||||
|
||||
# 以可编辑模式安装开发版 unilabos
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速):
|
||||
|
||||
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||
- `-i`: 使用清华镜像源加速下载
|
||||
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||
```bash
|
||||
# 自动检测中文环境,如果是中文系统则使用清华镜像
|
||||
python scripts/dev_install.py
|
||||
|
||||
# 或者手动指定:
|
||||
python scripts/dev_install.py --china # 强制使用清华镜像
|
||||
python scripts/dev_install.py --no-mirror # 强制使用 PyPI
|
||||
python scripts/dev_install.py --skip-deps # 跳过 pip 依赖安装
|
||||
python scripts/dev_install.py --use-pip # 使用 pip 而非 uv
|
||||
```
|
||||
|
||||
**手动安装**(如果脚本安装失败或速度太慢):
|
||||
|
||||
```bash
|
||||
# 1. 安装 unilabos(可编辑模式)
|
||||
pip install -e .
|
||||
|
||||
# 2. 使用 uv 安装 pip 依赖(推荐,速度更快)
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# 国内用户使用清华镜像:
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `uv` 已包含在 `unilabos-env` 中,无需单独安装
|
||||
- `unilabos/utils/requirements.txt` 包含运行 unilabos 所需的所有 pip 依赖
|
||||
- 部分特殊包(如 pylabrobot)会在运行时由 unilabos 自动检测并安装
|
||||
|
||||
**为什么使用可编辑模式?**
|
||||
|
||||
- `-e` (editable mode):代码修改**立即生效**,无需重新安装
|
||||
- 适合开发调试:修改代码后直接运行测试
|
||||
- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理
|
||||
|
||||
**验证安装**:
|
||||
|
||||
```bash
|
||||
# 检查 unilabos 版本
|
||||
python -c "import unilabos; print(unilabos.__version__)"
|
||||
|
||||
# 检查安装位置(应该指向你的代码目录)
|
||||
pip show unilabos | grep Location
|
||||
```
|
||||
|
||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||
|
||||
@@ -317,45 +384,6 @@ unilab --help
|
||||
|
||||
如果所有命令都正常输出,说明开发环境配置成功!
|
||||
|
||||
### 开发工具推荐
|
||||
|
||||
#### IDE
|
||||
|
||||
- **PyCharm Professional**: 强大的 Python IDE,支持远程调试
|
||||
- **VS Code**: 轻量级,配合 Python 扩展使用
|
||||
- **Vim/Emacs**: 适合终端开发
|
||||
|
||||
#### 推荐的 VS Code 扩展
|
||||
|
||||
- Python
|
||||
- Pylance
|
||||
- ROS
|
||||
- URDF
|
||||
- YAML
|
||||
|
||||
#### 调试工具
|
||||
|
||||
```bash
|
||||
# 安装调试工具
|
||||
pip install ipdb pytest pytest-cov -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 代码质量检查
|
||||
pip install black flake8 mypy -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
### 设置 pre-commit 钩子(可选)
|
||||
|
||||
```bash
|
||||
# 安装 pre-commit
|
||||
pip install pre-commit -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 设置钩子
|
||||
pre-commit install
|
||||
|
||||
# 手动运行检查
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证安装
|
||||
@@ -503,7 +531,45 @@ cd $CONDA_PREFIX/envs/unilab
|
||||
|
||||
### 问题 8: 环境很大,有办法减小吗?
|
||||
|
||||
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||
**解决方案**:
|
||||
|
||||
1. **使用 `unilabos` 标准版**(推荐大多数用户):
|
||||
```bash
|
||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
标准版包含完整功能,环境大小约 2-3GB(相比完整版的 8-10GB)。
|
||||
|
||||
2. **使用 `unilabos-env` 开发者版**(最小化):
|
||||
```bash
|
||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
||||
# 然后手动安装依赖
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
开发者版只包含环境依赖,体积最小约 2GB。
|
||||
|
||||
3. **按需安装额外组件**:
|
||||
如果后续需要特定功能,可以单独安装:
|
||||
```bash
|
||||
# 需要 Jupyter
|
||||
mamba install jupyter jupyros
|
||||
|
||||
# 需要可视化
|
||||
mamba install matplotlib opencv
|
||||
|
||||
# 需要仿真(注意:这会安装大量依赖)
|
||||
mamba install ros-humble-gazebo-ros
|
||||
```
|
||||
|
||||
4. **预打包环境问题**:
|
||||
预打包环境(方式一)包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。
|
||||
|
||||
**包选择建议**:
|
||||
| 需求 | 推荐包 | 预估大小 |
|
||||
|------|--------|----------|
|
||||
| 日常使用/生产部署 | `unilabos` | ~2-3 GB |
|
||||
| 开发调试(可编辑模式) | `unilabos-env` | ~2 GB |
|
||||
| 仿真/可视化 | `unilabos-full` | ~8-10 GB |
|
||||
|
||||
### 问题 9: 如何更新到最新版本?
|
||||
|
||||
@@ -542,14 +608,15 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f
|
||||
## 需要帮助?
|
||||
|
||||
- **故障排查**: 查看更详细的故障排查信息
|
||||
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
||||
|
||||
---
|
||||
|
||||
**提示**:
|
||||
|
||||
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||
- 开发和测试推荐使用方式三(开发者安装)
|
||||
- 快速体验和演示推荐使用方式一(一键安装)
|
||||
- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版
|
||||
- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖
|
||||
- **仿真/可视化**推荐安装 `unilabos-full` 完整版
|
||||
- **快速体验和演示**推荐使用方式一(一键安装)
|
||||
|
||||
@@ -22,7 +22,6 @@ options:
|
||||
--is_slave Run the backend as slave node (without host privileges).
|
||||
--slave_no_host Skip waiting for host service in slave mode
|
||||
--upload_registry Upload registry information when starting unilab
|
||||
--use_remote_resource Use remote resources when starting unilab
|
||||
--config CONFIG Configuration file path, supports .py format Python config files
|
||||
--port PORT Port for web service information page
|
||||
--disable_browser Disable opening information page on startup
|
||||
@@ -55,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||
|
||||
- **工作目录设置**:
|
||||
|
||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||
- 可通过 `--working_dir` 指定自定义工作目录
|
||||
@@ -69,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
|
||||
支持多种后端环境:
|
||||
|
||||
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
|
||||
- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
|
||||
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
|
||||
- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
|
||||
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
||||
- 自定义地址:直接指定完整 URL
|
||||
|
||||
@@ -85,7 +83,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
支持两种方式:
|
||||
|
||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||
- **远程资源**:不指定本地文件即可
|
||||
|
||||
### 7. 注册表构建
|
||||
|
||||
@@ -177,7 +175,7 @@ unilab --config path/to/your/config.py
|
||||
|
||||
如果是首次使用,系统会:
|
||||
|
||||
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
|
||||
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
|
||||
2. 引导创建配置文件
|
||||
3. 设置工作目录
|
||||
|
||||
@@ -196,7 +194,7 @@ unilab --config path/to/your/config.py
|
||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||
|
||||
# 使用远程资源启动
|
||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||
unilab --ak your_ak --sk your_sk
|
||||
|
||||
# 更新注册表
|
||||
unilab --ak your_ak --sk your_sk --complete_registry
|
||||
@@ -217,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
||||
|
||||
如果提示 "后续运行必须拥有一个实验室",请确保:
|
||||
|
||||
- 已在 https://uni-lab.bohrium.com 注册实验室
|
||||
- 已在 https://leap-lab.bohrium.com 注册实验室
|
||||
- 正确设置了 `--ak` 和 `--sk` 参数
|
||||
- 配置文件中包含正确的认证信息
|
||||
|
||||
|
||||
140
labware_mapping.yaml
Normal file
@@ -0,0 +1,140 @@
|
||||
# Opentrons → 目标仪器 物料映射表(P6.1.1)
|
||||
#
|
||||
# 两段顶层 key(P6.1.1 起 slot_remap 从顶层下沉到 target_devices 内):
|
||||
# kinds : labware 字符串 → kind 归类(与目标仪器无关,**保留全局**)
|
||||
# target_devices : 按目标仪器 + 型号组织;rule = kind + hole_count + volume_min/max → class_name;
|
||||
# slot_remap 也内嵌在 target_devices 下(按 deck 物理布局变化)
|
||||
#
|
||||
# target_devices 段内结构:
|
||||
# target_devices.<device>: # 厂商段(必填)
|
||||
# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段)
|
||||
# rules: [...] # 厂商级规则(缺失 → 继承 default 段)
|
||||
# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号)
|
||||
# <model_name>: # 型号子段
|
||||
# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级)
|
||||
# rules: [...] # 型号级覆盖(缺失 → 继承厂商级)
|
||||
#
|
||||
# 段名约定:
|
||||
# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。
|
||||
# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。
|
||||
# target_devices.<name> : 具体仪器段(prcxi / beckman / tecan ...)。
|
||||
#
|
||||
# 解析链(remap_slot / resolve_target_class 共用,字段级 fallback):
|
||||
# 1. target_devices.<device>.models.<model>.<field> (caller 同时传 device + model)
|
||||
# 2. target_devices.<device>.<field> (caller 传 device,或步骤 1 缺字段)
|
||||
# 3. target_devices.default.<field> (caller 传未声明 device,或步骤 2 缺字段)
|
||||
# 4. _BUILTIN_DEFAULT.target_devices.default.<field> (YAML 误删 default 段时的最后兜底)
|
||||
#
|
||||
# 编辑建议:
|
||||
# 1. 顺序敏感:kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。
|
||||
# 2. volume_min / volume_max 是闭区间(µL)。任一字段可省略;都省略 = 不限制体积。
|
||||
# 3. notes 仅作注释,不参与匹配。
|
||||
# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。
|
||||
# 5. 同厂商不同型号:在 target_devices.<device>.models.<model> 下显式覆盖差异字段;
|
||||
# 没声明的字段自动继承厂商级。
|
||||
# 6. P6.1.1 不再支持顶层 slot_remap;检出顶层 slot_remap → warning + fallback 到 builtin。
|
||||
#
|
||||
# 设计文档:product_designs/protocol_convert/06-labware-mapping-table.md(§11.8)
|
||||
|
||||
kinds:
|
||||
# 顺序敏感的 regex;第一个命中胜出
|
||||
# 注意:trash 必须在 tip_rack 之前;tip_rack 必须在 tube_rack 之前("tuberack" 含 "rack")
|
||||
- { pattern: "trash", kind: trash }
|
||||
- { pattern: "tiprack|tip[_ ]?rack|opentrons_\\d+_tiprack", kind: tip_rack }
|
||||
- { pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack }
|
||||
# 「<labware> 含 'rack' 但不含 'tip'」也归到 tube_rack(与历史 _infer_reagent_kind 行为一致)
|
||||
- { pattern: "(?:^|[^a-z])rack(?:[^a-z]|$)", kind: tube_rack }
|
||||
- { pattern: ".*", kind: plate }
|
||||
|
||||
target_devices:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# default:兜底物料集 + 兜底 slot_remap。
|
||||
# caller 传未声明的 target_device 时使用本段;**不支持 models 子段**。
|
||||
# 第一版内容按 prcxi 拷贝填充(值仍是 PRCXI_*),但语义独立,可独立演进。
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
default:
|
||||
notes: "默认兜底物料集;caller 传未声明 target_device 时使用此段。第一版按 prcxi 拷贝填充。"
|
||||
slot_remap:
|
||||
# raw slot → deck slot;与对象类型无关
|
||||
default:
|
||||
"4": "13"
|
||||
"8": "14"
|
||||
# 按 object 字段覆盖 default
|
||||
by_object:
|
||||
trash:
|
||||
"12": "16"
|
||||
rules:
|
||||
# ─ tip rack(默认量程档:≤10 / <300 / 否则 1000) ─
|
||||
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
||||
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
||||
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
||||
# ─ tube rack ─
|
||||
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
||||
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
||||
# ─ plate ─
|
||||
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
||||
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
||||
# ─ trash ─
|
||||
- { kind: trash, class_name: PRCXI_trash }
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# prcxi:PRCXI 仪器专用段。caller 显式传 --target_device prcxi 时命中此段。
|
||||
# 厂商级 slot_remap + rules 适用于"未声明 model"的调用;
|
||||
# models 子段下声明同厂商不同型号的 deck 物理布局差异。
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
prcxi:
|
||||
slot_remap:
|
||||
# PRCXI 多数型号通用的 deck 物理布局映射
|
||||
default:
|
||||
"4": "13"
|
||||
"8": "14"
|
||||
by_object:
|
||||
trash:
|
||||
"12": "16"
|
||||
rules:
|
||||
# ─ tip rack(PRCXI 量程档:≤10 / <300 / 否则 1000) ─
|
||||
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
||||
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
||||
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
||||
# ─ tube rack ─
|
||||
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
||||
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
||||
# ─ plate ─
|
||||
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
||||
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
||||
# ─ trash ─
|
||||
- { kind: trash, class_name: PRCXI_trash }
|
||||
models:
|
||||
# PRCXI 9320 —— 与厂商级完全一致(空 dict 仅作为合法 model 名占位)。
|
||||
# caller `--target_model 9320` 时所有字段继承厂商级 prcxi 段。
|
||||
"9320": {}
|
||||
# 演示:假想 PRCXI 4040 把 slot 4 物理位换到 16、trash 槽换到 20。
|
||||
# 仅 slot_remap 不同;rules 与厂商级一致 → 不重复声明(自动继承)。
|
||||
"4040":
|
||||
slot_remap:
|
||||
default:
|
||||
"4": "16"
|
||||
"8": "14"
|
||||
by_object:
|
||||
trash:
|
||||
"12": "20"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 演示:未来加新仪器只复制 prcxi 段、改 device 名 + slot_remap + rules。
|
||||
# 特别注意 tip 量程档可与 PRCXI 不同。
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# beckman:
|
||||
# slot_remap:
|
||||
# default: {"4": "13"}
|
||||
# by_object: {trash: {"12": "16"}}
|
||||
# rules:
|
||||
# - { kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips }
|
||||
# - { kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips }
|
||||
# - { kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips }
|
||||
# - { kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack }
|
||||
# - { kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate }
|
||||
# - { kind: trash, class_name: Beckman_Trash }
|
||||
# models:
|
||||
# "i7":
|
||||
# slot_remap:
|
||||
# default: {"4": "13", "5": "14"} # 假想 i7 多一个 slot 重映射
|
||||
@@ -1,5 +1,5 @@
|
||||
channel_sources:
|
||||
- robostack,robostack-staging,conda-forge,defaults
|
||||
- robostack,robostack-staging,conda-forge
|
||||
|
||||
gazebo:
|
||||
- '11'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.12
|
||||
version: 0.11.1
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
@@ -17,7 +17,7 @@ build:
|
||||
- bash $SRC_DIR/build_ament_cmake.sh
|
||||
|
||||
about:
|
||||
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: BSD-3-Clause
|
||||
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."
|
||||
|
||||
@@ -25,7 +25,7 @@ requirements:
|
||||
build:
|
||||
- ${{ compiler('cxx') }}
|
||||
- ${{ compiler('c') }}
|
||||
- python ==3.11.11
|
||||
- python ==3.11.14
|
||||
- numpy
|
||||
- if: build_platform != target_platform
|
||||
then:
|
||||
@@ -63,14 +63,14 @@ requirements:
|
||||
- robostack-staging::ros-humble-rosidl-default-generators
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.6
|
||||
- robostack-staging::ros2-distro-mutex=0.7
|
||||
run:
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.6
|
||||
- robostack-staging::ros2-distro-mutex=0.7
|
||||
- if: osx and x86_64
|
||||
then:
|
||||
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.12"
|
||||
version: "0.11.1"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -85,7 +85,7 @@ Verification:
|
||||
-------------
|
||||
|
||||
The verify_installation.py script will check:
|
||||
- Python version (3.11.11)
|
||||
- Python version (3.11.14)
|
||||
- ROS2 rclpy installation
|
||||
- UniLabOS installation and dependencies
|
||||
|
||||
@@ -104,7 +104,7 @@ Build Information:
|
||||
|
||||
Branch: {branch}
|
||||
Platform: {platform}
|
||||
Python: 3.11.11
|
||||
Python: 3.11.14
|
||||
Date: {build_date}
|
||||
|
||||
Troubleshooting:
|
||||
@@ -126,7 +126,7 @@ If installation fails:
|
||||
For more help:
|
||||
- Documentation: docs/user_guide/installation.md
|
||||
- Quick Start: QUICK_START_CONDA_PACK.md
|
||||
- Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues
|
||||
- Issues: https://github.com/deepmodeling/Uni-Lab-OS/issues
|
||||
|
||||
License:
|
||||
--------
|
||||
@@ -134,7 +134,7 @@ License:
|
||||
UniLabOS is licensed under GPL-3.0-only.
|
||||
See LICENSE file for details.
|
||||
|
||||
Repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||
Repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
"""
|
||||
|
||||
return readme
|
||||
|
||||
214
scripts/dev_install.py
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Development installation script for UniLabOS.
|
||||
Auto-detects Chinese locale and uses appropriate mirror.
|
||||
|
||||
Usage:
|
||||
python scripts/dev_install.py
|
||||
python scripts/dev_install.py --no-mirror # Force no mirror
|
||||
python scripts/dev_install.py --china # Force China mirror
|
||||
python scripts/dev_install.py --skip-deps # Skip pip dependencies installation
|
||||
|
||||
Flow:
|
||||
1. pip install -e . (install unilabos in editable mode)
|
||||
2. Detect Chinese locale
|
||||
3. Use uv to install pip dependencies from requirements.txt
|
||||
4. Special packages (like pylabrobot) are handled by environment_check.py at runtime
|
||||
"""
|
||||
|
||||
import locale
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Tsinghua mirror URL
|
||||
TSINGHUA_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||
|
||||
|
||||
def is_chinese_locale() -> bool:
|
||||
"""
|
||||
Detect if system is in Chinese locale.
|
||||
Same logic as EnvironmentChecker._is_chinese_locale()
|
||||
"""
|
||||
try:
|
||||
lang = locale.getdefaultlocale()[0]
|
||||
if lang and ("zh" in lang.lower() or "chinese" in lang.lower()):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def run_command(cmd: list, description: str, retry: int = 2) -> bool:
|
||||
"""Run command with retry support."""
|
||||
print(f"[INFO] {description}")
|
||||
print(f"[CMD] {' '.join(cmd)}")
|
||||
|
||||
for attempt in range(retry + 1):
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True, timeout=600)
|
||||
print(f"[OK] {description}")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
if attempt < retry:
|
||||
print(f"[WARN] Attempt {attempt + 1} failed, retrying...")
|
||||
else:
|
||||
print(f"[ERROR] {description} failed: {e}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[ERROR] {description} timed out")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def install_editable(project_root: Path, use_mirror: bool) -> bool:
|
||||
"""Install unilabos in editable mode using pip."""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-e", str(project_root)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing unilabos in editable mode")
|
||||
|
||||
|
||||
def install_requirements_uv(requirements_file: Path, use_mirror: bool) -> bool:
|
||||
"""Install pip dependencies using uv (installed via conda-forge::uv)."""
|
||||
cmd = ["uv", "pip", "install", "-r", str(requirements_file)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing pip dependencies with uv", retry=2)
|
||||
|
||||
|
||||
def install_requirements_pip(requirements_file: Path, use_mirror: bool) -> bool:
|
||||
"""Fallback: Install pip dependencies using pip."""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||
if use_mirror:
|
||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
||||
|
||||
return run_command(cmd, "Installing pip dependencies with pip", retry=2)
|
||||
|
||||
|
||||
def check_uv_available() -> bool:
|
||||
"""Check if uv is available (installed via conda-forge::uv)."""
|
||||
try:
|
||||
subprocess.run(["uv", "--version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Development installation script for UniLabOS")
|
||||
parser.add_argument("--china", action="store_true", help="Force use China mirror (Tsinghua)")
|
||||
parser.add_argument("--no-mirror", action="store_true", help="Force use default PyPI (no mirror)")
|
||||
parser.add_argument(
|
||||
"--skip-deps", action="store_true", help="Skip pip dependencies installation (only install unilabos)"
|
||||
)
|
||||
parser.add_argument("--use-pip", action="store_true", help="Use pip instead of uv for dependencies")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine project root
|
||||
script_dir = Path(__file__).parent
|
||||
project_root = script_dir.parent
|
||||
requirements_file = project_root / "unilabos" / "utils" / "requirements.txt"
|
||||
|
||||
if not (project_root / "setup.py").exists():
|
||||
print(f"[ERROR] setup.py not found in {project_root}")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 60)
|
||||
print("UniLabOS Development Installation")
|
||||
print("=" * 60)
|
||||
print(f"Project root: {project_root}")
|
||||
print()
|
||||
|
||||
# Determine mirror usage based on locale
|
||||
if args.no_mirror:
|
||||
use_mirror = False
|
||||
print("[INFO] Mirror disabled by --no-mirror flag")
|
||||
elif args.china:
|
||||
use_mirror = True
|
||||
print("[INFO] China mirror enabled by --china flag")
|
||||
else:
|
||||
use_mirror = is_chinese_locale()
|
||||
if use_mirror:
|
||||
print("[INFO] Chinese locale detected, using Tsinghua mirror")
|
||||
else:
|
||||
print("[INFO] Non-Chinese locale detected, using default PyPI")
|
||||
|
||||
print()
|
||||
|
||||
# Step 1: Install unilabos in editable mode
|
||||
print("[STEP 1] Installing unilabos in editable mode...")
|
||||
if not install_editable(project_root, use_mirror):
|
||||
print("[ERROR] Failed to install unilabos")
|
||||
print()
|
||||
print("Manual fallback:")
|
||||
if use_mirror:
|
||||
print(f" pip install -e {project_root} -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(f" pip install -e {project_root}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
|
||||
# Step 2: Install pip dependencies
|
||||
if args.skip_deps:
|
||||
print("[INFO] Skipping pip dependencies installation (--skip-deps)")
|
||||
else:
|
||||
print("[STEP 2] Installing pip dependencies...")
|
||||
|
||||
if not requirements_file.exists():
|
||||
print(f"[WARN] Requirements file not found: {requirements_file}")
|
||||
print("[INFO] Skipping dependencies installation")
|
||||
else:
|
||||
# Try uv first (faster), fallback to pip
|
||||
if args.use_pip:
|
||||
print("[INFO] Using pip (--use-pip flag)")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
elif check_uv_available():
|
||||
print("[INFO] Using uv (installed via conda-forge::uv)")
|
||||
success = install_requirements_uv(requirements_file, use_mirror)
|
||||
if not success:
|
||||
print("[WARN] uv failed, falling back to pip...")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
else:
|
||||
print("[WARN] uv not available (should be installed via: mamba install conda-forge::uv)")
|
||||
print("[INFO] Falling back to pip...")
|
||||
success = install_requirements_pip(requirements_file, use_mirror)
|
||||
|
||||
if not success:
|
||||
print()
|
||||
print("[WARN] Failed to install some dependencies automatically.")
|
||||
print("You can manually install them:")
|
||||
if use_mirror:
|
||||
print(f" uv pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
||||
print(" or:")
|
||||
print(f" pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(f" uv pip install -r {requirements_file}")
|
||||
print(" or:")
|
||||
print(f" pip install -r {requirements_file}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Installation complete!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Note: Some special packages (like pylabrobot) are installed")
|
||||
print("automatically at runtime by unilabos if needed.")
|
||||
print()
|
||||
print("Verify installation:")
|
||||
print(' python -c "import unilabos; print(unilabos.__version__)"')
|
||||
print()
|
||||
print("If you encounter issues, you can manually install dependencies:")
|
||||
if use_mirror:
|
||||
print(f" uv pip install -r unilabos/utils/requirements.txt -i {TSINGHUA_MIRROR}")
|
||||
else:
|
||||
print(" uv pip install -r unilabos/utils/requirements.txt")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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.12',
|
||||
version='0.11.1',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
539
test/devices/test_prcxi.py
Normal file
@@ -0,0 +1,539 @@
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import collections
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
||||
PRCXI9300Deck,
|
||||
PRCXI9300Container,
|
||||
PRCXI9300Trash,
|
||||
PRCXI9300Handler,
|
||||
PRCXI9300Backend,
|
||||
DefaultLayout,
|
||||
Material,
|
||||
WorkTablets,
|
||||
MatrixInfo
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_materials() -> Dict[str, Any]:
|
||||
"""加载 PRCXI 物料数据"""
|
||||
print("加载 PRCXI 物料数据...")
|
||||
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
||||
with open(material_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
print(f"加载了 {len(data)} 条物料数据")
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9300 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9320 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9300_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=8,
|
||||
axis="Left",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9300"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9320_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=1,
|
||||
axis="Right",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9320",
|
||||
is_9320=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 300μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_300ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 10μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_10ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 96 孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="well_plate_96",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建深孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="deep_well_plate",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
||||
"""创建垃圾桶"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
return trash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9300() -> DefaultLayout:
|
||||
"""创建 PRCXI 9300 默认布局"""
|
||||
return DefaultLayout("PRCXI9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9320() -> DefaultLayout:
|
||||
"""创建 PRCXI 9320 默认布局"""
|
||||
return DefaultLayout("PRCXI9320")
|
||||
|
||||
|
||||
class TestPRCXIDeckSetup:
|
||||
"""测试 PRCXI 工作台设置功能"""
|
||||
|
||||
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
||||
"""测试 PRCXI 9300 工作台创建"""
|
||||
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
||||
assert len(prcxi_9300_deck.sites) == 6
|
||||
assert prcxi_9300_deck._size_x == 100
|
||||
assert prcxi_9300_deck._size_y == 100
|
||||
assert prcxi_9300_deck._size_z == 100
|
||||
|
||||
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
||||
"""测试 PRCXI 9320 工作台创建"""
|
||||
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
||||
assert len(prcxi_9320_deck.sites) == 16
|
||||
assert prcxi_9320_deck._size_x == 100
|
||||
assert prcxi_9320_deck._size_y == 100
|
||||
assert prcxi_9320_deck._size_z == 100
|
||||
|
||||
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
||||
"""测试容器分配到工作台"""
|
||||
# 分配枪头盒
|
||||
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
||||
assert tip_rack_300ul in prcxi_9300_deck.children
|
||||
|
||||
# 分配孔板
|
||||
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
||||
assert well_plate_96 in prcxi_9300_deck.children
|
||||
|
||||
# 分配垃圾桶
|
||||
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
||||
assert trash_container in prcxi_9300_deck.children
|
||||
|
||||
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
||||
"""测试容器物料信息加载"""
|
||||
# 测试枪头盒物料信息
|
||||
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
||||
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
||||
assert tip_material["Name"] == "300μL Tip头"
|
||||
|
||||
# 测试孔板物料信息
|
||||
plate_material = well_plate_96._unilabos_state["Material"]
|
||||
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
||||
assert plate_material["Name"] == "96深孔板"
|
||||
|
||||
|
||||
class TestPRCXISingleStepOperations:
|
||||
"""测试 PRCXI 单步操作功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
||||
"""测试单通道拾取枪头"""
|
||||
# 将枪头盒添加到工作台
|
||||
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 初始化处理器
|
||||
await prcxi_9320_handler.setup()
|
||||
|
||||
# 设置枪头盒
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
|
||||
# 创建模拟的枪头位置
|
||||
from pylabrobot.resources import TipSpot, Tip
|
||||
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
||||
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
||||
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 直接测试后端方法
|
||||
from pylabrobot.liquid_handling import Pickup
|
||||
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
||||
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
||||
"""测试多通道拾取枪头"""
|
||||
# 设置枪头盒
|
||||
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
||||
|
||||
# 拾取8个枪头
|
||||
tip_spots = tip_rack_300ul.children[:8]
|
||||
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道吸取液体"""
|
||||
# 设置液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
||||
|
||||
# 吸取液体
|
||||
await prcxi_9320_handler.aspirate([well], [50], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Imbibing"
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道分配液体"""
|
||||
# 分配液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.dispense([well], [25], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Tapping"
|
||||
assert step["DosageNum"] == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道混合液体"""
|
||||
# 混合液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Blending"
|
||||
assert step["BlendingTimes"] == 3
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
||||
"""测试丢弃枪头到垃圾桶"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_tips(self, prcxi_9320_handler):
|
||||
"""测试丢弃枪头"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
||||
"""测试完整的液体转移工作流程"""
|
||||
# 设置枪头盒和液体
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
source_well = well_plate_96.get_item("A1")
|
||||
target_well = well_plate_96.get_item("B1")
|
||||
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
||||
|
||||
# 创建协议
|
||||
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
||||
|
||||
# 执行转移流程
|
||||
tip_spot = tip_rack_10ul.get_item("A1")
|
||||
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
||||
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
||||
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证所有步骤都已添加
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
||||
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
||||
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
||||
|
||||
|
||||
class TestPRCXILayoutRecommendation:
|
||||
"""测试 PRCXI 板位推荐功能"""
|
||||
|
||||
def test_9300_layout_creation(self, default_layout_9300):
|
||||
"""测试 PRCXI 9300 布局创建"""
|
||||
layout_info = default_layout_9300.get_layout()
|
||||
assert layout_info["rows"] == 2
|
||||
assert layout_info["columns"] == 3
|
||||
assert len(layout_info["layout"]) == 6
|
||||
assert layout_info["trash_slot"] == 6
|
||||
assert "waste_liquid_slot" not in layout_info
|
||||
|
||||
def test_9320_layout_creation(self, default_layout_9320):
|
||||
"""测试 PRCXI 9320 布局创建"""
|
||||
layout_info = default_layout_9320.get_layout()
|
||||
assert layout_info["rows"] == 4
|
||||
assert layout_info["columns"] == 4
|
||||
assert len(layout_info["layout"]) == 16
|
||||
assert layout_info["trash_slot"] == 16
|
||||
assert layout_info["waste_liquid_slot"] == 12
|
||||
|
||||
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
||||
"""测试 PRCXI 9320 板位推荐功能"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 推荐布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 3),
|
||||
("reagent_2", "12道储液槽", 1),
|
||||
("reagent_3", "200μL Tip头", 7),
|
||||
("reagent_4", "10μL加长 Tip头", 1),
|
||||
]
|
||||
|
||||
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
||||
|
||||
# 验证返回结果
|
||||
assert "MatrixId" in matrix_layout
|
||||
assert "MatrixName" in matrix_layout
|
||||
assert "MatrixCount" in matrix_layout
|
||||
assert "WorkTablets" in matrix_layout
|
||||
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
||||
|
||||
# 验证推荐的位置不包含预留位置
|
||||
reserved_positions = {12, 16}
|
||||
recommended_positions = [item["positions"] for item in layout_list]
|
||||
for pos in recommended_positions:
|
||||
assert pos not in reserved_positions
|
||||
|
||||
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐空间不足的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐超过可用空间的布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐物料不存在的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐不存在的物料
|
||||
needs = [
|
||||
("reagent_1", "不存在的物料", 1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
|
||||
class TestPRCXIBackendOperations:
|
||||
"""测试 PRCXI 后端操作功能"""
|
||||
|
||||
def test_backend_initialization(self, prcxi_9300_handler):
|
||||
"""测试后端初始化"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
assert isinstance(backend, PRCXI9300Backend)
|
||||
assert backend._num_channels == 8
|
||||
assert backend.debug is True
|
||||
|
||||
def test_protocol_creation(self, prcxi_9300_handler):
|
||||
"""测试协议创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
assert backend.protocol_name == "Test Protocol"
|
||||
assert len(backend.steps_todo_list) == 0
|
||||
|
||||
def test_channel_validation(self):
|
||||
"""测试通道验证"""
|
||||
# 测试正确的8通道配置
|
||||
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
result = PRCXI9300Backend.check_channels(valid_channels)
|
||||
assert result == valid_channels
|
||||
|
||||
# 测试错误的通道配置
|
||||
invalid_channels = [0, 1, 2, 3]
|
||||
result = PRCXI9300Backend.check_channels(invalid_channels)
|
||||
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
def test_matrix_info_creation(self, prcxi_9300_handler):
|
||||
"""测试矩阵信息创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
|
||||
# 模拟运行协议时的矩阵信息创建
|
||||
run_time = 1234567890
|
||||
matrix_info = MatrixInfo(
|
||||
MatrixId=f"{int(run_time)}",
|
||||
MatrixName=f"protocol_{run_time}",
|
||||
MatrixCount=len(backend.tablets_info),
|
||||
WorkTablets=backend.tablets_info,
|
||||
)
|
||||
|
||||
assert matrix_info["MatrixId"] == str(int(run_time))
|
||||
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
||||
assert "WorkTablets" in matrix_info
|
||||
|
||||
|
||||
class TestPRCXIContainerOperations:
|
||||
"""测试 PRCXI 容器操作功能"""
|
||||
|
||||
def test_container_serialization(self, tip_rack_300ul):
|
||||
"""测试容器序列化"""
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
assert "Material" in serialized
|
||||
assert serialized["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_container_deserialization(self, tip_rack_300ul):
|
||||
"""测试容器反序列化"""
|
||||
# 序列化
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
|
||||
# 创建新容器并反序列化
|
||||
new_tip_rack = PRCXI9300Container(
|
||||
name="new_tip_rack",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
new_tip_rack.load_state(serialized)
|
||||
|
||||
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_trash_container_creation(self, prcxi_materials):
|
||||
"""测试垃圾桶容器创建"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
|
||||
assert trash.name == "trash"
|
||||
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v"])
|
||||
7
tests/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
测试包根目录。
|
||||
|
||||
让 `tests.*` 模块可以被正常 import(例如给 `unilabos` 下的测试入口使用)。
|
||||
"""
|
||||
|
||||
|
||||
1
tests/devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
15
tests/devices/liquid_handling/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Liquid handling 集成测试
|
||||
|
||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||
|
||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||
|
||||
```bash
|
||||
export UNILAB_SIM_TEST=1
|
||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||
```
|
||||
|
||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||
|
||||
5
tests/devices/liquid_handling/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
液体处理设备相关测试。
|
||||
"""
|
||||
|
||||
|
||||
244
tests/devices/liquid_handling/test_liquid_history.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""P9 — ``liquid_history`` schema v3 + helper 单元测试。
|
||||
|
||||
测试覆盖:
|
||||
- :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限
|
||||
- :func:`normalize_liquid_history`:v3 dict / v2 tuple / list[str] / 混合 / 非法
|
||||
- :func:`well_current_liquid_name`:tracker.liquids 末项 / get_liquids fallback / 缺失
|
||||
|
||||
注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action)覆盖
|
||||
逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR
|
||||
真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py``
|
||||
范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。
|
||||
|
||||
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
# liquid_history 模块**不依赖** pylabrobot,可在 PLR 环境缺失时独立 import / 单测。
|
||||
from unilabos.devices.liquid_handling.liquid_history import (
|
||||
LIQUID_HISTORY_MAX_ENTRIES,
|
||||
LiquidHistoryEntry,
|
||||
append_liquid_history,
|
||||
normalize_liquid_history,
|
||||
well_current_liquid_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures:DummyTracker / DummyWell(避免引入真实 PLR Well/VolumeTracker 依赖)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyTracker:
|
||||
"""模拟 PLR VolumeTracker:仅暴露 P9 hook 关心的字段。"""
|
||||
|
||||
liquid_history: List[Any] = field(default_factory=list)
|
||||
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
||||
max_volume: float = 200.0
|
||||
is_disabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyWell:
|
||||
"""模拟 PLR Well:仅暴露 ``tracker``。"""
|
||||
|
||||
name: str = "well_A1"
|
||||
max_volume: float = 200.0
|
||||
tracker: DummyTracker = field(default_factory=DummyTracker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# append_liquid_history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAppendLiquidHistory:
|
||||
def test_append_creates_v3_entry(self) -> None:
|
||||
well = DummyWell()
|
||||
append_liquid_history(well, "Plasma", 100.0, "set")
|
||||
|
||||
assert len(well.tracker.liquid_history) == 1
|
||||
entry = well.tracker.liquid_history[0]
|
||||
assert entry["name"] == "Plasma"
|
||||
assert entry["volume"] == 100.0
|
||||
assert entry["action"] == "set"
|
||||
assert "timestamp" in entry and isinstance(entry["timestamp"], str)
|
||||
|
||||
def test_append_aspirate_negative_volume(self) -> None:
|
||||
well = DummyWell()
|
||||
append_liquid_history(well, "Water", -50.0, "aspirate")
|
||||
|
||||
assert well.tracker.liquid_history[0]["volume"] == -50.0
|
||||
assert well.tracker.liquid_history[0]["action"] == "aspirate"
|
||||
|
||||
def test_append_with_empty_name_keeps_empty_string(self) -> None:
|
||||
"""name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type)。"""
|
||||
well = DummyWell()
|
||||
append_liquid_history(well, "", 50.0, "dispense")
|
||||
|
||||
assert well.tracker.liquid_history[0]["name"] == ""
|
||||
|
||||
def test_append_with_none_name_normalized_to_empty_string(self) -> None:
|
||||
well = DummyWell()
|
||||
append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type]
|
||||
|
||||
assert well.tracker.liquid_history[0]["name"] == ""
|
||||
|
||||
def test_append_initializes_history_if_missing(self) -> None:
|
||||
"""tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。"""
|
||||
well = DummyWell()
|
||||
del well.tracker.liquid_history # 模拟全新 PLR tracker
|
||||
append_liquid_history(well, "X", 10.0, "set")
|
||||
|
||||
assert hasattr(well.tracker, "liquid_history")
|
||||
assert len(well.tracker.liquid_history) == 1
|
||||
|
||||
def test_append_no_tracker_is_graceful(self) -> None:
|
||||
"""well 无 tracker 时静默不抛(保护主流程)。"""
|
||||
|
||||
class NoTrackerWell:
|
||||
name = "no_tracker"
|
||||
|
||||
well = NoTrackerWell()
|
||||
append_liquid_history(well, "X", 10.0, "set") # 不应抛
|
||||
assert not hasattr(well, "tracker")
|
||||
|
||||
def test_append_action_defaults_to_legacy_when_empty(self) -> None:
|
||||
well = DummyWell()
|
||||
append_liquid_history(well, "X", 1.0, "")
|
||||
|
||||
assert well.tracker.liquid_history[0]["action"] == "legacy"
|
||||
|
||||
def test_append_respects_max_entries_rolling(self) -> None:
|
||||
"""超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。"""
|
||||
well = DummyWell()
|
||||
well.tracker.liquid_history = [
|
||||
{"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5)
|
||||
]
|
||||
append_liquid_history(well, "newest", 1.0, "set")
|
||||
|
||||
assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES
|
||||
assert well.tracker.liquid_history[-1]["name"] == "newest"
|
||||
assert well.tracker.liquid_history[0]["name"] != "old_0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_liquid_history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeLiquidHistory:
|
||||
def test_v3_dict_passthrough_with_field_defaults(self) -> None:
|
||||
raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
assert result == [{
|
||||
"name": "A",
|
||||
"volume": 100.0,
|
||||
"action": "set",
|
||||
"timestamp": "2026-05-22T00:00:00Z",
|
||||
}]
|
||||
|
||||
def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None:
|
||||
raw = [{"name": "A"}]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}]
|
||||
assert "timestamp" not in result[0]
|
||||
|
||||
def test_v2_tuple_upgraded_to_v3_legacy(self) -> None:
|
||||
raw = [("A", 100), ("B", 50.5)]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
assert result == [
|
||||
{"name": "A", "volume": 100.0, "action": "legacy"},
|
||||
{"name": "B", "volume": 50.5, "action": "legacy"},
|
||||
]
|
||||
|
||||
def test_list_of_strings_upgraded(self) -> None:
|
||||
raw = ["A", "B"]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
assert result == [
|
||||
{"name": "A", "volume": 0.0, "action": "legacy"},
|
||||
{"name": "B", "volume": 0.0, "action": "legacy"},
|
||||
]
|
||||
|
||||
def test_mixed_input_normalized(self) -> None:
|
||||
raw = [
|
||||
{"name": "A", "volume": 1, "action": "set"},
|
||||
("B", 2),
|
||||
"C",
|
||||
]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
assert [e["name"] for e in result] == ["A", "B", "C"]
|
||||
assert [e["action"] for e in result] == ["set", "legacy", "legacy"]
|
||||
|
||||
def test_invalid_entries_dropped(self) -> None:
|
||||
raw = [42, None, {"name": "A"}, ("only_one",)]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
# 只保留 {"name": "A"} 这一条;其它都被丢弃
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "A"
|
||||
assert result[0]["volume"] == 0.0 # 缺省补 0
|
||||
|
||||
def test_non_list_input_returns_empty(self) -> None:
|
||||
assert normalize_liquid_history(None) == []
|
||||
assert normalize_liquid_history("not_a_list") == []
|
||||
assert normalize_liquid_history({"name": "X"}) == []
|
||||
|
||||
def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None:
|
||||
raw = [("A", "not_a_number")]
|
||||
result = normalize_liquid_history(raw)
|
||||
|
||||
assert result[0]["volume"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# well_current_liquid_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWellCurrentLiquidName:
|
||||
def test_returns_last_liquid_name_from_tuple(self) -> None:
|
||||
well = DummyWell()
|
||||
well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)]
|
||||
assert well_current_liquid_name(well) == "Plasma"
|
||||
|
||||
def test_returns_enum_like_name_attr(self) -> None:
|
||||
class FakeLiquid:
|
||||
name = "ETHANOL"
|
||||
|
||||
well = DummyWell()
|
||||
well.tracker.liquids = [(FakeLiquid(), 100.0)]
|
||||
assert well_current_liquid_name(well) == "ETHANOL"
|
||||
|
||||
def test_empty_liquids_returns_empty_string(self) -> None:
|
||||
well = DummyWell()
|
||||
well.tracker.liquids = []
|
||||
assert well_current_liquid_name(well) == ""
|
||||
|
||||
def test_no_tracker_returns_empty_string(self) -> None:
|
||||
class NoTrackerWell:
|
||||
name = "x"
|
||||
|
||||
assert well_current_liquid_name(NoTrackerWell()) == ""
|
||||
|
||||
def test_none_liquid_returns_empty_string(self) -> None:
|
||||
well = DummyWell()
|
||||
well.tracker.liquids = [(None, 100.0)]
|
||||
assert well_current_liquid_name(well) == ""
|
||||
|
||||
def test_string_liquid_returned_as_is(self) -> None:
|
||||
well = DummyWell()
|
||||
well.tracker.liquids = ["Saline"]
|
||||
assert well_current_liquid_name(well) == "Saline"
|
||||
@@ -0,0 +1,239 @@
|
||||
"""P2 v2 跨板能力验证 —— device 层 ``set_liquid_from_plate`` 单测。
|
||||
|
||||
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.1 / §9.5 step 6.3。
|
||||
|
||||
本测试聚焦于 **`_set_liquid_grouped_by_plate`** 已天然支持跨板 wells 的能力(v2 设计
|
||||
的核心依据):
|
||||
|
||||
- 输入 ``wells`` 列表来自多个 plate(每板各一/多个 well)时,``set_liquid`` 应按 plate
|
||||
分桶串行调用,每板一次(plate-bucket 顺序按 first-occurrence)。
|
||||
- 同板内多孔归到同一桶。
|
||||
- 返回 ``volumes`` 按 **输入 index 顺序**回拼,与 wells 一致 —— 这是 v2 Stage 3
|
||||
merged ``set_liquid_from_plate.output_wells`` 的顺序权威来源。
|
||||
- ``Well.set_liquids`` 在 ``set_liquid`` 链内被逐孔调用,与 PLR 实现的预期接口一致。
|
||||
|
||||
为了避免引入完整 PLR 资源树,测试用 duck-typed ``DummyWell`` / ``DummyPlate`` +
|
||||
``ResourceTreeSet`` 的 monkeypatch(dump 直接返回输入列表)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 跨环境兼容:与现有 ``tests/devices/liquid_handling/test_transfer_liquid.py`` 一致,
|
||||
# 本测试通过 import ``unilabos.devices.liquid_handling.liquid_handler_abstract``
|
||||
# 拉起 pylabrobot 链;某些本地开发机的 pylabrobot 版本与代码库要求不一致,
|
||||
# 会在 import 阶段抛 ``ImportError``。这里用 ``importorskip`` 优雅跳过,让
|
||||
# CI(统一 pylabrobot 版本)跑全;纯逻辑测试(Stage 2 / Stage 3)不受影响。
|
||||
# ----------------------------------------------------------------------
|
||||
LiquidHandlerAbstract = pytest.importorskip(
|
||||
"unilabos.devices.liquid_handling.liquid_handler_abstract",
|
||||
reason="pylabrobot 链未完整可用,跳过 device 单测;CI 上请保证 pylabrobot ≥ 项目要求版本",
|
||||
exc_type=ImportError,
|
||||
).LiquidHandlerAbstract
|
||||
|
||||
|
||||
# ==================== Duck-typed PLR-like 资源 ====================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyPlate:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyPlate({self.name})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyWell:
|
||||
name: str
|
||||
parent: DummyPlate
|
||||
max_volume: float = 1000.0
|
||||
liquid_history: List[Tuple[str, float]] = field(default_factory=list)
|
||||
|
||||
def set_liquids(self, items):
|
||||
"""模拟 PLR ``Well.set_liquids([(name, vol), ...])`` 接口。"""
|
||||
for name, vol in items:
|
||||
self.liquid_history.append((str(name), float(vol)))
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyWell({self.parent.name}/{self.name})"
|
||||
|
||||
|
||||
# ==================== fixture:装一台 FakeLiquidHandler ====================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_resource_tree(monkeypatch):
|
||||
"""patch ``ResourceTreeSet.from_plr_resources`` 使其接受 duck-typed wells/plates。
|
||||
|
||||
返回的对象只要带 ``.dump()`` 即可(``_set_liquid_grouped_by_plate`` 仅消费该方法)。
|
||||
"""
|
||||
from unilabos.devices.liquid_handling import liquid_handler_abstract as lha
|
||||
|
||||
class _FakeTree:
|
||||
def __init__(self, items):
|
||||
self._items = items
|
||||
|
||||
def dump(self):
|
||||
return [
|
||||
{"name": getattr(x, "name", None), "type": type(x).__name__}
|
||||
for x in self._items
|
||||
]
|
||||
|
||||
def _fake_from_plr_resources(items, known_newly_created=False): # noqa: ARG001
|
||||
return _FakeTree(list(items))
|
||||
|
||||
monkeypatch.setattr(
|
||||
lha.ResourceTreeSet,
|
||||
"from_plr_resources",
|
||||
staticmethod(_fake_from_plr_resources),
|
||||
)
|
||||
return lha
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(patched_resource_tree):
|
||||
"""构造一台最小 LiquidHandlerAbstract 实例,绕过真实 backend / deck。"""
|
||||
|
||||
class _FakeHandler(LiquidHandlerAbstract):
|
||||
def __init__(self):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = 8
|
||||
self.support_touch_tip = True
|
||||
|
||||
return _FakeHandler()
|
||||
|
||||
|
||||
def _wells_grid(plate_name: str, well_names: List[str]) -> List[DummyWell]:
|
||||
plate = DummyPlate(name=plate_name)
|
||||
return [DummyWell(name=w, parent=plate) for w in well_names]
|
||||
|
||||
|
||||
# ==================== 用例 ====================
|
||||
|
||||
|
||||
def test_grouped_by_plate_single_plate_set_liquid_inline(handler):
|
||||
"""单 plate 多孔:set_liquids 按 wells 顺序逐项调用,volumes 回拼一致。"""
|
||||
wells = _wells_grid("plate_slot2", ["A1", "A2", "A3"])
|
||||
ret = handler._set_liquid_grouped_by_plate(
|
||||
wells=wells,
|
||||
liquid_names=["reagent_X"] * 3,
|
||||
volumes=[10.0, 20.0, 30.0],
|
||||
)
|
||||
|
||||
# 每个 well 的 liquid_history 各 1 条
|
||||
for w, expected_vol in zip(wells, [10.0, 20.0, 30.0]):
|
||||
assert w.liquid_history == [("reagent_X", expected_vol)]
|
||||
|
||||
# 返回 volumes 顺序与输入一致
|
||||
assert ret.volumes == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_grouped_by_plate_cross_plate_buckets_by_parent(handler):
|
||||
"""跨板 wells 列表 → 按 first-occurrence plate 顺序分桶,每板单独 set_liquid。
|
||||
|
||||
51b9a5 简化(每板 1 孔):4 plate × 1 well = 4 set_liquids 调用。
|
||||
"""
|
||||
p2 = _wells_grid("plate_slot2", ["A1"])
|
||||
p3 = _wells_grid("plate_slot3", ["A1"])
|
||||
p5 = _wells_grid("plate_slot5", ["A1"])
|
||||
p6 = _wells_grid("plate_slot6", ["A1"])
|
||||
wells = p2 + p3 + p5 + p6
|
||||
|
||||
ret = handler._set_liquid_grouped_by_plate(
|
||||
wells=wells,
|
||||
liquid_names=["l1"] * 4,
|
||||
volumes=[8.3] * 4,
|
||||
)
|
||||
|
||||
# 每个 well 都被 set_liquids 设过
|
||||
for w in wells:
|
||||
assert w.liquid_history == [("l1", 8.3)], f"well {w.parent.name}/{w.name} 未正确设液"
|
||||
|
||||
# volumes 顺序与输入对齐
|
||||
assert ret.volumes == [8.3, 8.3, 8.3, 8.3]
|
||||
|
||||
# plate dump 应含 4 个 plate(按 first-occurrence)
|
||||
plate_dump = ret.plate
|
||||
plate_names = [p["name"] for p in plate_dump]
|
||||
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||
|
||||
|
||||
def test_grouped_by_plate_interleaved_cross_plate_preserves_input_order(handler):
|
||||
"""交错跨板:wells=[p2.A1, p3.A1, p2.A2, p5.A1] → volumes 顺序按输入回拼。
|
||||
|
||||
内部仍按 plate 分桶执行 set_liquid(per-plate 串行),但返回顺序遵循输入 index。
|
||||
"""
|
||||
p2 = DummyPlate(name="plate_slot2")
|
||||
p3 = DummyPlate(name="plate_slot3")
|
||||
p5 = DummyPlate(name="plate_slot5")
|
||||
w_p2_a1 = DummyWell(name="A1", parent=p2)
|
||||
w_p2_a2 = DummyWell(name="A2", parent=p2)
|
||||
w_p3_a1 = DummyWell(name="A1", parent=p3)
|
||||
w_p5_a1 = DummyWell(name="A1", parent=p5)
|
||||
|
||||
wells = [w_p2_a1, w_p3_a1, w_p2_a2, w_p5_a1]
|
||||
ret = handler._set_liquid_grouped_by_plate(
|
||||
wells=wells,
|
||||
liquid_names=["l1"] * 4,
|
||||
volumes=[10.0, 20.0, 30.0, 40.0],
|
||||
)
|
||||
|
||||
# 每个 well 都被设液
|
||||
assert w_p2_a1.liquid_history == [("l1", 10.0)]
|
||||
assert w_p3_a1.liquid_history == [("l1", 20.0)]
|
||||
assert w_p2_a2.liquid_history == [("l1", 30.0)]
|
||||
assert w_p5_a1.liquid_history == [("l1", 40.0)]
|
||||
|
||||
# 返回 volumes 严格按输入 index 顺序回拼
|
||||
assert ret.volumes == [10.0, 20.0, 30.0, 40.0]
|
||||
|
||||
# plate dump:按 first-occurrence(plate_slot2 第 1 次出现于 idx=0,plate_slot3 idx=1,plate_slot5 idx=3)
|
||||
plate_names = [p["name"] for p in ret.plate]
|
||||
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5"]
|
||||
|
||||
|
||||
def test_grouped_by_plate_volumes_clamped_to_max_volume(handler):
|
||||
"""``set_liquid`` 会按 ``max_volume`` 做 clamp,防止初始化液量超容器容量。"""
|
||||
plate = DummyPlate(name="plate_slot2")
|
||||
well = DummyWell(name="A1", parent=plate, max_volume=200.0)
|
||||
|
||||
ret = handler._set_liquid_grouped_by_plate(
|
||||
wells=[well],
|
||||
liquid_names=["overflow"],
|
||||
volumes=[500.0], # 超过 max_volume=200
|
||||
)
|
||||
|
||||
assert well.liquid_history == [("overflow", 200.0)]
|
||||
assert ret.volumes == [200.0]
|
||||
|
||||
|
||||
def test_grouped_by_plate_empty_names_short_circuit(handler):
|
||||
"""``liquid_names`` 与 ``volumes`` 均为空:早返回,wells 列表回显但不设液。"""
|
||||
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
||||
ret = handler._set_liquid_grouped_by_plate(
|
||||
wells=wells,
|
||||
liquid_names=[],
|
||||
volumes=[],
|
||||
)
|
||||
# 不调用 set_liquids
|
||||
assert all(w.liquid_history == [] for w in wells)
|
||||
assert ret.volumes == []
|
||||
# wells dump 仍返回输入列表
|
||||
assert [w["name"] for w in ret.wells] == ["A1", "A2"]
|
||||
|
||||
|
||||
def test_grouped_by_plate_length_mismatch_raises(handler):
|
||||
"""wells / liquid_names / volumes 长度不一致应直接 raise(防御性校验)。"""
|
||||
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
||||
with pytest.raises(ValueError, match=r"必须等长"):
|
||||
handler._set_liquid_grouped_by_plate(
|
||||
wells=wells,
|
||||
liquid_names=["r"] * 2,
|
||||
volumes=[10.0], # 长度 1,不匹配
|
||||
)
|
||||
566
tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py
Normal file
@@ -0,0 +1,566 @@
|
||||
"""P10 v2 — Tip 复用 ``tracker.liquids`` 等价规则单元测试。
|
||||
|
||||
测试覆盖(详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §5):
|
||||
|
||||
- Helper:``is_known_liquid_name`` / ``same_liquid_via_liquids`` /
|
||||
``same_liquid_via_liquids_pair`` / ``capture_tip_liquid_name``(4 helper
|
||||
位于 ``liquid_history.py``,PLR-free 模块)。
|
||||
- 单通道 transfer_liquid 主循环:identity-keep / liquids-keep / 配置开关 /
|
||||
未知 name 保守换 tip / aspirate 顶层归零时序。
|
||||
- 8 通道分支:段锚孔 liquids-keep。
|
||||
- 跨节点边界:两个独立 transfer_liquid 调用状态隔离。
|
||||
|
||||
helper 测试独立于 PLR,可在 ``pylabrobot`` 缺失环境下单独运行;端到端
|
||||
``transfer_liquid`` 主循环测试需要 PLR 环境(沿用 ``test_transfer_liquid.py`` 的
|
||||
``FakeLiquidHandler`` 模式:跳过 ``super().__init__``,仅 stub 4 类方法记录调用)。
|
||||
若 PLR import 失败则自动 skip 端到端测试,保留 helper 测试结果。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
# P10 v2 helper 位于 PLR-free 模块,无论 pylabrobot 是否安装都能 import。
|
||||
from unilabos.devices.liquid_handling.liquid_history import (
|
||||
capture_tip_liquid_name,
|
||||
is_known_liquid_name,
|
||||
same_liquid_via_liquids,
|
||||
same_liquid_via_liquids_pair,
|
||||
)
|
||||
|
||||
# 端到端测试依赖 PLR 完整环境;若 import 失败(例如本地 PLR 版本不匹配),
|
||||
# 整段端到端测试自动 skip,但 helper 测试照常执行。
|
||||
try:
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
LiquidHandlerAbstract,
|
||||
)
|
||||
|
||||
_PLR_AVAILABLE = True
|
||||
_PLR_IMPORT_ERROR: Optional[Exception] = None
|
||||
except Exception as exc: # pragma: no cover - 环境相关
|
||||
LiquidHandlerAbstract = None # type: ignore[assignment, misc]
|
||||
_PLR_AVAILABLE = False
|
||||
_PLR_IMPORT_ERROR = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures:DummyTracker / DummyWell / DummyTipSpot / FakeLiquidHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyTracker:
|
||||
"""模拟 PLR ``VolumeTracker``:仅暴露 P10 v2 关心的 ``liquids`` 字段。"""
|
||||
|
||||
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
||||
max_volume: float = 200.0
|
||||
is_disabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyWell:
|
||||
"""模拟 PLR ``Well``:仅暴露 ``tracker``。"""
|
||||
|
||||
name: str = "well"
|
||||
tracker: DummyTracker = field(default_factory=DummyTracker)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyWell({self.name})"
|
||||
|
||||
|
||||
def make_well(name: str, liquid_name: Optional[str] = None, vol: float = 100.0) -> DummyWell:
|
||||
"""构造一个 well;若指定 ``liquid_name`` 则写入 ``tracker.liquids`` 顶层。"""
|
||||
well = DummyWell(name=name, tracker=DummyTracker())
|
||||
if liquid_name is not None:
|
||||
well.tracker.liquids = [(liquid_name, vol)]
|
||||
return well
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
# E2E 测试用的 base:PLR 可用时是 ``LiquidHandlerAbstract``,否则 fallback 到
|
||||
# ``object`` 让模块仍能 import;带 ``LiquidHandlerAbstract`` 的 e2e 测试用
|
||||
# ``skipif`` 跳过。
|
||||
_FakeBase = LiquidHandlerAbstract if _PLR_AVAILABLE else object
|
||||
|
||||
|
||||
class FakeLiquidHandler(_FakeBase): # type: ignore[misc, valid-type]
|
||||
"""不初始化真实 backend/deck;仅记录 transfer_liquid 内部 4 类调用序列。
|
||||
|
||||
P10 v2 测试关心 ``pick_up_tips`` / ``discard_tips`` 的触发次数 + 顺序,
|
||||
以推断 tip 是否被复用(一次 pick_up_tips 多次 aspirate/dispense → 复用)。
|
||||
"""
|
||||
|
||||
def __init__(self, channel_num: int = 1, tip_reuse_by_liquid_name: bool = True):
|
||||
# 不调用 super().__init__,避免硬件 / ROS / PLR Deck 初始化。
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter(2048))
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
self._tip_reuse_by_liquid_name: bool = tip_reuse_by_liquid_name
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
if not tip_racks:
|
||||
return
|
||||
# 跳过真实 set_tiprack(依赖 PLR Deck)
|
||||
return
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **kw):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
("aspirate", {"resources": list(resources), "vols": list(vols)})
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
("dispense", {"resources": list(resources), "vols": list(vols)})
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
self.calls.append(("discard_tips", {"use_channels": use_channels}))
|
||||
|
||||
|
||||
class AspiratePopFakeLiquidHandler(FakeLiquidHandler):
|
||||
"""T11 专用:aspirate 时模拟 PLR "顶层归零时 pop ``tracker.liquids`` 顶层" 的行为。
|
||||
|
||||
用于验证 P10 v2 的关键时序约束:tip name 必须在 aspirate **之前**预读,
|
||||
否则 aspirate 后再读 ``tracker.liquids[-1]`` 会拿不到液体身份。
|
||||
"""
|
||||
|
||||
async def aspirate(self, resources, vols, **kwargs):
|
||||
await super().aspirate(resources, vols, **kwargs)
|
||||
# 模拟 PLR 顶层归零时 pop:对每个 source well,若 liquids 非空则 pop 顶层
|
||||
for r in resources:
|
||||
tracker = getattr(r, "tracker", None)
|
||||
if tracker is not None and tracker.liquids:
|
||||
tracker.liquids.pop()
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def call_names(lh: FakeLiquidHandler) -> List[str]:
|
||||
return [c[0] for c in lh.calls]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper 单元测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsKnownLiquidName:
|
||||
def test_empty_string_is_unknown(self) -> None:
|
||||
assert is_known_liquid_name("") is False
|
||||
|
||||
def test_none_is_unknown(self) -> None:
|
||||
assert is_known_liquid_name(None) is False
|
||||
|
||||
def test_literal_unknown_is_unknown(self) -> None:
|
||||
assert is_known_liquid_name("unknown") is False
|
||||
assert is_known_liquid_name("UNKNOWN") is False
|
||||
assert is_known_liquid_name(" Unknown ") is False
|
||||
|
||||
def test_literal_none_string_is_unknown(self) -> None:
|
||||
assert is_known_liquid_name("none") is False
|
||||
assert is_known_liquid_name("None") is False
|
||||
|
||||
def test_real_liquid_name_is_known(self) -> None:
|
||||
assert is_known_liquid_name("PBS") is True
|
||||
assert is_known_liquid_name("Tris HCl") is True
|
||||
assert is_known_liquid_name("Liquid_3") is True
|
||||
|
||||
|
||||
class TestSameLiquidViaLiquids:
|
||||
def test_well_and_tip_same_name_match(self) -> None:
|
||||
well = make_well("A1", "PBS")
|
||||
assert same_liquid_via_liquids(well, "PBS") is True
|
||||
|
||||
def test_well_and_tip_different_names_no_match(self) -> None:
|
||||
well = make_well("A1", "PBS")
|
||||
assert same_liquid_via_liquids(well, "Tris HCl") is False
|
||||
|
||||
def test_tip_unknown_returns_false(self) -> None:
|
||||
well = make_well("A1", "PBS")
|
||||
assert same_liquid_via_liquids(well, None) is False
|
||||
assert same_liquid_via_liquids(well, "") is False
|
||||
assert same_liquid_via_liquids(well, "unknown") is False
|
||||
|
||||
def test_well_empty_liquids_returns_false(self) -> None:
|
||||
well = make_well("A1", liquid_name=None) # 不写 liquids
|
||||
assert same_liquid_via_liquids(well, "PBS") is False
|
||||
|
||||
def test_well_unknown_literal_returns_false(self) -> None:
|
||||
well = make_well("A1", "unknown")
|
||||
assert same_liquid_via_liquids(well, "unknown") is False
|
||||
|
||||
|
||||
class TestSameLiquidViaLiquidsPair:
|
||||
def test_two_wells_same_name_match(self) -> None:
|
||||
a = make_well("A1", "PBS")
|
||||
b = make_well("B1", "PBS")
|
||||
assert same_liquid_via_liquids_pair(a, b) is True
|
||||
|
||||
def test_two_wells_different_names_no_match(self) -> None:
|
||||
a = make_well("A1", "PBS")
|
||||
b = make_well("B1", "Tris HCl")
|
||||
assert same_liquid_via_liquids_pair(a, b) is False
|
||||
|
||||
def test_either_well_empty_returns_false(self) -> None:
|
||||
a = make_well("A1", "PBS")
|
||||
b = make_well("B1", liquid_name=None)
|
||||
assert same_liquid_via_liquids_pair(a, b) is False
|
||||
assert same_liquid_via_liquids_pair(b, a) is False
|
||||
|
||||
|
||||
class TestCaptureTipLiquidName:
|
||||
def test_known_name_returned(self) -> None:
|
||||
well = make_well("A1", "PBS")
|
||||
assert capture_tip_liquid_name(well) == "PBS"
|
||||
|
||||
def test_empty_well_returns_none(self) -> None:
|
||||
well = make_well("A1", liquid_name=None)
|
||||
assert capture_tip_liquid_name(well) is None
|
||||
|
||||
def test_unknown_literal_returns_none(self) -> None:
|
||||
well = make_well("A1", "unknown")
|
||||
assert capture_tip_liquid_name(well) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1–T12 端到端测试(单通道 transfer_liquid 主循环)
|
||||
#
|
||||
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
|
||||
# 若 PLR import 失败则整段 skip,helper 测试照常运行。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_skip_if_no_plr = pytest.mark.skipif(
|
||||
not _PLR_AVAILABLE,
|
||||
reason=f"pylabrobot import failed: {_PLR_IMPORT_ERROR}",
|
||||
)
|
||||
|
||||
|
||||
@_skip_if_no_plr
|
||||
class TestSingleChannelTipReuse:
|
||||
"""覆盖 §5 矩阵 T1 / T2 / T3 / T4 / T5 / T6 / T8 / T10 / T11。"""
|
||||
|
||||
def test_T1_identity_hit_reuses_tip(self) -> None:
|
||||
"""T1:连续 2 轮同 source/target → identity-keep 命中,复用 tip。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
src = make_well("S0", "PBS")
|
||||
tgt = make_well("T0")
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[src, src],
|
||||
targets=[tgt, tgt],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1],
|
||||
)
|
||||
)
|
||||
# 2 次 transfer,但 identity-keep → 仅 1 次 pick_up_tips / 1 次 discard_tips
|
||||
assert call_names(lh).count("pick_up_tips") == 1
|
||||
assert call_names(lh).count("discard_tips") == 1
|
||||
assert call_names(lh).count("aspirate") == 2
|
||||
assert call_names(lh).count("dispense") == 2
|
||||
|
||||
def test_T2_liquids_hit_across_plates(self) -> None:
|
||||
"""T2:9 个独立 source well(不同 PLR Well 对象)都装 PBS → identity 全 fail,liquids-keep 全命中。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
||||
targets = [make_well(f"T{i}") for i in range(9)]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
)
|
||||
)
|
||||
# 9 个 source 物理上同液 → 整段共用 1 个 tip
|
||||
assert call_names(lh).count("pick_up_tips") == 1
|
||||
assert call_names(lh).count("discard_tips") == 1
|
||||
assert call_names(lh).count("aspirate") == 9
|
||||
assert call_names(lh).count("dispense") == 9
|
||||
|
||||
def test_T3_liquids_hit_same_plate_different_wells(self) -> None:
|
||||
"""T3:同 plate 上 A1-H1 都装 PBS(8 个不同 Well 对象)→ identity 全 fail,liquids-keep 命中。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
sources = [make_well(f"A{i}", "PBS") for i in range(1, 9)]
|
||||
targets = [make_well(f"T{i}") for i in range(8)]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1] * 8,
|
||||
dis_vols=[1] * 8,
|
||||
)
|
||||
)
|
||||
assert call_names(lh).count("pick_up_tips") == 1
|
||||
assert call_names(lh).count("discard_tips") == 1
|
||||
|
||||
def test_T4_liquids_not_match_forces_tip_change(self) -> None:
|
||||
"""T4:A1=PBS,B1=Tris HCl → liquids 名不等,强制换 tip。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
sources = [make_well("A1", "PBS"), make_well("B1", "Tris HCl")]
|
||||
targets = [make_well("T0"), make_well("T1")]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1],
|
||||
)
|
||||
)
|
||||
# 2 次完全独立的 transfer:2 次 pick_up / 2 次 discard
|
||||
assert call_names(lh).count("pick_up_tips") == 2
|
||||
assert call_names(lh).count("discard_tips") == 2
|
||||
|
||||
def test_T5_empty_liquids_forces_tip_change(self) -> None:
|
||||
"""T5:source 从未调过 set_liquids(liquids 空)→ 视为未知,强制换 tip。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
sources = [make_well("A1"), make_well("B1")] # 没装液体名
|
||||
targets = [make_well("T0"), make_well("T1")]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1],
|
||||
)
|
||||
)
|
||||
assert call_names(lh).count("pick_up_tips") == 2
|
||||
assert call_names(lh).count("discard_tips") == 2
|
||||
|
||||
def test_T6_switch_off_disables_liquids_keep(self) -> None:
|
||||
"""T6:tip_reuse_by_liquid_name=False,T2 场景退化为 identity-only,强制换 tip。"""
|
||||
lh = FakeLiquidHandler(channel_num=1, tip_reuse_by_liquid_name=False)
|
||||
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
||||
targets = [make_well(f"T{i}") for i in range(9)]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
)
|
||||
)
|
||||
# 关闭开关后 → 退化为 identity-only,9 次独立换 tip
|
||||
assert call_names(lh).count("pick_up_tips") == 9
|
||||
assert call_names(lh).count("discard_tips") == 9
|
||||
|
||||
def test_T8_mix_style_same_source_reuses_via_identity(self) -> None:
|
||||
"""T8:单 source 反复 aspirate/dispense → identity-keep 命中(mix-style)。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
src = make_well("S0", "Methanol")
|
||||
tgt = make_well("T0")
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[src, src, src],
|
||||
targets=[tgt, tgt, tgt],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
)
|
||||
)
|
||||
assert call_names(lh).count("pick_up_tips") == 1
|
||||
assert call_names(lh).count("discard_tips") == 1
|
||||
|
||||
def test_T10_unknown_literal_treated_as_unknown(self) -> None:
|
||||
"""T10:``tracker.liquids = [("unknown", v)]``(兼容旧数据)→ 视为未知,强制换 tip。"""
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
sources = [make_well("A1", "unknown"), make_well("B1", "unknown")]
|
||||
targets = [make_well("T0"), make_well("T1")]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1],
|
||||
)
|
||||
)
|
||||
assert call_names(lh).count("pick_up_tips") == 2
|
||||
assert call_names(lh).count("discard_tips") == 2
|
||||
|
||||
def test_T11_aspirate_pop_timing_pre_read(self) -> None:
|
||||
"""T11:aspirate 顶层归零 → PLR pop ``tracker.liquids`` 顶层;
|
||||
验证 P10 v2 ``pending_tip_name`` 必须在 aspirate **之前**预读才能命中下一轮。
|
||||
"""
|
||||
lh = AspiratePopFakeLiquidHandler(channel_num=1)
|
||||
sources = [make_well(f"S{i}", "PBS") for i in range(3)]
|
||||
targets = [make_well(f"T{i}") for i in range(3)]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1] * 3,
|
||||
dis_vols=[1] * 3,
|
||||
)
|
||||
)
|
||||
# 即使 aspirate 后 source.tracker.liquids 被 pop,pending_tip_name 已捕获 "PBS"
|
||||
# → 下一轮 source 仍是 PBS(aspirate 还没发生),liquids-keep 命中
|
||||
# → 整段 1 次 pick_up_tips
|
||||
assert call_names(lh).count("pick_up_tips") == 1
|
||||
assert call_names(lh).count("discard_tips") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T7:跨节点边界(两个独立 transfer_liquid 调用,状态隔离)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_if_no_plr
|
||||
class TestCrossNodeBoundary:
|
||||
"""T7:两个 transfer_liquid 节点之间不复用 tip(每次调用初始化 current_tip_liquid_name=None)。"""
|
||||
|
||||
def test_T7_two_calls_dont_share_tip_state(self) -> None:
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
src_a = make_well("A_src", "PBS")
|
||||
tgt_a = make_well("A_tgt")
|
||||
src_b = make_well("B_src", "PBS") # 同名液,但不同 well
|
||||
tgt_b = make_well("B_tgt")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[src_a],
|
||||
targets=[tgt_a],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1],
|
||||
dis_vols=[1],
|
||||
)
|
||||
)
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[src_b],
|
||||
targets=[tgt_b],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1],
|
||||
dis_vols=[1],
|
||||
)
|
||||
)
|
||||
# 两次调用各自独立换 tip → 2 次 pick_up_tips / 2 次 discard_tips
|
||||
assert call_names(lh).count("pick_up_tips") == 2
|
||||
assert call_names(lh).count("discard_tips") == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T9:8 通道段锚孔 liquids-keep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_if_no_plr
|
||||
class TestEightChannelSegmentTipReuse:
|
||||
"""T9:8 通道分段,连续两段 src_slice[0] 同名 → 段间不换 tip。"""
|
||||
|
||||
def test_T9_two_segments_same_anchor_liquid(self) -> None:
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
# 16 个 source wells,分 2 段;段 1 锚孔 = sources[0],段 2 锚孔 = sources[8]
|
||||
sources = [make_well(f"S{i}", "PBS") for i in range(16)]
|
||||
targets = [make_well(f"T{i}") for i in range(16)]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 16,
|
||||
dis_vols=[1] * 16,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
# 2 段都同液 → liquids-keep 命中 → 仅 1 次 pick_up_tips
|
||||
assert call_names(lh).count("pick_up_tips") == 1
|
||||
assert call_names(lh).count("discard_tips") == 1
|
||||
|
||||
def test_T9b_two_segments_different_anchor_liquid_forces_tip_change(self) -> None:
|
||||
"""T9b:段 1 锚孔 = PBS,段 2 锚孔 = Tris → 段间强制换 tip。"""
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
seg1 = [make_well(f"S{i}", "PBS") for i in range(8)]
|
||||
seg2 = [make_well(f"S{i + 8}", "Tris HCl") for i in range(8)]
|
||||
sources = seg1 + seg2
|
||||
targets = [make_well(f"T{i}") for i in range(16)]
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 16,
|
||||
dis_vols=[1] * 16,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
# 2 段不同液 → 2 次独立换 tip
|
||||
assert call_names(lh).count("pick_up_tips") == 2
|
||||
assert call_names(lh).count("discard_tips") == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置开关默认值 / 实例字段读取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_if_no_plr
|
||||
class TestConfigDefault:
|
||||
def test_default_switch_is_on(self) -> None:
|
||||
"""默认 ``_tip_reuse_by_liquid_name`` 应为 True(测试 fixture 显式 default 一致)。"""
|
||||
lh = FakeLiquidHandler()
|
||||
assert lh._tip_reuse_by_liquid_name is True
|
||||
|
||||
def test_switch_off_takes_effect(self) -> None:
|
||||
lh = FakeLiquidHandler(tip_reuse_by_liquid_name=False)
|
||||
assert lh._tip_reuse_by_liquid_name is False
|
||||
510
tests/devices/liquid_handling/test_transfer_liquid.py
Normal file
@@ -0,0 +1,510 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
async def mix(self, targets, mix_time=None, mix_vol=None, height_to_bottom=None, offsets=None, mix_rate=None, none_keys=None):
|
||||
self.calls.append(
|
||||
(
|
||||
"mix",
|
||||
{
|
||||
"targets": targets,
|
||||
"mix_time": mix_time,
|
||||
"mix_vol": mix_vol,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
names = [name for name, _ in lh.calls]
|
||||
assert names.count("mix") == 1
|
||||
assert names.index("mix") < names.index("aspirate")
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
names = [name for name, _ in lh.calls]
|
||||
assert names[0] == "mix"
|
||||
assert names.count("mix") == 1
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
608
tests/devices/liquid_handling/unit_test.py
Normal file
@@ -0,0 +1,608 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
# transfer_liquid 总会调用 set_tiprack;测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 2
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [3]
|
||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||
assert transfer_payload["resources"] == [source]
|
||||
assert mix_idx < transfer_idx
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 1
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [4]
|
||||
# 第一個 mix 之後會真正開始吸 source
|
||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mix_single_target_produces_matching_cycles():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
target = DummyContainer("T_mix")
|
||||
|
||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
assert all(call["resources"] == [target] for call in aspirates)
|
||||
assert all(call["vols"] == [5] for call in aspirates)
|
||||
assert all(call["resources"] == [target] for call in dispenses)
|
||||
assert all(call["vols"] == [5] for call in dispenses)
|
||||
|
||||
|
||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||
offsets = ["left", "right"]
|
||||
heights = [0.1, 0.2]
|
||||
rates = [0.5, 1.0]
|
||||
|
||||
run(
|
||||
lh.mix(
|
||||
targets=targets,
|
||||
mix_time=1,
|
||||
mix_vol=3,
|
||||
offsets=offsets,
|
||||
height_to_bottom=heights,
|
||||
mix_rate=rates,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 2
|
||||
assert aspirates[0]["resources"] == [targets[0]]
|
||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||
assert aspirates[1]["resources"] == [targets[1]]
|
||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||
|
||||
|
||||
def test_set_tiprack_per_type_resumes_first_physical_rack():
|
||||
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
|
||||
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
|
||||
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
|
||||
|
||||
mk = lambda: Tip(
|
||||
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
|
||||
)
|
||||
|
||||
class TipTypeAlpha(TipRack):
|
||||
pass
|
||||
|
||||
class TipTypeBeta(TipRack):
|
||||
pass
|
||||
|
||||
def make_rack(cls: type, name: str) -> TipRack:
|
||||
items = create_equally_spaced(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=2,
|
||||
dx=0,
|
||||
dy=0,
|
||||
dz=0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
make_tip=mk,
|
||||
)
|
||||
return cls(name, 120, 40, 10, items=items)
|
||||
|
||||
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
|
||||
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
|
||||
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
|
||||
|
||||
lh = LiquidHandlerAbstract(
|
||||
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
|
||||
)
|
||||
flat1 = lh._flatten_tips_from_one(rack1)
|
||||
assert len(flat1) == 24
|
||||
|
||||
lh.set_tiprack([rack1])
|
||||
for i in range(12):
|
||||
assert lh._get_next_tip() is flat1[i]
|
||||
|
||||
lh.set_tiprack([rack2])
|
||||
spot_b = lh._get_next_tip()
|
||||
assert "rack_phys_2" in spot_b.name
|
||||
|
||||
lh.set_tiprack([rack3])
|
||||
spot_resume = lh._get_next_tip()
|
||||
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
|
||||
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]
|
||||
|
||||
|
||||
137
tests/resources/test_resource_tracker_history.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""P9 — ``_augment_states_with_liquid_history`` 单元测试(OS→Cloud sync 链路 Phase C)。
|
||||
|
||||
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3 / §8 T4。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.resources.resource_tracker import _augment_states_with_liquid_history
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures:纯 dataclass 模拟 PLR 资源树(避免引入 PLR 真实实例化)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTracker:
|
||||
liquid_history: Any = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeResource:
|
||||
name: str
|
||||
tracker: Any = None
|
||||
children: List["FakeResource"] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAugmentStatesWithLiquidHistory:
|
||||
def test_single_well_history_attached(self) -> None:
|
||||
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
|
||||
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||
]))
|
||||
states: Dict[str, Any] = {"well_A1": {"liquids": [], "pending_liquids": []}}
|
||||
|
||||
_augment_states_with_liquid_history(well, states)
|
||||
|
||||
assert "liquid_history" in states["well_A1"]
|
||||
assert states["well_A1"]["liquid_history"] == [
|
||||
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||
]
|
||||
|
||||
def test_recursive_walk_attaches_to_all_wells(self) -> None:
|
||||
"""resource 树有多层时,每个有 tracker 的节点都会被并入 states。"""
|
||||
wells = [
|
||||
FakeResource(f"well_{i}", tracker=FakeTracker(liquid_history=[
|
||||
{"name": f"L_{i}", "volume": i * 10, "action": "set"}
|
||||
]))
|
||||
for i in range(3)
|
||||
]
|
||||
plate = FakeResource("plate", children=wells)
|
||||
deck = FakeResource("deck", children=[plate])
|
||||
states: Dict[str, Any] = {
|
||||
"deck": {"liquids": []},
|
||||
"plate": {"liquids": []},
|
||||
"well_0": {"liquids": []},
|
||||
"well_1": {"liquids": []},
|
||||
"well_2": {"liquids": []},
|
||||
}
|
||||
|
||||
_augment_states_with_liquid_history(deck, states)
|
||||
|
||||
assert states["well_0"]["liquid_history"] == [{"name": "L_0", "volume": 0, "action": "set"}]
|
||||
assert states["well_1"]["liquid_history"] == [{"name": "L_1", "volume": 10, "action": "set"}]
|
||||
assert states["well_2"]["liquid_history"] == [{"name": "L_2", "volume": 20, "action": "set"}]
|
||||
|
||||
def test_no_tracker_node_skipped(self) -> None:
|
||||
"""没有 tracker 的节点(如 deck 自身)跳过,state dict 不被污染。"""
|
||||
deck = FakeResource("deck") # tracker=None
|
||||
states: Dict[str, Any] = {"deck": {"some_field": 1}}
|
||||
|
||||
_augment_states_with_liquid_history(deck, states)
|
||||
|
||||
assert "liquid_history" not in states["deck"]
|
||||
|
||||
def test_existing_liquid_history_in_state_not_overwritten(self) -> None:
|
||||
"""state 已经有 liquid_history 字段(例如 PLR 升级未来支持了)→ 不覆盖。"""
|
||||
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
|
||||
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||
]))
|
||||
states: Dict[str, Any] = {"well_A1": {"liquid_history": ["preexisting"]}}
|
||||
|
||||
_augment_states_with_liquid_history(well, states)
|
||||
|
||||
assert states["well_A1"]["liquid_history"] == ["preexisting"]
|
||||
|
||||
def test_history_is_shallow_copied(self) -> None:
|
||||
"""augment 后的 history 应是独立 list(避免运行时 mutate 污染 dump 结果)。"""
|
||||
original_history = [{"name": "X", "volume": 1, "action": "set"}]
|
||||
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=original_history))
|
||||
states: Dict[str, Any] = {"well_A1": {}}
|
||||
|
||||
_augment_states_with_liquid_history(well, states)
|
||||
|
||||
# mutate runtime history 不应反映到 augmented state
|
||||
original_history.append({"name": "Y", "volume": 2, "action": "set"})
|
||||
assert len(states["well_A1"]["liquid_history"]) == 1
|
||||
|
||||
def test_node_not_in_states_silently_skipped(self) -> None:
|
||||
"""resource 树中的节点 name 不在 ``states`` 字典里 → 静默跳过。"""
|
||||
well = FakeResource("well_orphan", tracker=FakeTracker(liquid_history=[
|
||||
{"name": "X", "volume": 1, "action": "set"}
|
||||
]))
|
||||
states: Dict[str, Any] = {"well_A1": {}}
|
||||
|
||||
_augment_states_with_liquid_history(well, states)
|
||||
|
||||
# 不应该新增 well_orphan 键,也不应污染 well_A1
|
||||
assert "well_orphan" not in states
|
||||
assert "liquid_history" not in states["well_A1"]
|
||||
|
||||
def test_non_list_liquid_history_skipped(self) -> None:
|
||||
"""tracker.liquid_history 非 list 时(异常情况)→ 跳过,不写入 state。"""
|
||||
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history="broken"))
|
||||
states: Dict[str, Any] = {"well_A1": {}}
|
||||
|
||||
_augment_states_with_liquid_history(well, states)
|
||||
|
||||
assert "liquid_history" not in states["well_A1"]
|
||||
|
||||
def test_empty_history_still_written(self) -> None:
|
||||
"""tracker.liquid_history = [] 是合法状态 → 应写入空 list(表示"未有任何液体操作")。"""
|
||||
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[]))
|
||||
states: Dict[str, Any] = {"well_A1": {}}
|
||||
|
||||
_augment_states_with_liquid_history(well, states)
|
||||
|
||||
assert states["well_A1"]["liquid_history"] == []
|
||||
@@ -2,9 +2,8 @@ import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
from pylabrobot.resources import Resource as ResourcePLR
|
||||
from unilabos.resources.graphio import resource_bioyond_to_plr
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
@@ -11,10 +11,10 @@ import os
|
||||
# 添加项目根目录到路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
# 导入测试模块
|
||||
from test.ros.msgs.test_basic import TestBasicFunctionality
|
||||
from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
||||
from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
||||
# 导入测试模块(统一从 tests 包获取)
|
||||
from tests.ros.msgs.test_basic import TestBasicFunctionality
|
||||
from tests.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
||||
from tests.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
||||
|
||||
|
||||
def run_tests():
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
35
tests/workflow/merge_workflow.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
graph = convert_from_json(data_path, 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)
|
||||
213
tests/workflow/test.json
Normal file
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"workflow": [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_1",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_4",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_5",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_6",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"reagent": {
|
||||
"Liquid_1": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_4": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"dest_set": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_5": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"dest_set_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"Liquid_6": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"dest_set_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"cell_lines": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_2": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_3": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
}
|
||||
}
|
||||
}
|
||||
351
tests/workflow/test_build_protocol_graph_target_device.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""P6.1 / P6.1.1 `build_protocol_graph` 集成测试 —— 对应 06-labware-mapping-table.md §11.7.7 C / §11.8.7 C。
|
||||
|
||||
6 条用例:
|
||||
|
||||
- `test_build_graph_default_target_device_prcxi` —— 不传 target_device 时默认 "prcxi",
|
||||
与 P6 等价(PRCXI_* class_name)。
|
||||
- `test_build_graph_explicit_target_device_prcxi` —— 显式 "prcxi" 与默认完全等价。
|
||||
- `test_build_graph_target_device_unknown_falls_back_to_default_section` —— 未声明的
|
||||
target_device 由 loader 自动 fallback 到 ``target_devices.default``;第一版 default
|
||||
段按 prcxi 拷贝,所以结果应与 "prcxi" 完全一致。
|
||||
- `test_build_graph_per_device_tip_class` —— 临时 YAML 同时声明 prcxi 与 beckman tip
|
||||
量程档;同一 transfer_liquid 在 target_device="prcxi" / "beckman" 下命中不同 class。
|
||||
- `test_field_renamed_target_class_name` —— `labware_info` 写入的字段是
|
||||
`target_class_name`,**旧字段 `prcxi_class_name` 不存在**。
|
||||
- `test_build_graph_model_level_slot_remap` —— P6.1.1:``target_model`` 透传到
|
||||
``_map_deck_slot`` 后改变 create_resource 的 slot(同厂商不同型号 deck 物理布局不同)。
|
||||
|
||||
本测试在导入 common.py 之前 mock 掉 matplotlib / networkx.drawing.nx_agraph,避免在
|
||||
没有图形依赖的最小 Python 环境下也能跑(与 P6 批量回归脚本同样的策略)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _install_fake_optional_deps() -> None:
|
||||
"""安装 matplotlib / networkx.drawing.nx_agraph 的 fake 实现,避免本地环境硬依赖。
|
||||
|
||||
common.py 在模块级 import 这些库做可视化辅助;build_protocol_graph 主路径不会真用到。
|
||||
fake 模块只需要满足 ``from X import Y`` 的查找即可。
|
||||
"""
|
||||
if "matplotlib" not in sys.modules:
|
||||
fake_matplotlib = types.ModuleType("matplotlib")
|
||||
sys.modules["matplotlib"] = fake_matplotlib
|
||||
if "matplotlib.pyplot" not in sys.modules:
|
||||
fake_plt = types.ModuleType("matplotlib.pyplot")
|
||||
sys.modules["matplotlib.pyplot"] = fake_plt
|
||||
# networkx.drawing.nx_agraph.to_agraph 依赖 pygraphviz;不可用时给个空 stub
|
||||
try:
|
||||
from networkx.drawing import nx_agraph # noqa: F401
|
||||
except Exception:
|
||||
nx_drawing = types.ModuleType("networkx.drawing")
|
||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||
|
||||
def _to_agraph(_g): # type: ignore[no-untyped-def]
|
||||
raise RuntimeError("nx_agraph fake — not used in build_protocol_graph main path")
|
||||
|
||||
nx_agraph_mod.to_agraph = _to_agraph # type: ignore[attr-defined]
|
||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||
sys.modules["networkx.drawing"] = nx_drawing
|
||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||
|
||||
|
||||
_install_fake_optional_deps()
|
||||
|
||||
from unilabos.workflow import labware_mapping as lm # noqa: E402
|
||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_mapping_cache():
|
||||
"""每个用例后清 lru_cache,避免跨用例污染。"""
|
||||
yield
|
||||
lm.reload_mapping()
|
||||
|
||||
|
||||
# ==================== 公共 fixture:最小 transfer_liquid 协议 ====================
|
||||
|
||||
|
||||
def _minimal_labware_info() -> dict:
|
||||
"""返回最小可用的 labware_info(mutable,每个 case 独立 build 一份)。
|
||||
|
||||
包含 tip rack + 24-tube rack + 96 wellplate(slot 1/2/3),覆盖 P6.1 主要 kind。
|
||||
tube rack / plate 显式声明 ``num_wells``,避免在无 labware_defs / 无 prcxi_labware 模板
|
||||
时通过 well-count 启发式(well_n=3)误判孔数;与真实协议中 labware_defs 提供 num_wells
|
||||
的行为对齐。
|
||||
"""
|
||||
return {
|
||||
"tips": {
|
||||
"slot": 1,
|
||||
"well": [],
|
||||
"labware": "opentrons_96_tiprack_300ul",
|
||||
"object": "tiprack",
|
||||
},
|
||||
"samples": {
|
||||
"slot": 2,
|
||||
"well": ["A1", "A2", "A3"],
|
||||
"labware": "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap",
|
||||
"object": "source",
|
||||
"num_wells": 24,
|
||||
},
|
||||
"plate_target": {
|
||||
"slot": 3,
|
||||
"well": ["A1", "A2", "A3"],
|
||||
"labware": "opentrons_96_wellplate_300ul_pcr",
|
||||
"object": "target",
|
||||
"num_wells": 96,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _minimal_protocol_steps() -> list:
|
||||
"""最小 transfer_liquid 协议步骤:asp_vols/dis_vols 最大 200 µL → PRCXI 300ul 档。"""
|
||||
return [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "samples",
|
||||
"targets": "plate_target",
|
||||
"tip_racks": "tips",
|
||||
"asp_vols": [200.0, 200.0, 200.0],
|
||||
"dis_vols": [200.0, 200.0, 200.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _collect_create_resource_classes(graph) -> dict:
|
||||
"""从工作流图中提取每个 create_resource 节点的 ``slot_on_deck → class_name``。"""
|
||||
out: dict = {}
|
||||
for _nid, node in graph.nodes.items():
|
||||
if node.get("template_name") != "create_resource":
|
||||
continue
|
||||
param = node.get("param") or {}
|
||||
slot = str(param.get("slot_on_deck") or "")
|
||||
cls = str(param.get("class_name") or "")
|
||||
if slot:
|
||||
out[slot] = cls
|
||||
return out
|
||||
|
||||
|
||||
# ==================== 5 条核心用例 ====================
|
||||
|
||||
|
||||
def test_build_graph_default_target_device_prcxi():
|
||||
"""不传 target_device → 默认 "prcxi" → 与 P6 等价(PRCXI_* class_name)。"""
|
||||
labware_info = _minimal_labware_info()
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
classes = _collect_create_resource_classes(g)
|
||||
assert classes["1"] == "PRCXI_300ul_Tips" # 200 µL → 300 档
|
||||
assert classes["2"] == "PRCXI_EP_Adapter" # 24-tube rack
|
||||
assert classes["3"] == "PRCXI_BioER_96_wellplate" # 96 wellplate
|
||||
|
||||
|
||||
def test_build_graph_explicit_target_device_prcxi():
|
||||
"""显式传 target_device="prcxi" 应与默认完全等价。"""
|
||||
labware_info_a = _minimal_labware_info()
|
||||
labware_info_b = _minimal_labware_info()
|
||||
g_default = build_protocol_graph(
|
||||
labware_info=labware_info_a,
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
g_prcxi = build_protocol_graph(
|
||||
labware_info=labware_info_b,
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
target_device="prcxi",
|
||||
)
|
||||
assert _collect_create_resource_classes(g_default) == _collect_create_resource_classes(g_prcxi)
|
||||
|
||||
|
||||
def test_build_graph_target_device_unknown_falls_back_to_default_section():
|
||||
"""未声明的 target_device → loader 自动 fallback 到固定段 target_devices.default + warning。
|
||||
|
||||
第一版 default 段按 prcxi 拷贝填充 → 结果应与 target_device="prcxi" 完全等价(PRCXI_*)。
|
||||
"""
|
||||
labware_info_a = _minimal_labware_info()
|
||||
labware_info_b = _minimal_labware_info()
|
||||
g_prcxi = build_protocol_graph(
|
||||
labware_info=labware_info_a,
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
target_device="prcxi",
|
||||
)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
g_unknown = build_protocol_graph(
|
||||
labware_info=labware_info_b,
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
target_device="unknown_xxx",
|
||||
)
|
||||
assert _collect_create_resource_classes(g_unknown) == _collect_create_resource_classes(g_prcxi)
|
||||
# loader 至少打 1 次 warning 提示「未声明、已回退到 default」
|
||||
assert any(
|
||||
("未在 labware_mapping.yaml" in str(w.message))
|
||||
or ("target_devices.default" in str(w.message))
|
||||
for w in caught
|
||||
)
|
||||
|
||||
|
||||
def test_build_graph_per_device_tip_class(tmp_path, monkeypatch):
|
||||
"""同一 protocol,target_device="prcxi" / "beckman" 在 200µL 下命中不同 tip 档(P6.1.1 schema)。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds:\n'
|
||||
' - {pattern: "trash", kind: trash}\n'
|
||||
' - {pattern: "tiprack|tip[_ ]?rack|opentrons_\\\\d+_tiprack", kind: tip_rack}\n'
|
||||
' - {pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack}\n'
|
||||
' - {pattern: ".*", kind: plate}\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
||||
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
||||
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
||||
' beckman:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n'
|
||||
' - {kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack}\n'
|
||||
' - {kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
|
||||
g_prcxi = build_protocol_graph(
|
||||
labware_info=_minimal_labware_info(),
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
target_device="prcxi",
|
||||
)
|
||||
g_beckman = build_protocol_graph(
|
||||
labware_info=_minimal_labware_info(),
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
target_device="beckman",
|
||||
)
|
||||
|
||||
classes_prcxi = _collect_create_resource_classes(g_prcxi)
|
||||
classes_beckman = _collect_create_resource_classes(g_beckman)
|
||||
|
||||
# 200 µL:prcxi 走 300 档;beckman 200 档已超 → 1000 档
|
||||
assert classes_prcxi["1"] == "PRCXI_300ul_Tips"
|
||||
assert classes_beckman["1"] == "Beckman_1000uL_Tips"
|
||||
# plate / tube rack 也按 target_device 输出对应厂商类
|
||||
assert classes_prcxi["2"] == "PRCXI_EP_Adapter"
|
||||
assert classes_beckman["2"] == "Beckman_24_TubeRack"
|
||||
assert classes_prcxi["3"] == "PRCXI_BioER_96_wellplate"
|
||||
assert classes_beckman["3"] == "Beckman_BioMek_96_wellplate"
|
||||
|
||||
|
||||
def test_field_renamed_target_class_name():
|
||||
"""`labware_info` 写入的字段是 `target_class_name`;旧字段 `prcxi_class_name` 不存在。"""
|
||||
labware_info = _minimal_labware_info()
|
||||
build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=_minimal_protocol_steps(),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
for lid, item in labware_info.items():
|
||||
assert "target_class_name" in item, f"{lid!r} 缺少 target_class_name 字段"
|
||||
assert "prcxi_class_name" not in item, f"{lid!r} 残留了旧字段 prcxi_class_name"
|
||||
assert item["target_class_name"], f"{lid!r} target_class_name 为空"
|
||||
|
||||
|
||||
# ==================== P6.1.1 新增集成测试 ====================
|
||||
|
||||
|
||||
def _labware_info_slot4_plate() -> dict:
|
||||
"""slot=4 的 96 板:用来验证 target_model 透传后 slot_remap 改变 create_resource 的槽位。"""
|
||||
return {
|
||||
"plate_slot4": {
|
||||
"slot": 4,
|
||||
"well": ["A1"],
|
||||
"labware": "opentrons_96_wellplate_300ul_pcr",
|
||||
"object": "target",
|
||||
"num_wells": 96,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_build_graph_model_level_slot_remap(tmp_path, monkeypatch):
|
||||
"""P6.1.1:target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
|
||||
|
||||
YAML 中 prcxi 厂商级 slot_remap 4→13;模型 "4040" 显式覆盖 4→16。
|
||||
同一份 labware_info(slot=4)build 出的两份图,slot_on_deck 应分别为 "13" 与 "16"。
|
||||
"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
||||
' models:\n'
|
||||
' "4040":\n'
|
||||
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
|
||||
g_default = build_protocol_graph(
|
||||
labware_info=_labware_info_slot4_plate(),
|
||||
protocol_steps=[],
|
||||
workstation_name="PRCXI",
|
||||
target_device="prcxi",
|
||||
)
|
||||
g_model_4040 = build_protocol_graph(
|
||||
labware_info=_labware_info_slot4_plate(),
|
||||
protocol_steps=[],
|
||||
workstation_name="PRCXI",
|
||||
target_device="prcxi",
|
||||
target_model="4040",
|
||||
)
|
||||
|
||||
classes_default = _collect_create_resource_classes(g_default)
|
||||
classes_4040 = _collect_create_resource_classes(g_model_4040)
|
||||
|
||||
# 厂商级(无 model)→ slot 4 → "13"
|
||||
assert "13" in classes_default, f"未找到 slot 13,实际生成的 slots: {list(classes_default)}"
|
||||
assert "16" not in classes_default
|
||||
# 模型 4040 → slot 4 → "16"
|
||||
assert "16" in classes_4040, f"未找到 slot 16,实际生成的 slots: {list(classes_4040)}"
|
||||
assert "13" not in classes_4040
|
||||
# class_name 不变(rules 继承厂商级)
|
||||
assert classes_default["13"] == "PRCXI_BioER_96_wellplate"
|
||||
assert classes_4040["16"] == "PRCXI_BioER_96_wellplate"
|
||||
369
tests/workflow/test_common_cross_slot_v2.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""P2 v2 跨 slot transfer_liquid 合并 —— Stage 3 (`workflow/common.py`) 集成测试。
|
||||
|
||||
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.5 step 6.2。
|
||||
|
||||
v2 设计要点(与本测试用例的映射)
|
||||
-----------------------------------
|
||||
当 transfer_liquid 节点 ``params.targets`` 是 ``list[str]`` 时,``build_protocol_graph``
|
||||
在该 transfer_liquid 之前**插入一个 merged ``set_liquid_from_plate`` 节点**:
|
||||
|
||||
- merged 节点的 ``param.wells`` 是按 ``params.targets`` 顺序通过 cursor 拼出来的有序跨板
|
||||
well refs(每个元素是 ``{id, name, parent: reagent_key, type: "well"}``)。
|
||||
- merged 节点接收来自每个涉及 plate 的 ``create_resource`` 节点的多入边
|
||||
(``labware`` → ``wells_identifier``)。
|
||||
- merged 节点的 ``output_wells`` 通过**单条边**连到 transfer_liquid 的 ``targets_identifier``。
|
||||
- transfer_liquid 节点的 ``params.targets`` 被改写为 synthetic key
|
||||
``_merged_targets_<idx>``(runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。
|
||||
|
||||
用例
|
||||
----
|
||||
- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key(51b9a5 主场景)。
|
||||
- ``test_emit_merged_set_liquid_repeat_key`` — 同 reagent_key 重复(同板多孔)。
|
||||
- ``test_emit_merged_set_liquid_mixed`` — 跨板混合 + 同板重复(cursor 推进)。
|
||||
- ``test_emit_merged_set_liquid_8ch`` — 与 P1 multi-channel 复合(8 通道 cross-slot)。
|
||||
- ``test_transfer_liquid_targets_rewrite`` — transfer_liquid 节点改写后只剩 1 条
|
||||
``targets_identifier`` 入边;params.targets 不再是 list。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _install_fake_optional_deps() -> None:
|
||||
"""与 test_build_protocol_graph_target_device.py 一致的可选依赖 stub。"""
|
||||
if "matplotlib" not in sys.modules:
|
||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||
if "matplotlib.pyplot" not in sys.modules:
|
||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||
try:
|
||||
from networkx.drawing import nx_agraph # noqa: F401
|
||||
except Exception:
|
||||
nx_drawing = types.ModuleType("networkx.drawing")
|
||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||
sys.modules["networkx.drawing"] = nx_drawing
|
||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||
|
||||
|
||||
_install_fake_optional_deps()
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||
|
||||
|
||||
# ==================== 测试辅助:从工作流图中提取节点/边 ====================
|
||||
|
||||
|
||||
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"id": nid, **node}
|
||||
for nid, node in graph.nodes.items()
|
||||
if node.get("template_name") == template_name
|
||||
]
|
||||
|
||||
|
||||
def _create_resource_by_slot(graph) -> Dict[str, str]:
|
||||
"""slot_on_deck (str) -> create_resource 节点 ID。"""
|
||||
out: Dict[str, str] = {}
|
||||
for nid, node in graph.nodes.items():
|
||||
if node.get("template_name") == "create_resource":
|
||||
slot = str(node.get("param", {}).get("slot_on_deck") or "")
|
||||
if slot:
|
||||
out[slot] = nid
|
||||
return out
|
||||
|
||||
|
||||
def _edges_to(graph, target_id: str) -> List[Dict[str, Any]]:
|
||||
return [e for e in graph.edges if e["target"] == target_id]
|
||||
|
||||
|
||||
def _edges_from(graph, source_id: str) -> List[Dict[str, Any]]:
|
||||
return [e for e in graph.edges if e["source"] == source_id]
|
||||
|
||||
|
||||
# ==================== fixture:构造跨板 labware + steps ====================
|
||||
|
||||
|
||||
def _cross_slot_labware_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""51b9a5 简化:slot1 source + slot2/3/5/6 target plates + slot12 tip。"""
|
||||
return {
|
||||
"l1": {
|
||||
"slot": 1,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_12_reservoir_15ml",
|
||||
"object": "source",
|
||||
},
|
||||
"plate_slot2": {
|
||||
"slot": 2,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
},
|
||||
"plate_slot3": {
|
||||
"slot": 3,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
},
|
||||
"plate_slot5": {
|
||||
"slot": 5,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
},
|
||||
"plate_slot6": {
|
||||
"slot": 6,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
},
|
||||
"tiprack_12": {
|
||||
"slot": 12,
|
||||
"well": [],
|
||||
"labware": "opentrons_96_tiprack_300ul",
|
||||
"object": "tiprack",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _cross_slot_protocol_steps(targets: List[str], dis_vols: List[float]) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "l1",
|
||||
"targets": targets,
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": dis_vols.copy(),
|
||||
"dis_vols": dis_vols.copy(),
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ==================== 用例 ====================
|
||||
|
||||
|
||||
def test_emit_merged_set_liquid_basic():
|
||||
"""51b9a5 主场景:targets=[A,B,C,D] → 1 merged set_liquid 节点
|
||||
+ 4 条入边(来自 4 个 distinct create_resource)+ 1 条出边(去 transfer_liquid)。
|
||||
"""
|
||||
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
||||
g = build_protocol_graph(
|
||||
labware_info=_cross_slot_labware_info(),
|
||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
set_liquid_nodes = _nodes_by_template(g, "set_liquid_from_plate")
|
||||
merged_nodes = [n for n in set_liquid_nodes if str(n.get("name", "")).startswith("_merged_targets_")]
|
||||
assert len(merged_nodes) == 1, (
|
||||
f"应有且仅有 1 个 merged set_liquid_from_plate 节点(v2 跨板聚合器);"
|
||||
f" 实际找到 {len(merged_nodes)}: {[n.get('name') for n in merged_nodes]}"
|
||||
)
|
||||
merged = merged_nodes[0]
|
||||
merged_id = merged["id"]
|
||||
|
||||
# param.wells:长度 4,每元素的 parent 是对应 reagent_key
|
||||
wells = merged.get("param", {}).get("wells") or []
|
||||
assert len(wells) == 4
|
||||
assert [w["parent"] for w in wells] == targets, "merged.wells 顺序必须严格按 targets 列表"
|
||||
# well 字段映射到 reagent.well[0](都是 "A1")
|
||||
for w, key in zip(wells, targets):
|
||||
assert w["id"].endswith("/A1"), f"well id 应包含 well 名: {w}"
|
||||
assert w["parent"] == key
|
||||
|
||||
# 入边:4 条来自 distinct create_resource 节点(slot 2/3/5/6),target_port=wells_identifier
|
||||
cr_by_slot = _create_resource_by_slot(g)
|
||||
in_edges = _edges_to(g, merged_id)
|
||||
in_sources = {e["source"] for e in in_edges if e.get("target_handle_key") == "wells_identifier"}
|
||||
expected_sources = {cr_by_slot[s] for s in ("2", "3", "5", "6")}
|
||||
assert in_sources == expected_sources, (
|
||||
f"merged 节点应接收 4 个 distinct create_resource 的 wells_identifier 边;"
|
||||
f" 实际 {in_sources} vs 期望 {expected_sources}"
|
||||
)
|
||||
|
||||
# 出边:1 条到 transfer_liquid(targets_identifier)
|
||||
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
||||
assert len(transfer_nodes) == 1
|
||||
transfer_id = transfer_nodes[0]["id"]
|
||||
out_to_transfer = [
|
||||
e for e in _edges_from(g, merged_id)
|
||||
if e["target"] == transfer_id and e.get("target_handle_key") == "targets_identifier"
|
||||
]
|
||||
assert len(out_to_transfer) == 1, (
|
||||
f"merged 节点应向 transfer_liquid.targets_identifier 发出唯一 1 条边;"
|
||||
f" 实际 {len(out_to_transfer)}"
|
||||
)
|
||||
|
||||
|
||||
def test_emit_merged_set_liquid_repeat_key():
|
||||
"""同 reagent_key 重复(同板多孔):targets=[A,A,A] + reagent.A.well=[A1,A2,A3]
|
||||
→ merged.wells 顺序 = [A/A1, A/A2, A/A3](cursor 推进取每个 well)。
|
||||
"""
|
||||
labware = _cross_slot_labware_info()
|
||||
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
||||
|
||||
targets = ["plate_slot2", "plate_slot2", "plate_slot2"]
|
||||
dis_vols = [10.0, 20.0, 30.0]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
merged_nodes = [
|
||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||
]
|
||||
assert len(merged_nodes) == 1
|
||||
wells = merged_nodes[0]["param"]["wells"]
|
||||
assert [w["id"].rsplit("/", 1)[-1] for w in wells] == ["A1", "A2", "A3"], (
|
||||
"cursor 应依次取 reagent.A.well[0/1/2]"
|
||||
)
|
||||
assert all(w["parent"] == "plate_slot2" for w in wells)
|
||||
|
||||
|
||||
def test_emit_merged_set_liquid_mixed():
|
||||
"""跨板 + 同板重复:targets=[A,B,A,C] + reagent.A.well=[A1,A2]
|
||||
→ merged.wells = [A/A1, B/A1, A/A2, C/A1]。
|
||||
"""
|
||||
labware = _cross_slot_labware_info()
|
||||
labware["plate_slot2"]["well"] = ["A1", "A2"]
|
||||
|
||||
targets = ["plate_slot2", "plate_slot3", "plate_slot2", "plate_slot5"]
|
||||
dis_vols = [10.0, 20.0, 30.0, 40.0]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
merged_nodes = [
|
||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||
]
|
||||
assert len(merged_nodes) == 1
|
||||
wells = merged_nodes[0]["param"]["wells"]
|
||||
ids = [(w["parent"], w["id"].rsplit("/", 1)[-1]) for w in wells]
|
||||
assert ids == [
|
||||
("plate_slot2", "A1"),
|
||||
("plate_slot3", "A1"),
|
||||
("plate_slot2", "A2"),
|
||||
("plate_slot5", "A1"),
|
||||
]
|
||||
|
||||
|
||||
def test_emit_merged_set_liquid_8ch():
|
||||
"""与 P1 multi-channel 复合:targets=[A]*8+[B]*8(每列 8 通道)。
|
||||
|
||||
merged.wells 长度 16,前 8 全 plate_slot2 的 8 个 well,后 8 全 plate_slot3 的 8 个 well。
|
||||
"""
|
||||
labware = _cross_slot_labware_info()
|
||||
# 8 通道场景 reagent.well 已被 P1 multi 展开为长度 8
|
||||
labware["plate_slot2"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
||||
labware["plate_slot3"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
||||
|
||||
targets = ["plate_slot2"] * 8 + ["plate_slot3"] * 8
|
||||
dis_vols = [5.0] * 16
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
merged_nodes = [
|
||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||
]
|
||||
assert len(merged_nodes) == 1
|
||||
wells = merged_nodes[0]["param"]["wells"]
|
||||
assert len(wells) == 16
|
||||
# 前 8 全 plate_slot2,后 8 全 plate_slot3(满足 cross-slot × 8ch 列对齐约束)
|
||||
assert all(w["parent"] == "plate_slot2" for w in wells[:8])
|
||||
assert all(w["parent"] == "plate_slot3" for w in wells[8:])
|
||||
# well 名顺序:A1..H1 重复两遍
|
||||
assert [w["id"].rsplit("/", 1)[-1] for w in wells[:8]] == [f"{r}1" for r in "ABCDEFGH"]
|
||||
assert [w["id"].rsplit("/", 1)[-1] for w in wells[8:]] == [f"{r}1" for r in "ABCDEFGH"]
|
||||
|
||||
|
||||
def test_transfer_liquid_targets_rewrite():
|
||||
"""transfer_liquid 节点改写后只剩 1 条 targets_identifier 入边;params.targets 不再是 list。"""
|
||||
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
||||
g = build_protocol_graph(
|
||||
labware_info=_cross_slot_labware_info(),
|
||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
||||
assert len(transfer_nodes) == 1
|
||||
tnode = transfer_nodes[0]
|
||||
transfer_id = tnode["id"]
|
||||
|
||||
# params.targets:v2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
|
||||
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
|
||||
tparams = tnode.get("param", {}) or {}
|
||||
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
|
||||
f"v2:params.targets 不再是非空 list;实际 {tparams.get('targets')!r}"
|
||||
)
|
||||
|
||||
# targets_identifier 端口:只有 1 条入边
|
||||
in_targets_edges = [
|
||||
e for e in _edges_to(g, transfer_id)
|
||||
if e.get("target_handle_key") == "targets_identifier"
|
||||
]
|
||||
assert len(in_targets_edges) == 1, (
|
||||
f"v2:transfer_liquid.targets_identifier 必须是单入边(来自 merged set_liquid);"
|
||||
f" 实际 {len(in_targets_edges)}"
|
||||
)
|
||||
|
||||
# 这条入边的源端口必须是 output_wells
|
||||
edge = in_targets_edges[0]
|
||||
assert edge.get("source_handle_key") == "output_wells"
|
||||
|
||||
|
||||
def test_str_targets_no_merged_node_emitted():
|
||||
"""对照组:targets 为 str(单 reagent) → 不插入 merged set_liquid_from_plate 节点。
|
||||
|
||||
保证 v2 改造**只**对 list 形态触发,单 reagent 走 P3 原有 per-plate set_liquid 路径。
|
||||
"""
|
||||
labware = _cross_slot_labware_info()
|
||||
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
||||
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "l1",
|
||||
"targets": "plate_slot2", # ← 单 str,非 list
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [8.3, 8.3, 8.3],
|
||||
"dis_vols": [8.3, 8.3, 8.3],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
merged_nodes = [
|
||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||
]
|
||||
assert merged_nodes == [], "str 形态 targets 不应触发 v2 merged 聚合节点"
|
||||
452
tests/workflow/test_common_liquid_name_from_reagent.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""P8 — Stage 3 (``workflow/common.py``) 写入 ``set_liquid_from_plate.param.liquid_names`` 时
|
||||
优先取 ``reagent[key].liquid_name``,缺省时 fallback 到 reagent_key。
|
||||
|
||||
对应 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4 + §5。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
- ``reagent[key].liquid_name`` 是 P8 新增的**可选**字段,承载真实化学名(与 reagent_key
|
||||
解耦:reagent_key 仍是数据流引用名 / 业务别名,``liquid_name`` 是写入 PLR tracker /
|
||||
前端的 human-readable 名称)。
|
||||
- ``liquid_name`` 来源优先级:Stage 0 mock ``Well.load_liquid(liquid=...)`` 实参 >
|
||||
README 语义词 > 不写(Stage 3 fallback 到 reagent_key)。
|
||||
- ``liquid_name`` 保留空格 / 中文 / 括号等原字符,**不**做 snake_case / underscore 替换。
|
||||
- 旧 JSON(无 ``liquid_name`` 字段)行为完全不变(设计点 §7.A)。
|
||||
|
||||
测试用例
|
||||
--------
|
||||
- ``test_per_plate_fallback_when_no_liquid_name`` —— 缺省 fallback:
|
||||
reagent 块无 ``liquid_name`` → liquid_names[i] == reagent_key(与 P8 前一致)。
|
||||
- ``test_per_plate_uses_explicit_liquid_name`` —— 显式 liquid_name:
|
||||
liquid_names[i] == "EDTA Plasma"。
|
||||
- ``test_per_plate_preserves_spaces_and_special_chars`` —— 含空格 / 括号:
|
||||
liquid_names[i] 不被 ``replace(" ", "_")`` 处理(不同于 reagent_key 用的 res_id)。
|
||||
- ``test_merged_node_uses_explicit_liquid_name_per_dispense`` —— merged 节点
|
||||
每个 dispense 独立取 ``liquid_name or key``,部分有部分无能共存。
|
||||
- ``test_liquid_name_independent_of_reagent_key_normalization`` —— 与 P4 共存:
|
||||
reagent_key 仍是 ``samples_2`` 等去重后缀,但 liquid_names 写的是真实化学名。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _install_fake_optional_deps() -> None:
|
||||
"""与 test_common_set_liquid_dedup.py 一致的可选依赖 stub。"""
|
||||
if "matplotlib" not in sys.modules:
|
||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||
if "matplotlib.pyplot" not in sys.modules:
|
||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||
try:
|
||||
from networkx.drawing import nx_agraph # noqa: F401
|
||||
except Exception:
|
||||
nx_drawing = types.ModuleType("networkx.drawing")
|
||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||
sys.modules["networkx.drawing"] = nx_drawing
|
||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||
|
||||
|
||||
_install_fake_optional_deps()
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||
|
||||
|
||||
# ==================== 辅助 ====================
|
||||
|
||||
|
||||
def _set_liquid_nodes(graph) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"id": nid, **node}
|
||||
for nid, node in graph.nodes.items()
|
||||
if node.get("template_name") == "set_liquid_from_plate"
|
||||
]
|
||||
|
||||
|
||||
def _per_plate_for(graph, reagent_key: str) -> Dict[str, Any]:
|
||||
"""根据 ``description = "Set liquid: <reagent_key>"`` 反查 per-plate 节点。"""
|
||||
for n in _set_liquid_nodes(graph):
|
||||
if n.get("description") == f"Set liquid: {reagent_key}":
|
||||
return n
|
||||
raise AssertionError(f"未找到 per-plate set_liquid_from_plate(reagent_key={reagent_key!r})")
|
||||
|
||||
|
||||
def _merged_nodes(graph) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
n for n in _set_liquid_nodes(graph)
|
||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||
]
|
||||
|
||||
|
||||
def _make_source_target_labware(
|
||||
*,
|
||||
source_key: str = "src_1",
|
||||
source_liquid_name: str | None = None,
|
||||
target_keys: List[str] | None = None,
|
||||
target_liquid_names: Dict[str, str] | None = None,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""构造 1 个 source + N 个 target reagent + 1 个 tip rack。
|
||||
|
||||
``*_liquid_name`` 为 None / 缺省时**不**写入 ``liquid_name`` 字段,
|
||||
模拟旧 schema / mock 未给 liquid_name 的真实回归场景。
|
||||
"""
|
||||
info: Dict[str, Dict[str, Any]] = {}
|
||||
source_entry: Dict[str, Any] = {
|
||||
"slot": 1,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_12_reservoir_15ml",
|
||||
"object": "source",
|
||||
}
|
||||
if source_liquid_name is not None:
|
||||
source_entry["liquid_name"] = source_liquid_name
|
||||
info[source_key] = source_entry
|
||||
|
||||
target_keys = target_keys or ["t_A"]
|
||||
target_liquid_names = target_liquid_names or {}
|
||||
for i, tk in enumerate(target_keys, start=1):
|
||||
entry: Dict[str, Any] = {
|
||||
"slot": 2 + i,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
}
|
||||
if tk in target_liquid_names:
|
||||
entry["liquid_name"] = target_liquid_names[tk]
|
||||
info[tk] = entry
|
||||
|
||||
info["tiprack_12"] = {
|
||||
"slot": 12,
|
||||
"well": [],
|
||||
"labware": "opentrons_96_tiprack_300ul",
|
||||
"object": "tiprack",
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
# ==================== T1 缺省 fallback ====================
|
||||
|
||||
|
||||
def test_per_plate_fallback_when_no_liquid_name():
|
||||
"""reagent block 无 ``liquid_name`` 字段 → liquid_names[i] == reagent_key(P8 前行为)。"""
|
||||
labware = _make_source_target_labware(
|
||||
source_key="src_1",
|
||||
target_keys=["t_A"],
|
||||
# 都不给 liquid_name
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
src_node = _per_plate_for(g, "src_1")
|
||||
tgt_node = _per_plate_for(g, "t_A")
|
||||
assert src_node["param"]["liquid_names"] == ["src_1"], (
|
||||
f"无 liquid_name 时 source per-plate 应 fallback 到 reagent_key;"
|
||||
f" 实际 {src_node['param']['liquid_names']}"
|
||||
)
|
||||
assert tgt_node["param"]["liquid_names"] == ["t_A"], (
|
||||
f"无 liquid_name 时 target per-plate 应 fallback 到 reagent_key;"
|
||||
f" 实际 {tgt_node['param']['liquid_names']}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== T2 显式 liquid_name ====================
|
||||
|
||||
|
||||
def test_per_plate_uses_explicit_liquid_name():
|
||||
"""reagent block 含 ``liquid_name`` → liquid_names[i] 用该值(不是 reagent_key)。"""
|
||||
labware = _make_source_target_labware(
|
||||
source_key="src_1",
|
||||
source_liquid_name="EDTA Plasma",
|
||||
target_keys=["t_A"],
|
||||
target_liquid_names={"t_A": "PBS Diluent"},
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
src_node = _per_plate_for(g, "src_1")
|
||||
tgt_node = _per_plate_for(g, "t_A")
|
||||
assert src_node["param"]["liquid_names"] == ["EDTA Plasma"], (
|
||||
f"source per-plate 应使用 reagent.liquid_name;实际 {src_node['param']['liquid_names']}"
|
||||
)
|
||||
assert tgt_node["param"]["liquid_names"] == ["PBS Diluent"], (
|
||||
f"target per-plate 应使用 reagent.liquid_name;实际 {tgt_node['param']['liquid_names']}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== T3 空格 / 括号 ====================
|
||||
|
||||
|
||||
def test_per_plate_preserves_spaces_and_special_chars():
|
||||
"""``liquid_name`` 保留空格 / 括号 / 中文等原字符,不被 replace(' ', '_') 处理。
|
||||
|
||||
这条与 reagent_key 走 ``res_id = str(labware_id).replace(' ', '_')`` 的语义不同。
|
||||
"""
|
||||
labware = _make_source_target_labware(
|
||||
source_key="src_1",
|
||||
source_liquid_name="Tris HCl pH 8.0 (1×)",
|
||||
target_keys=["t_A"],
|
||||
target_liquid_names={"t_A": "稀释液 A"},
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
src_node = _per_plate_for(g, "src_1")
|
||||
tgt_node = _per_plate_for(g, "t_A")
|
||||
|
||||
assert src_node["param"]["liquid_names"] == ["Tris HCl pH 8.0 (1×)"], (
|
||||
f"空格 / 括号应原样保留;实际 {src_node['param']['liquid_names']}"
|
||||
)
|
||||
assert tgt_node["param"]["liquid_names"] == ["稀释液 A"], (
|
||||
f"中文应原样保留;实际 {tgt_node['param']['liquid_names']}"
|
||||
)
|
||||
|
||||
# reagent_key 自身仍受 ``res_id = replace(' ', '_')`` 影响,
|
||||
# 但本测试 reagent_key 不含空格,故 sl_node_title 仍以 reagent_key 为根。
|
||||
# 这里仅断言 liquid_names 字段独立于 reagent_key normalize。
|
||||
|
||||
|
||||
# ==================== T4 merged 节点跨板部分有部分无 ====================
|
||||
|
||||
|
||||
def test_merged_node_uses_explicit_liquid_name_per_dispense():
|
||||
"""merged 节点 ``liquid_names`` 与 list-targets 同长,每个元素独立取
|
||||
``reagent[key].liquid_name or key``:本例 3 个 target,2 个有显式名、1 个无。
|
||||
"""
|
||||
labware = _make_source_target_labware(
|
||||
source_key="src_1",
|
||||
target_keys=["t_A", "t_B", "t_C"],
|
||||
target_liquid_names={
|
||||
"t_A": "Plasma",
|
||||
# t_B 无 liquid_name
|
||||
"t_C": "Buffer X",
|
||||
},
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": ["t_A", "t_B", "t_C"],
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [5.0] * 3,
|
||||
"dis_vols": [5.0] * 3,
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
merged = _merged_nodes(g)
|
||||
assert len(merged) == 1, f"应有 1 个 merged 节点,实际 {len(merged)}"
|
||||
liquid_names = merged[0]["param"]["liquid_names"]
|
||||
assert liquid_names == ["Plasma", "t_B", "Buffer X"], (
|
||||
f"merged 每 dispense 独立取 liquid_name or key;实际 {liquid_names}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== T5 与 P4 reagent_key 后缀共存 ====================
|
||||
|
||||
|
||||
def test_liquid_name_independent_of_reagent_key_normalization():
|
||||
"""P4 命名链产生 ``samples_2`` 这种带后缀的 reagent_key(跨板去重);
|
||||
P8 ``liquid_name`` 应保持原始化学名,**不**带 P4 的去重后缀。
|
||||
|
||||
构造:2 个 target reagent_keys ``samples`` / ``samples_2``(不同 slot,
|
||||
模拟跨板同液体被 Stage 2 去重),都标 liquid_name="Bacterial Culture"。
|
||||
"""
|
||||
labware = _make_source_target_labware(
|
||||
source_key="src_1",
|
||||
target_keys=["samples", "samples_2"],
|
||||
target_liquid_names={
|
||||
"samples": "Bacterial Culture",
|
||||
"samples_2": "Bacterial Culture",
|
||||
},
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": ["samples", "samples_2"],
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [5.0, 5.0],
|
||||
"dis_vols": [5.0, 5.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
merged = _merged_nodes(g)
|
||||
assert len(merged) == 1
|
||||
liquid_names = merged[0]["param"]["liquid_names"]
|
||||
assert liquid_names == ["Bacterial Culture", "Bacterial Culture"], (
|
||||
f"P8 liquid_name 应与 P4 reagent_key 后缀解耦:同液体的两个 reagent_key 应得相同"
|
||||
f" liquid_name;实际 {liquid_names}"
|
||||
)
|
||||
# 同时 reagent_key 仍是 samples / samples_2(不变)
|
||||
wells = merged[0]["param"]["wells"]
|
||||
parents = [w["parent"] for w in wells]
|
||||
assert parents == ["samples", "samples_2"], (
|
||||
f"merged wells.parent 应等于 list-targets reagent_keys;实际 {parents}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== T6 source per-plate / target per-plate 同步生效 ====================
|
||||
|
||||
|
||||
def test_both_source_and_target_per_plate_use_liquid_name():
|
||||
"""str-targets 路径(无 merged)下,source 和 target 都走 per-plate emit,
|
||||
各自独立取 ``liquid_name``。"""
|
||||
labware = _make_source_target_labware(
|
||||
source_key="src_1",
|
||||
source_liquid_name="Reagent A",
|
||||
target_keys=["t_A"],
|
||||
target_liquid_names={"t_A": "Reagent B"},
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A", # str-targets,不触发 merged
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
assert _merged_nodes(g) == [], "str-targets 不应产生 merged 节点"
|
||||
src_node = _per_plate_for(g, "src_1")
|
||||
tgt_node = _per_plate_for(g, "t_A")
|
||||
assert src_node["param"]["liquid_names"] == ["Reagent A"]
|
||||
assert tgt_node["param"]["liquid_names"] == ["Reagent B"]
|
||||
|
||||
|
||||
# ==================== T7 多孔同 reagent → 整列 liquid_names 一致 ====================
|
||||
|
||||
|
||||
def test_multi_well_reagent_replicates_liquid_name():
|
||||
"""1 个 reagent 含 8 wells(multi-channel 扩展场景)→ liquid_names 应是
|
||||
``[liquid_name] * 8``,与 wells 长度一致。"""
|
||||
labware: Dict[str, Dict[str, Any]] = {
|
||||
"src_1": {
|
||||
"slot": 1,
|
||||
"well": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
|
||||
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||
"object": "source",
|
||||
"liquid_name": "Mastermix",
|
||||
},
|
||||
"t_A": {
|
||||
"slot": 3,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
},
|
||||
"tiprack_12": {
|
||||
"slot": 12,
|
||||
"well": [],
|
||||
"labware": "opentrons_96_tiprack_300ul",
|
||||
"object": "tiprack",
|
||||
},
|
||||
}
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
src_node = _per_plate_for(g, "src_1")
|
||||
liquid_names = src_node["param"]["liquid_names"]
|
||||
assert liquid_names == ["Mastermix"] * 8, (
|
||||
f"per-plate 应把 liquid_name 复制 well_count 份;实际 {liquid_names}"
|
||||
)
|
||||
# 同时 wells / volumes 长度一致
|
||||
assert len(src_node["param"]["wells"]) == 8
|
||||
assert len(src_node["param"]["volumes"]) == 8
|
||||
174
tests/workflow/test_common_plate_num_children_hint.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""P6 §17 hint bug —— `_infer_plate_num_children_from_labware_hint` 误把
|
||||
reagent_id 末尾数字(如 ``samples_6`` 的 ``_6``)当作孔板规格,导致
|
||||
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板。
|
||||
|
||||
跨板 fix(P2 v2 §14)把 plate name 作为 prefix 编码进 ``well_names`` 之后,
|
||||
runtime 调用 ``plate.get_well("A5")`` 严格定位 well,trough plate 上不存在
|
||||
``A5`` 会直接 IndexError,使得这个隐藏多年的孔数推断 bug 浮出。
|
||||
|
||||
修复策略(方案 A)
|
||||
-----
|
||||
hint 只用 ``item.get("labware", "")``,**不再**拼上 ``labware_id``(reagent_key
|
||||
是业务名,不应参与孔板规格推断)。
|
||||
|
||||
测试矩阵
|
||||
----
|
||||
- ``test_reagent_key_numeric_suffix_must_not_match_hint`` —— samples_6 / samples_24 /
|
||||
samples_96 + nunc_rectangular_agar_plate → hint 返回 None(labware string 不带孔数信息)。
|
||||
- ``test_labware_string_X_well_correctly_inferred`` —— labware="nest_96_wellplate..." → 96;
|
||||
"custom_384_wellplate" → 384;"nest_24_wellplate_2ml_pcr" → 24。
|
||||
- ``test_apply_does_not_classify_samples_6_as_trough`` —— 集成:构造 Agar Plating-like
|
||||
reagent block(slot 8 上 12 个 samples_X,X 末尾含 6/24/96),跑
|
||||
``_apply_target_labware_class_auto_match`` 后,samples_6/24 不再得到 trough class。
|
||||
- ``test_real_labware_96_wellplate_still_inferred_via_labware_str`` —— 即便 labware_id
|
||||
与孔数无关,``nest_96_wellplate_100ul_pcr_full_skirt`` 这种 labware 命名仍应被识别为 96。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _install_fake_optional_deps() -> None:
|
||||
if "matplotlib" not in sys.modules:
|
||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||
if "matplotlib.pyplot" not in sys.modules:
|
||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||
|
||||
|
||||
_install_fake_optional_deps()
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from unilabos.workflow.common import ( # noqa: E402
|
||||
_apply_target_labware_class_auto_match,
|
||||
_infer_plate_num_children_from_labware_hint,
|
||||
_reconcile_slot_carrier_target_class,
|
||||
)
|
||||
|
||||
|
||||
# ==================== unit:hint 函数本身 ====================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"labware_id",
|
||||
["samples_6", "samples_24", "samples_96", "samples_12", "samples_48"],
|
||||
)
|
||||
def test_reagent_key_numeric_suffix_must_not_match_hint(labware_id):
|
||||
"""reagent_id 末尾的孔数关键字数字不应被识别为孔板规格。"""
|
||||
item = {
|
||||
"slot": 8,
|
||||
"well": ["A5"],
|
||||
"labware": "nunc_rectangular_agar_plate",
|
||||
"object": "target",
|
||||
}
|
||||
assert _infer_plate_num_children_from_labware_hint(labware_id, item) is None, (
|
||||
f"reagent_id {labware_id!r} 不应被识别为孔板规格 "
|
||||
f"(其末尾数字应当被忽略;labware string 不含 96/384/etc 关键字)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"labware_str,expected",
|
||||
[
|
||||
("nest_96_wellplate_100ul_pcr_full_skirt", 96),
|
||||
("custom_384_wellplate", 384),
|
||||
("nest_24_wellplate_2ml_pcr", 24),
|
||||
("custom_48_wellplate", 48),
|
||||
("opentrons_12_wellplate_15ml", 12),
|
||||
("nest_6_wellplate_5ml", 6),
|
||||
("nunc_rectangular_agar_plate", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_labware_string_well_count_inferred(labware_str, expected):
|
||||
item = {"labware": labware_str}
|
||||
assert (
|
||||
_infer_plate_num_children_from_labware_hint("samples", item) == expected
|
||||
), f"labware {labware_str!r} 应推断为 {expected!r}"
|
||||
|
||||
|
||||
# ==================== integration:模拟 Agar Plating ====================
|
||||
|
||||
|
||||
def _agar_plating_reagent_block():
|
||||
"""反推自 unilabos_data/req_workflow_upload.json:12 列 × 9 reagent per step。
|
||||
|
||||
slot 8 (mapped 14) 上 12 个 reagent_keys: samples_6, samples_15, samples_24,
|
||||
samples_33, samples_42, samples_51, samples_60, samples_69, samples_78,
|
||||
samples_87, samples_96, samples_105.
|
||||
"""
|
||||
info = {}
|
||||
slot_for_idx = {0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9, 7: 10, 8: 11}
|
||||
cols = [f"A{i + 1}" for i in range(12)]
|
||||
for col_i, col in enumerate(cols):
|
||||
for di in range(9):
|
||||
n = col_i * 9 + di + 1
|
||||
key = "samples" if n == 1 else f"samples_{n}"
|
||||
info[key] = {
|
||||
"slot": slot_for_idx[di],
|
||||
"well": [col],
|
||||
"labware": "nunc_rectangular_agar_plate",
|
||||
"object": "target",
|
||||
}
|
||||
for i in range(12):
|
||||
key = "sources" if i == 0 else f"sources_{i + 1}"
|
||||
info[key] = {
|
||||
"slot": 2,
|
||||
"well": [cols[i]],
|
||||
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||
"object": "source",
|
||||
}
|
||||
info["tiprack_1"] = {
|
||||
"slot": 1,
|
||||
"well": None,
|
||||
"labware": "opentrons_96_tiprack_10ul",
|
||||
"object": "tiprack",
|
||||
}
|
||||
info["trash"] = {
|
||||
"slot": 12,
|
||||
"well": None,
|
||||
"labware": "opentrons_1_trash_1100ml_fixed",
|
||||
"object": "trash",
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def test_apply_does_not_classify_samples_6_as_trough():
|
||||
"""集成回归:Agar Plating-like reagent block 跑完类匹配 + slot 统一后,
|
||||
slot 8 上 12 个 reagent 不应得到 4-孔 trough class。"""
|
||||
info = _agar_plating_reagent_block()
|
||||
_apply_target_labware_class_auto_match(
|
||||
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
||||
)
|
||||
_reconcile_slot_carrier_target_class(
|
||||
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
||||
)
|
||||
slot8_keys = [
|
||||
"samples_6", "samples_15", "samples_24", "samples_33",
|
||||
"samples_42", "samples_51", "samples_60", "samples_69",
|
||||
"samples_78", "samples_87", "samples_96", "samples_105",
|
||||
]
|
||||
for k in slot8_keys:
|
||||
cls = info[k].get("target_class_name") or ""
|
||||
assert "trough" not in cls.lower(), (
|
||||
f"reagent {k} 被误识别为 trough class: {cls!r};"
|
||||
"这通常是 hint 误把 reagent_id 末尾数字当孔板规格"
|
||||
)
|
||||
|
||||
|
||||
def test_real_labware_96_wellplate_still_inferred_via_labware_str():
|
||||
"""labware string 含 96_wellplate 时应该正常识别为 96,不被 fix 破坏。"""
|
||||
item = {
|
||||
"slot": 2,
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||
"object": "source",
|
||||
}
|
||||
assert _infer_plate_num_children_from_labware_hint("sources", item) == 96
|
||||
379
tests/workflow/test_common_set_liquid_dedup.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""P2 v2 §14 set_liquid_from_plate 去重 —— Stage 3 (`workflow/common.py`) 集成测试。
|
||||
|
||||
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §14(2026-05-22 plan)。
|
||||
|
||||
§14 设计要点
|
||||
-----------------
|
||||
当 ``transfer_liquid.params.targets`` 是 ``list[str]`` 时,``_emit_merged_set_liquid``
|
||||
已经为该 transfer 插入一个 merged ``set_liquid_from_plate`` 节点,
|
||||
其 ``param.wells`` 聚合了 list 中所有 reagent_keys 的跨板 wells。
|
||||
|
||||
§14 之前:第二步循环(``for labware_id, item in labware_info.items()``)仍然为
|
||||
list-targets 中出现的每个 reagent_key 创建一个 per-plate ``set_liquid_from_plate`` 节点,
|
||||
导致**节点冗余**(per-plate 节点的 ``output_wells`` 对 transfer_liquid 的
|
||||
``targets_identifier`` 边毫无贡献 —— transfer_liquid 单边只接 merged 节点)。
|
||||
|
||||
§14 改造:在第二步循环**之前**预扫描 protocol_steps,收集
|
||||
``set_liquid_covered_by_merged: Set[str]``(出现在某个 list[str] targets 中的所有 keys)
|
||||
与 ``set_liquid_referenced_by_str: Set[str]``(出现在 str targets 中的所有 keys)。
|
||||
循环内对 ``object="target"`` 且 ``key ∈ covered ∧ key ∉ referenced_by_str`` 的 reagent_key
|
||||
**跳过** per-plate 节点创建。
|
||||
|
||||
测试用例
|
||||
----
|
||||
- ``test_per_plate_skipped_when_covered_by_merged`` —— list-targets 覆盖的
|
||||
target reagent_keys 不再产生 per-plate set_liquid_from_plate。
|
||||
- ``test_per_plate_kept_when_also_referenced_by_str_targets`` —— R1 缓解:
|
||||
同时被 list-targets 和 str-targets 引用的 reagent_key 仍保留 per-plate。
|
||||
- ``test_str_targets_protocol_unaffected`` —— 单 slot 协议(仅 str-targets)
|
||||
节点数完全不变(回归防护)。
|
||||
- ``test_51b9a5_style_node_count`` —— 12 list-targets × len=9 大规模场景:
|
||||
set_liquid_from_plate 总节点数 = source per-plate + merged + 0 target per-plate。
|
||||
- ``test_source_per_plate_always_kept`` —— source 端不受 §14 影响:source
|
||||
reagent_keys 不出现在 targets 字段中,per-plate 节点恒在。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _install_fake_optional_deps() -> None:
|
||||
"""与 test_common_cross_slot_v2.py 一致的可选依赖 stub。"""
|
||||
if "matplotlib" not in sys.modules:
|
||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||
if "matplotlib.pyplot" not in sys.modules:
|
||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||
try:
|
||||
from networkx.drawing import nx_agraph # noqa: F401
|
||||
except Exception:
|
||||
nx_drawing = types.ModuleType("networkx.drawing")
|
||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||
sys.modules["networkx.drawing"] = nx_drawing
|
||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||
|
||||
|
||||
_install_fake_optional_deps()
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||
|
||||
|
||||
# ==================== 辅助 ====================
|
||||
|
||||
|
||||
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"id": nid, **node}
|
||||
for nid, node in graph.nodes.items()
|
||||
if node.get("template_name") == template_name
|
||||
]
|
||||
|
||||
|
||||
def _set_liquid_nodes_split(graph):
|
||||
"""返回 (per_plate_nodes, merged_nodes)。merged 节点 name 以 `_merged_targets_` 开头。"""
|
||||
all_sl = _nodes_by_template(graph, "set_liquid_from_plate")
|
||||
merged = [n for n in all_sl if str(n.get("name", "")).startswith("_merged_targets_")]
|
||||
per_plate = [n for n in all_sl if not str(n.get("name", "")).startswith("_merged_targets_")]
|
||||
return per_plate, merged
|
||||
|
||||
|
||||
def _labware_with_targets(target_keys: List[str], source_keys: List[str] | None = None) -> Dict[str, Dict[str, Any]]:
|
||||
"""构造 labware_info:source 端 1 个 + 任意数量 target plates + tip rack。"""
|
||||
info: Dict[str, Dict[str, Any]] = {}
|
||||
source_keys = source_keys or ["src_1"]
|
||||
for i, sk in enumerate(source_keys, start=1):
|
||||
info[sk] = {
|
||||
"slot": 1 + i - 1, # slot 1 占位(实际可能映射)
|
||||
"well": ["A1"],
|
||||
"labware": "nest_12_reservoir_15ml",
|
||||
"object": "source",
|
||||
}
|
||||
for i, tk in enumerate(target_keys, start=1):
|
||||
info[tk] = {
|
||||
"slot": 2 + i, # 错开 source 使用的 slot
|
||||
"well": ["A1"],
|
||||
"labware": "nest_96_wellplate_2ml_deep",
|
||||
"object": "target",
|
||||
}
|
||||
info["tiprack_12"] = {
|
||||
"slot": 12,
|
||||
"well": [],
|
||||
"labware": "opentrons_96_tiprack_300ul",
|
||||
"object": "tiprack",
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
# ==================== 用例 ====================
|
||||
|
||||
|
||||
def test_per_plate_skipped_when_covered_by_merged():
|
||||
"""单 list-targets transfer 覆盖 4 个 target reagent_keys → per-plate 不再出现。"""
|
||||
targets = ["t_A", "t_B", "t_C", "t_D"]
|
||||
labware = _labware_with_targets(targets, source_keys=["src_1"])
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": targets,
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [8.0] * 4,
|
||||
"dis_vols": [8.0] * 4,
|
||||
},
|
||||
"step_number": 1,
|
||||
}
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
per_plate, merged = _set_liquid_nodes_split(g)
|
||||
|
||||
# merged 节点:1 个
|
||||
assert len(merged) == 1, f"应有 1 个 merged 节点;实际 {len(merged)}"
|
||||
|
||||
# per-plate 节点:仅 source 1 个(src_1);target 端被全部跳过
|
||||
per_plate_names = {n.get("description", "") for n in per_plate}
|
||||
per_plate_keys = {
|
||||
n.get("description", "").replace("Set liquid: ", "")
|
||||
for n in per_plate
|
||||
}
|
||||
assert "src_1" in per_plate_keys, "source 端 per-plate 必须保留"
|
||||
for tk in targets:
|
||||
assert tk not in per_plate_keys, (
|
||||
f"§14:target reagent_key '{tk}' 已被 merged 覆盖,不应再有 per-plate 节点;"
|
||||
f" 实际 per_plate_keys={per_plate_keys}"
|
||||
)
|
||||
|
||||
|
||||
def test_per_plate_kept_when_also_referenced_by_str_targets():
|
||||
"""R1 缓解:t_A 既被 list-targets 引用,又被 str-targets 引用 → per-plate 必须保留。"""
|
||||
targets_list = ["t_A", "t_B", "t_C"]
|
||||
labware = _labware_with_targets(targets_list, source_keys=["src_1"])
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": targets_list,
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [5.0] * 3,
|
||||
"dis_vols": [5.0] * 3,
|
||||
},
|
||||
"step_number": 1,
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 2,
|
||||
},
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
per_plate, merged = _set_liquid_nodes_split(g)
|
||||
per_plate_keys = {
|
||||
n.get("description", "").replace("Set liquid: ", "")
|
||||
for n in per_plate
|
||||
}
|
||||
|
||||
assert "t_A" in per_plate_keys, (
|
||||
f"R1:t_A 被 str transfer #2 引用,必须保留 per-plate 节点;"
|
||||
f" 实际 per_plate_keys={per_plate_keys}"
|
||||
)
|
||||
assert "t_B" not in per_plate_keys, "t_B 仅出现在 list-targets,应跳过"
|
||||
assert "t_C" not in per_plate_keys, "t_C 仅出现在 list-targets,应跳过"
|
||||
|
||||
# merged 节点数:1(仅 list-targets transfer #1 生成)
|
||||
assert len(merged) == 1
|
||||
|
||||
|
||||
def test_str_targets_protocol_unaffected():
|
||||
"""单 slot 协议(全 str-targets)→ 每个 target reagent_key 仍有 per-plate(零回归)。"""
|
||||
labware = _labware_with_targets(["t_A", "t_B"], source_keys=["src_1"])
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 1,
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_1",
|
||||
"targets": "t_B",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [20.0],
|
||||
"dis_vols": [20.0],
|
||||
},
|
||||
"step_number": 2,
|
||||
},
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
per_plate, merged = _set_liquid_nodes_split(g)
|
||||
per_plate_keys = {
|
||||
n.get("description", "").replace("Set liquid: ", "")
|
||||
for n in per_plate
|
||||
}
|
||||
|
||||
assert merged == [], "全 str-targets 协议不应触发 merged 节点"
|
||||
assert {"src_1", "t_A", "t_B"}.issubset(per_plate_keys), (
|
||||
f"单 slot 协议每个 reagent_key(含 source/target)都应保留 per-plate;"
|
||||
f" 实际 {per_plate_keys}"
|
||||
)
|
||||
|
||||
|
||||
def test_51b9a5_style_node_count():
|
||||
"""大规模场景:N 个 list-targets transfers,每个长度 M(同 source 不同跨板)。
|
||||
|
||||
构造:2 个 source(src_A1、src_A2)+ 9 个 target plates × 2 个 well = 18 target reagent_keys。
|
||||
2 个 transfer:
|
||||
- transfer #1: targets = [t_A1_1, t_A1_2, ..., t_A1_9](同 source src_A1,跨 9 plate)
|
||||
- transfer #2: targets = [t_A2_1, t_A2_2, ..., t_A2_9](同 source src_A2,跨 9 plate)
|
||||
|
||||
期望 set_liquid_from_plate 总节点数 = 2 source per-plate + 2 merged + 0 target per-plate = 4。
|
||||
"""
|
||||
target_keys_a1 = [f"t_A1_{i}" for i in range(1, 10)]
|
||||
target_keys_a2 = [f"t_A2_{i}" for i in range(1, 10)]
|
||||
all_target_keys = target_keys_a1 + target_keys_a2
|
||||
|
||||
labware = _labware_with_targets(
|
||||
all_target_keys,
|
||||
source_keys=["src_A1", "src_A2"],
|
||||
)
|
||||
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_A1",
|
||||
"targets": target_keys_a1,
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [8.3] * 9,
|
||||
"dis_vols": [8.3] * 9,
|
||||
},
|
||||
"step_number": 1,
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_A2",
|
||||
"targets": target_keys_a2,
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [8.3] * 9,
|
||||
"dis_vols": [8.3] * 9,
|
||||
},
|
||||
"step_number": 2,
|
||||
},
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
per_plate, merged = _set_liquid_nodes_split(g)
|
||||
|
||||
assert len(merged) == 2, f"应有 2 个 merged 节点;实际 {len(merged)}"
|
||||
|
||||
per_plate_keys = {
|
||||
n.get("description", "").replace("Set liquid: ", "")
|
||||
for n in per_plate
|
||||
}
|
||||
|
||||
# source 端:2 个 per-plate
|
||||
assert "src_A1" in per_plate_keys and "src_A2" in per_plate_keys, (
|
||||
f"source 端必须有 src_A1 + src_A2 per-plate;实际 {per_plate_keys}"
|
||||
)
|
||||
|
||||
# target 端:18 个全部被跳过
|
||||
for tk in all_target_keys:
|
||||
assert tk not in per_plate_keys, (
|
||||
f"§14:target reagent_key '{tk}' 应被 merged 覆盖并跳过;"
|
||||
f" 实际 per_plate_keys 包含 {tk}"
|
||||
)
|
||||
|
||||
# 总节点数 == 2 + 2
|
||||
assert len(per_plate) + len(merged) == 4, (
|
||||
f"set_liquid_from_plate 总节点数应为 4 (2 source + 2 merged + 0 target per-plate);"
|
||||
f" 实际 per_plate={len(per_plate)} merged={len(merged)}"
|
||||
)
|
||||
|
||||
|
||||
def test_source_per_plate_always_kept():
|
||||
"""source reagent_keys 不出现在任何 targets 字段中 → per-plate 节点恒保留(与 §14 无关)。"""
|
||||
target_keys = ["t_A", "t_B", "t_C"]
|
||||
labware = _labware_with_targets(target_keys, source_keys=["src_X", "src_Y"])
|
||||
|
||||
steps = [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_X",
|
||||
"targets": target_keys,
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [5.0] * 3,
|
||||
"dis_vols": [5.0] * 3,
|
||||
},
|
||||
"step_number": 1,
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"parameters": {
|
||||
"sources": "src_Y",
|
||||
"targets": "t_A",
|
||||
"tip_racks": "tiprack_12",
|
||||
"asp_vols": [10.0],
|
||||
"dis_vols": [10.0],
|
||||
},
|
||||
"step_number": 2,
|
||||
},
|
||||
]
|
||||
g = build_protocol_graph(
|
||||
labware_info=labware,
|
||||
protocol_steps=steps,
|
||||
workstation_name="PRCXI",
|
||||
)
|
||||
|
||||
per_plate, _ = _set_liquid_nodes_split(g)
|
||||
per_plate_keys = {
|
||||
n.get("description", "").replace("Set liquid: ", "")
|
||||
for n in per_plate
|
||||
}
|
||||
|
||||
assert "src_X" in per_plate_keys, "source src_X 必须有 per-plate(source 不会被 §14 跳过)"
|
||||
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"
|
||||
534
tests/workflow/test_labware_mapping.py
Normal file
@@ -0,0 +1,534 @@
|
||||
"""P6 / P6.1 / P6.1.1 `labware_mapping.py` 单元测试 —— 对应 06-labware-mapping-table.md §11.7.7 / §11.8.7。
|
||||
|
||||
这些用例只依赖 `unilabos.workflow.labware_mapping` 自身与 PyYAML,
|
||||
不需要 ROS2 / matplotlib / networkx 等环境,可直接 `pytest tests/workflow/test_labware_mapping.py`。
|
||||
|
||||
P6.1.1 schema(v1.9):
|
||||
- 顶层 key 两段:``kinds`` / ``target_devices``(**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.<device>`` 内)
|
||||
- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段**
|
||||
- ``target_devices.<device>.models.<model>`` 是可选的型号粒度覆盖(slot_remap / rules)
|
||||
- 旧 schema(顶层 ``vendors`` / ``slot_remap`` 或 rule 含 ``prcxi_class``)会触发 warning + fallback 到 builtin
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from unilabos.workflow import labware_mapping as lm
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_lru_cache():
|
||||
"""每个用例后清缓存,避免 monkeypatch 跨用例污染。"""
|
||||
yield
|
||||
lm.reload_mapping()
|
||||
|
||||
|
||||
# ==================== slot_remap ====================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,object_type,want",
|
||||
[
|
||||
("4", "", "13"),
|
||||
("8", "", "14"),
|
||||
("12", "trash", "16"),
|
||||
("12", "source", "12"),
|
||||
("1", "", "1"),
|
||||
("", "", ""),
|
||||
(4, "", "13"), # 非字符串入参也应规整
|
||||
],
|
||||
)
|
||||
def test_remap_slot_basic(raw, object_type, want):
|
||||
assert lm.remap_slot(raw, object_type) == want
|
||||
|
||||
|
||||
def test_remap_slot_none_returns_empty():
|
||||
assert lm.remap_slot(None) == ""
|
||||
|
||||
|
||||
def test_remap_slot_passthrough_unknown():
|
||||
assert lm.remap_slot("99") == "99"
|
||||
|
||||
|
||||
# ==================== infer_kind ====================
|
||||
|
||||
|
||||
def test_infer_kind_trash_priority():
|
||||
"""`trash` 在 kinds 列表第 1 条 → 优先于含 'rack' 的字符串。"""
|
||||
assert lm.infer_kind("foo_trash_bar") == "trash"
|
||||
assert lm.infer_kind("opentrons_fixed_trash") == "trash"
|
||||
|
||||
|
||||
def test_infer_kind_tiprack_before_tuberack():
|
||||
"""`tiprack` 子串包含 'rack',但应被 tip_rack 规则先抓到(顺序敏感)。"""
|
||||
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
||||
assert lm.infer_kind("opentrons_96_tiprack_20ul") == "tip_rack"
|
||||
|
||||
|
||||
def test_infer_kind_tube_rack_variants():
|
||||
assert (
|
||||
lm.infer_kind("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap")
|
||||
== "tube_rack"
|
||||
)
|
||||
assert lm.infer_kind("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical") == "tube_rack"
|
||||
|
||||
|
||||
def test_infer_kind_object_overrides_string():
|
||||
"""object 字段优先:即使字符串看起来像 plate,trash / tiprack 也能强制归类。"""
|
||||
assert lm.infer_kind("anything_at_all", "tiprack") == "tip_rack"
|
||||
assert lm.infer_kind("opentrons_96_wellplate", "trash") == "trash"
|
||||
|
||||
|
||||
def test_infer_kind_default_plate():
|
||||
assert lm.infer_kind("opentrons_96_wellplate_300ul_pcr") == "plate"
|
||||
assert lm.infer_kind("custom_384_wellplate_2200ul") == "plate"
|
||||
|
||||
|
||||
def test_infer_kind_rack_without_tip_is_tube_rack():
|
||||
"""复现历史 `_infer_reagent_kind` 中「含 rack 不含 tip → tube_rack」的语义。"""
|
||||
assert lm.infer_kind("nest_4x6_rack") == "tube_rack"
|
||||
|
||||
|
||||
def test_infer_kind_empty_hint_returns_plate():
|
||||
assert lm.infer_kind("") == "plate"
|
||||
assert lm.infer_kind(None) == "plate" # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ==================== resolve_target_class(target_device="prcxi") ====================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vol,want",
|
||||
[
|
||||
(1, "PRCXI_10uL_Tips"),
|
||||
(9, "PRCXI_10uL_Tips"),
|
||||
(10, "PRCXI_10uL_Tips"), # 闭区间 ≤10
|
||||
(11, "PRCXI_300ul_Tips"),
|
||||
(200, "PRCXI_300ul_Tips"),
|
||||
(299.9, "PRCXI_300ul_Tips"),
|
||||
(300, "PRCXI_1000uL_Tips"), # 300 上一档(与 <300 半开等价)
|
||||
(500, "PRCXI_1000uL_Tips"),
|
||||
(1000, "PRCXI_1000uL_Tips"),
|
||||
],
|
||||
)
|
||||
def test_resolve_tip_volume_buckets(vol, want):
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, vol) == want
|
||||
|
||||
|
||||
def test_resolve_tube_rack_holes():
|
||||
assert lm.resolve_target_class("prcxi", "tube_rack", 24, None) == "PRCXI_EP_Adapter"
|
||||
assert lm.resolve_target_class("prcxi", "tube_rack", 10, None) == "PRCXI_EP_Adapter"
|
||||
|
||||
|
||||
def test_resolve_plate_holes():
|
||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
||||
assert (
|
||||
lm.resolve_target_class("prcxi", "plate", 384, None) == "PRCXI_BioER_384_wellplate"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_plate_unknown_holes_returns_none():
|
||||
"""48 孔板未在 YAML 列出 → None;交给 PRCXI 模板打分匹配 fallback。"""
|
||||
assert lm.resolve_target_class("prcxi", "plate", 48, 2200) is None
|
||||
|
||||
|
||||
def test_resolve_trash_any():
|
||||
assert lm.resolve_target_class("prcxi", "trash", None, None) == "PRCXI_trash"
|
||||
# trash 规则未约束 hole_count / volume,所以任意值都命中
|
||||
assert lm.resolve_target_class("prcxi", "trash", 0, 0) == "PRCXI_trash"
|
||||
|
||||
|
||||
# ==================== YAML 缺失 / 热加载 ====================
|
||||
|
||||
|
||||
def test_missing_yaml_uses_builtin(monkeypatch, tmp_path):
|
||||
"""YAML 文件不存在时,应自动落到 `_BUILTIN_DEFAULT`,且打 warning。"""
|
||||
bogus = tmp_path / "no_such_labware_mapping.yaml"
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", bogus)
|
||||
lm._load_mapping.cache_clear()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
assert lm.remap_slot("4") == "13"
|
||||
assert (
|
||||
lm.resolve_target_class("prcxi", "plate", 96, None)
|
||||
== "PRCXI_BioER_96_wellplate"
|
||||
)
|
||||
assert any("labware_mapping.yaml 未找到" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
def test_invalid_yaml_uses_builtin(monkeypatch, tmp_path):
|
||||
"""YAML 解析失败也应回退到 builtin,且打 warning。"""
|
||||
bad = tmp_path / "labware_mapping.yaml"
|
||||
bad.write_text("this is :: not valid: yaml: [unclosed", encoding="utf-8")
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", bad)
|
||||
lm._load_mapping.cache_clear()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
assert lm.remap_slot("4") == "13"
|
||||
assert any(
|
||||
"labware_mapping.yaml 解析失败" in str(w.message)
|
||||
or "labware_mapping.yaml 根不是 dict" in str(w.message)
|
||||
for w in caught
|
||||
)
|
||||
|
||||
|
||||
def test_yaml_reload_after_edit(monkeypatch, tmp_path):
|
||||
"""临时 YAML 覆盖 + reload_mapping → 新规则生效,且原规则失效(P6.1.1 schema)。"""
|
||||
tmp_yaml = tmp_path / "labware_mapping.yaml"
|
||||
tmp_yaml.write_text(
|
||||
'kinds:\n'
|
||||
" - { pattern: 'trash', kind: trash }\n"
|
||||
" - { pattern: '.*', kind: plate }\n"
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap:\n'
|
||||
' default: {"4": "99"}\n'
|
||||
' by_object: {}\n'
|
||||
' rules:\n'
|
||||
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n"
|
||||
' prcxi:\n'
|
||||
' slot_remap:\n'
|
||||
' default: {"4": "99"}\n'
|
||||
' by_object: {}\n'
|
||||
' rules:\n'
|
||||
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", tmp_yaml)
|
||||
lm.reload_mapping()
|
||||
assert lm.remap_slot("4") == "99"
|
||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_FooPlate"
|
||||
# 新表里只有 96,没有 384 → None
|
||||
assert lm.resolve_target_class("prcxi", "plate", 384, None) is None
|
||||
# tube_rack / tip_rack 在新表里没规则 → None
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) is None
|
||||
|
||||
|
||||
def test_missing_section_uses_builtin(monkeypatch, tmp_path):
|
||||
"""YAML 缺 `kinds` 段 → 该段使用 builtin,其它段保留用户值(P6.1.1 schema)。"""
|
||||
partial = tmp_path / "labware_mapping.yaml"
|
||||
partial.write_text(
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap:\n'
|
||||
' default: {"4": "88"}\n'
|
||||
' by_object: {}\n'
|
||||
' rules: []\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap:\n'
|
||||
' default: {"4": "88"}\n'
|
||||
' by_object: {}\n'
|
||||
' rules: []\n', # 故意没有 kinds 段
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", partial)
|
||||
lm._load_mapping.cache_clear()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
# slot_remap 用 YAML 中的覆盖值
|
||||
assert lm.remap_slot("4") == "88"
|
||||
# kinds 段缺失 → 使用 builtin 的 tiprack 规则
|
||||
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
||||
assert any("缺少 `kinds` 段" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
# ==================== P6.1 新增用例 ====================
|
||||
|
||||
|
||||
def test_resolve_target_class_prcxi_tip_buckets():
|
||||
"""PRCXI tip 量程档:≤10 / <300 / 否则 1000(与历史 _tip_prcxi_class_for_max_ul 等价)。"""
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 10) == "PRCXI_10uL_Tips"
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 1000) == "PRCXI_1000uL_Tips"
|
||||
|
||||
|
||||
def test_resolve_target_class_unknown_device_falls_back_to_default_section():
|
||||
"""未声明的 target_device 自动回退到固定段 target_devices.default,打 warning。
|
||||
第一版 default 段内容按 prcxi 拷贝 → 断言:caller 传 'tecan' 时,结果应等于查 default 段。"""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
# tecan / beckman / 任意未声明名字 → 全部回退到固定段 "default"
|
||||
assert (
|
||||
lm.resolve_target_class("tecan", "tip_rack", 96, 200)
|
||||
== lm.resolve_target_class("default", "tip_rack", 96, 200)
|
||||
== "PRCXI_300ul_Tips" # 第一版 default 段按 prcxi 填,所以值仍是 PRCXI_*
|
||||
)
|
||||
assert (
|
||||
lm.resolve_target_class("unknown_xxx", "plate", 96, None)
|
||||
== lm.resolve_target_class("default", "plate", 96, None)
|
||||
)
|
||||
# 至少打 1 次 warning,提示「未声明、已回退到 default 段」
|
||||
assert any(
|
||||
("未在 labware_mapping.yaml" in str(w.message))
|
||||
or ("target_devices.default" in str(w.message))
|
||||
for w in caught
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_target_class_per_device_tip_buckets(tmp_path, monkeypatch):
|
||||
"""**P6.1 核心断言**:不同 target_device 在同一体积下命中不同 tip 量程档(P6.1.1 schema)。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {}, by_object: {}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {}, by_object: {}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||
' beckman:\n'
|
||||
' slot_remap: {default: {}, by_object: {}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
||||
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
|
||||
# 同样的体积 200:prcxi 走 300 档、beckman 已超出 200 档 → 1000 档
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
||||
assert lm.resolve_target_class("beckman", "tip_rack", 96, 200) == "Beckman_1000uL_Tips"
|
||||
# 同样的体积 15:prcxi 已超出 10 档 → 300 档;beckman 仍在 20 档
|
||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 15) == "PRCXI_300ul_Tips"
|
||||
assert lm.resolve_target_class("beckman", "tip_rack", 96, 15) == "Beckman_20uL_Tips"
|
||||
|
||||
|
||||
def test_default_section_independent_from_prcxi(tmp_path, monkeypatch):
|
||||
"""default 与 prcxi 是两段独立物料集:改 default 不影响 prcxi、改 prcxi 不影响 default。
|
||||
|
||||
断言:把 default 段改成 Generic_Plate96,prcxi 段保持 PRCXI_Plate96 时,
|
||||
caller 传未声明的名字回退到 default 拿 Generic_Plate96,传 prcxi 仍拿 PRCXI_Plate96。
|
||||
"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n' # ← 独立改 default 段
|
||||
' slot_remap: {default: {}, by_object: {}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: plate, hole_count: 96, class_name: Generic_Plate96}\n'
|
||||
' prcxi:\n' # ← prcxi 段保持 PRCXI_*
|
||||
' slot_remap: {default: {}, by_object: {}}\n'
|
||||
' rules:\n'
|
||||
' - {kind: plate, hole_count: 96, class_name: PRCXI_Plate96}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
|
||||
# caller 传未声明的 tecan → 走 default 段 → Generic_*
|
||||
assert lm.resolve_target_class("tecan", "plate", 96, None) == "Generic_Plate96"
|
||||
# caller 显式传 prcxi → 走 prcxi 段 → PRCXI_*(**不**受 default 影响)
|
||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_Plate96"
|
||||
# 显式传 "default" 也合法(caller 可主动选择走 default 段)
|
||||
assert lm.resolve_target_class("default", "plate", 96, None) == "Generic_Plate96"
|
||||
|
||||
|
||||
def test_legacy_yaml_schema_rejected_with_warning(tmp_path, monkeypatch):
|
||||
"""旧 schema(vendors / prcxi_class)应被拒绝 + warning + 整段 fallback 到 builtin(P6.1.1 schema)。"""
|
||||
legacy = tmp_path / "labware_mapping.yaml"
|
||||
legacy.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'vendors:\n' # ← 旧顶层 key
|
||||
' opentrons:\n'
|
||||
' rules:\n'
|
||||
" - {kind: plate, hole_count: 96, prcxi_class: PRCXI_FooPlate}\n", # ← 旧字段
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
||||
lm._load_mapping.cache_clear()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
# 整段走 builtin → 96 板还是 PRCXI_BioER_96_wellplate(**不是**用户旧 YAML 中的 PRCXI_FooPlate)
|
||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
||||
assert any(
|
||||
("旧 schema" in str(w.message))
|
||||
or ("vendors" in str(w.message))
|
||||
or ("prcxi_class" in str(w.message))
|
||||
for w in caught
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_target_class_unknown_kind_returns_none():
|
||||
"""target_device 存在、kind 不存在 → None。"""
|
||||
assert lm.resolve_target_class("prcxi", "reservoir", 12, None) is None
|
||||
|
||||
|
||||
# ==================== P6.1.1 新增用例(slot_remap 按 device + model 分叉) ====================
|
||||
|
||||
|
||||
def test_remap_slot_model_level_overrides_device_level(tmp_path, monkeypatch):
|
||||
"""型号级 slot_remap 优先级 > 厂商级。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
||||
' rules: []\n'
|
||||
' models:\n'
|
||||
' "4040":\n'
|
||||
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
# device 级(不传 model)→ "13"
|
||||
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
||||
# model "4040" 覆盖 → "16"
|
||||
assert lm.remap_slot("4", target_device="prcxi", target_model="4040") == "16"
|
||||
# model "9320" 未声明 → 静默 fallback 到 device 级 → "13"
|
||||
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||
|
||||
|
||||
def test_remap_slot_model_inherits_device_when_field_missing(tmp_path, monkeypatch):
|
||||
"""model 子段声明但 slot_remap 字段缺失 → 静默继承厂商级;rules 同理。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {}, by_object: {}}\n'
|
||||
' rules: []\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {}}\n'
|
||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateA}]\n'
|
||||
' models:\n'
|
||||
' "9320":\n'
|
||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateB}]\n', # 仅覆盖 rules,未声明 slot_remap
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
# model 9320 的 slot_remap 缺字段 → 继承 prcxi.slot_remap → "4" → "13"
|
||||
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||
# model 9320 的 rules 覆盖 → PRCXI_PlateB
|
||||
assert (
|
||||
lm.resolve_target_class("prcxi", "plate", 96, None, target_model="9320")
|
||||
== "PRCXI_PlateB"
|
||||
)
|
||||
# 不传 model → 用厂商级 rules → PRCXI_PlateA
|
||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_PlateA"
|
||||
|
||||
|
||||
def test_legacy_top_level_slot_remap_rejected(tmp_path, monkeypatch):
|
||||
"""P6.1.1:顶层 slot_remap 段被视为旧 schema → warning + 整段 fallback 到 builtin。"""
|
||||
legacy = tmp_path / "labware_mapping.yaml"
|
||||
legacy.write_text(
|
||||
'slot_remap:\n' # ← P6.1.1 已不支持的顶层段
|
||||
' default: {"4": "99"}\n'
|
||||
' by_object: {}\n'
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
||||
lm._load_mapping.cache_clear()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
# 整段走 builtin → "4" 仍然 → "13"(builtin 值),**不是** YAML 顶层的 "99"
|
||||
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
||||
assert any(
|
||||
("顶层" in str(w.message) and "slot_remap" in str(w.message))
|
||||
or ("旧 schema" in str(w.message))
|
||||
for w in caught
|
||||
)
|
||||
|
||||
|
||||
def test_remap_slot_unknown_device_falls_back_with_warning(tmp_path, monkeypatch):
|
||||
"""未声明的 target_device → fallback 到 default.slot_remap + warning(与 resolve_target_class 同语义)。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
assert lm.remap_slot("4", target_device="tecan") == "13" # fallback 到 default
|
||||
assert any(
|
||||
("tecan" in str(w.message)) or ("target_devices.default" in str(w.message))
|
||||
for w in caught
|
||||
)
|
||||
|
||||
|
||||
def test_remap_slot_model_only_no_device_passthrough(tmp_path, monkeypatch):
|
||||
"""caller 传 target_model 但 target_device 段不存在 → 直接走 default.slot_remap(model 名忽略)。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n', # 没有 prcxi 段
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm.reload_mapping()
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
# target_device "prcxi" 不存在、target_model 即使传也忽略 → 走 default
|
||||
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||
|
||||
|
||||
def test_default_section_models_subsection_warns(tmp_path, monkeypatch):
|
||||
"""target_devices.default.models 不被支持 → warning,但 default.slot_remap 仍生效。"""
|
||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||
yaml_path.write_text(
|
||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||
'target_devices:\n'
|
||||
' default:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n'
|
||||
' models:\n' # ← default 段不支持 models
|
||||
' "ghost":\n'
|
||||
' slot_remap: {default: {"4": "99"}, by_object: {}}\n'
|
||||
' prcxi:\n'
|
||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||
' rules: []\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||
lm._load_mapping.cache_clear()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
# default 段的 models 被忽略 → 走 default.slot_remap → "13"(不是 "99")
|
||||
assert lm.remap_slot("4", target_device="tecan", target_model="ghost") == "13"
|
||||
assert any(
|
||||
("default" in str(w.message) and "models" in str(w.message))
|
||||
for w in caught
|
||||
)
|
||||
178
tests/workflow/test_wf_utils_workflow_name.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""``unilabos.workflow.wf_utils.upload_workflow`` 工作流名称 fallback 链单元测试。
|
||||
|
||||
对应需求:上传工作流时,**优先取 metadata.workflow_name**;缺失时再回退到顶层
|
||||
``workflow_name``(旧 node-link 形态遗留字段);最后才回退到文件名(去 ``.json`` 后缀)。
|
||||
CLI 显式 ``-n/--workflow_name`` 永远最优先。
|
||||
|
||||
本测试只校验「**名称 fallback 链 + tags fallback 链**」的纯逻辑路径,
|
||||
不实际访问 HTTP / 后端;通过 monkeypatch 把 ``http_client.workflow_import``
|
||||
桩成可观察的捕获函数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# 让 import 走 Uni-Lab-OS 包根
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC = ROOT / "unilabos"
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_upload(monkeypatch, tmp_path):
|
||||
"""Monkeypatch ``http_client.workflow_import`` + ``_convert_to_node_link``,
|
||||
返回 (helper, captured) 二元组:
|
||||
|
||||
- ``helper(workflow_data, **upload_kwargs)`` 写入 tmp_path/wf.json
|
||||
并调用 ``upload_workflow``;
|
||||
- ``captured`` 是 dict,记录 ``workflow_import`` 实际收到的 kwargs,
|
||||
以及 ``_convert_to_node_link`` 是否被调过。
|
||||
|
||||
本测试不依赖真实 ``unilabos.app.web``(其级联依赖含 ``fastapi`` 等重型
|
||||
package,本地 dev venv 不必装)。通过在 sys.modules 注入空壳 module 拦截
|
||||
delayed import。
|
||||
"""
|
||||
import types
|
||||
|
||||
captured: Dict[str, Any] = {"workflow_import_kwargs": None, "converted": False}
|
||||
|
||||
def fake_workflow_import(**kwargs): # noqa: ANN003
|
||||
captured["workflow_import_kwargs"] = kwargs
|
||||
return {"code": 0, "data": {"uuid": "fake-uuid", "name": kwargs.get("name")}}
|
||||
|
||||
# 关键:在 wf_utils 触发 `from unilabos.app.web import http_client` 之前
|
||||
# 用空壳 module 占位(避免触发真实 web 包的 fastapi 依赖链)。
|
||||
fake_http_client = types.ModuleType("unilabos.app.web.http_client")
|
||||
fake_http_client.workflow_import = fake_workflow_import # type: ignore[attr-defined]
|
||||
fake_web_pkg = types.ModuleType("unilabos.app.web")
|
||||
fake_web_pkg.http_client = fake_http_client # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "unilabos.app.web", fake_web_pkg)
|
||||
monkeypatch.setitem(sys.modules, "unilabos.app.web.http_client", fake_http_client)
|
||||
|
||||
from unilabos.workflow import wf_utils
|
||||
|
||||
# _convert_to_node_link 走真实路径会拉重型依赖,这里桩为 node-link 直返回
|
||||
def fake_convert_to_node_link(workflow_file, workflow_data, *, target_device="prcxi", target_model=None):
|
||||
captured["converted"] = True
|
||||
# 返回最小合法 node-link 形态(不带 metadata,模拟当前行为)
|
||||
return {"nodes": [], "edges": [], "workflow_uuid": ""}
|
||||
|
||||
monkeypatch.setattr(wf_utils, "_convert_to_node_link", fake_convert_to_node_link)
|
||||
|
||||
def helper(workflow_data: Dict[str, Any], **upload_kwargs: Any) -> Dict[str, Any]:
|
||||
wf_path = tmp_path / "transfer_actions_sample.json"
|
||||
wf_path.write_text(json.dumps(workflow_data, ensure_ascii=False), encoding="utf-8")
|
||||
return wf_utils.upload_workflow(str(wf_path), **upload_kwargs)
|
||||
|
||||
return helper, captured
|
||||
|
||||
|
||||
# ==================== workflow_name fallback 链 ====================
|
||||
|
||||
|
||||
def test_metadata_workflow_name_wins_over_filename(stub_upload):
|
||||
"""P5 主路径:transfer_actions JSON 含 metadata.workflow_name → 优先于文件名。"""
|
||||
helper, captured = stub_upload
|
||||
data = {
|
||||
"metadata": {"workflow_name": "PCR Prep with Categories", "tags": []},
|
||||
"workflow": [],
|
||||
"reagent": {},
|
||||
}
|
||||
helper(data)
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
assert kwargs is not None and captured["converted"] is True
|
||||
assert kwargs["name"] == "PCR Prep with Categories"
|
||||
assert kwargs["workflow_name"] == "PCR Prep with Categories"
|
||||
|
||||
|
||||
def test_cli_workflow_name_overrides_metadata(stub_upload):
|
||||
"""CLI 显式 -n/--workflow_name 永远最优先。"""
|
||||
helper, captured = stub_upload
|
||||
data = {
|
||||
"metadata": {"workflow_name": "Metadata Wins By Default"},
|
||||
"workflow": [],
|
||||
"reagent": {},
|
||||
}
|
||||
helper(data, workflow_name="CLI Override Name")
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
assert kwargs["name"] == "CLI Override Name"
|
||||
assert kwargs["workflow_name"] == "CLI Override Name"
|
||||
|
||||
|
||||
def test_filename_used_when_no_metadata_and_no_legacy(stub_upload):
|
||||
"""P5 之前的旧文件、且无顶层 workflow_name → 回退到去 .json 后缀的文件名。"""
|
||||
helper, captured = stub_upload
|
||||
data = {"workflow": [], "reagent": {}} # 既无 metadata,也无 workflow_name
|
||||
helper(data)
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
# 文件名由 fixture 固定为 transfer_actions_sample.json
|
||||
assert kwargs["name"] == "transfer_actions_sample"
|
||||
assert kwargs["workflow_name"] == "transfer_actions_sample"
|
||||
|
||||
|
||||
def test_metadata_empty_string_falls_back_to_filename(stub_upload):
|
||||
"""metadata.workflow_name 为空字符串(而非缺失)也应回退到文件名。"""
|
||||
helper, captured = stub_upload
|
||||
data = {
|
||||
"metadata": {"workflow_name": " "}, # whitespace-only
|
||||
"workflow": [],
|
||||
"reagent": {},
|
||||
}
|
||||
helper(data)
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
assert kwargs["name"] == "transfer_actions_sample"
|
||||
|
||||
|
||||
def test_legacy_top_level_workflow_name_used_when_metadata_missing(stub_upload, monkeypatch):
|
||||
"""旧 node-link 文件(已是 nodes/edges 形态)顶层 workflow_name → 应被使用。
|
||||
|
||||
覆盖路径:``_is_node_link_format`` 直接命中 → 不走转换 → workflow_data 保留顶层
|
||||
workflow_name;``orig_metadata`` 为空时 fallback 到该字段。
|
||||
"""
|
||||
helper, captured = stub_upload
|
||||
data = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"workflow_name": "Legacy Top Name",
|
||||
}
|
||||
helper(data)
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
assert captured["converted"] is False, "node-link 输入不应触发转换"
|
||||
assert kwargs["name"] == "Legacy Top Name"
|
||||
assert kwargs["workflow_name"] == "Legacy Top Name"
|
||||
|
||||
|
||||
# ==================== tags fallback 链 ====================
|
||||
|
||||
|
||||
def test_metadata_tags_used_when_cli_tags_missing(stub_upload):
|
||||
"""P5 主路径:metadata.tags 在 CLI 未传 tags 时被使用。"""
|
||||
helper, captured = stub_upload
|
||||
data = {
|
||||
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
||||
"workflow": [],
|
||||
"reagent": {},
|
||||
}
|
||||
helper(data)
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
assert kwargs["tags"] == ["Opentrons", "PCR"]
|
||||
|
||||
|
||||
def test_cli_tags_override_metadata_tags(stub_upload):
|
||||
"""CLI 显式 --tags 优先于 metadata.tags。"""
|
||||
helper, captured = stub_upload
|
||||
data = {
|
||||
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
||||
"workflow": [],
|
||||
"reagent": {},
|
||||
}
|
||||
helper(data, tags=["CLI", "Wins"])
|
||||
kwargs = captured["workflow_import_kwargs"]
|
||||
assert kwargs["tags"] == ["CLI", "Wins"]
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.12"
|
||||
__version__ = "0.11.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import threading
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +1,119 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import networkx as nx
|
||||
import yaml
|
||||
|
||||
# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符
|
||||
# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃
|
||||
if sys.platform == "win32":
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
# 首先添加项目根目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if unilabos_dir not in sys.path:
|
||||
sys.path.append(unilabos_dir)
|
||||
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
# Global restart flags (used by ws_client and web/server)
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
RESTART_EXIT_CODE = 42
|
||||
|
||||
|
||||
def _build_child_argv():
|
||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
||||
result = []
|
||||
skip_next = False
|
||||
for arg in sys.argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in ("--restart_mode", "--restart-mode"):
|
||||
continue
|
||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
||||
skip_next = True
|
||||
continue
|
||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
||||
continue
|
||||
result.append(arg)
|
||||
return result
|
||||
|
||||
|
||||
def _run_as_supervisor(max_restarts: int):
|
||||
"""
|
||||
Supervisor process that spawns and monitors child processes.
|
||||
|
||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
||||
it only launches the real process as a child and restarts it when the child
|
||||
exits with RESTART_EXIT_CODE.
|
||||
"""
|
||||
child_argv = [sys.executable] + _build_child_argv()
|
||||
restart_count = 0
|
||||
|
||||
print_status(
|
||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
||||
f"child command: {' '.join(child_argv)}",
|
||||
"info",
|
||||
)
|
||||
|
||||
while True:
|
||||
print_status(
|
||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
||||
"info",
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(child_argv)
|
||||
exit_code = process.wait()
|
||||
except KeyboardInterrupt:
|
||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
restart_count += 1
|
||||
if restart_count > max_restarts:
|
||||
print_status(
|
||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
||||
"warning",
|
||||
)
|
||||
sys.exit(1)
|
||||
print_status(
|
||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
||||
"info",
|
||||
)
|
||||
time.sleep(2)
|
||||
else:
|
||||
if exit_code != 0:
|
||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
||||
else:
|
||||
print_status("[Supervisor] Child exited normally", "info")
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
||||
@@ -41,7 +135,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 +143,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(
|
||||
@@ -58,6 +154,13 @@ def parse_args():
|
||||
action="append",
|
||||
help="Path to the registry directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to Python code directory for AST-based device/resource scanning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--working_dir",
|
||||
type=str,
|
||||
@@ -139,7 +242,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--addr",
|
||||
type=str,
|
||||
default="https://uni-lab.bohrium.com/api/v1",
|
||||
default="https://leap-lab.bohrium.com/api/v1",
|
||||
help="Laboratory backend address",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -147,11 +250,112 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Skip environment dependency check on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
help="Disable sending update feedback to server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--external_devices_only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra_resource",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto_restart_count",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
||||
)
|
||||
# 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)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default="",
|
||||
help="Workflow description, used when publishing the workflow",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--target_device",
|
||||
type=str,
|
||||
default="prcxi",
|
||||
help=(
|
||||
"Target instrument name at vendor granularity (e.g. 'prcxi', 'beckman', 'tecan'). "
|
||||
"Decides which target_devices.<name>.rules section in labware_mapping.yaml is used. "
|
||||
"Unknown names fall back to target_devices.default. Default: 'prcxi'."
|
||||
),
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--target_model",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Optional target instrument model name within the same vendor (e.g. '9320', '4040'). "
|
||||
"Used to look up target_devices.<target_device>.models.<target_model>.slot_remap / "
|
||||
".rules for model-specific deck layout or rule overrides. Falls back to the vendor-level "
|
||||
"configuration when omitted or the model is not declared. Default: None."
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
@@ -159,78 +363,120 @@ def parse_args():
|
||||
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)
|
||||
|
||||
# Supervisor mode: spawn child processes and monitor for restart
|
||||
if args_dict.get("restart_mode", False):
|
||||
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
||||
return
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
skip_env_check = args_dict.get("skip_env_check", False)
|
||||
check_mode = args_dict.get("check_mode", False)
|
||||
|
||||
if not skip_env_check:
|
||||
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
||||
|
||||
print_status("正在进行环境依赖检查...", "info")
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
|
||||
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
||||
devices_dirs_for_req = args_dict.get("devices", None)
|
||||
if devices_dirs_for_req:
|
||||
if not check_device_package_requirements(devices_dirs_for_req):
|
||||
print_status("设备包依赖检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
else:
|
||||
print_status("跳过环境依赖检查", "warning")
|
||||
|
||||
# 加载配置文件,优先加载config,然后从env读取
|
||||
config_path = args_dict.get("config")
|
||||
if os.getcwd().endswith("unilabos_data"):
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
else:
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
if args_dict.get("working_dir"):
|
||||
working_dir = args_dict.get("working_dir", "")
|
||||
if config_path and not os.path.exists(config_path):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
if not os.path.exists(config_path):
|
||||
print_status(
|
||||
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
os._exit(1)
|
||||
# === 解析 working_dir ===
|
||||
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
||||
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
||||
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
||||
raw_working_dir = args_dict.get("working_dir")
|
||||
if raw_working_dir:
|
||||
working_dir = os.path.abspath(raw_working_dir)
|
||||
elif config_path and os.path.exists(config_path):
|
||||
working_dir = os.path.dirname(config_path)
|
||||
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
elif not config_path and (
|
||||
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
||||
):
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
working_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
else:
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
|
||||
# unilabos_data 子目录自动检测
|
||||
if os.path.basename(working_dir) != "unilabos_data":
|
||||
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
||||
if os.path.isdir(unilabos_data_sub):
|
||||
working_dir = unilabos_data_sub
|
||||
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
||||
# 未显式指定路径,默认使用 cwd/unilabos_data
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
# === 解析 config_path ===
|
||||
if config_path and not os.path.exists(config_path):
|
||||
# config_path 传入但不存在,尝试在 working_dir 中查找
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
||||
else:
|
||||
print_status(
|
||||
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
||||
f"请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
os._exit(1)
|
||||
# 加载配置文件
|
||||
elif not config_path:
|
||||
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
||||
else:
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if check_mode or input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
||||
config_path,
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
else:
|
||||
os._exit(1)
|
||||
|
||||
# 加载配置文件 (check_mode 跳过)
|
||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||
load_config_from_file(config_path)
|
||||
if not check_mode:
|
||||
load_config_from_file(config_path)
|
||||
|
||||
# 根据配置重新设置日志级别
|
||||
from unilabos.utils.log import configure_logger, logger
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
if file_path is not None:
|
||||
logger.info(f"[LOG_FILE] {file_path}")
|
||||
|
||||
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://leap-lab.test.bohrium.com/api/v1"
|
||||
elif args.addr == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://leap-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 +485,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
|
||||
|
||||
@@ -254,40 +503,87 @@ def main():
|
||||
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
|
||||
if BasicConfig.extra_resource:
|
||||
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = platform.node()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
BasicConfig.check_mode = check_mode
|
||||
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
dict_from_graph,
|
||||
)
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.registry.registry import build_registry
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.app.web import http_client
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||
devices_dirs = args_dict.get("devices", None)
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
external_only = args_dict.get("external_devices_only", False)
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||
registry_paths=args_dict["registry_path"],
|
||||
devices_dirs=devices_dirs,
|
||||
upload_registry=BasicConfig.upload_registry,
|
||||
check_mode=check_mode,
|
||||
complete_registry=complete_registry,
|
||||
external_only=external_only,
|
||||
)
|
||||
|
||||
# Check mode: 注册表验证完成后直接退出
|
||||
if check_mode:
|
||||
device_count = len(lab_registry.device_type_registry)
|
||||
resource_count = len(lab_registry.resource_type_registry)
|
||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
dict_from_graph,
|
||||
modify_to_backend_format,
|
||||
)
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.app.web import http_client
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||
if BasicConfig.upload_registry:
|
||||
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")
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
graph: nx.Graph
|
||||
resource_tree_set: ResourceTreeSet
|
||||
@@ -327,6 +623,10 @@ def main():
|
||||
for ind, i in enumerate(resource_edge_info[::-1]):
|
||||
source_node: ResourceDict = nodes[i["source"]]
|
||||
target_node: ResourceDict = nodes[i["target"]]
|
||||
if "sourceHandle" not in source_node:
|
||||
continue
|
||||
if "targetHandle" not in target_node:
|
||||
continue
|
||||
source_handle = i["sourceHandle"]
|
||||
target_handle = i["targetHandle"]
|
||||
source_handler_keys = [
|
||||
@@ -351,31 +651,21 @@ def main():
|
||||
continue
|
||||
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
if request_startup_json and "nodes" in request_startup_json:
|
||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"])
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||
print_status("远端物料同步完成", "info")
|
||||
|
||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
||||
# TODO: 当 community device package 功能上线后,在这里调用
|
||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
||||
|
||||
# 使用 ResourceTreeSet 代替 list
|
||||
args_dict["resources_config"] = resource_tree_set
|
||||
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:
|
||||
@@ -390,6 +680,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)
|
||||
@@ -431,16 +722,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
|
||||
@@ -448,16 +736,26 @@ def main():
|
||||
time.sleep(1)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
restart_requested = start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
return
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
|
||||
# 启动服务器(默认支持WebSocket触发重启)
|
||||
restart_requested = start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
os._exit(RESTART_EXIT_CODE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
||||
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="")
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
||||
|
||||
|
||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||
@@ -11,50 +10,63 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
注册设备和资源到服务器(仅支持HTTP)
|
||||
"""
|
||||
|
||||
# 注册资源信息 - 使用HTTP方式
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||
|
||||
# 注册设备信息
|
||||
devices_to_register = {}
|
||||
for device_info in lab_registry.obtain_registry_device_info():
|
||||
devices_to_register[device_info["id"]] = json.loads(
|
||||
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||
)
|
||||
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
|
||||
resources_to_register = {}
|
||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||
resources_to_register[resource_info["id"]] = resource_info
|
||||
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
|
||||
if gather_only:
|
||||
return devices_to_register, resources_to_register
|
||||
# 注册设备
|
||||
|
||||
if devices_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(devices_to_register.values())},
|
||||
tag="device_registry",
|
||||
)
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
||||
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
# 注册资源
|
||||
if resources_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(resources_to_register.values())},
|
||||
tag="resource_registry",
|
||||
)
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
||||
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||
|
||||
176
unilabos/app/utils.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
UniLabOS 应用工具函数
|
||||
|
||||
提供清理、重启等工具函数
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def patch_rclpy_dll_windows():
|
||||
"""在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁"""
|
||||
if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"):
|
||||
return
|
||||
try:
|
||||
import rclpy
|
||||
|
||||
return
|
||||
except ImportError as e:
|
||||
if not str(e).startswith("DLL load failed"):
|
||||
return
|
||||
cp = os.environ["CONDA_PREFIX"]
|
||||
impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py")
|
||||
pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd"))
|
||||
if not os.path.exists(impl) or not pyd:
|
||||
return
|
||||
with open(impl, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/")
|
||||
patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n'
|
||||
shutil.copy2(impl, impl + ".bak")
|
||||
with open(impl, "w", encoding="utf-8") as f:
|
||||
f.write(patch + content)
|
||||
|
||||
|
||||
patch_rclpy_dll_windows()
|
||||
|
||||
import gc
|
||||
import threading
|
||||
import time
|
||||
|
||||
from unilabos.utils.banner_print import print_status
|
||||
|
||||
|
||||
def cleanup_for_restart() -> bool:
|
||||
"""
|
||||
Clean up all resources for restart without exiting the process.
|
||||
|
||||
This function prepares the system for re-initialization by:
|
||||
1. Stopping all communication clients
|
||||
2. Destroying ROS nodes
|
||||
3. Resetting singletons
|
||||
4. Waiting for threads to finish
|
||||
|
||||
Returns:
|
||||
bool: True if cleanup was successful, False otherwise
|
||||
"""
|
||||
print_status("[Restart] Starting cleanup for restart...", "info")
|
||||
|
||||
# Step 1: Stop WebSocket communication client
|
||||
print_status("[Restart] Step 1: Stopping WebSocket client...", "info")
|
||||
try:
|
||||
from unilabos.app.communication import get_communication_client
|
||||
|
||||
comm_client = get_communication_client()
|
||||
if comm_client is not None:
|
||||
comm_client.stop()
|
||||
print_status("[Restart] WebSocket client stopped", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error stopping WebSocket: {e}", "warning")
|
||||
|
||||
# Step 2: Get HostNode and cleanup ROS
|
||||
print_status("[Restart] Step 2: Cleaning up ROS nodes...", "info")
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
import rclpy
|
||||
from rclpy.timer import Timer
|
||||
|
||||
host_instance = HostNode.get_instance(timeout=5)
|
||||
if host_instance is not None:
|
||||
print_status(f"[Restart] Found HostNode: {host_instance.device_id}", "info")
|
||||
|
||||
# Gracefully shutdown background threads
|
||||
print_status("[Restart] Shutting down background threads...", "info")
|
||||
HostNode.shutdown_background_threads(timeout=5.0)
|
||||
print_status("[Restart] Background threads shutdown complete", "info")
|
||||
|
||||
# Stop discovery timer
|
||||
if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer):
|
||||
host_instance._discovery_timer.cancel()
|
||||
print_status("[Restart] Discovery timer cancelled", "info")
|
||||
|
||||
# Destroy device nodes
|
||||
device_count = len(host_instance.devices_instances)
|
||||
print_status(f"[Restart] Destroying {device_count} device instances...", "info")
|
||||
for device_id, device_node in list(host_instance.devices_instances.items()):
|
||||
try:
|
||||
if hasattr(device_node, "ros_node_instance") and device_node.ros_node_instance is not None:
|
||||
device_node.ros_node_instance.destroy_node()
|
||||
print_status(f"[Restart] Device {device_id} destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying device {device_id}: {e}", "warning")
|
||||
|
||||
# Clear devices instances
|
||||
host_instance.devices_instances.clear()
|
||||
host_instance.devices_names.clear()
|
||||
|
||||
# Destroy host node
|
||||
try:
|
||||
host_instance.destroy_node()
|
||||
print_status("[Restart] HostNode destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying HostNode: {e}", "warning")
|
||||
|
||||
# Reset HostNode state
|
||||
HostNode.reset_state()
|
||||
print_status("[Restart] HostNode state reset", "info")
|
||||
|
||||
# Shutdown executor first (to stop executor.spin() gracefully)
|
||||
if hasattr(rclpy, "__executor") and rclpy.__executor is not None:
|
||||
try:
|
||||
rclpy.__executor.shutdown()
|
||||
rclpy.__executor = None # Clear for restart
|
||||
print_status("[Restart] ROS executor shutdown complete", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error shutting down executor: {e}", "warning")
|
||||
|
||||
# Shutdown rclpy
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
print_status("[Restart] rclpy shutdown complete", "info")
|
||||
|
||||
except ImportError as e:
|
||||
print_status(f"[Restart] ROS modules not available: {e}", "warning")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error in ROS cleanup: {e}", "warning")
|
||||
return False
|
||||
|
||||
# Step 3: Reset communication client singleton
|
||||
print_status("[Restart] Step 3: Resetting singletons...", "info")
|
||||
try:
|
||||
from unilabos.app import communication
|
||||
|
||||
if hasattr(communication, "_communication_client"):
|
||||
communication._communication_client = None
|
||||
print_status("[Restart] Communication client singleton reset", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error resetting communication singleton: {e}", "warning")
|
||||
|
||||
# Step 4: Wait for threads to finish
|
||||
print_status("[Restart] Step 4: Waiting for threads to finish...", "info")
|
||||
time.sleep(3) # Give threads time to finish
|
||||
|
||||
# Check remaining threads
|
||||
remaining_threads = []
|
||||
for t in threading.enumerate():
|
||||
if t.name != "MainThread" and t.is_alive():
|
||||
remaining_threads.append(t.name)
|
||||
|
||||
if remaining_threads:
|
||||
print_status(
|
||||
f"[Restart] Warning: {len(remaining_threads)} threads still running: {remaining_threads}", "warning"
|
||||
)
|
||||
else:
|
||||
print_status("[Restart] All threads stopped", "info")
|
||||
|
||||
# Step 5: Force garbage collection
|
||||
print_status("[Restart] Step 5: Running garbage collection...", "info")
|
||||
gc.collect()
|
||||
gc.collect() # Run twice for weak references
|
||||
print_status("[Restart] Garbage collection complete", "info")
|
||||
|
||||
print_status("[Restart] Cleanup complete. Ready for re-initialization.", "info")
|
||||
return True
|
||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
||||
"result": {},
|
||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||
"handles": [],
|
||||
"handles": {},
|
||||
}
|
||||
# 不生成已配置action的动作
|
||||
for k, v in enhanced_info["action_methods"].items()
|
||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
||||
# 启动广播任务
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(broadcast_device_status())
|
||||
asyncio.create_task(broadcast_status_page_data())
|
||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
||||
|
||||
@@ -3,15 +3,15 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
||||
|
||||
import requests
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
from unilabos.config.config import HTTPConfig, BasicConfig
|
||||
from unilabos.utils import logger
|
||||
@@ -36,6 +36,9 @@ class HTTPClient:
|
||||
auth_secret = BasicConfig.auth_secret()
|
||||
self.auth = auth_secret
|
||||
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
||||
# 复用 TCP/TLS 连接,避免每次请求重新握手
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
|
||||
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
||||
|
||||
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||
@@ -48,7 +51,7 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.post(
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/edge/material/edge",
|
||||
json={
|
||||
"edges": resources,
|
||||
@@ -75,24 +78,28 @@ class HTTPClient:
|
||||
Returns:
|
||||
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))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
# dump() 只调用一次,复用给文件保存和 HTTP 请求
|
||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
|
||||
body_bytes = _fast_dumps(payload)
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
|
||||
f.write(_fast_dumps_pretty(payload))
|
||||
http_headers = {"Content-Type": "application/json"}
|
||||
if not self.initialized or first_add:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
data=body_bytes,
|
||||
headers=http_headers,
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
response = self._session.put(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
data=body_bytes,
|
||||
headers=http_headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -110,6 +117,7 @@ class HTTPClient:
|
||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||
else:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
logger.trace(f"添加物料失败: {nodes_info}")
|
||||
for u, n in old_uuids.items():
|
||||
if u in uuid_mapping:
|
||||
n.res_content.uuid = uuid_mapping[u]
|
||||
@@ -130,7 +138,7 @@ class HTTPClient:
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
||||
response = requests.post(
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/edge/material/query",
|
||||
json={"uuids": uuid_list, "with_children": with_children},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -144,6 +152,7 @@ class HTTPClient:
|
||||
logger.error(f"查询物料失败: {response.text}")
|
||||
else:
|
||||
data = res["data"]["nodes"]
|
||||
logger.trace(f"resource_tree_get查询到物料: {data}")
|
||||
return data
|
||||
else:
|
||||
logger.error(f"查询物料失败: {response.text}")
|
||||
@@ -161,14 +170,14 @@ class HTTPClient:
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
response = self._session.put(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -195,7 +204,7 @@ class HTTPClient:
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
||||
response = requests.get(
|
||||
response = self._session.get(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
params={"id": id, "with_children": with_children},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -236,14 +245,14 @@ class HTTPClient:
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
response = self._session.put(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -273,7 +282,7 @@ class HTTPClient:
|
||||
with open(file_path, "rb") as file:
|
||||
files = {"files": file}
|
||||
logger.info(f"上传文件: {file_path} 到 {scene}")
|
||||
response = requests.post(
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||
files=files,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -281,24 +290,60 @@ class HTTPClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||
def resource_registry(
|
||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
||||
) -> requests.Response:
|
||||
"""
|
||||
注册资源到服务器
|
||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
||||
|
||||
Args:
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
||||
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.post(
|
||||
# 序列化一次,同时用于保存和发送
|
||||
json_bytes = _fast_dumps(registry_data)
|
||||
|
||||
# 保存请求数据到 unilabos_data
|
||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
||||
try:
|
||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
||||
with open(req_path, "wb") as f:
|
||||
f.write(_fast_dumps_pretty(registry_data))
|
||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
||||
|
||||
compressed_body = gzip.compress(json_bytes)
|
||||
headers = {
|
||||
"Authorization": f"Lab {self.auth}",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
response = self._session.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
data=compressed_body,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 保存响应数据到 unilabos_data
|
||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
||||
try:
|
||||
with open(res_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}\n{response.text}")
|
||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"注册资源失败: {response.text}")
|
||||
return response
|
||||
|
||||
def request_startup_json(self) -> Optional[Dict[str, Any]]:
|
||||
@@ -311,7 +356,7 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.get(
|
||||
response = self._session.get(
|
||||
f"{self.remote_addr}/edge/material/download",
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=(3, 30),
|
||||
@@ -331,6 +376,106 @@ 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,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
workflow_uuid: 工作流UUID
|
||||
workflow_name: 工作流名称(data内部)
|
||||
nodes: 工作流节点列表
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
payload = {
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_name": workflow_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
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 = self._session.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
|
||||
# 导入成功后,如果需要发布则额外发起发布请求
|
||||
if published:
|
||||
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
||||
publish_res = self.workflow_publish(imported_uuid, description)
|
||||
res["publish_result"] = publish_res
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
发布工作流
|
||||
|
||||
Args:
|
||||
workflow_uuid: 工作流UUID
|
||||
description: 工作流描述
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据
|
||||
"""
|
||||
payload = {
|
||||
"uuid": workflow_uuid,
|
||||
"description": description,
|
||||
"published": True,
|
||||
}
|
||||
logger.info(f"正在发布工作流: {workflow_uuid}")
|
||||
response = requests.patch(
|
||||
f"{self.remote_addr}/lab/workflow/owner",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"发布工作流失败: {response.text}")
|
||||
else:
|
||||
logger.info(f"工作流发布成功: {workflow_uuid}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||