mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-10 01:35:10 +00:00
Compare commits
452 Commits
v0.10.14
...
af15ae0b3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af15ae0b3e | ||
|
|
5a5f4e037b | ||
|
|
a90613f3de | ||
|
|
2b04457037 | ||
|
|
9a06ef3836 | ||
|
|
ce3f2b33c5 | ||
|
|
31c9f9a172 | ||
|
|
6f143b068b | ||
|
|
02cd8de4c5 | ||
|
|
03423e4791 | ||
|
|
a66603ec1c | ||
|
|
ec015e16cd | ||
|
|
9fc6781406 | ||
|
|
9753ef02c3 | ||
|
|
965bf36e8d | ||
|
|
6a0614c0c9 | ||
|
|
aacf3497e0 | ||
|
|
657f952e7a | ||
|
|
0165590290 | ||
|
|
daea1ab54d | ||
|
|
a25e8f6853 | ||
|
|
93cb307396 | ||
|
|
1c312772ae | ||
|
|
bad1db5094 | ||
|
|
f26eb69eca | ||
|
|
12c0770c92 | ||
|
|
3d2d428a96 | ||
|
|
78bf57f590 | ||
|
|
e227cddab3 | ||
|
|
f2b993643f | ||
|
|
2e14bf197c | ||
|
|
66c18c080a | ||
|
|
5c249e66a2 | ||
|
|
93ac095e0a | ||
|
|
288d9fea91 | ||
|
|
a1c34f138e | ||
|
|
75bb5ec553 | ||
|
|
bb95c89829 | ||
|
|
394c140830 | ||
|
|
e6d8d41183 | ||
|
|
847a300af3 | ||
|
|
a201d7c307 | ||
|
|
3433766bc5 | ||
|
|
7e9e93b29c | ||
|
|
9e1e6da505 | ||
|
|
81b28cef71 | ||
|
|
d57e5ffdae | ||
|
|
d5e0d76311 | ||
|
|
beaa1d7213 | ||
|
|
1e5f6b0c04 | ||
|
|
8a0f000bab | ||
|
|
2ffeb49acb | ||
|
|
5fec753fb9 | ||
|
|
acbaff7bb7 | ||
|
|
706323dc3e | ||
|
|
b0804d939c | ||
|
|
5ae89d8607 | ||
|
|
74d0ea3379 | ||
|
|
97788b4e07 | ||
|
|
39cc280c91 | ||
|
|
440c9965fd | ||
|
|
9cac852bc3 | ||
|
|
de662a42aa | ||
|
|
d0ac452405 | ||
|
|
152d3a7563 | ||
|
|
ef14737839 | ||
|
|
5d5569121c | ||
|
|
d23e85ade4 | ||
|
|
02afafd423 | ||
|
|
6ac510dcd2 | ||
|
|
632f9b90d1 | ||
|
|
d7c970d244 | ||
|
|
2c69e663a7 | ||
|
|
f03ff96ae4 | ||
|
|
ed56c1eba2 | ||
|
|
16ee3de086 | ||
|
|
c68903ed83 | ||
|
|
8efbbbe72a | ||
|
|
ced961050d | ||
|
|
11b2c99836 | ||
|
|
04024bc8a3 | ||
|
|
154048107d | ||
|
|
0b896870ba | ||
|
|
ee609e4aa2 | ||
|
|
5551fbf360 | ||
|
|
4a23b05abc | ||
|
|
e13b250632 | ||
|
|
6d8884a2c7 | ||
|
|
b8278c5026 | ||
|
|
53e767a054 | ||
|
|
cf7032fa81 | ||
|
|
97681ba433 | ||
|
|
3fa81ab4f6 | ||
|
|
c0e7a69553 | ||
|
|
fb6ee79577 | ||
|
|
9f4a69ddf5 | ||
|
|
05ae4e72df | ||
|
|
dbe129caab | ||
|
|
7250995891 | ||
|
|
2870c04086 | ||
|
|
343e87df0d | ||
|
|
68eddbdffd | ||
|
|
32bd234176 | ||
|
|
3d62e8bf6c | ||
|
|
efec1dd501 | ||
|
|
c16756ddb3 | ||
|
|
daf41871a1 | ||
|
|
6b0b28becf | ||
|
|
5d0807cba6 | ||
|
|
0f7366f3ee | ||
|
|
4875977d5f | ||
|
|
956b1c905b | ||
|
|
944911c52a | ||
|
|
a13b790926 | ||
|
|
9feadd68c6 | ||
|
|
c68d5246d0 | ||
|
|
49073f2c77 | ||
|
|
b2afc29f15 | ||
|
|
4061280f6b | ||
|
|
6a681e1d73 | ||
|
|
653e6e1ac3 | ||
|
|
2c774bcd1d | ||
|
|
2ba395b681 | ||
|
|
b6b3d59083 | ||
|
|
f40e3f521c | ||
|
|
7cc2fe036f | ||
|
|
f81d20bb1d | ||
|
|
db1b5a869f | ||
|
|
0136630700 | ||
|
|
3c31811f9e | ||
|
|
64f02ff129 | ||
|
|
7d097b8222 | ||
|
|
d266d21104 | ||
|
|
b6d0bbcb17 | ||
|
|
31ebff8e37 | ||
|
|
2132895ba2 | ||
|
|
850eeae55a | ||
|
|
d869c14233 | ||
|
|
24101b3cec | ||
|
|
3bf8aad4d5 | ||
|
|
a599eb70e5 | ||
|
|
0bf6994f95 | ||
|
|
c36f53791c | ||
|
|
eb4d2d96c5 | ||
|
|
8233c41b1d | ||
|
|
0dfd4ce8a8 | ||
|
|
7953b3820e | ||
|
|
eed233fa76 | ||
|
|
0c55147ee4 | ||
|
|
ce6267b8e0 | ||
|
|
975e51cd96 | ||
|
|
c5056b381c | ||
|
|
c35da65b15 | ||
|
|
659cf05be6 | ||
|
|
3b8deb4d1d | ||
|
|
c796615f9f | ||
|
|
a5bad6074f | ||
|
|
1d3a07a736 | ||
|
|
cc2cd57cdf | ||
|
|
39bb7dc627 | ||
|
|
0fda155f55 | ||
|
|
6e3eacd2f0 | ||
|
|
062f1a2153 | ||
|
|
61e8d67800 | ||
|
|
d0884cdbd8 | ||
|
|
545ea45024 | ||
|
|
b9ddee8f2c | ||
|
|
a0c5095304 | ||
|
|
e504505137 | ||
|
|
4d9d5701e9 | ||
|
|
6016c4b588 | ||
|
|
be02bef9c4 | ||
|
|
e62f0c2585 | ||
|
|
b6de0623e2 | ||
|
|
9d081e9fcd | ||
|
|
85a58e3464 | ||
|
|
85590672d8 | ||
|
|
1d4018196d | ||
|
|
5d34f742af | ||
|
|
5bef19e6d6 | ||
|
|
f816799753 | ||
|
|
a45d841769 | ||
|
|
7f0b33b3e3 | ||
|
|
2006406a24 | ||
|
|
f94985632b | ||
|
|
12ba110569 | ||
|
|
97212be8b7 | ||
|
|
9bdd42f12f | ||
|
|
627140da03 | ||
|
|
5ceedb0565 | ||
|
|
8c77a20c43 | ||
|
|
3ff894feee | ||
|
|
fa5896ffdb | ||
|
|
eb504803ac | ||
|
|
8b0c845661 | ||
|
|
693873bfa9 | ||
|
|
57da2d8da2 | ||
|
|
8d1fd01259 | ||
|
|
388259e64b | ||
|
|
2c130e7f37 | ||
|
|
9f7c3f02f9 | ||
|
|
19dd80dcdb | ||
|
|
9d5ed627a2 | ||
|
|
2d0ff87bc8 | ||
|
|
d78475de9a | ||
|
|
88ae56806c | ||
|
|
95dd8beb81 | ||
|
|
4ab3fadbec | ||
|
|
229888f834 | ||
|
|
b443b39ebf | ||
|
|
0434bbc15b | ||
|
|
5791b81954 | ||
|
|
bd51c74fab | ||
|
|
ba81cbddf8 | ||
|
|
4e92a26057 | ||
|
|
c2895bb197 | ||
|
|
0423f4f452 | ||
|
|
41390fbef9 | ||
|
|
98bdb4e7e4 | ||
|
|
30037a077a | ||
|
|
6972680099 | ||
|
|
9d2c93807d | ||
|
|
e728007bc5 | ||
|
|
9c5ecda7cc | ||
|
|
2d26c3fac6 | ||
|
|
f5753afb7c | ||
|
|
398b2dde3f | ||
|
|
62c4135938 | ||
|
|
027b4269c4 | ||
|
|
3757bd9c58 | ||
|
|
c75b7d5aae | ||
|
|
dfc635189c | ||
|
|
d8f3ebac15 | ||
|
|
4a1e703a3a | ||
|
|
55d22a7c29 | ||
|
|
03a4e4ecba | ||
|
|
2316c34cb5 | ||
|
|
a8887161d3 | ||
|
|
25834f5ba0 | ||
|
|
a1e9332b51 | ||
|
|
357fc038ef | ||
|
|
fd58ef07f3 | ||
|
|
93dee2c1dc | ||
|
|
70fbf19009 | ||
|
|
9149155232 | ||
|
|
1ca1792e3c | ||
|
|
485e7e8dd2 | ||
|
|
4ddabdcb65 | ||
|
|
a5b0325301 | ||
|
|
50b44938c7 | ||
|
|
df0d2235b0 | ||
|
|
4e434eeb97 | ||
|
|
ca027bf0eb | ||
|
|
635a332b4e | ||
|
|
edf7a117ca | ||
|
|
70b2715996 | ||
|
|
7e8dfc2dc5 | ||
|
|
9b626489a8 | ||
|
|
03fe208743 | ||
|
|
e913e540a3 | ||
|
|
aed39b648d | ||
|
|
8c8359fab3 | ||
|
|
5d20be0762 | ||
|
|
09f745d300 | ||
|
|
bbcbcde9a4 | ||
|
|
42b437cdea | ||
|
|
ffd0f2d26a | ||
|
|
32422c0b3d | ||
|
|
c44e597dc0 | ||
|
|
4eef012a8e | ||
|
|
ac69452f3c | ||
|
|
57b30f627b | ||
|
|
2d2a4ca067 | ||
|
|
a2613aad4c | ||
|
|
54f75183ff | ||
|
|
735be067dc | ||
|
|
0fe62d64f0 | ||
|
|
2d4ecec1e1 | ||
|
|
0f976a1874 | ||
|
|
b263a7e679 | ||
|
|
7c7f1b31c5 | ||
|
|
00e668e140 | ||
|
|
4989f65a0b | ||
|
|
9fa3688196 | ||
|
|
40fb1ea49c | ||
|
|
18b0bb397e | ||
|
|
65abc5dbf7 | ||
|
|
2455ca15ba | ||
|
|
05a3ff607a | ||
|
|
ec882df36d | ||
|
|
43b992e3eb | ||
|
|
6422fa5a9a | ||
|
|
434b9e98e0 | ||
|
|
040073f430 | ||
|
|
3d95c9896a | ||
|
|
9aa97ed01e | ||
|
|
0b8bdf5e0a | ||
|
|
299f010754 | ||
|
|
15ce0d6883 | ||
|
|
dec474e1a7 | ||
|
|
5f187899fc | ||
|
|
c8d16c7024 | ||
|
|
25d46dc9d5 | ||
|
|
88c4d1a9d1 | ||
|
|
81fd8291c5 | ||
|
|
3a11eb90d4 | ||
|
|
387866b9c9 | ||
|
|
7f40f141f6 | ||
|
|
6fc7ed1b88 | ||
|
|
93f0e08d75 | ||
|
|
4b43734b55 | ||
|
|
174b1914d4 | ||
|
|
704e13f030 | ||
|
|
0c42d60cf2 | ||
|
|
df33e1a214 | ||
|
|
1f49924966 | ||
|
|
609b6006e8 | ||
|
|
67c01271b7 | ||
|
|
a1783f489e | ||
|
|
a8f6527de9 | ||
|
|
54cfaf15f3 | ||
|
|
5610c28b67 | ||
|
|
cfc1ee6e79 | ||
|
|
1c9d2ee98a | ||
|
|
3fe8f4ca44 | ||
|
|
2476821dcc | ||
|
|
7b426ed5ae | ||
|
|
9bbae96447 | ||
|
|
10aabb7592 | ||
|
|
709eb0d91c | ||
|
|
14b7d52825 | ||
|
|
a5397ffe12 | ||
|
|
c6c2da69ba | ||
|
|
622e579063 | ||
|
|
196e0f7e2b | ||
|
|
a632fd495e | ||
|
|
a8cc02a126 | ||
|
|
ad2e1432c6 | ||
|
|
c3b9583eac | ||
|
|
5c47cd0c8a | ||
|
|
63ab1af45d | ||
|
|
a8419dc0c3 | ||
|
|
34f05f2e25 | ||
|
|
0dc2488f02 | ||
|
|
f13156e792 | ||
|
|
13fd1ac572 | ||
|
|
f8ef6e0686 | ||
|
|
94a7b8aaca | ||
|
|
301bea639e | ||
|
|
4b5a83efa4 | ||
|
|
2889e9be2c | ||
|
|
304aebbba7 | ||
|
|
091c9fa247 | ||
|
|
67ca45a240 | ||
|
|
7aab2ea493 | ||
|
|
62f3a6d696 | ||
|
|
eb70ad0e18 | ||
|
|
768f43880e | ||
|
|
762c3c737c | ||
|
|
ace98a4472 | ||
|
|
41eaa88c6f | ||
|
|
a1a55a2c0a | ||
|
|
2eaa0ca729 | ||
|
|
6f8f070f40 | ||
|
|
da4bd927e0 | ||
|
|
01f8816597 | ||
|
|
e5006285df | ||
|
|
573c724a5c | ||
|
|
09549d2839 | ||
|
|
50c7777cea | ||
|
|
4888f02c09 | ||
|
|
779c9693d9 | ||
|
|
ffa841a41a | ||
|
|
fc669f09f8 | ||
|
|
2ca0311de6 | ||
|
|
94cdcbf24e | ||
|
|
1cd07915e7 | ||
|
|
b600fc666d | ||
|
|
9e214c56c1 | ||
|
|
bdf27a7e82 | ||
|
|
2493fb9f94 | ||
|
|
c7a0ff67a9 | ||
|
|
711a7c65fa | ||
|
|
cde7956896 | ||
|
|
95b6fd0451 | ||
|
|
513e848d89 | ||
|
|
58d1cc4720 | ||
|
|
5676dd6589 | ||
|
|
1ae274a833 | ||
|
|
22b88c8441 | ||
|
|
81bcc1907d | ||
|
|
8cffd3dc21 | ||
|
|
a722636938 | ||
|
|
f68340d932 | ||
|
|
361eae2f6d | ||
|
|
c25283ae04 | ||
|
|
961752fb0d | ||
|
|
55165024dd | ||
|
|
6ddceb8393 | ||
|
|
4e52c7d2f4 | ||
|
|
0b56efc89d | ||
|
|
a27b93396a | ||
|
|
2a60a6c27e | ||
|
|
5dda94044d | ||
|
|
0cfc6f45e3 | ||
|
|
831f4549f9 | ||
|
|
f4d4eb06d3 | ||
|
|
e3b8164f6b | ||
|
|
78c04acc2e | ||
|
|
cd0428ea78 | ||
|
|
bdddbd57ba | ||
|
|
a312de08a5 | ||
|
|
68513b5745 | ||
|
|
19027350fb | ||
|
|
bbbdb06bbc | ||
|
|
cd84e26126 | ||
|
|
ce5bab3af1 | ||
|
|
82d9ef6bf7 | ||
|
|
332b33c6f4 | ||
|
|
1ec642ee3a | ||
|
|
7d8e6d029b | ||
|
|
5ec8a57a1f | ||
|
|
ae3c1100ae | ||
|
|
14bc2e6cda | ||
|
|
9f823a4198 | ||
|
|
02c79363c1 | ||
|
|
227ff1284a | ||
|
|
4b7bde6be5 | ||
|
|
8a669ac35a | ||
|
|
a1538da39e | ||
|
|
0063df4cf3 | ||
|
|
e570ba4976 | ||
|
|
e8c1f76dbb | ||
|
|
f791c1a342 | ||
|
|
ea60cbe891 | ||
|
|
eac9b8ab3d | ||
|
|
573bcf1a6c | ||
|
|
50e93cb1af | ||
|
|
fe1a029a9b | ||
|
|
662c063f50 | ||
|
|
01cbbba0b3 | ||
|
|
e6c556cf19 | ||
|
|
0605f305ed | ||
|
|
37d8108ec4 | ||
|
|
6081dac561 | ||
|
|
5b2d066127 | ||
|
|
06e66765e7 | ||
|
|
98ce360088 | ||
|
|
5cd0f72fbd | ||
|
|
343f394203 | ||
|
|
46aa7a7bd2 | ||
|
|
a66369e2c3 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.14
|
||||
version: 0.10.15
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
2
.github/workflows/conda-pack-build.yml
vendored
2
.github/workflows/conda-pack-build.yml
vendored
@@ -24,7 +24,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
|
||||
|
||||
2
.github/workflows/multi-platform-build.yml
vendored
2
.github/workflows/multi-platform-build.yml
vendored
@@ -27,7 +27,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
|
||||
|
||||
2
.github/workflows/unilabos-conda-build.yml
vendored
2
.github/workflows/unilabos-conda-build.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
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
|
||||
|
||||
14
README.md
14
README.md
@@ -31,7 +31,9 @@ Detailed documentation can be found at:
|
||||
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
# Create new environment
|
||||
@@ -40,7 +42,7 @@ mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## Install Dev Uni-Lab-OS
|
||||
2. Install Dev Uni-Lab-OS
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -51,17 +53,21 @@ cd Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. Start Uni-Lab System:
|
||||
3. Start Uni-Lab System
|
||||
|
||||
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/deepmodeling/Uni-Lab-OS/releases) page.
|
||||
|
||||
## Citation
|
||||
|
||||
If you use Uni-Lab-OS in academic research, please cite:
|
||||
If you use [Uni-Lab-OS](https://arxiv.org/abs/2512.21766) in academic research, please cite:
|
||||
|
||||
```bibtex
|
||||
@article{gao2025unilabos,
|
||||
|
||||
@@ -53,17 +53,21 @@ cd Uni-Lab-OS
|
||||
pip install .
|
||||
```
|
||||
|
||||
3. 启动 Uni-Lab 系统:
|
||||
3. 启动 Uni-Lab 系统
|
||||
|
||||
请见[文档-启动样例](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/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
||||
|
||||
## 引用
|
||||
|
||||
如果您在学术研究中使用 Uni-Lab-OS,请引用:
|
||||
如果您在学术研究中使用 [Uni-Lab-OS](https://arxiv.org/abs/2512.21766),请引用:
|
||||
|
||||
```bibtex
|
||||
@article{gao2025unilabos,
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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) # {}
|
||||
```
|
||||
|
||||
### 格式迁移建议
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.14
|
||||
version: 0.10.15
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.14"
|
||||
version: "0.10.15"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
@@ -25,7 +24,15 @@ class SimpleGraph:
|
||||
|
||||
def add_edge(self, source, target, **attrs):
|
||||
"""添加边"""
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
# edge = {"source": source, "target": target, **attrs}
|
||||
edge = {
|
||||
"source": source, "target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_io": "source",
|
||||
"target_handle_io": "target",
|
||||
**attrs
|
||||
}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -42,6 +49,7 @@ class SimpleGraph:
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
@@ -58,495 +66,8 @@ def extract_json_from_markdown(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template = OPERATION_MAPPING.get(operation)
|
||||
if not template:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||
else:
|
||||
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template": template,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||
}
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||
) -> SimpleGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||
G = SimpleGraph()
|
||||
resource_last_writer = {}
|
||||
LAB_NAME = "SynBioFactory"
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps)
|
||||
|
||||
# 检查协议步骤中的模板来判断协议类型
|
||||
has_biomek_template = any(
|
||||
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||
for step in protocol_steps
|
||||
)
|
||||
|
||||
if has_biomek_template:
|
||||
# 生物实验协议图构建
|
||||
for labware_id, labware in labware_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
labware_attrs = labware.copy()
|
||||
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||
labware_attrs["description"] = labware_id
|
||||
labware_attrs["lab_node_type"] = (
|
||||
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||
)
|
||||
labware_attrs["device_id"] = workstation_name
|
||||
|
||||
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||
|
||||
# 处理协议步骤
|
||||
prev_node = None
|
||||
for i, step in enumerate(protocol_steps):
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 添加控制流边
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||
prev_node = node_id
|
||||
|
||||
# 处理物料流
|
||||
params = step.get("parameters", {})
|
||||
if "sources" in params and params["sources"] in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||
|
||||
if "targets" in params:
|
||||
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||
|
||||
# 添加协议结束节点
|
||||
end_id = str(uuid.uuid4())
|
||||
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||
|
||||
else:
|
||||
# 有机化学协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
for item_id, item in labware_info.items():
|
||||
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# 判断节点类型
|
||||
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||
if "reactor" not in str(item_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {item_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {item_id}"
|
||||
liquid_type = [item_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template=f"{LAB_NAME}-host_node-create_resource",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
res_id=item_id,
|
||||
device_id=WORKSTATION_ID,
|
||||
class_name="container",
|
||||
parent=WORKSTATION_ID,
|
||||
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
liquid_input_slot=[-1],
|
||||
liquid_type=liquid_type,
|
||||
liquid_volume=liquid_volume,
|
||||
slot_on_deck="",
|
||||
role=item.get("role", ""),
|
||||
)
|
||||
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||
|
||||
last_control_node_id = None
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 物料流
|
||||
params = step.get("parameters", {})
|
||||
input_resources = {
|
||||
"Vessel": params.get("vessel"),
|
||||
"ToVessel": params.get("to_vessel"),
|
||||
"FromVessel": params.get("from_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources": params.get("sources"),
|
||||
"targets": params.get("targets"),
|
||||
}
|
||||
|
||||
for target_port, resource_name in input_resources.items():
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
|
||||
output_resources = {
|
||||
"VesselOut": params.get("vessel"),
|
||||
"FromVesselOut": params.get("from_vessel"),
|
||||
"ToVesselOut": params.get("to_vessel"),
|
||||
"FiltrateOut": params.get("filtrate_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources_out": params.get("sources"),
|
||||
"targets_out": params.get("targets"),
|
||||
}
|
||||
|
||||
for source_port, resource_name in output_resources.items():
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import re
|
||||
|
||||
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_port")
|
||||
tp = edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for (u, v, sp, tp) in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
|
||||
def create_workflow(
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.14',
|
||||
version='0.10.15',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.14"
|
||||
__version__ = "0.10.15"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ if unilabos_dir not in sys.path:
|
||||
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
|
||||
# Global restart flags (used by ws_client and web/server)
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
@@ -41,7 +47,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 +55,8 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
|
||||
|
||||
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
|
||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
|
||||
parser.add_argument(
|
||||
@@ -153,6 +161,44 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
help="Disable sending update feedback to server",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
aliases=["wf"],
|
||||
help="Upload workflow from xdl/json/python files",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-f",
|
||||
"--workflow_file",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the workflow file (JSON format)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-n",
|
||||
"--workflow_name",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Workflow name, if not provided will use the name from file or filename",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--tags",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Tags for the workflow (space-separated)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--published",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -168,7 +214,6 @@ def main():
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
|
||||
print_status("正在进行环境依赖检查...", "info")
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
@@ -241,9 +286,12 @@ def main():
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
BasicConfig.working_dir = working_dir
|
||||
|
||||
workflow_upload = args_dict.get("command") in ("workflow_upload", "wf")
|
||||
|
||||
# 使用远程资源启动
|
||||
if args_dict["use_remote_resource"]:
|
||||
if not workflow_upload and args_dict["use_remote_resource"]:
|
||||
print_status("使用远程资源启动", "info")
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
@@ -256,10 +304,10 @@ 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.communication_protocol = "websocket"
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
@@ -278,16 +326,38 @@ def main():
|
||||
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
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
# 处理 workflow_upload 子命令
|
||||
if workflow_upload:
|
||||
from unilabos.workflow.wf_utils import handle_workflow_upload_command
|
||||
|
||||
handle_workflow_upload_command(args_dict)
|
||||
print_status("工作流上传完成,程序退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
@@ -368,20 +438,6 @@ def main():
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
args_dict["graph"] = graph_res.physical_setup_graph
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
if args_dict["controllers"] is not None:
|
||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||
else:
|
||||
@@ -396,6 +452,7 @@ def main():
|
||||
comm_client = get_communication_client()
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(comm_client)
|
||||
|
||||
def _exit(signum, frame):
|
||||
comm_client.stop()
|
||||
sys.exit(0)
|
||||
@@ -437,16 +494,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
|
||||
@@ -454,13 +508,19 @@ 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,
|
||||
)
|
||||
|
||||
144
unilabos/app/utils.py
Normal file
144
unilabos/app/utils.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
UniLabOS 应用工具函数
|
||||
|
||||
提供清理、重启等工具函数
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
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
|
||||
@@ -6,12 +6,10 @@ HTTP客户端模块
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
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
|
||||
@@ -76,7 +74,8 @@ class HTTPClient:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
if not self.initialized or first_add:
|
||||
@@ -335,6 +334,67 @@ class HTTPClient:
|
||||
logger.error(f"响应内容: {response.text}")
|
||||
return None
|
||||
|
||||
def workflow_import(
|
||||
self,
|
||||
name: str,
|
||||
workflow_uuid: str,
|
||||
workflow_name: str,
|
||||
nodes: List[Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
workflow_uuid: 工作流UUID
|
||||
workflow_name: 工作流名称(data内部)
|
||||
nodes: 工作流节点列表
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||
payload = {
|
||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_name": workflow_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
# 保存响应到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -6,7 +6,6 @@ Web服务器模块
|
||||
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import Response
|
||||
@@ -96,7 +95,7 @@ def setup_server() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool:
|
||||
"""
|
||||
启动服务器
|
||||
|
||||
@@ -104,7 +103,14 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
host: 服务器主机
|
||||
port: 服务器端口
|
||||
open_browser: 是否自动打开浏览器
|
||||
|
||||
Returns:
|
||||
bool: True if restart was requested, False otherwise
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from uvicorn import Config, Server
|
||||
|
||||
# 设置服务器
|
||||
setup_server()
|
||||
|
||||
@@ -123,7 +129,37 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
|
||||
# 启动服务器
|
||||
info(f"[Web] 启动FastAPI服务器: {host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_config=log_config)
|
||||
|
||||
# 使用支持重启的模式
|
||||
config = Config(app=app, host=host, port=port, log_config=log_config)
|
||||
server = Server(config)
|
||||
|
||||
# 启动服务器线程
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
while server_thread.is_alive():
|
||||
if hasattr(main_module, "_restart_requested") and main_module._restart_requested:
|
||||
info(
|
||||
f"[Web] Restart requested via WebSocket, reason: {getattr(main_module, '_restart_reason', 'unknown')}"
|
||||
)
|
||||
main_module._restart_requested = False
|
||||
|
||||
# 停止服务器
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
info("[Web] Server stopped, ready for restart")
|
||||
return True
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# 当脚本直接运行时启动服务器
|
||||
|
||||
@@ -359,7 +359,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = ""
|
||||
self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -438,7 +438,7 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
@@ -488,7 +488,16 @@ class MessageProcessor:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self._process_message(data)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}")
|
||||
logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}")
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
@@ -503,7 +512,7 @@ class MessageProcessor:
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.debug("[MessageProcessor] Send handler started")
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
|
||||
try:
|
||||
while self.connected and self.websocket:
|
||||
@@ -554,11 +563,8 @@ class MessageProcessor:
|
||||
finally:
|
||||
logger.debug("[MessageProcessor] Send handler stopped")
|
||||
|
||||
async def _process_message(self, data: Dict[str, Any]):
|
||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
try:
|
||||
@@ -571,16 +577,19 @@ class MessageProcessor:
|
||||
elif message_type == "cancel_action" or message_type == "cancel_task":
|
||||
await self._handle_cancel_action(message_data)
|
||||
elif message_type == "add_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "add")
|
||||
elif message_type == "update_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "request_reload":
|
||||
await self._handle_request_reload(message_data)
|
||||
# elif message_type == "session_id":
|
||||
# self.session_id = message_data.get("session_id")
|
||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "request_restart":
|
||||
await self._handle_request_restart(message_data)
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -839,7 +848,7 @@ class MessageProcessor:
|
||||
device_action_groups[key_add].append(item["uuid"])
|
||||
|
||||
logger.info(
|
||||
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
)
|
||||
else:
|
||||
# 正常update
|
||||
@@ -854,11 +863,11 @@ class MessageProcessor:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
|
||||
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
|
||||
# 为每个(device_id, action)创建独立的更新线程
|
||||
for (device_id, actual_action), items in device_action_groups.items():
|
||||
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
||||
|
||||
def _notify_resource_tree(dev_id, act, item_list):
|
||||
try:
|
||||
@@ -890,19 +899,48 @@ class MessageProcessor:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_request_reload(self, data: Dict[str, Any]):
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重载请求
|
||||
|
||||
当LabGo发送request_reload时,重新发送设备注册信息
|
||||
处理重启请求
|
||||
|
||||
当LabGo发送request_restart时,执行清理并触发重启
|
||||
"""
|
||||
reason = data.get("reason", "unknown")
|
||||
logger.info(f"[MessageProcessor] Received reload request, reason: {reason}")
|
||||
|
||||
# 重新发送host_node_ready信息
|
||||
delay = data.get("delay", 2) # 默认延迟2秒
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
|
||||
# 发送确认消息
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
logger.info("[MessageProcessor] Re-sent host_node_ready after reload request")
|
||||
await self.websocket_client.send_message({
|
||||
"action": "restart_acknowledged",
|
||||
"data": {"reason": reason, "delay": delay}
|
||||
})
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
main_module._restart_requested = True
|
||||
main_module._restart_reason = reason
|
||||
|
||||
# 延迟后执行清理
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# 在新线程中执行清理,避免阻塞当前事件循环
|
||||
def do_cleanup():
|
||||
import time
|
||||
time.sleep(0.5) # 给当前消息处理完成的时间
|
||||
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
||||
try:
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
if cleanup_for_restart():
|
||||
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
||||
else:
|
||||
logger.error("[MessageProcessor] Cleanup failed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
||||
|
||||
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
||||
cleanup_thread.start()
|
||||
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
||||
|
||||
async def _send_action_state_response(
|
||||
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||
@@ -981,7 +1019,7 @@ class QueueProcessor:
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1191,7 +1229,6 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
else:
|
||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||
|
||||
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||
return url
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -1204,13 +1241,11 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||
return
|
||||
|
||||
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||
|
||||
# 启动两个核心线程
|
||||
self.message_processor.start()
|
||||
self.queue_processor.start()
|
||||
|
||||
logger.info("[WebSocketClient] All threads started")
|
||||
logger.trace("[WebSocketClient] All threads started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止WebSocket客户端"""
|
||||
@@ -1340,7 +1375,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
# 收集设备信息
|
||||
devices = []
|
||||
machine_name = BasicConfig.machine_name
|
||||
|
||||
|
||||
try:
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
@@ -1348,7 +1383,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
for device_id, namespace in host_node.devices_names.items():
|
||||
device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||
is_online = device_key in host_node._online_devices
|
||||
|
||||
|
||||
# 获取设备的动作信息
|
||||
actions = {}
|
||||
for action_id, client in host_node._action_clients.items():
|
||||
@@ -1359,7 +1394,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
"action_path": action_id,
|
||||
"action_type": str(type(client).__name__),
|
||||
}
|
||||
|
||||
|
||||
devices.append({
|
||||
"device_id": device_id,
|
||||
"namespace": namespace,
|
||||
@@ -1368,7 +1403,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
||||
"actions": actions,
|
||||
})
|
||||
|
||||
|
||||
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")
|
||||
|
||||
@@ -16,12 +16,14 @@ class BasicConfig:
|
||||
upload_registry = False
|
||||
machine_name = "undefined"
|
||||
vis_2d_enable = False
|
||||
no_update_feedback = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@classmethod
|
||||
def auth_secret(cls):
|
||||
@@ -65,13 +67,14 @@ def _update_config_from_module(module):
|
||||
if not attr.startswith("_"):
|
||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||
|
||||
|
||||
def _update_config_from_env():
|
||||
prefix = "UNILABOS_"
|
||||
for env_key, env_value in os.environ.items():
|
||||
if not env_key.startswith(prefix):
|
||||
continue
|
||||
try:
|
||||
key_path = env_key[len(prefix):] # Remove UNILAB_ prefix
|
||||
key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix
|
||||
class_field = key_path.upper().split("_", 1)
|
||||
if len(class_field) != 2:
|
||||
logger.warning(f"[ENV] 环境变量格式不正确:{env_key}")
|
||||
|
||||
@@ -6,7 +6,7 @@ Coin Cell Assembly Workstation
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
|
||||
from unilabos.device_comms.workstation_communication import (
|
||||
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
|
||||
@@ -61,7 +61,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
# 创建资源跟踪器(如果没有提供)
|
||||
if resource_tracker is None:
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
|
||||
resource_tracker = DeviceNodeResourceTracker()
|
||||
|
||||
# 初始化基类
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
@@ -210,15 +210,15 @@ class Method(Base):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
@@ -227,16 +227,16 @@ class Method(Base):
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
@@ -271,7 +271,7 @@ class Method(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
@@ -285,7 +285,7 @@ class Method(Base):
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
@@ -293,7 +293,7 @@ class Object(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
@@ -301,4 +301,4 @@ class Object(Base):
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
return [], True
|
||||
|
||||
@@ -13,7 +13,7 @@ from pylabrobot.resources import (
|
||||
import copy
|
||||
from unilabos_msgs.msg import Resource
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker # type: ignore
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker # type: ignore
|
||||
|
||||
|
||||
class LiquidHandlerBiomek:
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
|
||||
|
||||
|
||||
class SimpleLogger:
|
||||
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
self.config = config
|
||||
self.api_key = config["api_key"]
|
||||
self.host = config["api_host"]
|
||||
|
||||
# 初始化 location_mapping
|
||||
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||
self.location_mapping = {}
|
||||
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||
self._logger = SimpleLogger()
|
||||
self.material_cache = {}
|
||||
self._load_material_cache()
|
||||
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
|
||||
return {}
|
||||
|
||||
print(f"add material data: {response['data']}")
|
||||
return response.get("data", {})
|
||||
|
||||
# 自动更新缓存
|
||||
data = response.get("data", {})
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
# 如果返回的是字符串,通常是ID
|
||||
mat_id = data
|
||||
name = params.get("name")
|
||||
else:
|
||||
# 如果返回的是字典,尝试获取name和id
|
||||
name = data.get("name") or params.get("name")
|
||||
mat_id = data.get("id")
|
||||
|
||||
if name and mat_id:
|
||||
self.material_cache[name] = mat_id
|
||||
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||
|
||||
# 处理返回数据中的 details (如果有)
|
||||
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||
details = data.get("details", []) if isinstance(data, dict) else []
|
||||
if not details and isinstance(data, dict):
|
||||
details = data.get("detail", [])
|
||||
|
||||
if details:
|
||||
for detail in details:
|
||||
d_name = detail.get("name")
|
||||
# 尝试从不同字段获取 ID
|
||||
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||
|
||||
if d_name and d_id:
|
||||
self.material_cache[d_name] = d_id
|
||||
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||
|
||||
return data
|
||||
|
||||
def query_matial_type_id(self, data) -> list:
|
||||
"""查找物料typeid"""
|
||||
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": {},
|
||||
"data": 0,
|
||||
})
|
||||
if not response or response['code'] != 1:
|
||||
return []
|
||||
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return {}
|
||||
|
||||
# 自动更新缓存 - 移除被删除的物料
|
||||
for name, mid in list(self.material_cache.items()):
|
||||
if mid == material_id:
|
||||
del self.material_cache[name]
|
||||
print(f"已从缓存移除物料: {name}")
|
||||
break
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
location_id = self.location_mapping.get(location_name, location_name)
|
||||
|
||||
params = {
|
||||
"materialId": material_id,
|
||||
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
|
||||
for detail_material in detail_materials:
|
||||
detail_name = detail_material.get("name")
|
||||
detail_id = detail_material.get("detailMaterialId")
|
||||
if not detail_id:
|
||||
# 尝试其他可能的字段
|
||||
detail_id = detail_material.get("id")
|
||||
|
||||
if detail_name and detail_id:
|
||||
self.material_cache[detail_name] = detail_id
|
||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
# 如果缓存中没有,尝试刷新缓存
|
||||
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||
self.refresh_material_cache()
|
||||
if material_name_or_id in self.material_cache:
|
||||
material_id = self.material_cache[material_name_or_id]
|
||||
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||
return material_name_or_id
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# config.py
|
||||
"""
|
||||
配置文件 - 包含所有配置信息和映射关系
|
||||
"""
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "",
|
||||
"api_host": ""
|
||||
}
|
||||
|
||||
# 工作流映射配置
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "",
|
||||
"reactor_taken_in": "",
|
||||
"Solid_feeding_vials": "",
|
||||
"Liquid_feeding_vials(non-titration)": "",
|
||||
"Liquid_feeding_solvents": "",
|
||||
"Liquid_feeding(titration)": "",
|
||||
"liquid_feeding_beaker": "",
|
||||
"Drip_back": "",
|
||||
}
|
||||
|
||||
# 工作流名称到DisplaySectionName的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'reactor_taken_out': '反应器取出'
|
||||
}
|
||||
|
||||
# 库位映射配置
|
||||
WAREHOUSE_MAPPING = {
|
||||
"粉末堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
# 样品板
|
||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
# 分装板
|
||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 物料类型配置
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
}
|
||||
|
||||
# 步骤参数配置(各工作流的步骤UUID)
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": ""
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
}
|
||||
}
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
|
||||
ACTION_NAMES = {}
|
||||
|
||||
HTTP_SERVICE_CONFIG = {}
|
||||
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
@@ -0,0 +1,329 @@
|
||||
# config.py
|
||||
"""
|
||||
Bioyond工作站配置文件
|
||||
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||
"""
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
|
||||
# ============================================================================
|
||||
# 基础配置
|
||||
# ============================================================================
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44402"
|
||||
}
|
||||
|
||||
# HTTP 报送服务配置
|
||||
HTTP_SERVICE_CONFIG = {
|
||||
"http_service_host": "127.0.0.1", # 监听地址
|
||||
"http_service_port": 8080, # 监听端口
|
||||
}
|
||||
|
||||
# Deck配置 - 反应站工作台配置
|
||||
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||
|
||||
# ============================================================================
|
||||
# 工作流配置
|
||||
# ============================================================================
|
||||
|
||||
# 工作流ID映射
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||
}
|
||||
|
||||
# 工作流名称到显示名称的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'reactor_taken_out': '反应器取出',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Drip_back': '液体回滴'
|
||||
}
|
||||
|
||||
# 工作流步骤ID配置
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||
}
|
||||
}
|
||||
|
||||
# 工作流动作名称配置
|
||||
ACTION_NAMES = {
|
||||
"reactor_taken_in": {
|
||||
"config": "通量-配置",
|
||||
"stirring": "反应模块-开始搅拌"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "粉末加样模块-投料",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "稀释液瓶加液位-液体投料",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-向下滴定结果观察"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 仓库配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 出库和入库操作都需要UUID
|
||||
WAREHOUSE_MAPPING = {
|
||||
# ========== 反应站仓库 ==========
|
||||
|
||||
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||
"堆栈1左": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||
},
|
||||
},
|
||||
|
||||
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||
"堆栈1右": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||
},
|
||||
},
|
||||
|
||||
# 站内试剂存放堆栈
|
||||
"站内试剂存放堆栈": {
|
||||
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
# 测量小瓶仓库(测密度)
|
||||
"测量小瓶仓库": {
|
||||
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||
"site_uuids": {
|
||||
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||
}
|
||||
},
|
||||
|
||||
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||
"站内Tip盒堆栈": {
|
||||
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||
}
|
||||
},
|
||||
# ========== 配液站仓库 ==========
|
||||
"粉末堆栈": {
|
||||
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
# ================================================配液站资源============================================================
|
||||
# ==================================================样品===============================================================
|
||||
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||
# ==================================================试剂===============================================================
|
||||
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||
# ============================================================================
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||
|
||||
# ============================================================================
|
||||
# 物料默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||
# - 格式: 物料名称 → {参数字典}
|
||||
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||
|
||||
MATERIAL_DEFAULT_PARAMETERS = {
|
||||
# 溶剂类
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||
},
|
||||
# 可以继续添加其他物料...
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料类型(UUID)自动添加默认参数
|
||||
# - 格式: Bioyond类型UUID → {参数字典}
|
||||
# - 优先级低于按名称匹配的配置
|
||||
MATERIAL_TYPE_PARAMETERS = {
|
||||
# 示例:
|
||||
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||
# "unit": "个"
|
||||
# }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing_extensions import TypedDict
|
||||
import requests
|
||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||
import pint
|
||||
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
|
||||
class BioyondDispensingStation(BioyondWorkstation):
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
# 桌子
|
||||
deck,
|
||||
*args,
|
||||
config: dict = None,
|
||||
deck=None,
|
||||
protocol_type=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(config, deck, *args, **kwargs)
|
||||
):
|
||||
"""初始化配液站
|
||||
|
||||
Args:
|
||||
config: 配置字典,应包含material_type_mappings等配置
|
||||
deck: Deck对象
|
||||
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||
**kwargs: 其他可能的参数
|
||||
"""
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||
config.update(kwargs)
|
||||
|
||||
if deck is None and config:
|
||||
deck = config.get('deck')
|
||||
|
||||
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||
if config and "warehouse_mapping" in config and deck:
|
||||
warehouse_mapping = config["warehouse_mapping"]
|
||||
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||
|
||||
user_deck = deck
|
||||
# 初始化 warehouses 字典
|
||||
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||
user_deck.warehouses = {}
|
||||
|
||||
# 1. 尝试从 children 中查找匹配的资源
|
||||
for child in user_deck.children:
|
||||
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||
if child.name in warehouse_mapping:
|
||||
user_deck.warehouses[child.name] = child
|
||||
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||
|
||||
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||
try:
|
||||
user_deck.setup()
|
||||
# setup 后重新检查
|
||||
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||
except Exception as e:
|
||||
print(f" - 调用 setup() 失败: {e}")
|
||||
|
||||
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||
if not user_deck.warehouses:
|
||||
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||
|
||||
for wh_name, wh_config in warehouse_mapping.items():
|
||||
target_uuid = wh_config.get("uuid")
|
||||
|
||||
# 尝试在 deck.warehouses 中查找
|
||||
wh_resource = None
|
||||
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||
wh_resource = user_deck.warehouses[wh_name]
|
||||
|
||||
# 如果没找到,尝试在所有子资源中查找
|
||||
if not wh_resource:
|
||||
wh_resource = user_deck.get_resource(wh_name)
|
||||
|
||||
if wh_resource:
|
||||
if target_uuid:
|
||||
current_uuid = getattr(wh_resource, "uuid", None)
|
||||
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||
|
||||
# 动态添加 uuid 属性
|
||||
wh_resource.uuid = target_uuid
|
||||
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||
# wh_resource.category = "warehouse"
|
||||
else:
|
||||
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||
else:
|
||||
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||
|
||||
super().__init__(bioyond_config=config, deck=deck)
|
||||
|
||||
# self.config = config
|
||||
# self.api_key = config["api_key"]
|
||||
# self.host = config["api_host"]
|
||||
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||
self.order_completion_status = {}
|
||||
|
||||
# 初始化 pint 单位注册表
|
||||
self.ureg = pint.UnitRegistry()
|
||||
|
||||
# 化合物信息
|
||||
self.compound_info = {
|
||||
"MolWt": {
|
||||
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||
},
|
||||
"FuncGroup": {
|
||||
"MDA": "Amine",
|
||||
"TDA": "Amine",
|
||||
"PAPP": "Amine",
|
||||
"BTDA": "Anhydride",
|
||||
"BPDA": "Anhydride",
|
||||
"6FAP": "Amine",
|
||||
"MPDA": "Amine",
|
||||
"SIDA": "Amine",
|
||||
"PMDA": "Anhydride",
|
||||
"ODA": "Amine",
|
||||
"4,4'-ODA": "Amine",
|
||||
"134": "Amine",
|
||||
}
|
||||
}
|
||||
|
||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||
"""项目接口通用POST调用
|
||||
|
||||
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
ratio = json.loads(ratio)
|
||||
except Exception:
|
||||
ratio = {}
|
||||
root = str(Path(__file__).resolve().parents[3])
|
||||
if root not in sys.path:
|
||||
sys.path.append(root)
|
||||
try:
|
||||
mod = importlib.import_module("tem.compute")
|
||||
except Exception as e:
|
||||
raise BioyondException(f"无法导入计算模块: {e}")
|
||||
try:
|
||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||
except Exception as e:
|
||||
raise BioyondException(f"参数解析失败: {e}")
|
||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
||||
|
||||
# 2. 调用内部计算方法
|
||||
res = self._generate_experiment_design(
|
||||
ratio=ratio,
|
||||
wt_percent=wp,
|
||||
m_tot=mt,
|
||||
titration_percent=tp
|
||||
)
|
||||
|
||||
# 3. 构造返回结果
|
||||
out = {
|
||||
"solutions": res.get("solutions", []),
|
||||
"titration": res.get("titration", {}),
|
||||
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
"return_info": json.dumps(res, ensure_ascii=False)
|
||||
}
|
||||
return out
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BioyondException(str(e))
|
||||
|
||||
def _generate_experiment_design(
|
||||
self,
|
||||
ratio: dict,
|
||||
wt_percent: float = 0.25,
|
||||
m_tot: float = 70,
|
||||
titration_percent: float = 0.03,
|
||||
) -> dict:
|
||||
"""内部方法:生成实验设计
|
||||
|
||||
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||
|
||||
参数:
|
||||
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||
wt_percent: 固体重量百分比
|
||||
m_tot: 反应混合物总质量(g)
|
||||
titration_percent: 滴定溶液百分比
|
||||
|
||||
返回:
|
||||
包含实验设计详细参数的字典
|
||||
"""
|
||||
# 溶剂密度
|
||||
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||
# 二酐溶解度
|
||||
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||
# 投入固体时最小溶剂体积
|
||||
V_min = 30 * self.ureg.ml
|
||||
m_tot = m_tot * self.ureg.g
|
||||
|
||||
# 保持ratio中的顺序
|
||||
compound_names = list(ratio.keys())
|
||||
compound_ratios = list(ratio.values())
|
||||
|
||||
# 验证所有化合物是否在 compound_info 中定义
|
||||
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||
if undefined_compounds:
|
||||
available = list(self.compound_info["MolWt"].keys())
|
||||
raise ValueError(
|
||||
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||
f"可用的化合物: {available}"
|
||||
)
|
||||
|
||||
# 获取各化合物的分子量和官能团类型
|
||||
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||
|
||||
# 记录化合物信息用于调试
|
||||
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||
|
||||
# 按原始顺序分离二胺和二酐
|
||||
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||
|
||||
if not diamine_compounds or not anhydride_compounds:
|
||||
raise ValueError(
|
||||
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||
)
|
||||
|
||||
# 计算加权平均分子量 (基于摩尔比)
|
||||
total_molar_ratio = sum(compound_ratios)
|
||||
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||
|
||||
# 取最后一个二酐用于滴定
|
||||
titration_anhydride = anhydride_compounds[-1]
|
||||
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||
|
||||
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||
diamine_solutions = []
|
||||
total_diamine_volume = 0 * self.ureg.ml
|
||||
|
||||
# 计算反应物的总摩尔量
|
||||
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||
|
||||
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||
# 跳过 SIDA
|
||||
if name == "SIDA":
|
||||
continue
|
||||
|
||||
# 计算该二胺需要的摩尔数
|
||||
n_diamine_needed = n_reactant * ratio_val
|
||||
|
||||
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||
|
||||
# 计算该二胺溶液的浓度
|
||||
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||
|
||||
# 计算需要移取的溶液体积
|
||||
V_diamine_needed = n_diamine_needed / c_diamine
|
||||
|
||||
diamine_solutions.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"solid_mass": m_diamine_solid.magnitude,
|
||||
"solvent_volume": V_solvent_for_this.magnitude,
|
||||
"concentration": c_diamine.magnitude,
|
||||
"volume_needed": V_diamine_needed.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
total_diamine_volume += V_diamine_needed
|
||||
|
||||
# 按原始顺序排序
|
||||
diamine_solutions.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算滴定二酐的质量
|
||||
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||
m_titration_10 = m_titration_anhydride * titration_percent
|
||||
|
||||
# 计算其他固体二酐的质量 (按顺序)
|
||||
solid_anhydride_masses = []
|
||||
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||
mass = n_reactant * ratio_val * mw
|
||||
solid_anhydride_masses.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"mass": mass.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
# 按原始顺序排序
|
||||
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算溶剂用量
|
||||
total_diamine_solution_mass = sum(
|
||||
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||
) * self.ureg.ml
|
||||
|
||||
# 预估滴定溶剂量、计算补加溶剂量
|
||||
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||
|
||||
# 检查最小溶剂体积要求
|
||||
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||
|
||||
# 如果需要,按比例放大
|
||||
scale_factor = 1.0
|
||||
if m_tot_min > m_tot:
|
||||
scale_factor = (m_tot_min / m_tot).magnitude
|
||||
m_titration_90 *= scale_factor
|
||||
m_titration_10 *= scale_factor
|
||||
m_solvent_add *= scale_factor
|
||||
m_solvent_titration *= scale_factor
|
||||
|
||||
# 更新二胺溶液用量
|
||||
for sol in diamine_solutions:
|
||||
sol["volume_needed"] *= scale_factor
|
||||
|
||||
# 更新固体二酐用量
|
||||
for anhydride in solid_anhydride_masses:
|
||||
anhydride["mass"] *= scale_factor
|
||||
|
||||
m_tot = m_tot_min
|
||||
|
||||
# 生成投料顺序
|
||||
feeding_order = []
|
||||
|
||||
# 1. 固体二酐 (按顺序)
|
||||
for anhydride in solid_anhydride_masses:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "solid_anhydride",
|
||||
"name": anhydride["name"],
|
||||
"amount": anhydride["mass"],
|
||||
"order": anhydride["order"]
|
||||
})
|
||||
|
||||
# 2. 二胺溶液 (按顺序)
|
||||
for sol in diamine_solutions:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "diamine_solution",
|
||||
"name": sol["name"],
|
||||
"amount": sol["volume_needed"],
|
||||
"order": sol["order"]
|
||||
})
|
||||
|
||||
# 3. 主要二酐粉末
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "main_anhydride",
|
||||
"name": titration_name,
|
||||
"amount": m_titration_90.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 4. 补加溶剂
|
||||
if m_solvent_add > 0:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "additional_solvent",
|
||||
"name": "溶剂",
|
||||
"amount": m_solvent_add.magnitude,
|
||||
"order": 999
|
||||
})
|
||||
|
||||
# 5. 滴定二酐溶液
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "titration_anhydride",
|
||||
"name": f"{titration_name} 滴定液",
|
||||
"amount": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 返回实验设计结果
|
||||
results = {
|
||||
"total_mass": m_tot.magnitude,
|
||||
"scale_factor": scale_factor,
|
||||
"solutions": diamine_solutions,
|
||||
"solids": solid_anhydride_masses,
|
||||
"titration": {
|
||||
"name": titration_name,
|
||||
"main_portion": m_titration_90.magnitude,
|
||||
"titration_portion": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
},
|
||||
"solvents": {
|
||||
"additional_solvent": m_solvent_add.magnitude,
|
||||
"total_liquid_volume": total_liquid_volume.magnitude
|
||||
},
|
||||
"feeding_order": feeding_order,
|
||||
"minimum_required_mass": m_tot_min.magnitude
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
# 90%10%小瓶投料任务创建方法
|
||||
def create_90_10_vial_feeding_task(self,
|
||||
order_name: str = None,
|
||||
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
'actualVolume': actual_volume
|
||||
}
|
||||
|
||||
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||
if not isinstance(report, dict):
|
||||
return report
|
||||
|
||||
data = report.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
return report
|
||||
|
||||
# 提取关键信息
|
||||
simplified = {
|
||||
'name': data.get('name'),
|
||||
'code': data.get('code'),
|
||||
'requester': data.get('requester'),
|
||||
'workflowName': data.get('workflowName'),
|
||||
'workflowStep': data.get('workflowStep'),
|
||||
'requestTime': data.get('requestTime'),
|
||||
'startPreparationTime': data.get('startPreparationTime'),
|
||||
'completeTime': data.get('completeTime'),
|
||||
'useTime': data.get('useTime'),
|
||||
'status': data.get('status'),
|
||||
'statusName': data.get('statusName'),
|
||||
}
|
||||
|
||||
# 提取物料信息(简化版)
|
||||
pre_intakes = data.get('preIntakes', [])
|
||||
if pre_intakes and isinstance(pre_intakes, list):
|
||||
first_intake = pre_intakes[0]
|
||||
sample_materials = first_intake.get('sampleMaterials', [])
|
||||
|
||||
# 简化物料信息
|
||||
simplified_materials = []
|
||||
for material in sample_materials:
|
||||
if isinstance(material, dict):
|
||||
mat_info = {
|
||||
'materialName': material.get('materialName'),
|
||||
'materialTypeName': material.get('materialTypeName'),
|
||||
'materialCode': material.get('materialCode'),
|
||||
'materialLocation': material.get('materialLocation'),
|
||||
}
|
||||
|
||||
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||
params_str = material.get('parameters', '{}')
|
||||
try:
|
||||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||
if isinstance(params, dict):
|
||||
# 只保留关键参数
|
||||
if 'density' in params:
|
||||
mat_info['density'] = params['density']
|
||||
if 'feedingHistory' in params:
|
||||
mat_info['feedingHistory'] = params['feedingHistory']
|
||||
if 'liquidVolume' in params:
|
||||
mat_info['liquidVolume'] = params['liquidVolume']
|
||||
if 'm_diamine_tot' in params:
|
||||
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||
if 'wt_diamine' in params:
|
||||
mat_info['wt_diamine'] = params['wt_diamine']
|
||||
except:
|
||||
pass
|
||||
|
||||
simplified_materials.append(mat_info)
|
||||
|
||||
simplified['sampleMaterials'] = simplified_materials
|
||||
|
||||
# 提取extraProperties中的实际值
|
||||
extra_props = first_intake.get('extraProperties', {})
|
||||
if isinstance(extra_props, dict):
|
||||
simplified_extra = {}
|
||||
for key, value in extra_props.items():
|
||||
try:
|
||||
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||
simplified_extra[key] = parsed_value
|
||||
except:
|
||||
simplified_extra[key] = value
|
||||
simplified['extraProperties'] = simplified_extra
|
||||
|
||||
return {
|
||||
'data': simplified,
|
||||
'code': report.get('code'),
|
||||
'message': report.get('message'),
|
||||
'timestamp': report.get('timestamp')
|
||||
}
|
||||
|
||||
def scheduler_start(self) -> dict:
|
||||
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
|
||||
Returns:
|
||||
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||
|
||||
Raises:
|
||||
BioyondException: 调度器启动失败时抛出异常
|
||||
"""
|
||||
result = self.hardware_interface.scheduler_start()
|
||||
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||
|
||||
if result != 1:
|
||||
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||
self.hardware_interface._logger.error(error_msg)
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
return {"return_info": result}
|
||||
|
||||
# 等待多个任务完成并获取实验报告
|
||||
def wait_for_multiple_orders_and_get_reports(self,
|
||||
batch_create_result: str = None,
|
||||
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证batch_create_result参数
|
||||
if not batch_create_result or batch_create_result == "":
|
||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||
raise BioyondException(
|
||||
"batch_create_result参数为空,请确保:\n"
|
||||
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||
"2. batch_create节点成功执行并返回了结果\n"
|
||||
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||
)
|
||||
|
||||
# 解析batch_create_result JSON对象
|
||||
try:
|
||||
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证提取的数据
|
||||
if not order_codes:
|
||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||
self.hardware_interface._logger.error(
|
||||
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||
)
|
||||
raise BioyondException(
|
||||
"batch_create_result中未找到order_codes或为空。\n"
|
||||
"可能的原因:\n"
|
||||
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||
"3. Bioyond系统状态异常\n"
|
||||
f"请检查batch_create任务的执行结果"
|
||||
)
|
||||
if not order_ids:
|
||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||
|
||||
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功获取任务 {order_code} 的实验报告"
|
||||
)
|
||||
# 简化报告,去除冗余信息
|
||||
report = self._simplify_report(report)
|
||||
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||
)
|
||||
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
results = []
|
||||
successful_count = 0
|
||||
failed_count = 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
||||
"""
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class ConnectionMonitor:
|
||||
"""Bioyond连接监控器"""
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_status = "unknown"
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||
self._thread.start()
|
||||
logger.info("Bioyond连接监控器已启动")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
logger.info("Bioyond连接监控器已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 使用 lightweight API 检查连接
|
||||
# query_matial_type_list 是比较快的查询
|
||||
start_time = time.time()
|
||||
result = self.workstation.hardware_interface.material_type_list()
|
||||
|
||||
status = "online" if result else "offline"
|
||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||
|
||||
if status != self._last_status:
|
||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||
self._publish_event(status, msg)
|
||||
self._last_status = status
|
||||
|
||||
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||
# self._publish_event(status, msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond连接检查异常: {e}")
|
||||
if self._last_status != "error":
|
||||
self._publish_event("error", str(e))
|
||||
self._last_status = "error"
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _publish_event(self, status, message):
|
||||
try:
|
||||
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||
event_data = {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||
# 话题: /<namespace>/events/device_status
|
||||
ns = self.workstation._ros_node.namespace
|
||||
topic = f"{ns}/events/device_status"
|
||||
|
||||
# 使用 ROS2DeviceNode 的发布功能
|
||||
# 如果没有预定义的 publisher,需要动态创建
|
||||
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||
# 这里我们先尝试用 String json 发布
|
||||
|
||||
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||
if not hasattr(self.workstation, "_device_status_pub"):
|
||||
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self.workstation._device_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布设备状态事件失败: {e}")
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
"""Bioyond资源同步器
|
||||
|
||||
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
else:
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:获取仓库配置
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
# 第2步:转换为 Bioyond 格式
|
||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||
|
||||
# 导入物料默认参数配置
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
return material_bioyond_id
|
||||
|
||||
# 转换为 Bioyond 格式
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||
|
||||
# 获取仓库配置和目标库位 UUID
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
parent_name = None
|
||||
target_location_uuid = None
|
||||
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
|
||||
集成Bioyond物料管理的工作站实现
|
||||
"""
|
||||
|
||||
def _publish_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
task_type: str,
|
||||
status: str,
|
||||
result: dict = None,
|
||||
progress: float = 0.0,
|
||||
task_code: str = None
|
||||
):
|
||||
"""发布任务状态事件"""
|
||||
try:
|
||||
if not getattr(self, "_ros_node", None):
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"task_id": task_id,
|
||||
"task_code": task_code,
|
||||
"task_type": task_type,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
if result:
|
||||
event_data["result"] = result
|
||||
|
||||
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||
|
||||
if not hasattr(self, "_task_status_pub"):
|
||||
self._task_status_pub = self._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self._task_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布任务状态事件失败: {e}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -605,10 +737,28 @@ class BioyondWorkstation(WorkstationBase):
|
||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||
|
||||
# 初始化 warehouses 属性
|
||||
self.deck.warehouses = {}
|
||||
for resource in self.deck.children:
|
||||
if isinstance(resource, WareHouse):
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||
self.deck.warehouses = {}
|
||||
|
||||
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||
if not self.deck.warehouses:
|
||||
for resource in self.deck.children:
|
||||
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||
|
||||
# 如果配置中有定义,也可以认定为 warehouse
|
||||
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||
is_warehouse = True
|
||||
|
||||
if is_warehouse:
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
# 确保 category 被正确设置,方便后续使用
|
||||
if getattr(resource, "category", "") != "warehouse":
|
||||
try:
|
||||
resource.category = "warehouse"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 创建通信模块
|
||||
self._create_communication_module(bioyond_config)
|
||||
@@ -627,18 +777,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||
|
||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
||||
# 从 bioyond_config 中的 http_service_config 获取
|
||||
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_service_cfg.get("http_service_port", 8080)
|
||||
}
|
||||
self.http_service = None # 将在 post_init 中启动
|
||||
self.http_service = None # 将在 post_init 启动
|
||||
self.connection_monitor = None # 将在 post_init 启动
|
||||
|
||||
logger.info(f"Bioyond工作站初始化完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||
try:
|
||||
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||
self.connection_monitor.stop()
|
||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||
logger.info("正在停止 HTTP 报送服务...")
|
||||
self.http_service.stop()
|
||||
@@ -648,6 +802,13 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动连接监控失败: {e}")
|
||||
|
||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||
if hasattr(self, '_http_service_config'):
|
||||
try:
|
||||
@@ -688,19 +849,14 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 创建默认配置
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
|
||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
||||
# 直接使用传入的配置,不再使用默认值
|
||||
# 所有配置必须从 JSON 文件中提供
|
||||
if config:
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
self.bioyond_config = config
|
||||
else:
|
||||
self.bioyond_config = default_config
|
||||
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||
self.bioyond_config = {}
|
||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
|
||||
@@ -1014,7 +1170,15 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
workflow_id = self._get_workflow(actual_workflow_name)
|
||||
if workflow_id:
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||
if isinstance(self.workflow_sequence, list):
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||
self._cached_workflow_sequence.append(workflow_id)
|
||||
else:
|
||||
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||
return False
|
||||
|
||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||
return True
|
||||
return False
|
||||
@@ -1215,6 +1379,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||
# 例如:更新数据库、触发后续流程等
|
||||
|
||||
# 发布任务状态事件 (running/progress update)
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_step",
|
||||
status="running",
|
||||
progress=0.5, # 步骤完成视为任务进行中
|
||||
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"step_id": data.get('stepId'),
|
||||
@@ -1249,6 +1429,17 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_sample",
|
||||
status="running",
|
||||
progress=0.7,
|
||||
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"sample_id": data.get('sampleId'),
|
||||
@@ -1288,6 +1479,32 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||
# 例如:更新物料库存、生成报表等
|
||||
|
||||
# 映射状态到事件状态
|
||||
event_status = "completed"
|
||||
if str(data.get('status')) in ["-11", "-12"]:
|
||||
event_status = "error"
|
||||
elif str(data.get('status')) == "30":
|
||||
event_status = "completed"
|
||||
else:
|
||||
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_order",
|
||||
status=event_status,
|
||||
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||
if event_status == "completed":
|
||||
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"order_code": data.get('orderCode'),
|
||||
|
||||
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
# 验证必需字段
|
||||
if 'brand' in request_data:
|
||||
if request_data['brand'] == "bioyond": # 奔曜
|
||||
error_msg = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||
material_data = request_data["text"]
|
||||
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: {error_msg}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||
message=f"物料变更报送已收到: {material_data}",
|
||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||
data=None
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -5,229 +5,6 @@ bioyond_dispensing_station:
|
||||
- bioyond_dispensing_station
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-brief_step_parameters:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
data: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-compute_experiment_design:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
type: string
|
||||
ratio:
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesignReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
used_materials: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-project_order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-query_resource_by_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_90_10_vial_feeding_tasks:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -394,6 +171,99 @@ bioyond_dispensing_station:
|
||||
title: BatchCreateDiamineSolutionTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
compute_experiment_design:
|
||||
feedback: {}
|
||||
goal:
|
||||
m_tot: m_tot
|
||||
ratio: ratio
|
||||
titration_percent: titration_percent
|
||||
wt_percent: wt_percent
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: ''
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles:
|
||||
output:
|
||||
- data_key: solutions
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: solutions
|
||||
io_type: sink
|
||||
label: Solution Data From Python
|
||||
- data_key: titration
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: titration
|
||||
io_type: sink
|
||||
label: Titration Data From Calculation Node
|
||||
- data_key: solvents
|
||||
data_source: executor
|
||||
data_type: object
|
||||
handler_key: solvents
|
||||
io_type: sink
|
||||
label: Solvents Data From Calculation Node
|
||||
- data_key: feeding_order
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: feeding_order
|
||||
io_type: sink
|
||||
label: Feeding Order Data From Calculation Node
|
||||
result:
|
||||
feeding_order: feeding_order
|
||||
return_info: return_info
|
||||
solutions: solutions
|
||||
solvents: solvents
|
||||
titration: titration
|
||||
schema:
|
||||
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
description: 总质量(g)
|
||||
type: string
|
||||
ratio:
|
||||
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||
type: string
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
description: 滴定比例(10%部分)
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
description: 目标固含质量分数
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
type: array
|
||||
return_info:
|
||||
type: string
|
||||
solutions:
|
||||
type: array
|
||||
solvents:
|
||||
type: object
|
||||
titration:
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesign_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: ComputeExperimentDesign
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -620,6 +490,89 @@ bioyond_dispensing_station:
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_device_id: target_device_id
|
||||
transfer_groups: transfer_groups
|
||||
goal_default:
|
||||
target_device_id: ''
|
||||
transfer_groups: ''
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
target_device_id: unilabos_devices
|
||||
result: {}
|
||||
schema:
|
||||
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备)
|
||||
type: string
|
||||
transfer_groups:
|
||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||
items:
|
||||
properties:
|
||||
materials:
|
||||
description: 物料名称(手动输入,系统将通过RPC查询验证)
|
||||
type: string
|
||||
target_sites:
|
||||
description: 目标库位(手动输入,如"A01")
|
||||
type: string
|
||||
target_stack:
|
||||
description: 目标堆栈名称(从列表选择)
|
||||
enum:
|
||||
- 堆栈1左
|
||||
- 堆栈1右
|
||||
- 站内试剂存放堆栈
|
||||
type: string
|
||||
required:
|
||||
- materials
|
||||
- target_stack
|
||||
- target_sites
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -688,7 +641,7 @@ bioyond_dispensing_station:
|
||||
title: WaitForMultipleOrdersAndGetReports
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -699,15 +652,16 @@ bioyond_dispensing_station:
|
||||
config:
|
||||
properties:
|
||||
config:
|
||||
type: string
|
||||
type: object
|
||||
deck:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
- deck
|
||||
protocol_type:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
model: {}
|
||||
version: 1.0.0
|
||||
|
||||
@@ -9278,7 +9278,13 @@ liquid_handler.prcxi:
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
handles: {}
|
||||
handles:
|
||||
input:
|
||||
- data_key: wells
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: input_wells
|
||||
label: InputWells
|
||||
placeholder_keys:
|
||||
wells: unilabos_resources
|
||||
result: {}
|
||||
@@ -9737,7 +9743,34 @@ liquid_handler.prcxi:
|
||||
touch_tip: false
|
||||
use_channels:
|
||||
- 0
|
||||
handles: {}
|
||||
handles:
|
||||
input:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: targets
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
|
||||
@@ -4,213 +4,88 @@ reaction_station.bioyond:
|
||||
- reaction_station_bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-create_order:
|
||||
add_time_constraint:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal:
|
||||
duration: duration
|
||||
end_point: end_point
|
||||
end_step_key: end_step_key
|
||||
start_point: start_point
|
||||
start_step_key: start_step_key
|
||||
goal_default:
|
||||
json_str: null
|
||||
duration: 0
|
||||
end_point: 0
|
||||
end_step_key: ''
|
||||
start_point: 0
|
||||
start_step_key: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
description: 添加时间约束 - 在两个工作流之间添加时间约束
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
json_str:
|
||||
type: string
|
||||
required:
|
||||
- json_str
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_order参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-hard_delete_merged_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_ids: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_ids:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- workflow_ids
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: hard_delete_merged_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-merge_workflow_with_parameters:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
json_str: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
json_str:
|
||||
type: string
|
||||
required:
|
||||
- json_str
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: merge_workflow_with_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_temperature_cutoff_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_temperature_cutoff_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_web_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
web_workflow_json: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
web_workflow_json:
|
||||
type: string
|
||||
required:
|
||||
- web_workflow_json
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_web_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-skip_titration_steps:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
preintake_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
preintake_id:
|
||||
type: string
|
||||
required:
|
||||
- preintake_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: skip_titration_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
type: string
|
||||
check_interval:
|
||||
default: 10
|
||||
type: integer
|
||||
timeout:
|
||||
default: 7200
|
||||
duration:
|
||||
description: 时间(秒)
|
||||
type: integer
|
||||
end_point:
|
||||
default: Start
|
||||
description: 终点计时点 (Start=开始前, End=结束后)
|
||||
enum:
|
||||
- Start
|
||||
- End
|
||||
type: string
|
||||
end_step_key:
|
||||
description: 终点步骤Key (可选, 默认为空则自动选择)
|
||||
type: string
|
||||
start_point:
|
||||
default: Start
|
||||
description: 起点计时点 (Start=开始前, End=结束后)
|
||||
enum:
|
||||
- Start
|
||||
- End
|
||||
type: string
|
||||
start_step_key:
|
||||
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
|
||||
type: string
|
||||
required:
|
||||
- duration
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: add_time_constraint参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
clean_all_server_workflows:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
code: code
|
||||
message: message
|
||||
schema:
|
||||
description: 清空服务端所有非核心工作流 (保留核心流程)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_step_query:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
result:
|
||||
properties:
|
||||
workflow_id:
|
||||
code:
|
||||
description: 操作结果代码(1表示成功)
|
||||
type: integer
|
||||
message:
|
||||
description: 结果描述
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_step_query参数
|
||||
title: clean_all_server_workflows参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
drip_back:
|
||||
@@ -247,13 +122,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume
|
||||
@@ -353,13 +234,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume
|
||||
@@ -403,7 +290,7 @@ reaction_station.bioyond:
|
||||
label: Solvents Data From Calculation Node
|
||||
result: {}
|
||||
schema:
|
||||
description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||
description: 液体投料-溶剂。可以直接提供volume(mL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -423,15 +310,21 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟),默认360
|
||||
type: string
|
||||
titration_type:
|
||||
default: '1'
|
||||
description: 是否滴定(1=否, 2=是),默认1
|
||||
default: 'NO'
|
||||
description: 是否滴定(NO=否, YES=是),默认NO
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
default: '2'
|
||||
description: 是否观察 (1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume:
|
||||
description: 分液量(μL)。可直接提供,或通过solvents参数自动计算
|
||||
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -504,15 +397,21 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟),默认90
|
||||
type: string
|
||||
titration_type:
|
||||
default: '2'
|
||||
description: 是否滴定(1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否滴定(NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
default: '2'
|
||||
description: 是否观察 (1=否, 2=是),默认2
|
||||
default: 'YES'
|
||||
description: 是否观察 (NO=否, YES=是),默认YES
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume_formula:
|
||||
description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||
type: string
|
||||
x_value:
|
||||
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
||||
@@ -560,13 +459,19 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
titration_type:
|
||||
description: 是否滴定(1=否, 2=是)
|
||||
description: 是否滴定(NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
volume_formula:
|
||||
description: 分液公式(μL)
|
||||
description: 分液公式(mL)
|
||||
type: string
|
||||
required:
|
||||
- volume_formula
|
||||
@@ -680,6 +585,35 @@ reaction_station.bioyond:
|
||||
title: reactor_taken_out参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
scheduler_start:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
solid_feeding_vials:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -706,7 +640,11 @@ reaction_station.bioyond:
|
||||
description: 物料名称(用于获取试剂瓶位ID)
|
||||
type: string
|
||||
material_id:
|
||||
description: 粉末类型ID,1=盐(21分钟),2=面粉(27分钟),3=BTDA(38分钟)
|
||||
description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟)
|
||||
enum:
|
||||
- Salt
|
||||
- Flour
|
||||
- BTDA
|
||||
type: string
|
||||
temperature:
|
||||
description: 温度设定(°C)
|
||||
@@ -715,7 +653,10 @@ reaction_station.bioyond:
|
||||
description: 观察时间(分钟)
|
||||
type: string
|
||||
torque_variation:
|
||||
description: 是否观察 (1=否, 2=是)
|
||||
description: 是否观察 (NO=否, YES=是)
|
||||
enum:
|
||||
- 'NO'
|
||||
- 'YES'
|
||||
type: string
|
||||
required:
|
||||
- assign_material_name
|
||||
@@ -730,9 +671,19 @@ reaction_station.bioyond:
|
||||
title: solid_feeding_vials参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactionStation
|
||||
protocol_type: []
|
||||
status_types:
|
||||
average_viscosity: Float64
|
||||
force: Float64
|
||||
in_temperature: Float64
|
||||
out_temperature: Float64
|
||||
pt100_temperature: Float64
|
||||
sensor_average_temperature: Float64
|
||||
setting_temperature: Float64
|
||||
speed: Float64
|
||||
target_temperature: Float64
|
||||
viscosity: Float64
|
||||
workflow_sequence: String
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -765,34 +716,19 @@ reaction_station.reactor:
|
||||
- reactor
|
||||
- reaction_station_bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-update_metrics:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
payload: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
payload:
|
||||
type: object
|
||||
required:
|
||||
- payload
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: update_metrics参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
|
||||
status_types: {}
|
||||
action_value_mappings: {}
|
||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactor
|
||||
status_types:
|
||||
average_viscosity: Float64
|
||||
force: Float64
|
||||
in_temperature: Float64
|
||||
out_temperature: Float64
|
||||
pt100_temperature: Float64
|
||||
sensor_average_temperature: Float64
|
||||
setting_temperature: Float64
|
||||
speed: Float64
|
||||
target_temperature: Float64
|
||||
viscosity: Float64
|
||||
type: python
|
||||
config_info: []
|
||||
description: 反应站子设备-反应器
|
||||
|
||||
@@ -124,11 +124,25 @@ class Registry:
|
||||
"output": [
|
||||
{
|
||||
"handler_key": "labware",
|
||||
"label": "Labware",
|
||||
"data_type": "resource",
|
||||
"data_source": "handle",
|
||||
"data_key": "liquid",
|
||||
}
|
||||
"label": "Labware",
|
||||
"data_source": "executor",
|
||||
"data_key": "created_resource_tree.@flatten",
|
||||
},
|
||||
{
|
||||
"handler_key": "liquid_slots",
|
||||
"data_type": "resource",
|
||||
"label": "LiquidSlots",
|
||||
"data_source": "executor",
|
||||
"data_key": "liquid_input_resource_tree.@flatten",
|
||||
},
|
||||
{
|
||||
"handler_key": "materials",
|
||||
"data_type": "resource",
|
||||
"label": "AllMaterials",
|
||||
"data_source": "executor",
|
||||
"data_key": "[created_resource_tree,liquid_input_resource_tree].@flatten.@flatten",
|
||||
},
|
||||
]
|
||||
},
|
||||
"placeholder_keys": {
|
||||
@@ -186,7 +200,17 @@ class Registry:
|
||||
"resources": "unilabos_resources",
|
||||
},
|
||||
"goal_default": {},
|
||||
"handles": {},
|
||||
"handles": {
|
||||
"input": [
|
||||
{
|
||||
"handler_key": "input_resources",
|
||||
"data_type": "resource",
|
||||
"label": "InputResources",
|
||||
"data_source": "handle",
|
||||
"data_key": "resources", # 不为空
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -222,7 +246,7 @@ class Registry:
|
||||
abs_path = Path(path).absolute()
|
||||
resource_path = abs_path / "resources"
|
||||
files = list(resource_path.glob("*/*.yaml"))
|
||||
logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}")
|
||||
logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}")
|
||||
current_resource_number = len(self.resource_type_registry) + 1
|
||||
for i, file in enumerate(files):
|
||||
with open(file, encoding="utf-8", mode="r") as f:
|
||||
|
||||
@@ -20,6 +20,17 @@ BIOYOND_PolymerStation_Liquid_Vial:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_Measurement_Vial:
|
||||
category:
|
||||
- bottles
|
||||
class:
|
||||
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Measurement_Vial
|
||||
type: pylabrobot
|
||||
description: 聚合站-测量小瓶(测密度)
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
BIOYOND_PolymerStation_Reactor:
|
||||
category:
|
||||
- bottles
|
||||
|
||||
@@ -193,3 +193,20 @@ def BIOYOND_PolymerStation_Flask(
|
||||
barcode=barcode,
|
||||
model="BIOYOND_PolymerStation_Flask",
|
||||
)
|
||||
|
||||
def BIOYOND_PolymerStation_Measurement_Vial(
|
||||
name: str,
|
||||
diameter: float = 25.0,
|
||||
height: float = 60.0,
|
||||
max_volume: float = 20000.0, # 20mL
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建测量小瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="BIOYOND_PolymerStation_Measurement_Vial",
|
||||
)
|
||||
|
||||
@@ -49,20 +49,17 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01~B03
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
|
||||
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
|
||||
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
|
||||
"堆栈1左": Coordinate(-200.0, 450.0, 0.0), # 左侧位置
|
||||
"堆栈1右": Coordinate(2350.0, 450.0, 0.0), # 右侧位置
|
||||
"站内试剂存放堆栈": Coordinate(730.0, 390.0, 0.0),
|
||||
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
|
||||
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
|
||||
"测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
|
||||
"测量小瓶仓库(测密度)": Coordinate(940.0, 530.0, 0.0),
|
||||
}
|
||||
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
|
||||
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
|
||||
class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -144,6 +141,7 @@ class BIOYOND_YB_Deck(Deck):
|
||||
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
|
||||
def YB_Deck(name: str) -> Deck:
|
||||
by=BIOYOND_YB_Deck(name=name)
|
||||
by.setup()
|
||||
|
||||
@@ -46,41 +46,55 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
)
|
||||
|
||||
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
||||
"""创建测量小瓶仓库(测密度) A01~B03"""
|
||||
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行
|
||||
布局(从下到上,从左到右):
|
||||
| A03 | B03 | ← 顶部
|
||||
| A02 | B02 | ← 中部
|
||||
| A01 | B01 | ← 底部
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, # 3列(01-03)
|
||||
num_items_y=2, # 2行(A-B)
|
||||
num_items_x=2, # 2列(A, B)
|
||||
num_items_y=3, # 3行(01-03,从下到上)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=40.0,
|
||||
item_dy=40.0,
|
||||
item_dx=40.0, # 列间距(A到B的横向距离)
|
||||
item_dy=40.0, # 行间距(01到02到03的竖向距离)
|
||||
item_dz=50.0,
|
||||
# 用更小的 resource_size 来表现 "小点的孔位"
|
||||
# ⭐ 竖向warehouse:槽位尺寸也是竖向的(小瓶已经是正方形,无需调整)
|
||||
resource_size_x=30.0,
|
||||
resource_size_y=30.0,
|
||||
resource_size_z=12.0,
|
||||
category="warehouse",
|
||||
col_offset=0,
|
||||
layout="row-major",
|
||||
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||
)
|
||||
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
"""创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)"""
|
||||
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行
|
||||
布局(竖向,从下到上):
|
||||
| A02 | ← 顶部
|
||||
| A01 | ← 底部
|
||||
"""
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2, # 2列(01-02)
|
||||
num_items_y=1, # 1行(A)
|
||||
num_items_x=1, # 1列
|
||||
num_items_y=2, # 2行(01-02,从下到上)
|
||||
num_items_z=1, # 1层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dx=96.0, # 列间距(这里只有1列,不重要)
|
||||
item_dy=137.0, # 行间距(A01到A02的竖向距离)
|
||||
item_dz=120.0,
|
||||
# ⭐ 竖向warehouse:交换槽位尺寸,使槽位框也是竖向的
|
||||
resource_size_x=86.0, # 原来的 resource_size_y
|
||||
resource_size_y=127.0, # 原来的 resource_size_x
|
||||
resource_size_z=25.0,
|
||||
category="warehouse",
|
||||
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||
)
|
||||
|
||||
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
||||
|
||||
@@ -27,7 +27,7 @@ class RegularContainer(Container):
|
||||
def get_regular_container(name="container"):
|
||||
r = RegularContainer(name=name)
|
||||
r.category = "container"
|
||||
return RegularContainer(name=name)
|
||||
return r
|
||||
|
||||
#
|
||||
# class RegularContainer(object):
|
||||
|
||||
@@ -13,7 +13,7 @@ from unilabos.config.config import BasicConfig
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier
|
||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
)
|
||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
||||
Returns:
|
||||
ResourceTreeSet: 标准化后的资源树集合
|
||||
"""
|
||||
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
||||
|
||||
# 第一步:基本预处理(处理graphml的label字段)
|
||||
outer_host_node_id = None
|
||||
@@ -779,6 +779,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
if not locations:
|
||||
logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置")
|
||||
|
||||
# ⭐ 预先检查:如果物料的任何location在竖向warehouse中,提前交换尺寸
|
||||
# 这样可以避免多个location时尺寸不一致的问题
|
||||
needs_size_swap = False
|
||||
for loc in locations:
|
||||
wh_name_check = loc.get("whName")
|
||||
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
needs_size_swap = True
|
||||
break
|
||||
|
||||
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
|
||||
original_x = plr_material.size_x
|
||||
original_y = plr_material.size_y
|
||||
plr_material.size_x = original_y
|
||||
plr_material.size_y = original_x
|
||||
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse,预先交换尺寸: {original_x}×{original_y} → {plr_material.size_x}×{plr_material.size_y}")
|
||||
|
||||
for loc in locations:
|
||||
wh_name = loc.get("whName")
|
||||
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
|
||||
@@ -800,7 +816,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
|
||||
|
||||
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
||||
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
|
||||
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
||||
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
||||
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
||||
@@ -809,12 +824,23 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
if wh_name == "堆栈1右":
|
||||
y = y - 4 # 将5-8映射到1-4
|
||||
|
||||
# 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈)
|
||||
# Bioyond的y坐标表示线性位置序号,而不是列号
|
||||
if warehouse.num_items_y == 1:
|
||||
# 1行warehouse: 直接用y作为线性索引
|
||||
idx = y - 1
|
||||
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
|
||||
# 特殊处理竖向warehouse(站内试剂存放堆栈、测量小瓶仓库)
|
||||
# 这些warehouse使用 vertical-col-major 布局
|
||||
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
# vertical-col-major 布局的坐标映射:
|
||||
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向)
|
||||
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上
|
||||
# vertical-col-major 中: row=0 对应底部,row=n-1 对应顶部
|
||||
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
|
||||
# 索引计算: idx = row * num_cols + col
|
||||
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
|
||||
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
|
||||
layer_idx = z - 1
|
||||
|
||||
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx
|
||||
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
|
||||
|
||||
# 普通横向warehouse的处理
|
||||
else:
|
||||
# 多行warehouse: 根据 layout 使用不同的索引计算
|
||||
row_idx = x - 1 # x表示行: 转为0-based
|
||||
@@ -838,6 +864,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
|
||||
if 0 <= idx < warehouse.capacity:
|
||||
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
||||
# 物料尺寸已在放入warehouse前根据需要进行了交换
|
||||
warehouse[idx] = plr_material
|
||||
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
||||
else:
|
||||
@@ -1011,11 +1038,24 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
||||
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
|
||||
|
||||
# 🎯 处理物料默认参数和单位
|
||||
# 检查是否有该物料名称的默认参数配置
|
||||
# 优先级: typeId参数 > 物料名称参数 > 默认值
|
||||
default_unit = "个" # 默认单位
|
||||
material_parameters = {}
|
||||
|
||||
if material_name in material_params:
|
||||
# 1️⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取,key 格式为 "type:<typeId>")
|
||||
type_params_key = f"type:{type_id}"
|
||||
if type_params_key in material_params:
|
||||
params_config = material_params[type_params_key].copy()
|
||||
|
||||
# 提取 unit 字段(如果有)
|
||||
if "unit" in params_config:
|
||||
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
|
||||
|
||||
# 剩余的字段放入 Parameters
|
||||
material_parameters = params_config
|
||||
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
# 2️⃣ 其次检查是否有该物料名称的默认参数配置
|
||||
elif material_name in material_params:
|
||||
params_config = material_params[material_name].copy()
|
||||
|
||||
# 提取 unit 字段(如果有)
|
||||
@@ -1024,7 +1064,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
||||
|
||||
# 剩余的字段放入 Parameters
|
||||
material_parameters = params_config
|
||||
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||
|
||||
# 转换为 JSON 字符串
|
||||
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
|
||||
@@ -1151,11 +1191,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
|
||||
if resource_class_config["type"] == "pylabrobot":
|
||||
resource_plr = RESOURCE(name=resource_config["name"])
|
||||
if resource_type != ResourcePLR:
|
||||
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr])
|
||||
# r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None))
|
||||
# # r = resource_plr_to_ulab(resource_plr=resource_plr)
|
||||
# if resource_config.get("position") is not None:
|
||||
# r["position"] = resource_config["position"]
|
||||
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True)
|
||||
r = tree_sets.dump()
|
||||
else:
|
||||
r = resource_plr
|
||||
|
||||
@@ -50,13 +50,46 @@ class Bottle(Well):
|
||||
self.barcode = barcode
|
||||
|
||||
def serialize(self) -> dict:
|
||||
# Pylabrobot expects barcode to be an object with serialize(), but here it is a str.
|
||||
# We temporarily unset it to avoid AttributeError in super().serialize().
|
||||
_barcode = self.barcode
|
||||
self.barcode = None
|
||||
try:
|
||||
data = super().serialize()
|
||||
finally:
|
||||
self.barcode = _barcode
|
||||
|
||||
return {
|
||||
**super().serialize(),
|
||||
**data,
|
||||
"diameter": self.diameter,
|
||||
"height": self.height,
|
||||
"barcode": self.barcode,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal: bool = False):
|
||||
# Extract barcode before calling parent deserialize to avoid type error
|
||||
barcode_data = data.pop("barcode", None)
|
||||
|
||||
# Call parent deserialize
|
||||
instance = super(Bottle, cls).deserialize(data, allow_marshal=allow_marshal)
|
||||
|
||||
# Set barcode as string (not as Barcode object)
|
||||
if barcode_data:
|
||||
if isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
elif isinstance(barcode_data, dict):
|
||||
# If it's a dict (Barcode serialized format), extract the data field
|
||||
instance.barcode = barcode_data.get("data", "")
|
||||
else:
|
||||
instance.barcode = ""
|
||||
|
||||
# Set additional attributes
|
||||
instance.diameter = data.get("diameter", instance._size_x)
|
||||
instance.height = data.get("height", instance._size_z)
|
||||
|
||||
return instance
|
||||
|
||||
T = TypeVar("T", bound=ResourceHolder)
|
||||
|
||||
S = TypeVar("S", bound=ResourceHolder)
|
||||
@@ -149,6 +182,7 @@ class ItemizedCarrier(ResourcePLR):
|
||||
|
||||
if not reassign and self.sites[idx] is not None:
|
||||
raise ValueError(f"a site with index {idx} already exists")
|
||||
location = list(self.child_locations.values())[idx]
|
||||
super().assign_child_resource(resource, location=location, reassign=reassign)
|
||||
self.sites[idx] = resource
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import inspect
|
||||
import traceback
|
||||
import uuid
|
||||
from pydantic import BaseModel, field_serializer, field_validator
|
||||
from pydantic import BaseModel, field_serializer, field_validator, ValidationError
|
||||
from pydantic import Field
|
||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
||||
|
||||
@@ -14,9 +14,9 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class ResourceDictPositionSize(BaseModel):
|
||||
depth: float = Field(description="Depth", default=0.0)
|
||||
width: float = Field(description="Width", default=0.0)
|
||||
height: float = Field(description="Height", default=0.0)
|
||||
depth: float = Field(description="Depth", default=0.0) # z
|
||||
width: float = Field(description="Width", default=0.0) # x
|
||||
height: float = Field(description="Height", default=0.0) # y
|
||||
|
||||
|
||||
class ResourceDictPositionScale(BaseModel):
|
||||
@@ -66,8 +66,8 @@ class ResourceDict(BaseModel):
|
||||
klass: str = Field(alias="class", description="Resource class name")
|
||||
pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
||||
config: Dict[str, Any] = Field(description="Resource configuration")
|
||||
data: Dict[str, Any] = Field(description="Resource data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data")
|
||||
data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data, eg: slot index")
|
||||
|
||||
@field_serializer("parent_uuid")
|
||||
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
|
||||
@@ -147,20 +147,24 @@ class ResourceDictInstance(object):
|
||||
if not content.get("extra"): # MagicCode
|
||||
content["extra"] = {}
|
||||
if "position" in content:
|
||||
pose = content.get("pose",{})
|
||||
if "position" not in pose :
|
||||
pose = content.get("pose", {})
|
||||
if "position" not in pose:
|
||||
if "position" in content["position"]:
|
||||
pose["position"] = content["position"]["position"]
|
||||
else:
|
||||
pose["position"] = {"x": 0, "y": 0, "z": 0}
|
||||
if "size" not in pose:
|
||||
pose["size"] = {
|
||||
"width": content["config"].get("size_x", 0),
|
||||
"height": content["config"].get("size_y", 0),
|
||||
"depth": content["config"].get("size_z", 0)
|
||||
"width": content["config"].get("size_x", 0),
|
||||
"height": content["config"].get("size_y", 0),
|
||||
"depth": content["config"].get("size_z", 0),
|
||||
}
|
||||
content["pose"] = pose
|
||||
return ResourceDictInstance(ResourceDict.model_validate(content))
|
||||
try:
|
||||
res_dict = ResourceDict.model_validate(content)
|
||||
return ResourceDictInstance(res_dict)
|
||||
except ValidationError as err:
|
||||
raise err
|
||||
|
||||
def get_plr_nested_dict(self) -> Dict[str, Any]:
|
||||
"""获取资源实例的嵌套字典表示"""
|
||||
@@ -322,7 +326,7 @@ class ResourceTreeSet(object):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet":
|
||||
def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
|
||||
"""
|
||||
从plr资源创建ResourceTreeSet
|
||||
"""
|
||||
@@ -339,6 +343,8 @@ class ResourceTreeSet(object):
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
elif source is None:
|
||||
return ""
|
||||
else:
|
||||
print("转换pylabrobot的时候,出现未知类型", source)
|
||||
return source
|
||||
@@ -349,7 +355,8 @@ class ResourceTreeSet(object):
|
||||
if not uid:
|
||||
uid = str(uuid.uuid4())
|
||||
res.unilabos_uuid = uid
|
||||
logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}")
|
||||
if not known_newly_created:
|
||||
logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}")
|
||||
|
||||
# 获取unilabos_extra,默认为空字典
|
||||
extra = getattr(res, "unilabos_extra", {})
|
||||
@@ -448,7 +455,13 @@ class ResourceTreeSet(object):
|
||||
from pylabrobot.utils.object_parsing import find_subclass
|
||||
|
||||
# 类型映射
|
||||
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"}
|
||||
TYPE_MAP = {
|
||||
"plate": "Plate",
|
||||
"well": "Well",
|
||||
"deck": "Deck",
|
||||
"container": "RegularContainer",
|
||||
"tip_spot": "TipSpot",
|
||||
}
|
||||
|
||||
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
|
||||
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
|
||||
@@ -469,9 +482,9 @@ class ResourceTreeSet(object):
|
||||
**res.config,
|
||||
"name": res.name,
|
||||
"type": res.config.get("type", plr_type),
|
||||
"size_x": res.config.get("size_x", 0),
|
||||
"size_y": res.config.get("size_y", 0),
|
||||
"size_z": res.config.get("size_z", 0),
|
||||
"size_x": res.pose.size.width,
|
||||
"size_y": res.pose.size.height,
|
||||
"size_z": res.pose.size.depth,
|
||||
"location": {
|
||||
"x": res.pose.position.x,
|
||||
"y": res.pose.position.y,
|
||||
@@ -608,6 +621,16 @@ class ResourceTreeSet(object):
|
||||
"""
|
||||
return [tree.root_node for tree in self.trees]
|
||||
|
||||
@property
|
||||
def root_nodes_uuid(self) -> List[ResourceDictInstance]:
|
||||
"""
|
||||
获取所有树的根节点
|
||||
|
||||
Returns:
|
||||
所有根节点的资源实例列表
|
||||
"""
|
||||
return [tree.root_node.res_content.uuid for tree in self.trees]
|
||||
|
||||
@property
|
||||
def all_nodes(self) -> List[ResourceDictInstance]:
|
||||
"""
|
||||
@@ -918,6 +941,33 @@ class DeviceNodeResourceTracker(object):
|
||||
|
||||
return self._traverse_and_process(resource, process)
|
||||
|
||||
def loop_find_with_uuid(self, resource, target_uuid: str):
|
||||
"""
|
||||
递归遍历资源树,根据 uuid 查找并返回对应的资源
|
||||
|
||||
Args:
|
||||
resource: 资源对象(可以是list、dict或实例)
|
||||
target_uuid: 要查找的uuid
|
||||
|
||||
Returns:
|
||||
找到的资源对象,未找到则返回None
|
||||
"""
|
||||
found_resource = None
|
||||
|
||||
def process(res):
|
||||
nonlocal found_resource
|
||||
if found_resource is not None:
|
||||
return 0 # 已找到,跳过后续处理
|
||||
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
|
||||
if current_uuid and current_uuid == target_uuid:
|
||||
found_resource = res
|
||||
logger.trace(f"找到资源UUID: {target_uuid}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
self._traverse_and_process(resource, process)
|
||||
return found_resource
|
||||
|
||||
def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int:
|
||||
"""
|
||||
递归遍历资源树,根据 name 设置所有节点的 extra
|
||||
@@ -1103,7 +1153,7 @@ class DeviceNodeResourceTracker(object):
|
||||
for key in keys_to_remove:
|
||||
self.resource2parent_resource.pop(key, None)
|
||||
|
||||
logger.debug(f"成功移除资源: {resource}")
|
||||
logger.trace(f"[ResourceTracker] 成功移除资源: {resource}")
|
||||
return True
|
||||
|
||||
def clear_resource(self):
|
||||
@@ -42,6 +42,10 @@ def warehouse_factory(
|
||||
if layout == "row-major":
|
||||
# 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值
|
||||
y = dy + row * item_dy
|
||||
elif layout == "vertical-col-major":
|
||||
# 竖向warehouse: row=0 对应顶部(y小),row=n-1 对应底部(y大)
|
||||
# 但标签 01 应该在底部,所以使用反向映射
|
||||
y = dy + (num_items_y - row - 1) * item_dy
|
||||
else:
|
||||
# 列优先:保持原逻辑(row=0 对应较大的 y)
|
||||
y = dy + (num_items_y - row - 1) * item_dy
|
||||
@@ -66,6 +70,14 @@ def warehouse_factory(
|
||||
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
|
||||
# locations[0] 对应 row=0, y最大(前端顶部)→ 应该是 A01
|
||||
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
|
||||
elif layout == "vertical-col-major":
|
||||
# ⭐ 竖向warehouse专用布局:
|
||||
# 字母(A,B,C...)对应列(横向, x方向),数字(01,02,03...)对应行(竖向, y方向,从下到上)
|
||||
# locations 生成顺序: row→col (row=0,col=0 → row=0,col=1 → row=1,col=0 → ...)
|
||||
# 其中 row=0 对应底部(y大),row=n-1 对应顶部(y小)
|
||||
# 标签中 01 对应底部(row=0),02 对应中间(row=1),03 对应顶部(row=2)
|
||||
# 标签顺序: A01,B01,A02,B02,A03,B03
|
||||
keys = [f"{LETTERS[col]}{row + 1 + col_offset:02d}" for row in range(len_y) for col in range(len_x)]
|
||||
else:
|
||||
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
|
||||
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
|
||||
|
||||
@@ -5,7 +5,7 @@ from unilabos.ros.msgs.message_converter import (
|
||||
get_action_type,
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
||||
|
||||
# 定义泛型类型变量
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import copy
|
||||
from typing import Optional
|
||||
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.ros.device_node_wrapper import ros2_device_node
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.exception import DeviceClassInvalid
|
||||
from unilabos.utils.import_manager import default_manager
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
|
||||
# from nt import device_encoding
|
||||
import threading
|
||||
import time
|
||||
@@ -10,7 +11,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Response
|
||||
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher
|
||||
from unilabos_msgs.srv import SerialCommand # type: ignore
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
@@ -55,7 +56,11 @@ def main(
|
||||
) -> None:
|
||||
"""主函数"""
|
||||
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
# Support restart - check if rclpy is already initialized
|
||||
if not rclpy.ok():
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
else:
|
||||
logger.info("[ROS] rclpy already initialized, reusing context")
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
# 创建主机节点
|
||||
host_node = HostNode(
|
||||
@@ -88,7 +93,7 @@ def main(
|
||||
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
|
||||
# lh_joint_pub = LiquidHandlerJointPublisher(
|
||||
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
|
||||
# )
|
||||
# )
|
||||
executor.add_node(resource_mesh_manager)
|
||||
executor.add_node(joint_republisher)
|
||||
# executor.add_node(lh_joint_pub)
|
||||
|
||||
@@ -159,10 +159,14 @@ _msg_converter: Dict[Type, Any] = {
|
||||
else Pose()
|
||||
),
|
||||
config=json.dumps(x.get("config", {})),
|
||||
data=json.dumps(x.get("data", {})),
|
||||
data=json.dumps(obtain_data_with_uuid(x)),
|
||||
),
|
||||
}
|
||||
|
||||
def obtain_data_with_uuid(x: dict):
|
||||
data = x.get("data", {})
|
||||
data["unilabos_uuid"] = x.get("uuid", None)
|
||||
return data
|
||||
|
||||
def json_or_yaml_loads(data: str) -> Any:
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
@@ -13,7 +12,6 @@ import asyncio
|
||||
|
||||
import rclpy
|
||||
import yaml
|
||||
from msgcenterpy import ROS2MessageInstance
|
||||
from rclpy.node import Node
|
||||
from rclpy.action import ActionServer, ActionClient
|
||||
from rclpy.action.server import ServerGoalHandle
|
||||
@@ -22,15 +20,13 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.service import Service
|
||||
from unilabos_msgs.action import SendCmd
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.utils.decorator import get_topic_config, get_all_subscriptions
|
||||
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import (
|
||||
resource_ulab_to_plr,
|
||||
initialize_resources,
|
||||
dict_to_tree,
|
||||
resource_plr_to_ulab,
|
||||
tree_to_list,
|
||||
)
|
||||
from unilabos.resources.plr_additional_res_reg import register
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
@@ -47,7 +43,7 @@ from unilabos_msgs.srv import (
|
||||
) # type: ignore
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
from unilabos.resources.resource_tracker import (
|
||||
DeviceNodeResourceTracker,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
@@ -363,7 +359,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return res
|
||||
|
||||
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources import Plate
|
||||
@@ -435,11 +430,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
})
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
plr_instances = rts.to_plr_resources()
|
||||
for plr_instance in plr_instances:
|
||||
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
|
||||
rts: ResourceTreeSet = ResourceTreeSet.from_plr_resources(plr_instances)
|
||||
self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
||||
final_response = {
|
||||
"created_resources": rts.dump(),
|
||||
"liquid_input_resources": [],
|
||||
"created_resource_tree": rts.dump(),
|
||||
"liquid_input_resource_tree": [],
|
||||
}
|
||||
res.response = json.dumps(final_response)
|
||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||
@@ -465,7 +463,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return res
|
||||
try:
|
||||
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
||||
plr_instance = rts.to_plr_resources()[0]
|
||||
plr_instance = plr_instances[0]
|
||||
if isinstance(plr_instance, Plate):
|
||||
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
@@ -490,7 +488,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
res.response = json.dumps(final_response)
|
||||
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
|
||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||
@@ -624,7 +622,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
) # type: ignore
|
||||
raw_nodes = json.loads(response.response)
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes)
|
||||
self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树")
|
||||
self.lab_logger().trace(f"获取资源结果: {len(tree_set.trees)} 个资源树 {tree_set.root_nodes}")
|
||||
return tree_set
|
||||
|
||||
async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR":
|
||||
@@ -658,61 +656,71 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def transfer_to_new_resource(
|
||||
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
|
||||
):
|
||||
) -> Optional["ResourcePLR"]:
|
||||
parent_uuid = tree.root_node.res_content.parent_uuid
|
||||
if parent_uuid:
|
||||
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
|
||||
if parent_resource is None:
|
||||
if not parent_uuid:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource} parent未知,挂载到当前节点下,额外参数:{additional_add_params}"
|
||||
)
|
||||
return None
|
||||
if parent_uuid == self.uuid:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载到{self.identifier},额外参数:{additional_add_params}"
|
||||
)
|
||||
return None
|
||||
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
|
||||
if parent_resource is None:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# 特殊兼容所有plr的物料的assign方法,和create_resource append_resource后期同步
|
||||
additional_params = {}
|
||||
extra = getattr(plr_resource, "unilabos_extra", {})
|
||||
if len(extra):
|
||||
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
|
||||
if "update_resource_site" in extra:
|
||||
additional_add_params["site"] = extra["update_resource_site"]
|
||||
site = additional_add_params.get("site", None)
|
||||
spec = inspect.signature(parent_resource.assign_child_resource)
|
||||
if "spot" in spec.parameters:
|
||||
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
|
||||
if ordering_dict:
|
||||
site = list(ordering_dict.keys()).index(site)
|
||||
additional_params["spot"] = site
|
||||
old_parent = plr_resource.parent
|
||||
if old_parent is not None:
|
||||
# plr并不支持同一个deck的加载和卸载
|
||||
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
|
||||
old_parent.unassign_child_resource(plr_resource)
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
|
||||
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# 特殊兼容所有plr的物料的assign方法,和create_resource append_resource后期同步
|
||||
additional_params = {}
|
||||
extra = getattr(plr_resource, "unilabos_extra", {})
|
||||
if len(extra):
|
||||
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
|
||||
if "update_resource_site" in extra:
|
||||
additional_add_params["site"] = extra["update_resource_site"]
|
||||
site = additional_add_params.get("site", None)
|
||||
spec = inspect.signature(parent_resource.assign_child_resource)
|
||||
if "spot" in spec.parameters:
|
||||
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
|
||||
if ordering_dict:
|
||||
site = list(ordering_dict.keys()).index(site)
|
||||
additional_params["spot"] = site
|
||||
old_parent = plr_resource.parent
|
||||
if old_parent is not None:
|
||||
# plr并不支持同一个deck的加载和卸载
|
||||
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
|
||||
old_parent.unassign_child_resource(plr_resource)
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
|
||||
)
|
||||
|
||||
# ⭐ assign 之前,需要从 resources 列表中移除
|
||||
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
|
||||
# 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children
|
||||
resource_id = id(plr_resource)
|
||||
for i, r in enumerate(self.resource_tracker.resources):
|
||||
if id(r) == resource_id:
|
||||
self.resource_tracker.resources.pop(i)
|
||||
self.lab_logger().debug(
|
||||
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
|
||||
)
|
||||
break
|
||||
# ⭐ assign 之前,需要从 resources 列表中移除
|
||||
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
|
||||
# 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children
|
||||
resource_id = id(plr_resource)
|
||||
for i, r in enumerate(self.resource_tracker.resources):
|
||||
if id(r) == resource_id:
|
||||
self.resource_tracker.resources.pop(i)
|
||||
self.lab_logger().debug(
|
||||
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
|
||||
)
|
||||
break
|
||||
|
||||
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
|
||||
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
|
||||
|
||||
func = getattr(self.driver_instance, "resource_tree_transfer", None)
|
||||
if callable(func):
|
||||
# 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了)
|
||||
func(old_parent, plr_resource, parent_resource)
|
||||
except Exception as e:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
|
||||
)
|
||||
func = getattr(self.driver_instance, "resource_tree_transfer", None)
|
||||
if callable(func):
|
||||
# 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了)
|
||||
func(old_parent, plr_resource, parent_resource)
|
||||
return parent_resource
|
||||
except Exception as e:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
|
||||
"""
|
||||
@@ -727,7 +735,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def _handle_add(
|
||||
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
"""
|
||||
处理资源添加操作的内部函数
|
||||
|
||||
@@ -739,15 +747,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
Returns:
|
||||
操作结果字典
|
||||
"""
|
||||
parents = [] # 放的是被变更的物料 / 被变更的物料父级
|
||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||
self.resource_tracker.add_resource(plr_resource)
|
||||
self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
|
||||
parent = self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
parents.append(parent)
|
||||
else:
|
||||
parents.append(plr_resource)
|
||||
|
||||
func = getattr(self.driver_instance, "resource_tree_add", None)
|
||||
if callable(func):
|
||||
func(plr_resources)
|
||||
|
||||
return {"success": True, "action": "add"}
|
||||
return {"success": True, "action": "add"}, parents
|
||||
|
||||
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -782,11 +795,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if plr_resource.parent is not None:
|
||||
plr_resource.parent.unassign_child_resource(plr_resource)
|
||||
self.resource_tracker.remove_resource(plr_resource)
|
||||
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点")
|
||||
self.lab_logger().info(f"[资源同步] 移除物料 {plr_resource} 及其子节点")
|
||||
|
||||
for other_plr_resource in other_plr_resources:
|
||||
self.resource_tracker.remove_resource(other_plr_resource)
|
||||
self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点")
|
||||
self.lab_logger().info(f"[资源同步] 移除物料 {other_plr_resource} 及其子节点")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -797,7 +810,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def _handle_update(
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
"""
|
||||
处理资源更新操作的内部函数
|
||||
|
||||
@@ -809,6 +822,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
Returns:
|
||||
操作结果字典
|
||||
"""
|
||||
original_instances = []
|
||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||
if isinstance(plr_resource, ResourceDictInstance):
|
||||
self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新")
|
||||
@@ -817,11 +831,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
|
||||
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
|
||||
)
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
parent_appended = False
|
||||
|
||||
# Update操作中包含改名:需要先remove再add
|
||||
if original_instance.name != plr_resource.name:
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
# Update操作中包含改名:需要先remove再add,这里更新父节点即可
|
||||
if not not_same_parent and old_name != new_name:
|
||||
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
|
||||
|
||||
# 收集所有相关的uuid(包括子节点)
|
||||
@@ -830,12 +849,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
_handle_add([original_instance], tree_set, additional_add_params)
|
||||
|
||||
self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}")
|
||||
original_instances.append(original_parent_resource)
|
||||
parent_appended = True
|
||||
|
||||
# 常规更新:不涉及改名
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
|
||||
self.lab_logger().info(
|
||||
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
|
||||
f"目标父节点{target_parent_resource_uuid} 更新"
|
||||
@@ -846,11 +863,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
|
||||
|
||||
# 如果父节点变化,需要重新挂载
|
||||
if (
|
||||
original_parent_resource_uuid != target_parent_resource_uuid
|
||||
and original_parent_resource is not None
|
||||
):
|
||||
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
if not_same_parent:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
original_instances.append(parent)
|
||||
parent_appended = True
|
||||
else:
|
||||
# 判断是否变更了resource_site,重新登记
|
||||
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
||||
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
|
||||
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
||||
if target_site is not None and sites is not None and site_names is not None:
|
||||
site_index = sites.index(original_instance)
|
||||
site_name = site_names[site_index]
|
||||
if site_name != target_site:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
original_instances.append(parent)
|
||||
parent_appended = True
|
||||
|
||||
# 加载状态
|
||||
original_instance.load_all_state(states)
|
||||
@@ -858,13 +887,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().info(
|
||||
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个"
|
||||
)
|
||||
if not parent_appended:
|
||||
original_instances.append(original_instance)
|
||||
|
||||
# 调用driver的update回调
|
||||
func = getattr(self.driver_instance, "resource_tree_update", None)
|
||||
if callable(func):
|
||||
func(plr_resources)
|
||||
func(original_instances)
|
||||
|
||||
return {"success": True, "action": "update"}
|
||||
return {"success": True, "action": "update"}, original_instances
|
||||
|
||||
try:
|
||||
data = json.loads(req.command)
|
||||
@@ -874,8 +905,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action = i.get("action") # remove, add, update
|
||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||
self.lab_logger().info(
|
||||
f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}"
|
||||
self.lab_logger().trace(
|
||||
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
||||
)
|
||||
tree_set = None
|
||||
if action in ["add", "update"]:
|
||||
@@ -887,7 +918,26 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if tree_set is None:
|
||||
raise ValueError("tree_set不能为None")
|
||||
plr_resources = tree_set.to_plr_resources()
|
||||
result = _handle_add(plr_resources, tree_set, additional_add_params)
|
||||
result, parents = _handle_add(plr_resources, tree_set, additional_add_params)
|
||||
parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None]
|
||||
# de_dupe_parents = list(set(parents))
|
||||
# Fix unhashable type error for WareHouse
|
||||
de_dupe_parents = []
|
||||
_seen_ids = set()
|
||||
for p in parents:
|
||||
if id(p) not in _seen_ids:
|
||||
_seen_ids.add(id(p))
|
||||
de_dupe_parents.append(p)
|
||||
new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重
|
||||
for tree in new_tree_set.trees:
|
||||
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "update":
|
||||
if tree_set is None:
|
||||
@@ -898,7 +948,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
plr_resources.append(tree.root_node)
|
||||
else:
|
||||
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
|
||||
result = _handle_update(plr_resources, tree_set, additional_add_params)
|
||||
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
|
||||
if not BasicConfig.no_update_feedback:
|
||||
new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) # 去重
|
||||
for tree in new_tree_set.trees:
|
||||
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "remove":
|
||||
result = _handle_remove(resources_uuid)
|
||||
@@ -912,15 +973,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 返回处理结果
|
||||
result_json = {"results": results, "total": len(data)}
|
||||
res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder)
|
||||
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
|
||||
# self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Invalid JSON format: {str(e)}"
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
self.lab_logger().error(f"[资源同步] {error_msg}")
|
||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
self.lab_logger().error(f"[资源同步] {error_msg}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||
|
||||
@@ -1241,7 +1302,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
|
||||
|
||||
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
|
||||
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
|
||||
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {str(action_kwargs)[:1000]}")
|
||||
self.lab_logger().trace(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
|
||||
error_skip = False
|
||||
# 向Host查询物料当前状态,如果是host本身的增加物料的请求,则直接跳过
|
||||
if action_name not in ["create_resource_detailed", "create_resource"]:
|
||||
@@ -1257,9 +1319,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 批量查询资源
|
||||
queried_resources = []
|
||||
for resource_data in resource_inputs:
|
||||
plr_resource = await self.get_resource_with_dir(
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
|
||||
if unilabos_uuid is None:
|
||||
plr_resource = await self.get_resource_with_dir(
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
else:
|
||||
resource_tree = await self.get_resource([unilabos_uuid])
|
||||
plr_resource = resource_tree.to_plr_resources()[0]
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
queried_resources.append(plr_resource)
|
||||
@@ -1308,9 +1375,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = True
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
|
||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
@@ -1330,8 +1396,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
|
||||
@@ -1399,7 +1466,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
for r in rs:
|
||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||
else:
|
||||
res = self.resource_tracker.parent_resource(r)
|
||||
res = self.resource_tracker.parent_resource(rs)
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
@@ -1475,8 +1542,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_data = function_args[arg_name]
|
||||
if isinstance(resource_data, dict) and "id" in resource_data:
|
||||
try:
|
||||
converted_resource = self._convert_resource_sync(resource_data)
|
||||
function_args[arg_name] = converted_resource
|
||||
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
@@ -1490,12 +1556,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_list = function_args[arg_name]
|
||||
if isinstance(resource_list, list):
|
||||
try:
|
||||
converted_resources = []
|
||||
for resource_data in resource_list:
|
||||
if isinstance(resource_data, dict) and "id" in resource_data:
|
||||
converted_resource = self._convert_resource_sync(resource_data)
|
||||
converted_resources.append(converted_resource)
|
||||
function_args[arg_name] = converted_resources
|
||||
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
@@ -1508,20 +1570,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def _convert_resource_sync(self, resource_data: Dict[str, Any]):
|
||||
"""同步转换资源数据为实例"""
|
||||
# 创建资源查询请求
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{
|
||||
"id": resource_data.get("id", None),
|
||||
"uuid": resource_data.get("uuid", None),
|
||||
"with_children": True,
|
||||
}
|
||||
)
|
||||
def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]:
|
||||
"""同步转换资源 UUID 为实例
|
||||
|
||||
# 同步调用资源查询服务
|
||||
future = self._resource_clients["resource_get"].call_async(r)
|
||||
Args:
|
||||
*uuids: 一个或多个资源 UUID
|
||||
|
||||
Returns:
|
||||
单个 UUID 时返回单个资源实例,多个 UUID 时返回资源实例列表
|
||||
"""
|
||||
if not uuids:
|
||||
raise ValueError("至少需要提供一个 UUID")
|
||||
|
||||
uuids_list = list(uuids)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
))
|
||||
|
||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||
timeout = 30.0
|
||||
@@ -1531,27 +1600,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
elapsed += 0.05
|
||||
|
||||
if not future.done():
|
||||
raise Exception(f"资源查询超时: {resource_data}")
|
||||
raise Exception(f"资源查询超时: {uuids_list}")
|
||||
|
||||
response = future.result()
|
||||
if response is None:
|
||||
raise Exception(f"资源查询返回空结果: {resource_data}")
|
||||
raise Exception(f"资源查询返回空结果: {uuids_list}")
|
||||
|
||||
raw_data = json.loads(response.response)
|
||||
|
||||
# 转换为 PLR 资源
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
plr_resource = tree_set.to_plr_resources()[0]
|
||||
if not len(tree_set.trees):
|
||||
raise Exception(f"资源查询返回空树: {raw_data}")
|
||||
plr_resources = tree_set.to_plr_resources()
|
||||
|
||||
# 通过资源跟踪器获取本地实例
|
||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||
if len(res) == 0:
|
||||
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
|
||||
return plr_resource
|
||||
elif len(res) == 1:
|
||||
return res[0]
|
||||
else:
|
||||
raise ValueError(f"资源转换得到多个实例: {res}")
|
||||
figured_resources: List[ResourcePLR] = []
|
||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||
if len(res) == 0:
|
||||
self.lab_logger().warning(f"资源转换未能索引到实例: {tree.root_node.res_content},返回新建实例")
|
||||
figured_resources.append(plr_resource)
|
||||
elif len(res) == 1:
|
||||
figured_resources.append(res[0])
|
||||
else:
|
||||
raise ValueError(f"资源转换得到多个实例: {res}")
|
||||
|
||||
mapped_plr_resources = []
|
||||
for uuid in uuids_list:
|
||||
for plr_resource in figured_resources:
|
||||
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
||||
mapped_plr_resources.append(r)
|
||||
break
|
||||
|
||||
return mapped_plr_resources
|
||||
|
||||
async def _execute_driver_command_async(self, string: str):
|
||||
try:
|
||||
@@ -1765,6 +1847,7 @@ class ROS2DeviceNode:
|
||||
or driver_class.__name__ == "LiquidHandlerBiomek"
|
||||
or driver_class.__name__ == "PRCXI9300Handler"
|
||||
or driver_class.__name__ == "TransformXYZHandler"
|
||||
or driver_class.__name__ == "OpcUaClient"
|
||||
)
|
||||
|
||||
# 创建设备类实例
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Type
|
||||
from action_msgs.msg import GoalStatus
|
||||
from geometry_msgs.msg import Point
|
||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.service import Service
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos_msgs.srv import (
|
||||
@@ -19,12 +18,12 @@ from unilabos_msgs.srv import (
|
||||
ResourceUpdate,
|
||||
ResourceList,
|
||||
SerialCommand,
|
||||
ResourceGet,
|
||||
) # type: ignore
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import initialize_resource
|
||||
from unilabos.resources.registry import add_schema
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
@@ -37,7 +36,7 @@ from unilabos.ros.msgs.message_converter import (
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
@@ -72,6 +71,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
_instance: ClassVar[Optional["HostNode"]] = None
|
||||
_ready_event: ClassVar[threading.Event] = threading.Event()
|
||||
_shutting_down: ClassVar[bool] = False # Flag to signal shutdown to background threads
|
||||
_background_threads: ClassVar[List[threading.Thread]] = [] # Track all background threads for cleanup
|
||||
_device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict(
|
||||
DeviceActionStatus
|
||||
)
|
||||
@@ -83,6 +84,48 @@ class HostNode(BaseROS2DeviceNode):
|
||||
return cls._instance
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def shutdown_background_threads(cls, timeout: float = 5.0) -> None:
|
||||
"""
|
||||
Gracefully shutdown all background threads for clean exit or restart.
|
||||
|
||||
This method:
|
||||
1. Sets shutdown flag to stop background operations
|
||||
2. Waits for background threads to finish with timeout
|
||||
3. Cleans up finished threads from tracking list
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for each thread (seconds)
|
||||
"""
|
||||
cls._shutting_down = True
|
||||
|
||||
# Wait for background threads to finish
|
||||
active_threads = []
|
||||
for t in cls._background_threads:
|
||||
if t.is_alive():
|
||||
t.join(timeout=timeout)
|
||||
if t.is_alive():
|
||||
active_threads.append(t.name)
|
||||
|
||||
if active_threads:
|
||||
logger.warning(f"[Host Node] Some background threads still running: {active_threads}")
|
||||
|
||||
# Clear the thread list
|
||||
cls._background_threads.clear()
|
||||
logger.info(f"[Host Node] Background threads shutdown complete")
|
||||
|
||||
@classmethod
|
||||
def reset_state(cls) -> None:
|
||||
"""
|
||||
Reset the HostNode singleton state for restart or clean exit.
|
||||
Call this after destroying the instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._ready_event.clear()
|
||||
cls._shutting_down = False
|
||||
cls._background_threads.clear()
|
||||
logger.info("[Host Node] State reset complete")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
@@ -296,12 +339,36 @@ class HostNode(BaseROS2DeviceNode):
|
||||
bridge.publish_host_ready()
|
||||
self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}")
|
||||
|
||||
def _send_re_register(self, sclient):
|
||||
sclient.wait_for_service()
|
||||
request = SerialCommand.Request()
|
||||
request.command = ""
|
||||
future = sclient.call_async(request)
|
||||
response = future.result()
|
||||
def _send_re_register(self, sclient, device_namespace: str):
|
||||
"""
|
||||
Send re-register command to a device. This is a one-time operation.
|
||||
|
||||
Args:
|
||||
sclient: The service client
|
||||
device_namespace: The device namespace for logging
|
||||
"""
|
||||
try:
|
||||
# Use timeout to prevent indefinite blocking
|
||||
if not sclient.wait_for_service(timeout_sec=10.0):
|
||||
self.lab_logger().debug(f"[Host Node] Re-register timeout for {device_namespace}")
|
||||
return
|
||||
|
||||
# Check shutdown flag after wait
|
||||
if self._shutting_down:
|
||||
self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (shutdown)")
|
||||
return
|
||||
|
||||
request = SerialCommand.Request()
|
||||
request.command = ""
|
||||
future = sclient.call_async(request)
|
||||
# Use timeout for result as well
|
||||
future.result()
|
||||
except Exception as e:
|
||||
# Gracefully handle destruction during shutdown
|
||||
if "destruction was requested" in str(e) or self._shutting_down:
|
||||
self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (cleanup)")
|
||||
else:
|
||||
self.lab_logger().warning(f"[Host Node] Re-register failed for {device_namespace}: {e}")
|
||||
|
||||
def _discover_devices(self) -> None:
|
||||
"""
|
||||
@@ -333,23 +400,27 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self._create_action_clients_for_device(device_id, namespace)
|
||||
self._online_devices.add(device_key)
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
|
||||
threading.Thread(
|
||||
t = threading.Thread(
|
||||
target=self._send_re_register,
|
||||
args=(sclient,),
|
||||
args=(sclient, namespace),
|
||||
daemon=True,
|
||||
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
|
||||
).start()
|
||||
)
|
||||
self._background_threads.append(t)
|
||||
t.start()
|
||||
elif device_key not in self._online_devices:
|
||||
# 设备重新上线
|
||||
self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}")
|
||||
self._online_devices.add(device_key)
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
|
||||
threading.Thread(
|
||||
t = threading.Thread(
|
||||
target=self._send_re_register,
|
||||
args=(sclient,),
|
||||
args=(sclient, namespace),
|
||||
daemon=True,
|
||||
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
|
||||
).start()
|
||||
)
|
||||
self._background_threads.append(t)
|
||||
t.start()
|
||||
|
||||
# 检测离线设备
|
||||
offline_devices = self._online_devices - current_devices
|
||||
@@ -515,11 +586,10 @@ class HostNode(BaseROS2DeviceNode):
|
||||
)
|
||||
|
||||
try:
|
||||
new_li = []
|
||||
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||
for i in response:
|
||||
res = json.loads(i)
|
||||
new_li.append(res)
|
||||
return {"resources": new_li, "liquid_input_resources": new_li}
|
||||
return res
|
||||
except Exception as ex:
|
||||
pass
|
||||
_n = "\n"
|
||||
@@ -707,13 +777,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
raise ValueError(f"ActionClient {action_id} not found.")
|
||||
|
||||
action_client: ActionClient = self._action_clients[action_id]
|
||||
|
||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
||||
def assign_sample_id(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "sample_uuid" in obj:
|
||||
obj["sample_id"] = obj["sample_uuid"]
|
||||
obj.pop("sample_uuid")
|
||||
for k,v in obj.items():
|
||||
for k, v in obj.items():
|
||||
if k != "unilabos_extra":
|
||||
assign_sample_id(v)
|
||||
elif isinstance(obj, list):
|
||||
@@ -723,7 +794,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
assign_sample_id(action_kwargs)
|
||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
action_client.wait_for_server()
|
||||
goal_uuid_obj = UUID(uuid=list(u.bytes))
|
||||
|
||||
@@ -744,9 +816,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted")
|
||||
self._goals[item.job_id] = goal_handle
|
||||
goal_future = goal_handle.get_result_async()
|
||||
goal_future.add_done_callback(
|
||||
lambda f: self.get_result_callback(item, action_id, f)
|
||||
)
|
||||
goal_future.add_done_callback(lambda f: self.get_result_callback(item, action_id, f))
|
||||
goal_future.result()
|
||||
|
||||
def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None:
|
||||
@@ -1063,11 +1133,11 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
接收序列化的 ResourceTreeSet 数据并进行处理
|
||||
"""
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
|
||||
try:
|
||||
# 解析请求数据
|
||||
data = json.loads(request.command)
|
||||
action = data["action"]
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
|
||||
data = data["data"]
|
||||
if action == "add":
|
||||
await self._resource_tree_action_add_callback(data, response)
|
||||
@@ -1168,11 +1238,13 @@ class HostNode(BaseROS2DeviceNode):
|
||||
响应对象,包含查询到的资源
|
||||
"""
|
||||
try:
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
data = json.loads(request.command)
|
||||
if "uuid" in data and data["uuid"] is not None:
|
||||
http_req = self.bridges[-1].resource_tree_get([data["uuid"]], data["with_children"])
|
||||
elif "id" in data and data["id"].startswith("/"):
|
||||
http_req = self.bridges[-1].resource_get(data["id"], data["with_children"])
|
||||
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
|
||||
elif "id" in data:
|
||||
http_req = http_client.resource_get(data["id"], data["with_children"])
|
||||
else:
|
||||
raise ValueError("没有使用正确的物料 id 或 uuid")
|
||||
response.response = json.dumps(http_req["data"])
|
||||
@@ -1381,10 +1453,16 @@ class HostNode(BaseROS2DeviceNode):
|
||||
}
|
||||
|
||||
def test_resource(
|
||||
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]
|
||||
self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
|
||||
) -> TestResourceReturn:
|
||||
if resources is None:
|
||||
resources = []
|
||||
if devices is None:
|
||||
devices = []
|
||||
if resource is None:
|
||||
resource = RegularContainer("test_resource传入None")
|
||||
return {
|
||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
|
||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
||||
"devices": [device, *devices],
|
||||
}
|
||||
|
||||
@@ -1436,7 +1514,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
# 构建服务地址
|
||||
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
||||
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation")
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
|
||||
|
||||
# 创建服务客户端
|
||||
sclient = self.create_client(SerialCommand, srv_address)
|
||||
@@ -1471,9 +1549,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
time.sleep(0.05)
|
||||
|
||||
response = future.result()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
|
||||
)
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,11 +12,10 @@ from unilabos_msgs.srv import ResourceUpdate
|
||||
from unilabos.messages import * # type: ignore # protocol names
|
||||
from rclpy.action import ActionServer, ActionClient
|
||||
from rclpy.action.server import ServerGoalHandle
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
|
||||
from unilabos.compile import action_protocol_generators
|
||||
from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list
|
||||
from unilabos.resources.graphio import nested_dict_to_list
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
get_action_type,
|
||||
@@ -24,7 +23,7 @@ from unilabos.ros.msgs.message_converter import (
|
||||
convert_from_ros_msg_with_mapping,
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDictInstance
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDictInstance
|
||||
from unilabos.utils.type_check import get_result_info_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -11,10 +11,9 @@ import traceback
|
||||
from abc import abstractmethod
|
||||
from typing import Type, Any, Dict, Optional, TypeVar, Generic, List
|
||||
|
||||
from unilabos.resources.graphio import nested_dict_to_list, resource_ulab_to_plr
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \
|
||||
from unilabos.resources.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \
|
||||
ResourceTreeInstance
|
||||
from unilabos.utils import logger, import_manager
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.cls_creator import create_instance_from_config
|
||||
|
||||
# 定义泛型类型变量
|
||||
@@ -135,7 +134,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
Returns:
|
||||
处理后的数据
|
||||
"""
|
||||
from pylabrobot.resources import Deck, Resource
|
||||
from pylabrobot.resources import Resource
|
||||
|
||||
if states is None:
|
||||
states = {}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
],
|
||||
"type": "device",
|
||||
"class": "reaction_station.bioyond",
|
||||
"position": {"x": 0, "y": 3800, "z": 0},
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 1100,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
@@ -57,6 +61,10 @@
|
||||
"BIOYOND_PolymerStation_TipBox": [
|
||||
"枪头盒",
|
||||
"3a143890-9d51-60ac-6d6f-6edb43c12041"
|
||||
],
|
||||
"BIOYOND_PolymerStation_Measurement_Vial": [
|
||||
"测量小瓶",
|
||||
"b1fc79c9-5864-4f05-8052-6ed3abc18a97"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -66,6 +74,9 @@
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
},
|
||||
"size_x": 2700.0,
|
||||
"size_y": 1080.0,
|
||||
"size_z": 2000.0,
|
||||
"protocol_type": []
|
||||
},
|
||||
"data": {}
|
||||
@@ -77,7 +88,11 @@
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "reaction_station.reactor",
|
||||
"position": {"x": 1150, "y": 380, "z": 0},
|
||||
"position": {
|
||||
"x": 1150,
|
||||
"y": 380,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {}
|
||||
},
|
||||
@@ -88,7 +103,11 @@
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "reaction_station.reactor",
|
||||
"position": {"x": 1365, "y": 380, "z": 0},
|
||||
"position": {
|
||||
"x": 1365,
|
||||
"y": 380,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {}
|
||||
},
|
||||
@@ -99,7 +118,11 @@
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "reaction_station.reactor",
|
||||
"position": {"x": 1580, "y": 380, "z": 0},
|
||||
"position": {
|
||||
"x": 1580,
|
||||
"y": 380,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {}
|
||||
},
|
||||
@@ -110,7 +133,11 @@
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "reaction_station.reactor",
|
||||
"position": {"x": 1790, "y": 380, "z": 0},
|
||||
"position": {
|
||||
"x": 1790,
|
||||
"y": 380,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {}
|
||||
},
|
||||
@@ -121,7 +148,11 @@
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "device",
|
||||
"class": "reaction_station.reactor",
|
||||
"position": {"x": 2010, "y": 380, "z": 0},
|
||||
"position": {
|
||||
"x": 2010,
|
||||
"y": 380,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {}
|
||||
},
|
||||
@@ -134,7 +165,7 @@
|
||||
"class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"y": 1100,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
|
||||
0
unilabos/workflow/__init__.py
Normal file
0
unilabos/workflow/__init__.py
Normal file
547
unilabos/workflow/common.py
Normal file
547
unilabos/workflow/common.py
Normal file
@@ -0,0 +1,547 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import networkx as nx
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import matplotlib.pyplot as plt
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
# ---------------- Graph ----------------
|
||||
|
||||
|
||||
class WorkflowGraph:
|
||||
"""简单的有向图实现:使用 params 单层参数;inputs 内含连线;支持 node-link 导出"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: Dict[str, Dict[str, Any]] = {}
|
||||
self.edges: List[Dict[str, Any]] = []
|
||||
|
||||
def add_node(self, node_id: str, **attrs):
|
||||
self.nodes[node_id] = attrs
|
||||
|
||||
def add_edge(self, source: str, target: str, **attrs):
|
||||
# 将 source_port/target_port 映射为服务端期望的 source_handle_key/target_handle_key
|
||||
source_handle_key = attrs.pop("source_port", "") or attrs.pop("source_handle_key", "")
|
||||
target_handle_key = attrs.pop("target_port", "") or attrs.pop("target_handle_key", "")
|
||||
|
||||
edge = {
|
||||
"source": source,
|
||||
"target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_key": source_handle_key,
|
||||
"source_handle_io": attrs.pop("source_handle_io", "source"),
|
||||
"target_handle_key": target_handle_key,
|
||||
"target_handle_io": attrs.pop("target_handle_io", "target"),
|
||||
**attrs,
|
||||
}
|
||||
self.edges.append(edge)
|
||||
|
||||
def _materialize_wiring_into_inputs(
|
||||
self,
|
||||
obj: Any,
|
||||
inputs: Dict[str, Any],
|
||||
variable_sources: Dict[str, Dict[str, Any]],
|
||||
target_node_id: str,
|
||||
base_path: List[str],
|
||||
):
|
||||
has_var = False
|
||||
|
||||
def walk(node: Any, path: List[str]):
|
||||
nonlocal has_var
|
||||
if isinstance(node, dict):
|
||||
if "__var__" in node:
|
||||
has_var = True
|
||||
varname = node["__var__"]
|
||||
placeholder = f"${{{varname}}}"
|
||||
src = variable_sources.get(varname)
|
||||
if src:
|
||||
key = ".".join(path) # e.g. "params.foo.bar.0"
|
||||
inputs[key] = {"node": src["node_id"], "output": src.get("output_name", "result")}
|
||||
self.add_edge(
|
||||
str(src["node_id"]),
|
||||
target_node_id,
|
||||
source_handle_io=src.get("output_name", "result"),
|
||||
target_handle_io=key,
|
||||
)
|
||||
return placeholder
|
||||
return {k: walk(v, path + [k]) for k, v in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [walk(v, path + [str(i)]) for i, v in enumerate(node)]
|
||||
return node
|
||||
|
||||
replaced = walk(obj, base_path[:])
|
||||
return replaced, has_var
|
||||
|
||||
def add_workflow_node(
|
||||
self,
|
||||
node_id: int,
|
||||
*,
|
||||
device_key: Optional[str] = None, # 实例名,如 "ser"
|
||||
resource_name: Optional[str] = None, # registry key(原 device_class)
|
||||
module: Optional[str] = None,
|
||||
template_name: Optional[str] = None, # 动作/模板名(原 action_key)
|
||||
params: Dict[str, Any],
|
||||
variable_sources: Dict[str, Dict[str, Any]],
|
||||
add_ready_if_no_vars: bool = True,
|
||||
prev_node_id: Optional[int] = None,
|
||||
**extra_attrs,
|
||||
) -> None:
|
||||
"""添加工作流节点:params 单层;自动变量连线与 ready 串联;支持附加属性"""
|
||||
node_id_str = str(node_id)
|
||||
inputs: Dict[str, Any] = {}
|
||||
|
||||
params, has_var = self._materialize_wiring_into_inputs(
|
||||
params, inputs, variable_sources, node_id_str, base_path=["params"]
|
||||
)
|
||||
|
||||
if add_ready_if_no_vars and not has_var:
|
||||
last_id = str(prev_node_id) if prev_node_id is not None else "-1"
|
||||
inputs["ready"] = {"node": int(last_id), "output": "ready"}
|
||||
self.add_edge(last_id, node_id_str, source_handle_io="ready", target_handle_io="ready")
|
||||
|
||||
node_obj = {
|
||||
"device_key": device_key,
|
||||
"resource_name": resource_name, # ✅ 新名字
|
||||
"module": module,
|
||||
"template_name": template_name, # ✅ 新名字
|
||||
"params": params,
|
||||
"inputs": inputs,
|
||||
}
|
||||
node_obj.update(extra_attrs or {})
|
||||
self.add_node(node_id_str, parameters=node_obj)
|
||||
|
||||
# 顺序工作流导出(连线在 inputs,不返回 edges)
|
||||
def to_dict(self) -> List[Dict[str, Any]]:
|
||||
result = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node = {"uuid": node_id}
|
||||
params = dict(attrs.get("parameters", {}) or {})
|
||||
flat = {k: v for k, v in attrs.items() if k != "parameters"}
|
||||
flat.update(params)
|
||||
node.update(flat)
|
||||
result.append(node)
|
||||
return sorted(result, key=lambda n: int(n["uuid"]) if str(n["uuid"]).isdigit() else n["uuid"])
|
||||
|
||||
# node-link 导出(含 edges)
|
||||
def to_node_link_dict(self) -> Dict[str, Any]:
|
||||
nodes_list = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node_attrs = attrs.copy()
|
||||
params = node_attrs.pop("parameters", {}) or {}
|
||||
node_attrs.update(params)
|
||||
nodes_list.append({"uuid": node_id, **node_attrs})
|
||||
return {
|
||||
"directed": True,
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
|
||||
def refactor_data(
|
||||
data: List[Dict[str, Any]],
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板
|
||||
|
||||
Args:
|
||||
data: 原始步骤数据列表
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "transfer_liquid",
|
||||
"transfer": "transfer",
|
||||
"incubation": "incubation",
|
||||
"move_labware": "move_labware",
|
||||
"oscillation": "oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "HeatChillProtocol",
|
||||
"StopHeatChill": "HeatChillStopProtocol",
|
||||
"StartHeatChill": "HeatChillStartProtocol",
|
||||
"HeatChill": "HeatChillProtocol",
|
||||
"Dissolve": "DissolveProtocol",
|
||||
"Transfer": "TransferProtocol",
|
||||
"Evaporate": "EvaporateProtocol",
|
||||
"Recrystallize": "RecrystallizeProtocol",
|
||||
"Filter": "FilterProtocol",
|
||||
"Dry": "DryProtocol",
|
||||
"Add": "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, action_resource_mapping)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template_name = OPERATION_MAPPING.get(operation)
|
||||
if not template_name:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template_name = f"biomek-{operation}"
|
||||
else:
|
||||
template_name = f"{operation}Protocol"
|
||||
|
||||
# 获取 resource_name
|
||||
resource_name = f"device.{operation.lower()}"
|
||||
if action_resource_mapping:
|
||||
resource_name = action_resource_mapping.get(operation, resource_name)
|
||||
|
||||
# 获取步骤编号,生成 name 字段
|
||||
step_number = step.get("step_number")
|
||||
name = f"Step {step_number}" if step_number is not None else None
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template_name": template_name,
|
||||
"resource_name": resource_name,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"param": step.get("parameters", step.get("action_args", {})),
|
||||
"footer": f"{template_name}-{resource_name}",
|
||||
}
|
||||
if name:
|
||||
step_data["name"] = name
|
||||
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,
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> WorkflowGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||
|
||||
Args:
|
||||
labware_info: labware 信息字典
|
||||
protocol_steps: 协议步骤列表
|
||||
workstation_name: 工作站名称
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
"""
|
||||
G = WorkflowGraph()
|
||||
resource_last_writer = {}
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||
# 有机化学&移液站协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
res_index = 0
|
||||
for labware_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 "Rack" in str(labware_id) or "Tip" in str(labware_id):
|
||||
lab_node_type = "Labware"
|
||||
description = f"Prepare Labware: {labware_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
elif item.get("type") == "hardware" or "reactor" in str(labware_id).lower():
|
||||
if "reactor" not in str(labware_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {labware_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {labware_id}"
|
||||
liquid_type = [labware_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
res_index += 1
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="create_resource",
|
||||
resource_name="host_node",
|
||||
name=f"Res {res_index}",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
footer="create_resource-host_node",
|
||||
param={
|
||||
"res_id": labware_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": "",
|
||||
},
|
||||
)
|
||||
resource_last_writer[labware_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("param", {})
|
||||
input_resources_possible_names = [
|
||||
"vessel",
|
||||
"to_vessel",
|
||||
"from_vessel",
|
||||
"reagent",
|
||||
"solvent",
|
||||
"compound",
|
||||
"sources",
|
||||
"targets",
|
||||
]
|
||||
|
||||
for target_port in input_resources_possible_names:
|
||||
resource_name = params.get(target_port)
|
||||
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 = {
|
||||
"vessel_out": params.get("vessel"),
|
||||
"from_vessel_out": params.get("from_vessel"),
|
||||
"to_vessel_out": params.get("to_vessel"),
|
||||
"filtrate_out": 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: WorkflowGraph, 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_name", 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}'")
|
||||
|
||||
|
||||
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_name", 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_handle_key") or edge.get("source_port")
|
||||
tp = edge.get("target_handle_key") or edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_handle_key=sp, target_handle_key=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}'")
|
||||
|
||||
|
||||
# ---------------- Registry Adapter ----------------
|
||||
|
||||
|
||||
class RegistryAdapter:
|
||||
"""根据 module 的类名(冒号右侧)反查 registry 的 resource_name(原 device_class),并抽取参数顺序"""
|
||||
|
||||
def __init__(self, device_registry: Dict[str, Any]):
|
||||
self.device_registry = device_registry or {}
|
||||
self.module_class_to_resource = self._build_module_class_index()
|
||||
|
||||
def _build_module_class_index(self) -> Dict[str, str]:
|
||||
idx = {}
|
||||
for resource_name, info in self.device_registry.items():
|
||||
module = info.get("module")
|
||||
if isinstance(module, str) and ":" in module:
|
||||
cls = module.split(":")[-1]
|
||||
idx[cls] = resource_name
|
||||
idx[cls.lower()] = resource_name
|
||||
return idx
|
||||
|
||||
def resolve_resource_by_classname(self, class_name: str) -> Optional[str]:
|
||||
if not class_name:
|
||||
return None
|
||||
return self.module_class_to_resource.get(class_name) or self.module_class_to_resource.get(class_name.lower())
|
||||
|
||||
def get_device_module(self, resource_name: Optional[str]) -> Optional[str]:
|
||||
if not resource_name:
|
||||
return None
|
||||
return self.device_registry.get(resource_name, {}).get("module")
|
||||
|
||||
def get_actions(self, resource_name: Optional[str]) -> Dict[str, Any]:
|
||||
if not resource_name:
|
||||
return {}
|
||||
return (self.device_registry.get(resource_name, {}).get("class", {}).get("action_value_mappings", {})) or {}
|
||||
|
||||
def get_action_schema(self, resource_name: Optional[str], template_name: str) -> Optional[Json]:
|
||||
return (self.get_actions(resource_name).get(template_name) or {}).get("schema")
|
||||
|
||||
def get_action_goal_default(self, resource_name: Optional[str], template_name: str) -> Json:
|
||||
return (self.get_actions(resource_name).get(template_name) or {}).get("goal_default", {}) or {}
|
||||
|
||||
def get_action_input_keys(self, resource_name: Optional[str], template_name: str) -> List[str]:
|
||||
schema = self.get_action_schema(resource_name, template_name) or {}
|
||||
goal = (schema.get("properties") or {}).get("goal") or {}
|
||||
props = goal.get("properties") or {}
|
||||
required = goal.get("required") or []
|
||||
return list(dict.fromkeys(required + list(props.keys())))
|
||||
356
unilabos/workflow/convert_from_json.py
Normal file
356
unilabos/workflow/convert_from_json.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
JSON 工作流转换模块
|
||||
|
||||
提供从多种 JSON 格式转换为统一工作流格式的功能。
|
||||
支持的格式:
|
||||
1. workflow/reagent 格式
|
||||
2. steps_info/labware_info 格式
|
||||
"""
|
||||
|
||||
import json
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
|
||||
def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
从 registry 获取指定设备和动作的 handles 配置
|
||||
|
||||
Args:
|
||||
resource_name: 设备资源名称,如 "liquid_handler.prcxi"
|
||||
template_name: 动作模板名称,如 "transfer_liquid"
|
||||
|
||||
Returns:
|
||||
包含 source 和 target handler_keys 的字典:
|
||||
{"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]}
|
||||
"""
|
||||
result = {"source": [], "target": []}
|
||||
|
||||
device_info = lab_registry.device_type_registry.get(resource_name, {})
|
||||
if not device_info:
|
||||
return result
|
||||
|
||||
action_mappings = device_info.get("class", {}).get("action_value_mappings", {})
|
||||
action_config = action_mappings.get(template_name, {})
|
||||
handles = action_config.get("handles", {})
|
||||
|
||||
if isinstance(handles, dict):
|
||||
# 处理 input handles (作为 target)
|
||||
for handle in handles.get("input", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["source"].append(handler_key)
|
||||
# 处理 output handles (作为 source)
|
||||
for handle in handles.get("output", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["target"].append(handler_key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
校验工作流图中所有边的句柄配置是否正确
|
||||
|
||||
Args:
|
||||
graph: 工作流图对象
|
||||
|
||||
Returns:
|
||||
(is_valid, errors): 是否有效,错误信息列表
|
||||
"""
|
||||
errors = []
|
||||
nodes = graph.nodes
|
||||
|
||||
for edge in graph.edges:
|
||||
left_uuid = edge.get("source")
|
||||
right_uuid = edge.get("target")
|
||||
# target_handle_key是target, right的输入节点(入节点)
|
||||
# source_handle_key是source, left的输出节点(出节点)
|
||||
right_source_conn_key = edge.get("target_handle_key", "")
|
||||
left_target_conn_key = edge.get("source_handle_key", "")
|
||||
|
||||
# 获取源节点和目标节点信息
|
||||
left_node = nodes.get(left_uuid, {})
|
||||
right_node = nodes.get(right_uuid, {})
|
||||
|
||||
left_res_name = left_node.get("resource_name", "")
|
||||
left_template_name = left_node.get("template_name", "")
|
||||
right_res_name = right_node.get("resource_name", "")
|
||||
right_template_name = right_node.get("template_name", "")
|
||||
|
||||
# 获取源节点的 output handles
|
||||
left_node_handles = get_action_handles(left_res_name, left_template_name)
|
||||
target_valid_keys = left_node_handles.get("target", [])
|
||||
target_valid_keys.append("ready")
|
||||
|
||||
# 获取目标节点的 input handles
|
||||
right_node_handles = get_action_handles(right_res_name, right_template_name)
|
||||
source_valid_keys = right_node_handles.get("source", [])
|
||||
source_valid_keys.append("ready")
|
||||
|
||||
# 如果节点配置了 output handles,则 source_port 必须有效
|
||||
if not right_source_conn_key:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}")
|
||||
elif right_source_conn_key not in source_valid_keys:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(
|
||||
f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}"
|
||||
)
|
||||
|
||||
# 如果节点配置了 input handles,则 target_port 必须有效
|
||||
if not left_target_conn_key:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}")
|
||||
elif left_target_conn_key not in target_valid_keys:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(
|
||||
f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在,"
|
||||
f"支持的端点: {target_valid_keys}"
|
||||
)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
# action 到 resource_name 的映射
|
||||
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "liquid_handler.prcxi",
|
||||
"transfer": "liquid_handler.prcxi",
|
||||
"incubation": "incubator.prcxi",
|
||||
"move_labware": "labware_mover.prcxi",
|
||||
"oscillation": "shaker.prcxi",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "heatchill.chemputer",
|
||||
"StopHeatChill": "heatchill.chemputer",
|
||||
"StartHeatChill": "heatchill.chemputer",
|
||||
"HeatChill": "heatchill.chemputer",
|
||||
"Dissolve": "stirrer.chemputer",
|
||||
"Transfer": "liquid_handler.chemputer",
|
||||
"Evaporate": "rotavap.chemputer",
|
||||
"Recrystallize": "reactor.chemputer",
|
||||
"Filter": "filter.chemputer",
|
||||
"Dry": "dryer.chemputer",
|
||||
"Add": "liquid_handler.chemputer",
|
||||
}
|
||||
|
||||
|
||||
def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将不同格式的步骤数据规范化为统一格式
|
||||
|
||||
支持的输入格式:
|
||||
- action + parameters
|
||||
- action + action_args
|
||||
- operation + parameters
|
||||
|
||||
Args:
|
||||
data: 原始步骤数据列表
|
||||
|
||||
Returns:
|
||||
规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...]
|
||||
"""
|
||||
normalized = []
|
||||
for idx, step in enumerate(data):
|
||||
# 获取动作名称(支持 action 或 operation 字段)
|
||||
action = step.get("action") or step.get("operation")
|
||||
if not action:
|
||||
continue
|
||||
|
||||
# 获取参数(支持 parameters 或 action_args 字段)
|
||||
raw_params = step.get("parameters") or step.get("action_args") or {}
|
||||
params = dict(raw_params)
|
||||
|
||||
# 规范化 source/target -> sources/targets
|
||||
if "source" in raw_params and "sources" not in raw_params:
|
||||
params["sources"] = raw_params["source"]
|
||||
if "target" in raw_params and "targets" not in raw_params:
|
||||
params["targets"] = raw_params["target"]
|
||||
|
||||
# 获取描述(支持 description 或 purpose 字段)
|
||||
description = step.get("description") or step.get("purpose")
|
||||
|
||||
# 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1)
|
||||
step_number = step.get("step_number", idx + 1)
|
||||
|
||||
step_dict = {"action": action, "parameters": params, "step_number": step_number}
|
||||
if description:
|
||||
step_dict["description"] = description
|
||||
|
||||
normalized.append(step_dict)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
将不同格式的 labware 数据规范化为统一的字典格式
|
||||
|
||||
支持的输入格式:
|
||||
- reagent_name + material_name + positions
|
||||
- name + labware + slot
|
||||
|
||||
Args:
|
||||
data: 原始 labware 数据列表
|
||||
|
||||
Returns:
|
||||
规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...}
|
||||
"""
|
||||
labware = {}
|
||||
for item in data:
|
||||
# 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name)
|
||||
reagent_name = item.get("reagent_name")
|
||||
key = reagent_name or item.get("material_name") or item.get("name")
|
||||
if not key:
|
||||
continue
|
||||
|
||||
key = str(key)
|
||||
|
||||
# 处理重复 key,自动添加后缀
|
||||
idx = 1
|
||||
original_key = key
|
||||
while key in labware:
|
||||
idx += 1
|
||||
key = f"{original_key}_{idx}"
|
||||
|
||||
labware[key] = {
|
||||
"slot": item.get("positions") or item.get("slot"),
|
||||
"labware": item.get("material_name") or item.get("labware"),
|
||||
"well": item.get("well", []),
|
||||
"type": item.get("type", "reagent"),
|
||||
"role": item.get("role", ""),
|
||||
"name": key,
|
||||
}
|
||||
|
||||
return labware
|
||||
|
||||
|
||||
def convert_from_json(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
validate: bool = True,
|
||||
) -> WorkflowGraph:
|
||||
"""
|
||||
从 JSON 数据或文件转换为 WorkflowGraph
|
||||
|
||||
支持的 JSON 格式:
|
||||
1. {"workflow": [...], "reagent": {...}} - 直接格式
|
||||
2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
validate: 是否校验句柄配置,默认 True
|
||||
|
||||
Returns:
|
||||
WorkflowGraph: 构建好的工作流图
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的 JSON 格式 或 句柄校验失败
|
||||
FileNotFoundError: 文件不存在
|
||||
json.JSONDecodeError: JSON 解析失败
|
||||
"""
|
||||
# 处理输入数据
|
||||
if isinstance(data, (str, PathLike)):
|
||||
path = Path(data)
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as fp:
|
||||
json_data = json.load(fp)
|
||||
elif isinstance(data, str):
|
||||
# 尝试作为 JSON 字符串解析
|
||||
json_data = json.loads(data)
|
||||
else:
|
||||
raise FileNotFoundError(f"文件不存在: {data}")
|
||||
elif isinstance(data, dict):
|
||||
json_data = data
|
||||
else:
|
||||
raise TypeError(f"不支持的数据类型: {type(data)}")
|
||||
|
||||
# 根据格式解析数据
|
||||
if "workflow" in json_data and "reagent" in json_data:
|
||||
# 格式1: workflow/reagent(已经是规范格式)
|
||||
protocol_steps = json_data["workflow"]
|
||||
labware_info = json_data["reagent"]
|
||||
elif "steps_info" in json_data and "labware_info" in json_data:
|
||||
# 格式2: steps_info/labware_info(需要规范化)
|
||||
protocol_steps = normalize_steps(json_data["steps_info"])
|
||||
labware_info = normalize_labware(json_data["labware_info"])
|
||||
elif "steps" in json_data and "labware" in json_data:
|
||||
# 格式3: steps/labware(另一种常见格式)
|
||||
protocol_steps = normalize_steps(json_data["steps"])
|
||||
if isinstance(json_data["labware"], list):
|
||||
labware_info = normalize_labware(json_data["labware"])
|
||||
else:
|
||||
labware_info = json_data["labware"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"不支持的 JSON 格式。支持的格式:\n"
|
||||
"1. {'workflow': [...], 'reagent': {...}}\n"
|
||||
"2. {'steps_info': [...], 'labware_info': [...]}\n"
|
||||
"3. {'steps': [...], 'labware': [...]}"
|
||||
)
|
||||
|
||||
# 构建工作流图
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
if validate:
|
||||
is_valid, errors = validate_workflow_handles(graph)
|
||||
if not is_valid:
|
||||
import warnings
|
||||
|
||||
for error in errors:
|
||||
warnings.warn(f"句柄校验警告: {error}")
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def convert_json_to_node_link(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 JSON 数据转换为 node-link 格式的字典
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
Dict: node-link 格式的工作流数据
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_node_link_dict()
|
||||
|
||||
|
||||
def convert_json_to_workflow_list(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 JSON 数据转换为工作流列表格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
List: 工作流节点列表
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_dict()
|
||||
|
||||
|
||||
# 为了向后兼容,保留下划线前缀的别名
|
||||
_normalize_steps = normalize_steps
|
||||
_normalize_labware = normalize_labware
|
||||
241
unilabos/workflow/from_python_script.py
Normal file
241
unilabos/workflow/from_python_script.py
Normal file
@@ -0,0 +1,241 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
from .common import WorkflowGraph, RegistryAdapter
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
# ---------------- Converter ----------------
|
||||
|
||||
class DeviceMethodConverter:
|
||||
"""
|
||||
- 字段统一:resource_name(原 device_class)、template_name(原 action_key)
|
||||
- params 单层;inputs 使用 'params.' 前缀
|
||||
- SimpleGraph.add_workflow_node 负责变量连线与边
|
||||
"""
|
||||
def __init__(self, device_registry: Optional[Dict[str, Any]] = None):
|
||||
self.graph = WorkflowGraph()
|
||||
self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name}
|
||||
self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name
|
||||
self.node_id_counter: int = 0
|
||||
self.registry = RegistryAdapter(device_registry or {})
|
||||
|
||||
# ---- helpers ----
|
||||
def _new_node_id(self) -> int:
|
||||
nid = self.node_id_counter
|
||||
self.node_id_counter += 1
|
||||
return nid
|
||||
|
||||
def _assign_targets(self, targets) -> List[str]:
|
||||
names: List[str] = []
|
||||
import ast
|
||||
if isinstance(targets, ast.Tuple):
|
||||
for elt in targets.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
names.append(elt.id)
|
||||
elif isinstance(targets, ast.Name):
|
||||
names.append(targets.id)
|
||||
return names
|
||||
|
||||
def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]:
|
||||
import ast
|
||||
if not isinstance(node.value, ast.Call):
|
||||
return None
|
||||
callee = node.value.func
|
||||
if isinstance(callee, ast.Name):
|
||||
class_name = callee.id
|
||||
elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name):
|
||||
class_name = callee.attr
|
||||
else:
|
||||
return None
|
||||
if isinstance(node.targets[0], ast.Name):
|
||||
instance = node.targets[0].id
|
||||
return instance, class_name
|
||||
return None
|
||||
|
||||
def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]:
|
||||
import ast
|
||||
owner_name, method_name, call_kind = "", "", "func"
|
||||
if isinstance(call.func, ast.Attribute):
|
||||
method_name = call.func.attr
|
||||
if isinstance(call.func.value, ast.Name):
|
||||
owner_name = call.func.value.id
|
||||
call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module"
|
||||
elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name):
|
||||
owner_name = call.func.value.attr
|
||||
call_kind = "class_or_module"
|
||||
elif isinstance(call.func, ast.Name):
|
||||
method_name = call.func.id
|
||||
call_kind = "func"
|
||||
|
||||
def pack(node):
|
||||
if isinstance(node, ast.Name):
|
||||
return {"type": "variable", "value": node.id}
|
||||
if isinstance(node, ast.Constant):
|
||||
return {"type": "constant", "value": node.value}
|
||||
if isinstance(node, ast.Dict):
|
||||
return {"type": "dict", "value": self._parse_dict(node)}
|
||||
if isinstance(node, ast.List):
|
||||
return {"type": "list", "value": self._parse_list(node)}
|
||||
return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)}
|
||||
|
||||
args: Dict[str, Any] = {}
|
||||
pos: List[Any] = []
|
||||
for a in call.args:
|
||||
pos.append(pack(a))
|
||||
for kw in call.keywords:
|
||||
args[kw.arg] = pack(kw.value)
|
||||
if pos:
|
||||
args["_positional"] = pos
|
||||
return owner_name, method_name, args, call_kind
|
||||
|
||||
def _parse_dict(self, node) -> Dict[str, Any]:
|
||||
import ast
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in zip(node.keys, node.values):
|
||||
if isinstance(k, ast.Constant):
|
||||
key = str(k.value)
|
||||
if isinstance(v, ast.Name):
|
||||
out[key] = f"var:{v.id}"
|
||||
elif isinstance(v, ast.Constant):
|
||||
out[key] = v.value
|
||||
elif isinstance(v, ast.Dict):
|
||||
out[key] = self._parse_dict(v)
|
||||
elif isinstance(v, ast.List):
|
||||
out[key] = self._parse_list(v)
|
||||
return out
|
||||
|
||||
def _parse_list(self, node) -> List[Any]:
|
||||
import ast
|
||||
out: List[Any] = []
|
||||
for elt in node.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
out.append(f"var:{elt.id}")
|
||||
elif isinstance(elt, ast.Constant):
|
||||
out.append(elt.value)
|
||||
elif isinstance(elt, ast.Dict):
|
||||
out.append(self._parse_dict(elt))
|
||||
elif isinstance(elt, ast.List):
|
||||
out.append(self._parse_list(elt))
|
||||
return out
|
||||
|
||||
def _normalize_var_tokens(self, x: Any) -> Any:
|
||||
if isinstance(x, str) and x.startswith("var:"):
|
||||
return {"__var__": x[4:]}
|
||||
if isinstance(x, list):
|
||||
return [self._normalize_var_tokens(i) for i in x]
|
||||
if isinstance(x, dict):
|
||||
return {k: self._normalize_var_tokens(v) for k, v in x.items()}
|
||||
return x
|
||||
|
||||
def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else []
|
||||
defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {}
|
||||
params: Dict[str, Any] = dict(defaults)
|
||||
|
||||
def unpack(p):
|
||||
t, v = p.get("type"), p.get("value")
|
||||
if t == "variable":
|
||||
return {"__var__": v}
|
||||
if t == "dict":
|
||||
return self._normalize_var_tokens(v)
|
||||
if t == "list":
|
||||
return self._normalize_var_tokens(v)
|
||||
return v
|
||||
|
||||
for k, p in call_args.items():
|
||||
if k == "_positional":
|
||||
continue
|
||||
params[k] = unpack(p)
|
||||
|
||||
pos = call_args.get("_positional", [])
|
||||
if pos:
|
||||
if input_keys:
|
||||
for i, p in enumerate(pos):
|
||||
if i >= len(input_keys):
|
||||
break
|
||||
name = input_keys[i]
|
||||
if name in params:
|
||||
continue
|
||||
params[name] = unpack(p)
|
||||
else:
|
||||
for i, p in enumerate(pos):
|
||||
params[f"arg_{i}"] = unpack(p)
|
||||
return params
|
||||
|
||||
# ---- handlers ----
|
||||
def _on_assign(self, stmt):
|
||||
import ast
|
||||
inst = self._extract_device_instantiation(stmt)
|
||||
if inst:
|
||||
instance, code_class = inst
|
||||
resource_name = self.registry.resolve_resource_by_classname(code_class)
|
||||
self.instance_to_resource[instance] = resource_name
|
||||
return
|
||||
|
||||
if isinstance(stmt.value, ast.Call):
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
out_vars = self._assign_targets(stmt.targets[0])
|
||||
for var in out_vars:
|
||||
self.variable_sources[var] = {"node_id": nid, "output_name": "result"}
|
||||
|
||||
def _on_expr(self, stmt):
|
||||
import ast
|
||||
if not isinstance(stmt.value, ast.Call):
|
||||
return
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
def convert(self, python_code: str):
|
||||
tree = ast.parse(python_code)
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, ast.Assign):
|
||||
self._on_assign(stmt)
|
||||
elif isinstance(stmt, ast.Expr):
|
||||
self._on_expr(stmt)
|
||||
return self
|
||||
131
unilabos/workflow/from_xdl.py
Normal file
131
unilabos/workflow/from_xdl.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from typing import List, Any, Dict
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
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 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)}"
|
||||
return {"error": error_msg, "success": False}
|
||||
@@ -2,7 +2,7 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos_msgs</name>
|
||||
<version>0.10.14</version>
|
||||
<version>0.10.15</version>
|
||||
<description>ROS2 Messages package for unilabos devices</description>
|
||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||
|
||||
Reference in New Issue
Block a user