mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-08 07:55:12 +00:00
Compare commits
98 Commits
1ec642ee3a
...
workstatio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5805f94e9a | ||
|
|
3adcc41ce8 | ||
|
|
243922caf4 | ||
|
|
079ec9d1b4 | ||
|
|
54cfaf15f3 | ||
|
|
1c9d2ee98a | ||
|
|
3fe8f4ca44 | ||
|
|
2476821dcc | ||
|
|
7b426ed5ae | ||
|
|
9bbae96447 | ||
|
|
10aabb7592 | ||
|
|
a5397ffe12 | ||
|
|
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 | ||
|
|
02c79363c1 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.4
|
||||
version: 0.10.6
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
@@ -10,7 +10,6 @@ build:
|
||||
python:
|
||||
entry_points:
|
||||
- unilab = unilabos.app.main:main
|
||||
- unilab-register = unilabos.app.register:main
|
||||
script:
|
||||
- set PIP_NO_INDEX=
|
||||
- if: win
|
||||
@@ -61,7 +60,7 @@ requirements:
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
- websockets
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -246,3 +246,5 @@ local_test2.py
|
||||
ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
*.bz2
|
||||
test_config.py
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Uni-Lab-OS
|
||||
|
||||
<!-- Language switcher -->
|
||||
|
||||
**English** | [中文](README_zh.md)
|
||||
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Uni-Lab-OS
|
||||
|
||||
<!-- Language switcher -->
|
||||
|
||||
[English](README.md) | **中文**
|
||||
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||
@@ -12,7 +13,7 @@
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||
|
||||
Uni-Lab-OS是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
||||
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
||||
|
||||
## 🏆 比赛
|
||||
|
||||
@@ -34,7 +35,7 @@ Uni-Lab-OS是一个用于实验室自动化的综合平台,旨在连接和控
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 配置Conda环境
|
||||
1. 配置 Conda 环境
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
|
||||
|
||||
@@ -43,7 +44,7 @@ Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. 安装开发版Uni-Lab-OS:
|
||||
2. 安装开发版 Uni-Lab-OS:
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
|
||||
@@ -13,18 +13,16 @@
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PLR_STATION",
|
||||
"name": "PLR_LH_TEST",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
"deck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PLR_STATION",
|
||||
"name": "PLR_LH_TEST",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": ["deck"]
|
||||
},
|
||||
{
|
||||
"id": "deck",
|
||||
"name": "deck",
|
||||
@@ -32,12 +30,12 @@
|
||||
"class": null,
|
||||
"parent": "PLR_STATION",
|
||||
"children": [
|
||||
"trash",
|
||||
"trash_core96",
|
||||
"teaching_carrier",
|
||||
"tip_rack",
|
||||
"plate"
|
||||
]
|
||||
"trash",
|
||||
"trash_core96",
|
||||
"teaching_carrier",
|
||||
"tip_rack",
|
||||
"plate"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
@@ -45,6 +43,7 @@
|
||||
```
|
||||
|
||||
配置文件定义了移液站的组成部分,主要包括:
|
||||
|
||||
- 移液站本体(LiquidHandler)- 设备类型
|
||||
- 移液站携带物料实例(deck)- 物料类型
|
||||
|
||||
@@ -55,7 +54,7 @@
|
||||
使用以下命令启动移液站设备:
|
||||
|
||||
```bash
|
||||
unilab -g test/experiments/plr_test.json --app_bridges ""
|
||||
unilab -g test/experiments/plr_test.json --ak [通过网页获取的ak值] --sk [通过网页获取的sk值]
|
||||
```
|
||||
|
||||
### 2. 执行枪头插入操作
|
||||
@@ -66,7 +65,7 @@ unilab -g test/experiments/plr_test.json --app_bridges ""
|
||||
ros2 action send_goal /devices/PLR_STATION/pick_up_tips unilabos_msgs/action/_liquid_handler_pick_up_tips/LiquidHandlerPickUpTips "{ tip_spots: [ { id: 'tip_rack_tipspot_0_0', name: 'tip_rack_tipspot_0_0', sample_id: null, children: [], parent: 'tip_rack', type: 'device', config: { position: { x: 7.2, y: 68.3, z: -83.5 }, size_x: 9.0, size_y: 9.0, size_z: 0, rotation: { x: 0, y: 0, z: 0, type: 'Rotation' }, category: 'tip_spot', model: null, type: 'TipSpot', prototype_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } }, data: { tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' }, tip_state: { liquids: [], pending_liquids: [], liquid_history: [] }, pending_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } } } ], use_channels: [ 0 ], offsets: [ { x: 0.0, y: 0.0, z: 0.0 } ] }"
|
||||
```
|
||||
|
||||
此命令会通过ros通信触发移液站执行枪头插入操作,得到如下的PyLabRobot的输出日志。
|
||||
此命令会通过 ros 通信触发移液站执行枪头插入操作,得到如下的 PyLabRobot 的输出日志。
|
||||
|
||||
```log
|
||||
Picking up tips:
|
||||
@@ -74,27 +73,42 @@ pip# resource offset tip type max volume (µL) fitti
|
||||
p0: tip_rack_tipspot_0_0 0.0,0.0,0.0 HamiltonTip 1065 8 95.1 Yes
|
||||
```
|
||||
|
||||
也可以登陆网页,给`tip_spots`选择`tip_rack_tipspot_0_0`,`use_channels`为`0`,`offsets`均填写`0`,同样可观察到上面的日志
|
||||
|
||||
## 常见问题
|
||||
|
||||
1. **重复插入枪头不成功**:操作编排应该符合实际操作顺序,可自行通过PyLabRobot进行测试
|
||||
1. **重复插入枪头不成功**:操作编排应该符合实际操作顺序,可自行通过 PyLabRobot 进行测试
|
||||
|
||||
## 移液站支持的操作
|
||||
|
||||
移液站支持多种操作,以下是当前系统支持的操作列表:
|
||||
|
||||
1. **LiquidHandlerAspirate** - 吸液操作
|
||||
2. **LiquidHandlerDispense** - 排液操作
|
||||
3. **LiquidHandlerDiscardTips** - 丢弃枪头
|
||||
4. **LiquidHandlerDropTips** - 卸下枪头
|
||||
5. **LiquidHandlerDropTips96** - 卸下96通道枪头
|
||||
6. **LiquidHandlerMoveLid** - 移动盖子
|
||||
7. **LiquidHandlerMovePlate** - 移动板子
|
||||
8. **LiquidHandlerMoveResource** - 移动资源
|
||||
9. **LiquidHandlerPickUpTips** - 插入枪头
|
||||
10. **LiquidHandlerPickUpTips96** - 插入96通道枪头
|
||||
11. **LiquidHandlerReturnTips** - 归还枪头
|
||||
12. **LiquidHandlerReturnTips96** - 归还96通道枪头
|
||||
13. **LiquidHandlerStamp** - 打印标记
|
||||
14. **LiquidHandlerTransfer** - 液体转移
|
||||
1. **LiquidHandlerProtocolCreation** - 协议创建
|
||||
2. **LiquidHandlerAspirate** - 吸液操作
|
||||
3. **LiquidHandlerDispense** - 排液操作
|
||||
4. **LiquidHandlerDiscardTips** - 丢弃枪头
|
||||
5. **LiquidHandlerDropTips** - 卸下枪头
|
||||
6. **LiquidHandlerDropTips96** - 卸下 96 通道枪头
|
||||
7. **LiquidHandlerMoveLid** - 移动盖子
|
||||
8. **LiquidHandlerMovePlate** - 移动板子
|
||||
9. **LiquidHandlerMoveResource** - 移动资源
|
||||
10. **LiquidHandlerPickUpTips** - 插入枪头
|
||||
11. **LiquidHandlerPickUpTips96** - 插入 96 通道枪头
|
||||
12. **LiquidHandlerReturnTips** - 归还枪头
|
||||
13. **LiquidHandlerReturnTips96** - 归还 96 通道枪头
|
||||
14. **LiquidHandlerSetLiquid** - 设置液体
|
||||
15. **LiquidHandlerSetTipRack** - 设置枪头架
|
||||
16. **LiquidHandlerStamp** - 打印标记
|
||||
17. **LiquidHandlerTransfer** - 液体转移
|
||||
18. **LiquidHandlerSetGroup** - 设置分组
|
||||
19. **LiquidHandlerTransferBiomek** - Biomek 液体转移
|
||||
20. **LiquidHandlerIncubateBiomek** - Biomek 孵育
|
||||
21. **LiquidHandlerMoveBiomek** - Biomek 移动
|
||||
22. **LiquidHandlerOscillateBiomek** - Biomek 振荡
|
||||
23. **LiquidHandlerTransferGroup** - 分组转移
|
||||
24. **LiquidHandlerAdd** - 添加操作
|
||||
25. **LiquidHandlerMix** - 混合操作
|
||||
26. **LiquidHandlerMoveTo** - 移动到指定位置
|
||||
27. **LiquidHandlerRemove** - 移除操作
|
||||
|
||||
这些操作可通过ROS2 Action接口进行调用,以实现复杂的移液流程。
|
||||
这些操作可通过 ROS2 Action 接口进行调用,以实现复杂的移液流程。
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
## 简单单变量动作函数
|
||||
|
||||
|
||||
### `SendCmd`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/SendCmd.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `StrSingleInput`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `IntSingleInput`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `FloatSingleInput`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/FloatSingleInput.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Point3DSeparateInput`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Point3DSeparateInput.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Wait`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Wait.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常量有机化学操作
|
||||
|
||||
Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。
|
||||
|
||||
|
||||
|
||||
### `Clean`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Clean.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `EvacuateAndRefill`
|
||||
|
||||
@@ -28,7 +66,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `Evaporate`
|
||||
|
||||
@@ -36,7 +74,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `HeatChill`
|
||||
|
||||
@@ -44,7 +82,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `HeatChillStart`
|
||||
|
||||
@@ -52,7 +90,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `HeatChillStop`
|
||||
|
||||
@@ -60,7 +98,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `PumpTransfer`
|
||||
|
||||
@@ -68,7 +106,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `Separate`
|
||||
|
||||
@@ -76,7 +114,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `Stir`
|
||||
|
||||
@@ -84,20 +122,179 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `Add`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Add.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `AddSolid`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/AddSolid.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `AdjustPH`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Centrifuge`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `CleanVessel`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Crystallize`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Crystallize.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Dissolve`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Dissolve.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Dry`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Dry.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Filter`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Filter.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `FilterThrough`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Hydrogenate`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Purge`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Purge.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Recrystallize`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `RunColumn`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/RunColumn.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `StartPurge`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/StartPurge.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `StartStir`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/StartStir.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `StopPurge`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/StopPurge.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `StopStir`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/StopStir.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Transfer`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Transfer.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `WashSolid`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/WashSolid.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 移液工作站及相关生物自动化设备操作
|
||||
|
||||
Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。
|
||||
|
||||
|
||||
|
||||
### `LiquidHandlerAspirate`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerDiscardTips`
|
||||
|
||||
@@ -105,7 +302,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerDispense`
|
||||
|
||||
@@ -113,7 +310,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerDropTips`
|
||||
|
||||
@@ -121,7 +318,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerDropTips96`
|
||||
|
||||
@@ -129,7 +326,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerMoveLid`
|
||||
|
||||
@@ -137,7 +334,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerMovePlate`
|
||||
|
||||
@@ -145,7 +342,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerMoveResource`
|
||||
|
||||
@@ -153,7 +350,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerPickUpTips`
|
||||
|
||||
@@ -161,7 +358,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerPickUpTips96`
|
||||
|
||||
@@ -169,7 +366,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerReturnTips`
|
||||
|
||||
@@ -177,7 +374,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerReturnTips96`
|
||||
|
||||
@@ -185,7 +382,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerStamp`
|
||||
|
||||
@@ -193,7 +390,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `LiquidHandlerTransfer`
|
||||
|
||||
@@ -201,9 +398,113 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
## 多工作站及小车运行、物料转移
|
||||
---
|
||||
|
||||
### `LiquidHandlerAdd`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerIncubateBiomek`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerIncubateBiomek.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerMix`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMix.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerMoveBiomek`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveBiomek.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerMoveTo`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveTo.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerOscillateBiomek`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerOscillateBiomek.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerProtocolCreation`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerProtocolCreation.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerRemove`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerRemove.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerSetGroup`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetGroup.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerSetLiquid`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetLiquid.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerSetTipRack`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetTipRack.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerTransferBiomek`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferBiomek.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `LiquidHandlerTransferGroup`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferGroup.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 多工作站及小车运行、物料转移
|
||||
|
||||
### `AGVTransfer`
|
||||
|
||||
@@ -211,7 +512,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `WorkStationRun`
|
||||
|
||||
@@ -219,12 +520,64 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `ResetHandling`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ResourceCreateFromOuter`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ResourceCreateFromOuterEasy`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `SetPumpPosition`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 固体分配与处理设备操作
|
||||
|
||||
### `SolidDispenseAddPowderTube`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/SolidDispenseAddPowderTube.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 其他设备操作
|
||||
|
||||
### `EmptyIn`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 机械臂、夹爪等机器人设备
|
||||
|
||||
Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`:
|
||||
|
||||
|
||||
### `FollowJointTrajectory`
|
||||
|
||||
```yaml
|
||||
@@ -292,7 +645,8 @@ trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `GripperCommand`
|
||||
|
||||
```yaml
|
||||
@@ -310,17 +664,19 @@ bool reached_goal # True iff the gripper position has reached the commanded setp
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `JointTrajectory`
|
||||
|
||||
```yaml
|
||||
trajectory_msgs/JointTrajectory trajectory
|
||||
---
|
||||
---
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `PointHead`
|
||||
|
||||
```yaml
|
||||
@@ -330,12 +686,13 @@ string pointing_frame
|
||||
builtin_interfaces/Duration min_duration
|
||||
float64 max_velocity
|
||||
---
|
||||
|
||||
---
|
||||
float64 pointing_angle_error
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `SingleJointPosition`
|
||||
|
||||
```yaml
|
||||
@@ -343,15 +700,16 @@ float64 position
|
||||
builtin_interfaces/Duration min_duration
|
||||
float64 max_velocity
|
||||
---
|
||||
|
||||
---
|
||||
std_msgs/Header header
|
||||
float64 position
|
||||
float64 velocity
|
||||
float64 error
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `AssistedTeleop`
|
||||
|
||||
```yaml
|
||||
@@ -363,10 +721,10 @@ builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback
|
||||
builtin_interfaces/Duration current_teleop_duration
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `BackUp`
|
||||
|
||||
```yaml
|
||||
@@ -380,10 +738,10 @@ builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
float32 distance_traveled
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `ComputePathThroughPoses`
|
||||
|
||||
```yaml
|
||||
@@ -398,10 +756,10 @@ nav_msgs/Path path
|
||||
builtin_interfaces/Duration planning_time
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `ComputePathToPose`
|
||||
|
||||
```yaml
|
||||
@@ -416,10 +774,10 @@ nav_msgs/Path path
|
||||
builtin_interfaces/Duration planning_time
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `DriveOnHeading`
|
||||
|
||||
```yaml
|
||||
@@ -433,10 +791,10 @@ builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
float32 distance_traveled
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `DummyBehavior`
|
||||
|
||||
```yaml
|
||||
@@ -447,10 +805,10 @@ std_msgs/String command
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `FollowPath`
|
||||
|
||||
```yaml
|
||||
@@ -465,10 +823,10 @@ std_msgs/Empty result
|
||||
#feedback definition
|
||||
float32 distance_to_goal
|
||||
float32 speed
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `FollowWaypoints`
|
||||
|
||||
```yaml
|
||||
@@ -480,10 +838,10 @@ int32[] missed_waypoints
|
||||
---
|
||||
#feedback definition
|
||||
uint32 current_waypoint
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `NavigateThroughPoses`
|
||||
|
||||
```yaml
|
||||
@@ -501,10 +859,10 @@ builtin_interfaces/Duration estimated_time_remaining
|
||||
int16 number_of_recoveries
|
||||
float32 distance_remaining
|
||||
int16 number_of_poses_remaining
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `NavigateToPose`
|
||||
|
||||
```yaml
|
||||
@@ -521,10 +879,10 @@ builtin_interfaces/Duration navigation_time
|
||||
builtin_interfaces/Duration estimated_time_remaining
|
||||
int16 number_of_recoveries
|
||||
float32 distance_remaining
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `SmoothPath`
|
||||
|
||||
```yaml
|
||||
@@ -540,10 +898,10 @@ builtin_interfaces/Duration smoothing_duration
|
||||
bool was_completed
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `Spin`
|
||||
|
||||
```yaml
|
||||
@@ -556,10 +914,10 @@ builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
float32 angular_distance_traveled
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
### `Wait`
|
||||
|
||||
```yaml
|
||||
@@ -571,7 +929,6 @@ builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
builtin_interfaces/Duration time_left
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
@@ -1,37 +1,142 @@
|
||||
# 添加新动作指令(Action)
|
||||
|
||||
1. 在 `unilabos_msgs/action` 中新建实验操作名和参数列表,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成,分别是目标(Goal)、结果(Result)和反馈(Feedback),之间使用 `---` 分隔:
|
||||
本指南将引导你完成添加新动作指令的整个流程,包括编写、在线构建和测试。
|
||||
|
||||
## 1. 编写新的 Action
|
||||
|
||||
### 1.1 创建 Action 文件
|
||||
|
||||
在 `unilabos_msgs/action` 目录中新建实验操作文件,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成,分别是目标(Goal)、结果(Result)和反馈(Feedback),之间使用 `---` 分隔:
|
||||
|
||||
```action
|
||||
# 目标(Goal)
|
||||
# 目标(Goal)- 定义动作执行所需的参数
|
||||
string command
|
||||
float64 timeout
|
||||
---
|
||||
# 结果(Result)
|
||||
bool success
|
||||
# 结果(Result)- 定义动作完成后返回的结果
|
||||
bool success # 要求必须包含success,以便回传执行结果
|
||||
string return_info # 要求必须包含return_info,以便回传执行结果
|
||||
... # 其他
|
||||
---
|
||||
# 反馈(Feedback)
|
||||
# 反馈(Feedback)- 定义动作执行过程中的反馈信息
|
||||
float64 progress
|
||||
string status
|
||||
```
|
||||
|
||||
2. 在 `unilabos_msgs/CMakeLists.txt` 中添加新定义的 action
|
||||
### 1.2 更新 CMakeLists.txt
|
||||
|
||||
在 `unilabos_msgs/CMakeLists.txt` 中的 `add_action_files()` 部分添加新定义的 action:
|
||||
|
||||
```cmake
|
||||
add_action_files(
|
||||
FILES
|
||||
MyDeviceCmd.action
|
||||
# 其他已有的 action 文件...
|
||||
)
|
||||
```
|
||||
|
||||
3. 因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径:
|
||||
## 2. 在线构建和测试
|
||||
|
||||
为了简化开发流程并确保构建环境的一致性,我们使用 GitHub Actions 进行在线构建。
|
||||
|
||||
### 2.1 Fork 仓库并创建分支
|
||||
|
||||
1. **Fork 仓库**:在 GitHub 上 fork `Uni-Lab-OS` 仓库到你的个人账户
|
||||
|
||||
2. **Clone 你的 fork**:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
3. **创建功能分支**:
|
||||
|
||||
```bash
|
||||
git checkout -b add-my-device-action
|
||||
```
|
||||
|
||||
4. **提交你的更改**:
|
||||
```bash
|
||||
git add unilabos_msgs/action/MyDeviceCmd.action
|
||||
git add unilabos_msgs/CMakeLists.txt
|
||||
git commit -m "Add MyDeviceCmd action for device control"
|
||||
git push origin add-my-device-action
|
||||
```
|
||||
|
||||
### 2.2 触发在线构建
|
||||
|
||||
1. **访问你的 fork 仓库**:在浏览器中打开你的 fork 仓库页面
|
||||
|
||||
2. **手动触发构建**:
|
||||
|
||||
- 点击 "Actions" 标签
|
||||
- 选择 "Multi-Platform Conda Build" 工作流
|
||||
- 点击 "Run workflow" 按钮
|
||||
|
||||
3. **监控构建状态**:
|
||||
- 构建过程大约需要 5-10 分钟
|
||||
- 在 Actions 页面可以实时查看构建日志
|
||||
- 构建完成后,可以下载生成的 conda 包进行测试
|
||||
|
||||
### 2.3 下载和测试构建包
|
||||
|
||||
1. **下载构建产物**:
|
||||
|
||||
- 在构建完成的 Action 页面,找到 "Artifacts" 部分
|
||||
- 下载对应平台的 `conda-package-*` 文件
|
||||
|
||||
2. **本地测试安装**:
|
||||
|
||||
```bash
|
||||
# 解压下载的构建产物
|
||||
unzip conda-package-linux-64.zip # 或其他平台
|
||||
|
||||
# 安装测试包
|
||||
mamba install ./ros-humble-unilabos-msgs-*.conda
|
||||
```
|
||||
|
||||
3. **验证 Action 是否正确添加**:
|
||||
```bash
|
||||
# 检查 action 是否可用
|
||||
ros2 interface show unilabos_msgs/action/MyDeviceCmd
|
||||
```
|
||||
|
||||
## 3. 提交 Pull Request
|
||||
|
||||
测试成功后,向主仓库提交 Pull Request:
|
||||
|
||||
1. **创建 Pull Request**:
|
||||
|
||||
- 在你的 fork 仓库页面,点击 "New Pull Request"
|
||||
- 选择你的功能分支作为源分支
|
||||
- 填写详细的 PR 描述,包括:
|
||||
- 添加的 Action 功能说明
|
||||
- 测试结果
|
||||
- 相关的设备或用例
|
||||
|
||||
2. **等待审核和合并**:
|
||||
- 维护者会审核你的代码
|
||||
- CI/CD 系统会自动运行完整的测试套件
|
||||
- 合并后,新的指令集会自动发布到官方 conda 仓库
|
||||
|
||||
## 4. 使用新的 Action
|
||||
|
||||
如果采用自己构建的action包,可以通过以下命令更新安装:
|
||||
|
||||
```bash
|
||||
cd unilabos_msgs
|
||||
colcon build
|
||||
source ./install/local_setup.sh
|
||||
cd ..
|
||||
mamba remove --force ros-humble-unilabos-msgs
|
||||
mamba config set safety_checks disabled # 如果没有提升版本号,会触发md5与网络上md5不一致,是正常现象,因此通过本指令关闭md5检查
|
||||
mamba install xxx.conda --offline
|
||||
```
|
||||
|
||||
调试成功后,发起 pull request,Uni-Lab 的 CI/CD 系统会自动将新的指令集编译打包,mamba执行升级即可永久生效:
|
||||
## 常见问题
|
||||
|
||||
```bash
|
||||
mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging
|
||||
```
|
||||
**Q: 构建失败怎么办?**
|
||||
A: 检查 Actions 日志中的错误信息,通常是语法错误或依赖问题。修复后重新推送代码即可自动触发新的构建。
|
||||
|
||||
**Q: 如何测试特定平台?**
|
||||
A: 在手动触发构建时,在平台选择中只填写你需要的平台,如 `linux-64` 或 `win-64`。
|
||||
|
||||
**Q: 构建包在哪里下载?**
|
||||
A: 在 Actions 页面的构建结果中,查找 "Artifacts" 部分,每个平台都有对应的构建包可供下载。
|
||||
|
||||
@@ -94,107 +94,61 @@ public class MockGripper
|
||||
|
||||
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
|
||||
|
||||
## 注册表文件位置
|
||||
## 快速开始:使用注册表编辑器(推荐)
|
||||
|
||||
Uni-Lab 启动时会自动读取默认注册表路径 `unilabos/registry/devices` 下的所有注册设备。您也可以任意维护自己的注册表路径,只需要在 Uni-Lab 启动时使用 `--registry` 参数将路径添加即可。
|
||||
推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置:
|
||||
|
||||
在 `<path-to-registry>/devices` 中新建一个 yaml 文件,即可开始撰写。您可以将多个设备写到同一个 yaml 文件中。
|
||||
1. 启动 Uni-Lab-OS
|
||||
2. 在浏览器中打开"注册表编辑器"页面
|
||||
3. 选择您的 Python 设备驱动文件
|
||||
4. 点击"分析文件",让系统读取类信息
|
||||
5. 填写基本信息(设备描述、图标等)
|
||||
6. 点击"生成注册表",复制生成的内容
|
||||
7. 保存到 `devices/` 目录下
|
||||
|
||||
## 注册表的结构
|
||||
---
|
||||
|
||||
1. 顶层名称:每个设备的注册表以设备名称开头,例如 `new_device`, `gripper.mock`。
|
||||
1. `class` 字段:定义设备的模块路径和驱动程序语言。
|
||||
1. `status_types` 字段:定义设备定时对 Uni-Lab 实验室内发送的属性名及其类型。
|
||||
1. `action_value_mappings` 字段:定义设备支持的动作及其目标、反馈和结果。
|
||||
1. `schema` 字段:定义设备定时对 Uni-Lab 云端监控发送的属性名及其类型、描述(非必须)
|
||||
## 手动编写注册表(简化版)
|
||||
|
||||
## 创建新的注册表教程
|
||||
如果需要手动编写,只需要提供两个必需字段,系统会自动补全其余内容:
|
||||
|
||||
1. 创建文件
|
||||
在 devices 文件夹中创建一个新的 YAML 文件,例如 `new_device.yaml`。
|
||||
2. 定义设备名称
|
||||
在文件中定义设备的顶层名称,例如:`new_device` 或 `gripper.mock`
|
||||
3. 定义设备的类信息
|
||||
添加设备的模块路径和类型:
|
||||
### 最小配置示例
|
||||
|
||||
```yaml
|
||||
gripper.mock:
|
||||
class: # 定义设备的类信息
|
||||
module: unilabos.devices.gripper.mock:MockGripper
|
||||
type: python # 指定驱动语言为 Python
|
||||
status_types:
|
||||
position: Float64
|
||||
torque: Float64
|
||||
status: String
|
||||
my_device: # 设备唯一标识符
|
||||
class:
|
||||
module: unilabos.devices.your_module.my_device:MyDevice # Python 类路径
|
||||
type: python # 驱动类型
|
||||
```
|
||||
|
||||
4. 定义设备的定时发布属性。注意,对于 Python Class 来说,PROP 是 class 的 `property`,或满足能被 `getattr(cls, PROP)` 或 `cls.get_PROP` 读取到的属性值的对象。
|
||||
### 注册表文件位置
|
||||
|
||||
- 默认路径:`unilabos/registry/devices`
|
||||
- 自定义路径:启动时使用 `--registry` 参数指定
|
||||
- 可将多个设备写在同一个 yaml 文件中
|
||||
|
||||
### 系统自动生成的内容
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `get_*` 方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
|
||||
### 完整结构概览
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
PROP: TYPE
|
||||
```
|
||||
5. 定义设备支持的动作
|
||||
添加设备支持的动作及其目标、反馈和结果:
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
set_speed:
|
||||
type: SendCmd
|
||||
goal:
|
||||
command: speed
|
||||
feedback: {}
|
||||
result:
|
||||
success: success
|
||||
my_device:
|
||||
class:
|
||||
module: unilabos.devices.your_module.my_device:MyDevice
|
||||
type: python
|
||||
status_types: {} # 自动生成
|
||||
action_value_mappings: {} # 自动生成
|
||||
description: '' # 可选:设备描述
|
||||
icon: '' # 可选:设备图标
|
||||
init_param_schema: {} # 自动生成
|
||||
schema: {} # 自动生成
|
||||
```
|
||||
|
||||
在 devices 文件夹中的 YAML 文件中,action_value_mappings 是用来将驱动内的动作函数,映射到 Uni-Lab 标准动作(actions)及其目标参数值(goal)、反馈值(feedback)和结果值(result)的映射规则。若在 Uni-Lab 指令集内找不到符合心意的,请【创建新指令】。
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
<action_name>: # <action_name>:动作的名称
|
||||
# start:启动设备或某个功能。
|
||||
# stop:停止设备或某个功能。
|
||||
# set_speed:设置设备的速度。
|
||||
# set_temperature:设置设备的温度。
|
||||
# move_to_position:移动设备到指定位置。
|
||||
# stir:执行搅拌操作。
|
||||
# heatchill:执行加热或冷却操作。
|
||||
# send_nav_task:发送导航任务(例如机器人导航)。
|
||||
# set_timer:设置设备的计时器。
|
||||
# valve_open_cmd:打开阀门。
|
||||
# valve_close_cmd:关闭阀门。
|
||||
# execute_command_from_outer:执行外部命令。
|
||||
# push_to:控制设备推送到某个位置(例如机械爪)。
|
||||
# move_through_points:导航设备通过多个点。
|
||||
|
||||
type: <ActionType> # 动作的类型,表示动作的功能
|
||||
# 根据动作的功能选择合适的类型,请查阅 Uni-Lab 已支持的指令集。
|
||||
|
||||
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
|
||||
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
|
||||
|
||||
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
|
||||
<feedback_key>: <mapped_value>
|
||||
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
|
||||
<result_key>: <mapped_value>
|
||||
```
|
||||
|
||||
6. 定义设备的网页展示属性类型,这部分会被用于在 Uni-Lab 网页端渲染成状态监控
|
||||
添加设备的属性模式,包括属性类型和描述:
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
description: The status of the device
|
||||
speed:
|
||||
type: number
|
||||
description: The speed of the device
|
||||
required:
|
||||
- status
|
||||
- speed
|
||||
additionalProperties: false
|
||||
```
|
||||
详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 <add_yaml>`。
|
||||
|
||||
@@ -1,95 +1,610 @@
|
||||
# yaml注册表编写指南
|
||||
# yaml 注册表编写指南
|
||||
|
||||
`注册表的结构`
|
||||
## 快速开始:使用注册表编辑器
|
||||
|
||||
1. 顶层名称:每个设备的注册表以设备名称开头,例如 new_device。
|
||||
2. class 字段:定义设备的模块路径和类型。
|
||||
3. schema 字段:定义设备的属性模式,包括属性类型、描述和必需字段。
|
||||
4. action_value_mappings 字段:定义设备支持的动作及其目标、反馈和结果。
|
||||
推荐使用 UniLabOS 自带的可视化编辑器,它能帮你自动生成大部分配置,省去手写的麻烦。
|
||||
|
||||
`创建新的注册表教程`
|
||||
1. 创建文件
|
||||
在 devices 文件夹中创建一个新的 YAML 文件,例如 new_device.yaml。
|
||||
### 怎么用编辑器
|
||||
|
||||
2. 定义设备名称
|
||||
在文件中定义设备的顶层名称,例如:new_device
|
||||
1. 启动 UniLabOS
|
||||
2. 在浏览器中打开"注册表编辑器"页面
|
||||
3. 选择你的 Python 设备驱动文件
|
||||
4. 点击"分析文件",让系统读取你的类信息
|
||||
5. 填写一些基本信息(设备描述、图标啥的)
|
||||
6. 点击"生成注册表",复制生成的内容
|
||||
7. 把内容保存到 `devices/` 目录下
|
||||
|
||||
3. 定义设备的类信息
|
||||
添加设备的模块路径和类型:
|
||||
我们为你准备了一个测试驱动,用于在界面上尝试注册表生成,参见目录:test\registry\example_devices.py
|
||||
|
||||
```python
|
||||
new_device: # 定义一个名为 linear_motion.grbl 的设备
|
||||
---
|
||||
|
||||
## 手动编写指南
|
||||
|
||||
class: # 定义设备的类信息
|
||||
module: unilabos.devices_names.new_device:NewDeviceClass # 指定模块路径和类名
|
||||
type: python # 指定类型为 Python 类
|
||||
status_types:
|
||||
```
|
||||
4. 定义设备支持的动作
|
||||
添加设备支持的动作及其目标、反馈和结果:
|
||||
```python
|
||||
action_value_mappings:
|
||||
set_speed:
|
||||
type: SendCmd
|
||||
goal:
|
||||
command: speed
|
||||
feedback: {}
|
||||
result:
|
||||
success: success
|
||||
```
|
||||
`如何编写action_valve_mappings`
|
||||
1. 在 devices 文件夹中的 YAML 文件中,action_value_mappings 是用来定义设备支持的动作(actions)及其目标值(goal)、反馈值(feedback)和结果值(result)的映射规则。以下是规则和编写方法:
|
||||
```python
|
||||
action_value_mappings:
|
||||
<action_name>: # <action_name>:动作的名称
|
||||
# start:启动设备或某个功能。
|
||||
# stop:停止设备或某个功能。
|
||||
# set_speed:设置设备的速度。
|
||||
# set_temperature:设置设备的温度。
|
||||
# move_to_position:移动设备到指定位置。
|
||||
# stir:执行搅拌操作。
|
||||
# heatchill:执行加热或冷却操作。
|
||||
# send_nav_task:发送导航任务(例如机器人导航)。
|
||||
# set_timer:设置设备的计时器。
|
||||
# valve_open_cmd:打开阀门。
|
||||
# valve_close_cmd:关闭阀门。
|
||||
# execute_command_from_outer:执行外部命令。
|
||||
# push_to:控制设备推送到某个位置(例如机械爪)。
|
||||
# move_through_points:导航设备通过多个点。
|
||||
如果你想自己写 yaml 文件,或者想深入了解结构,查阅下方说明。
|
||||
|
||||
type: <ActionType> # 动作的类型,表示动作的功能
|
||||
# 根据动作的功能选择合适的类型:
|
||||
# SendCmd:发送简单命令。
|
||||
# NavigateThroughPoses:导航动作。
|
||||
# SingleJointPosition:设置单一关节的位置。
|
||||
# Stir:搅拌动作。
|
||||
# HeatChill:加热或冷却动作。
|
||||
## 注册表的基本结构
|
||||
|
||||
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
|
||||
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
|
||||
yaml 注册表就是设备的配置文件,里面定义了设备怎么用、有什么功能。好消息是系统会自动帮你填大部分内容,你只需要写两个必需的东西:设备名和 class 信息。
|
||||
|
||||
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
|
||||
<feedback_key>: <mapped_value>
|
||||
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
|
||||
<result_key>: <mapped_value>
|
||||
### 各字段用途
|
||||
|
||||
| 字段名 | 类型 | 需要手写 | 说明 |
|
||||
| ----------------- | ------ | -------- | ----------------------------------- |
|
||||
| 设备标识符 | string | 是 | 设备的唯一名字,比如 `mock_chiller` |
|
||||
| class | object | 部分 | 设备的核心信息,必须写 |
|
||||
| description | string | 否 | 设备描述,系统默认给空字符串 |
|
||||
| handles | array | 否 | 连接关系,默认是空的 |
|
||||
| icon | string | 否 | 图标路径,默认为空 |
|
||||
| init_param_schema | object | 否 | 初始化参数,系统自动分析生成 |
|
||||
| version | string | 否 | 版本号,默认 "1.0.0" |
|
||||
| category | array | 否 | 设备分类,默认用文件名 |
|
||||
| config_info | array | 否 | 嵌套配置,默认为空 |
|
||||
| file_path | string | 否 | 文件路径,系统自动设置 |
|
||||
| registry_type | string | 否 | 注册表类型,自动设为 "device" |
|
||||
|
||||
### class 字段里有啥
|
||||
|
||||
class 是核心部分,包含这些内容:
|
||||
|
||||
| 字段名 | 类型 | 需要手写 | 说明 |
|
||||
| --------------------- | ------ | -------- | ---------------------------------- |
|
||||
| module | string | 是 | Python 类的路径,必须写 |
|
||||
| type | string | 是 | 驱动类型,一般写 "python" |
|
||||
| status_types | object | 否 | 状态类型,系统自动分析生成 |
|
||||
| action_value_mappings | object | 部分 | 动作配置,系统会自动生成一些基础的 |
|
||||
|
||||
## 怎么创建新的注册表
|
||||
|
||||
### 创建文件
|
||||
|
||||
在 devices 文件夹里新建一个 yaml 文件,比如 `new_device.yaml`。
|
||||
|
||||
### 完整结构是什么样的
|
||||
|
||||
```yaml
|
||||
new_device: # 设备名,要唯一
|
||||
class: # 核心配置
|
||||
action_value_mappings: # 动作配置(后面会详细说)
|
||||
action_name:
|
||||
# 具体的动作设置
|
||||
module: unilabos.devices.your_module.new_device:NewDeviceClass # 你的 Python 类
|
||||
status_types: # 状态类型(系统会自动生成)
|
||||
status: str
|
||||
temperature: float
|
||||
# 其他状态
|
||||
type: python # 驱动类型,一般就是 python
|
||||
|
||||
description: New Device Description # 设备描述
|
||||
handles: [] # 连接关系,通常是空的
|
||||
icon: '' # 图标路径
|
||||
init_param_schema: # 初始化参数(系统会自动生成)
|
||||
config: # 初始化时需要的参数
|
||||
properties:
|
||||
port:
|
||||
default: DEFAULT_PORT
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
data: # 前端显示用的数据类型
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
temperature:
|
||||
type: number
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
|
||||
version: 0.0.1 # 版本号
|
||||
category:
|
||||
- device_category # 设备类别
|
||||
config_info: [] # 嵌套配置,通常为空
|
||||
```
|
||||
|
||||
6. 定义设备的属性模式
|
||||
添加设备的属性模式,包括属性类型和描述:
|
||||
```python
|
||||
schema:
|
||||
type: object
|
||||
## action_value_mappings 怎么写
|
||||
|
||||
这个部分定义设备能做哪些动作。好消息是系统会自动生成大部分动作,你通常只需要添加一些特殊的自定义动作。
|
||||
|
||||
### 系统自动生成哪些动作
|
||||
|
||||
系统会帮你生成这些:
|
||||
|
||||
1. 以 `auto-` 开头的动作:从你 Python 类的方法自动生成
|
||||
2. 通用的驱动动作:
|
||||
- `_execute_driver_command`:同步执行驱动命令
|
||||
- `_execute_driver_command_async`:异步执行驱动命令
|
||||
|
||||
### 如果要手动定义动作
|
||||
|
||||
如果你需要自定义一些特殊动作,需要这些字段:
|
||||
|
||||
| 字段名 | 需要手写 | 说明 |
|
||||
| ---------------- | -------- | -------------------------------- |
|
||||
| type | 是 | 动作类型,必须指定 |
|
||||
| goal | 是 | 输入参数怎么映射 |
|
||||
| feedback | 否 | 实时反馈,通常为空 |
|
||||
| result | 是 | 结果怎么返回 |
|
||||
| goal_default | 部分 | 参数默认值,ROS 动作会自动生成 |
|
||||
| schema | 部分 | 前端表单配置,ROS 动作会自动生成 |
|
||||
| handles | 否 | 连接关系,默认为空 |
|
||||
| placeholder_keys | 否 | 特殊输入字段配置 |
|
||||
|
||||
### 动作类型有哪些
|
||||
|
||||
| 类型 | 什么时候用 | 系统会自动生成什么 |
|
||||
| ---------------------- | -------------------- | ---------------------- |
|
||||
| UniLabJsonCommand | 自定义同步 JSON 命令 | 啥都不生成 |
|
||||
| UniLabJsonCommandAsync | 自定义异步 JSON 命令 | 啥都不生成 |
|
||||
| ROS 动作类型 | 标准 ROS 动作 | goal_default 和 schema |
|
||||
|
||||
常用的 ROS 动作类型:
|
||||
|
||||
- `SendCmd`:发送简单命令
|
||||
- `NavigateThroughPoses`:导航动作
|
||||
- `SingleJointPosition`:单关节位置控制
|
||||
- `Stir`:搅拌动作
|
||||
- `HeatChill`、`HeatChillStart`:加热冷却动作
|
||||
|
||||
### 复杂一点的例子
|
||||
|
||||
```yaml
|
||||
heat_chill_start:
|
||||
type: HeatChillStart
|
||||
goal:
|
||||
purpose: purpose
|
||||
temp: temp
|
||||
goal_default: # ROS动作会自动生成,你也可以手动覆盖
|
||||
purpose: ''
|
||||
temp: 0.0
|
||||
handles:
|
||||
output:
|
||||
- handler_key: labware
|
||||
label: Labware
|
||||
data_type: resource
|
||||
data_source: handle
|
||||
data_key: liquid
|
||||
placeholder_keys:
|
||||
purpose: unilabos_resources
|
||||
result:
|
||||
status: status
|
||||
success: success
|
||||
# schema 系统会自动生成,不用写
|
||||
```
|
||||
|
||||
### 动作名字怎么起
|
||||
|
||||
根据设备用途来起名字:
|
||||
|
||||
- 启动停止类:`start`、`stop`、`pause`、`resume`
|
||||
- 设置参数类:`set_speed`、`set_temperature`、`set_timer`
|
||||
- 移动控制类:`move_to_position`、`move_through_points`
|
||||
- 功能操作类:`stir`、`heat_chill_start`、`heat_chill_stop`
|
||||
- 开关控制类:`valve_open_cmd`、`valve_close_cmd`、`push_to`
|
||||
- 命令执行类:`send_nav_task`、`execute_command_from_outer`
|
||||
|
||||
### 常用的动作类型
|
||||
|
||||
- `UniLabJsonCommand`:自定义 JSON 命令(不走 ROS)
|
||||
- `UniLabJsonCommandAsync`:异步 JSON 命令(不走 ROS)
|
||||
- `SendCmd`:发送简单命令
|
||||
- `NavigateThroughPoses`:导航相关
|
||||
- `SingleJointPosition`:单关节控制
|
||||
- `Stir`:搅拌
|
||||
- `HeatChill`、`HeatChillStart`:加热冷却
|
||||
- 其他的 ROS 动作类型:看具体的 ROS 服务
|
||||
|
||||
### 示例:完整的动作配置
|
||||
|
||||
```yaml
|
||||
heat_chill_start:
|
||||
type: HeatChillStart
|
||||
goal:
|
||||
purpose: purpose
|
||||
temp: temp
|
||||
goal_default:
|
||||
purpose: ''
|
||||
temp: 0.0
|
||||
handles:
|
||||
output:
|
||||
- handler_key: labware
|
||||
label: Labware
|
||||
data_type: resource
|
||||
data_source: handle
|
||||
data_key: liquid
|
||||
placeholder_keys:
|
||||
purpose: unilabos_resources
|
||||
result:
|
||||
status: status
|
||||
success: success
|
||||
schema:
|
||||
description: '启动加热冷却功能'
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
purpose:
|
||||
type: string
|
||||
description: '用途说明'
|
||||
temp:
|
||||
type: number
|
||||
description: '目标温度'
|
||||
required:
|
||||
- purpose
|
||||
- temp
|
||||
title: HeatChillStart_Goal
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: HeatChillStart
|
||||
type: object
|
||||
feedback: {}
|
||||
```
|
||||
|
||||
## 系统自动生成的字段
|
||||
|
||||
### status_types
|
||||
|
||||
系统会扫描你的 Python 类,从状态方法自动生成这部分:
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
current_temperature: float # 从 get_current_temperature() 方法来的
|
||||
is_heating: bool # 从 get_is_heating() 方法来的
|
||||
status: str # 从 get_status() 方法来的
|
||||
```
|
||||
|
||||
注意几点:
|
||||
|
||||
- 系统会找所有 `get_` 开头的方法
|
||||
- 类型会自动转成 ROS 类型(比如 `str` 变成 `String`)
|
||||
- 如果类型是 `Any`、`None` 或者不知道的,就默认用 `String`
|
||||
|
||||
### init_param_schema
|
||||
|
||||
这个完全是系统自动生成的,你不用管:
|
||||
|
||||
```yaml
|
||||
init_param_schema:
|
||||
config: # 从你类的 __init__ 方法分析出来的
|
||||
properties:
|
||||
port:
|
||||
type: string
|
||||
default: '/dev/ttyUSB0'
|
||||
baudrate:
|
||||
type: integer
|
||||
default: 9600
|
||||
required: []
|
||||
type: object
|
||||
|
||||
data: # 根据 status_types 生成的前端用的类型
|
||||
properties:
|
||||
current_temperature:
|
||||
type: number
|
||||
is_heating:
|
||||
type: boolean
|
||||
status:
|
||||
type: string
|
||||
description: The status of the device
|
||||
speed:
|
||||
type: number
|
||||
description: The speed of the device
|
||||
required:
|
||||
- status
|
||||
- speed
|
||||
additionalProperties: false
|
||||
type: object
|
||||
```
|
||||
# 写完yaml注册表后需要添加到哪些其他文件?
|
||||
|
||||
生成规则很简单:
|
||||
|
||||
- `config` 部分:看你类的 `__init__` 方法有什么参数,类型和默认值是啥
|
||||
- `data` 部分:根据 `status_types` 生成前端显示用的类型定义
|
||||
|
||||
### 其他自动填充的字段
|
||||
|
||||
```yaml
|
||||
version: '1.0.0' # 默认版本
|
||||
category: ['文件名'] # 用你的 yaml 文件名当类别
|
||||
description: '' # 默认为空,你可以手动改
|
||||
icon: '' # 默认为空,你可以加图标
|
||||
handles: [] # 默认空数组
|
||||
config_info: [] # 默认空数组
|
||||
file_path: '/path/to/file' # 系统自动填文件路径
|
||||
registry_type: 'device' # 自动设为设备类型
|
||||
```
|
||||
|
||||
### handles 字段
|
||||
|
||||
这个是定义设备连接关系的,类似动作里的 handles 一样:
|
||||
|
||||
```yaml
|
||||
handles: # 大多数时候都是空的,除非设备本身需要连接啥
|
||||
- handler_key: device_output
|
||||
label: Device Output
|
||||
data_type: resource
|
||||
data_source: value
|
||||
data_key: default_value
|
||||
```
|
||||
|
||||
### 其他可以配置的字段
|
||||
|
||||
```yaml
|
||||
description: '设备的详细描述' # 写清楚设备是干啥的
|
||||
|
||||
icon: 'device_icon.webp' # 设备图标,文件名(会上传到OSS)
|
||||
|
||||
version: '0.0.1' # 版本号
|
||||
|
||||
category: # 设备分类,前端会用这个分组
|
||||
- 'heating'
|
||||
- 'cooling'
|
||||
- 'temperature_control'
|
||||
|
||||
config_info: # 嵌套配置,如果设备包含子设备
|
||||
- children:
|
||||
- opentrons_24_tuberack_nest_1point5ml_snapcap_A1
|
||||
- other_nested_component
|
||||
```
|
||||
|
||||
## 完整的例子
|
||||
|
||||
这里是一个比较完整的设备配置示例:
|
||||
|
||||
```yaml
|
||||
my_temperature_controller:
|
||||
class:
|
||||
action_value_mappings:
|
||||
heat_start:
|
||||
type: HeatChillStart
|
||||
goal:
|
||||
target_temp: temp
|
||||
vessel: vessel
|
||||
goal_default:
|
||||
target_temp: 25.0
|
||||
vessel: ''
|
||||
handles:
|
||||
output:
|
||||
- handler_key: heated_sample
|
||||
label: Heated Sample
|
||||
data_type: resource
|
||||
data_source: handle
|
||||
data_key: sample
|
||||
placeholder_keys:
|
||||
vessel: unilabos_resources
|
||||
result:
|
||||
status: status
|
||||
success: success
|
||||
schema:
|
||||
description: '启动加热功能'
|
||||
properties:
|
||||
goal:
|
||||
properties:
|
||||
target_temp:
|
||||
type: number
|
||||
description: '目标温度'
|
||||
vessel:
|
||||
type: string
|
||||
description: '容器标识'
|
||||
required:
|
||||
- target_temp
|
||||
- vessel
|
||||
title: HeatStart_Goal
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: HeatStart
|
||||
type: object
|
||||
feedback: {}
|
||||
|
||||
stop:
|
||||
type: UniLabJsonCommand
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
result:
|
||||
status: status
|
||||
schema:
|
||||
description: '停止设备'
|
||||
properties:
|
||||
goal:
|
||||
type: object
|
||||
title: Stop_Goal
|
||||
title: Stop
|
||||
type: object
|
||||
feedback: {}
|
||||
|
||||
module: unilabos.devices.temperature.my_controller:MyTemperatureController
|
||||
status_types:
|
||||
current_temperature: float
|
||||
target_temperature: float
|
||||
is_heating: bool
|
||||
is_cooling: bool
|
||||
status: str
|
||||
vessel: str
|
||||
type: python
|
||||
|
||||
description: '我的温度控制器设备'
|
||||
handles: []
|
||||
icon: 'temperature_controller.webp'
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
port:
|
||||
default: '/dev/ttyUSB0'
|
||||
type: string
|
||||
baudrate:
|
||||
default: 9600
|
||||
type: number
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
current_temperature:
|
||||
type: number
|
||||
target_temperature:
|
||||
type: number
|
||||
is_heating:
|
||||
type: boolean
|
||||
is_cooling:
|
||||
type: boolean
|
||||
status:
|
||||
type: string
|
||||
vessel:
|
||||
type: string
|
||||
required:
|
||||
- current_temperature
|
||||
- target_temperature
|
||||
- status
|
||||
type: object
|
||||
|
||||
version: '1.0.0'
|
||||
category:
|
||||
- 'temperature_control'
|
||||
- 'heating'
|
||||
config_info: []
|
||||
```
|
||||
|
||||
## 怎么部署和使用
|
||||
|
||||
### 方法一:用编辑器(推荐)
|
||||
|
||||
1. 先写好你的 Python 驱动类
|
||||
2. 用注册表编辑器自动生成 yaml 配置
|
||||
3. 把生成的文件保存到 `devices/` 目录
|
||||
4. 重启 UniLabOS 就能用了
|
||||
|
||||
### 方法二:手动写(简化版)
|
||||
|
||||
1. 创建最简配置:
|
||||
|
||||
```yaml
|
||||
# devices/my_device.yaml
|
||||
my_device:
|
||||
class:
|
||||
module: unilabos.devices.my_module.my_device:MyDevice
|
||||
type: python
|
||||
```
|
||||
|
||||
2. 启动系统时用 `complete_registry=True` 参数,让系统自动补全
|
||||
|
||||
3. 检查一下生成的配置是不是你想要的
|
||||
|
||||
### Python 驱动类要怎么写
|
||||
|
||||
你的设备类要符合这些要求:
|
||||
|
||||
```python
|
||||
from unilabos.common.device_base import DeviceBase
|
||||
|
||||
class MyDevice(DeviceBase):
|
||||
def __init__(self, config):
|
||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||
super().__init__(config)
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
|
||||
# 状态方法(会自动生成到 status_types)
|
||||
def get_status(self):
|
||||
"""返回设备状态"""
|
||||
return "idle"
|
||||
|
||||
def get_temperature(self):
|
||||
"""返回当前温度"""
|
||||
return 25.0
|
||||
|
||||
# 动作方法(会自动生成 auto- 开头的动作)
|
||||
async def start_heating(self, temperature: float):
|
||||
"""开始加热到指定温度"""
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
"""停止操作"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 系统集成
|
||||
|
||||
1. 把 yaml 文件放到 `devices/` 目录下
|
||||
2. 系统启动时会自动扫描并加载设备
|
||||
3. 系统会自动补全所有缺失的字段
|
||||
4. 设备马上就能在前端界面中使用
|
||||
|
||||
### 高级配置
|
||||
|
||||
如果需要特殊设置,可以手动加:
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
class:
|
||||
module: unilabos.devices.my_module.my_device:MyDevice
|
||||
type: python
|
||||
action_value_mappings:
|
||||
# 自定义动作
|
||||
special_command:
|
||||
type: UniLabJsonCommand
|
||||
goal: {}
|
||||
result: {}
|
||||
|
||||
# 可选的自定义配置
|
||||
description: '我的特殊设备'
|
||||
icon: 'my_device.webp'
|
||||
category: ['temperature', 'heating']
|
||||
```
|
||||
|
||||
## 常见问题怎么排查
|
||||
|
||||
### 设备加载不了
|
||||
|
||||
1. 检查模块路径:确认 `class.module` 路径写对了
|
||||
2. 确认类能导入:看看你的 Python 驱动类能不能正常导入
|
||||
3. 检查语法:用 yaml 验证器看看文件格式对不对
|
||||
4. 查看日志:看 UniLabOS 启动时有没有报错信息
|
||||
|
||||
### 自动生成失败了
|
||||
|
||||
1. 类分析出问题:确认你的类继承了正确的基类
|
||||
2. 方法类型不明确:确保状态方法的返回类型写清楚了
|
||||
3. 导入有问题:检查类能不能被动态导入
|
||||
4. 没开完整注册:确认启用了 `complete_registry=True`
|
||||
|
||||
### 前端显示有问题
|
||||
|
||||
1. 重新生成:删掉旧的 yaml 文件,用编辑器重新生成
|
||||
2. 清除缓存:清除浏览器缓存,重新加载页面
|
||||
3. 检查字段:确认必需的字段(比如 `schema`)都有
|
||||
4. 验证数据:检查 `goal_default` 和 `schema` 的数据类型是不是一致
|
||||
|
||||
### 动作执行出错
|
||||
|
||||
1. 方法名不对:确认动作方法名符合规范(比如 `execute_<action_name>`)
|
||||
2. 参数映射错误:检查 `goal` 字段的参数映射是否正确
|
||||
3. 返回格式不对:确认方法返回值格式符合 `result` 映射
|
||||
4. 没异常处理:在驱动类里加上异常处理
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 开发流程
|
||||
|
||||
1. **优先使用编辑器**:除非有特殊需求,否则优先使用注册表编辑器
|
||||
2. **最小化配置**:手动配置时只定义必要字段,让系统自动生成其他内容
|
||||
3. **增量开发**:先创建基本配置,后续根据需要添加特殊动作
|
||||
|
||||
### 代码规范
|
||||
|
||||
1. **方法命名**:状态方法使用 `get_` 前缀,动作方法使用动词开头
|
||||
2. **类型注解**:为方法参数和返回值添加类型注解
|
||||
3. **文档字符串**:为类和方法添加详细的文档字符串
|
||||
4. **异常处理**:实现完善的错误处理和日志记录
|
||||
|
||||
### 配置管理
|
||||
|
||||
1. **版本控制**:所有 yaml 文件纳入版本控制
|
||||
2. **命名一致性**:设备 ID、文件名、类名保持一致的命名风格
|
||||
3. **定期更新**:定期运行完整注册以更新自动生成的字段
|
||||
4. **备份配置**:在修改前备份重要的手动配置
|
||||
|
||||
### 测试验证
|
||||
|
||||
1. **本地测试**:在本地环境充分测试后再部署
|
||||
2. **渐进部署**:先部署到测试环境,验证无误后再上生产环境
|
||||
3. **监控日志**:密切监控设备加载和运行日志
|
||||
4. **回滚准备**:准备快速回滚机制,以应对紧急情况
|
||||
|
||||
### 性能优化
|
||||
|
||||
1. **按需加载**:只加载实际使用的设备类型
|
||||
2. **缓存利用**:充分利用系统的注册表缓存机制
|
||||
3. **资源管理**:合理管理设备连接和资源占用
|
||||
4. **监控指标**:设置关键性能指标的监控和告警
|
||||
|
||||
@@ -1,82 +1,73 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
Uni-Lab支持通过Python配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的Uni-Lab环境。
|
||||
Uni-Lab 支持通过 Python 配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的 Uni-Lab 环境。
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
Uni-Lab支持Python格式的配置文件,它比YAML或JSON提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
Uni-Lab 支持 Python 格式的配置文件,它比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
### 基本配置示例
|
||||
### 默认配置示例
|
||||
|
||||
一个典型的配置文件包含以下部分:
|
||||
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`:
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
|
||||
sk = "" # 实验室网页给您提供的sk代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
|
||||
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
您可以根据需要添加更多配置选项:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
# 基础配置
|
||||
class BasicConfig:
|
||||
ak = "your_access_key" # 实验室访问密钥
|
||||
sk = "your_secret_key" # 实验室私钥
|
||||
working_dir = "" # 工作目录(通常自动设置)
|
||||
config_path = "" # 配置文件路径(自动设置)
|
||||
is_host_mode = True # 是否为主站模式
|
||||
slave_no_host = False # 从站模式下是否跳过等待主机服务
|
||||
upload_registry = False # 是否上传注册表
|
||||
machine_name = "undefined" # 机器名称(自动获取)
|
||||
vis_2d_enable = False # 是否启用2D可视化
|
||||
enable_resource_load = True # 是否启用资源加载
|
||||
communication_protocol = "websocket" # 通信协议
|
||||
|
||||
# 配置类定义
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
class MQConfig:
|
||||
"""MQTT 配置类"""
|
||||
lab_id: str = "YOUR_LAB_ID"
|
||||
# 更多配置...
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = "" # API主机地址
|
||||
authorization = "" # 授权信息
|
||||
init_endpoint = "" # 初始化端点
|
||||
complete_endpoint = "" # 完成端点
|
||||
max_retries = 3 # 最大重试次数
|
||||
|
||||
# 其他配置类...
|
||||
```
|
||||
|
||||
## 配置选项说明
|
||||
|
||||
### MQTT配置 (MQConfig)
|
||||
|
||||
MQTT配置用于连接消息队列服务,是Uni-Lab与云端通信的主要方式。
|
||||
|
||||
```python
|
||||
|
||||
class MQConfig:
|
||||
"""MQTT 配置类"""
|
||||
lab_id: str = "7AAEDBEA" # 实验室唯一标识
|
||||
instance_id: str = "mqtt-cn-instance"
|
||||
access_key: str = "your-access-key"
|
||||
secret_key: str = "your-secret-key"
|
||||
group_id: str = "GID_labs"
|
||||
broker_url: str = "mqtt-cn-instance.mqtt.aliyuncs.com"
|
||||
port: int = 8883
|
||||
|
||||
# 可以直接提供证书文件路径
|
||||
ca_file: str = "/path/to/ca.pem" # 相对config.py所在目录的路径
|
||||
cert_file: str = "/path/to/cert.pem" # 相对config.py所在目录的路径
|
||||
key_file: str = "/path/to/key.pem" # 相对config.py所在目录的路径
|
||||
|
||||
# 或者直接提供证书内容
|
||||
ca_content: str = ""
|
||||
cert_content: str = ""
|
||||
key_content: str = ""
|
||||
```
|
||||
|
||||
#### 证书配置
|
||||
|
||||
MQTT连接支持两种方式配置证书:
|
||||
|
||||
1. **文件路径方式**(推荐):指定证书文件的路径,系统会自动读取文件内容
|
||||
2. **直接内容方式**:直接在配置中提供证书内容
|
||||
|
||||
推荐使用文件路径方式,便于证书的更新和管理。
|
||||
|
||||
### HTTP客户端配置 (HTTPConfig)
|
||||
|
||||
即将开放 Uni-Lab 云端实验室。
|
||||
|
||||
### ROS模块配置 (ROSConfig)
|
||||
|
||||
配置ROS消息转换器需要加载的模块:
|
||||
|
||||
```python
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1" # 远程地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
"""ROS模块配置"""
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
@@ -85,25 +76,365 @@ class ROSConfig:
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
] # 需要加载的ROS模块
|
||||
```
|
||||
|
||||
## 命令行参数覆盖配置
|
||||
|
||||
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。命令行参数的优先级高于配置文件。
|
||||
|
||||
### 支持命令行覆盖的配置项
|
||||
|
||||
以下配置项可以通过命令行参数进行覆盖:
|
||||
|
||||
| 配置类 | 配置字段 | 命令行参数 | 说明 |
|
||||
| ------------- | ----------------- | ------------------- | -------------------------------- |
|
||||
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
|
||||
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
|
||||
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
|
||||
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
|
||||
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
|
||||
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
|
||||
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
|
||||
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
|
||||
|
||||
### 特殊命令行参数
|
||||
|
||||
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `--config` | 指定配置文件路径 |
|
||||
| `--port` | Web 服务端口(不影响配置文件) |
|
||||
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
|
||||
| `--visual` | 可视化工具选择(不影响配置文件) |
|
||||
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
|
||||
|
||||
### 配置优先级
|
||||
|
||||
配置项的生效优先级从高到低为:
|
||||
|
||||
1. **命令行参数**:最高优先级
|
||||
2. **环境变量**:中等优先级
|
||||
3. **配置文件**:基础优先级
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 通过命令行覆盖认证信息
|
||||
unilab --ak "new_access_key" --sk "new_secret_key"
|
||||
|
||||
# 覆盖服务器地址
|
||||
unilab --addr "https://custom.server.com/api/v1"
|
||||
|
||||
# 启用从站模式并跳过等待主机
|
||||
unilab --is_slave --slave_no_host
|
||||
|
||||
# 启用上传注册表和2D可视化
|
||||
unilab --upload_registry --2d_vis
|
||||
|
||||
# 组合使用多个覆盖参数
|
||||
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis
|
||||
```
|
||||
|
||||
### 预设环境地址
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
## 配置选项详解
|
||||
|
||||
### 基础配置 (BasicConfig)
|
||||
|
||||
基础配置包含了系统运行的核心参数:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------------- | ------------------------------------------ |
|
||||
| `ak` | str | `""` | 实验室访问密钥(必需) |
|
||||
| `sk` | str | `""` | 实验室私钥(必需) |
|
||||
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
|
||||
| `is_host_mode` | bool | `True` | 是否为主站模式 |
|
||||
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
|
||||
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
|
||||
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
|
||||
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
|
||||
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
|
||||
|
||||
#### 认证配置
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级)
|
||||
- **配置文件**:在 `BasicConfig` 类中设置
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
3. **优先级顺序**:命令行参数 > 环境变量 > 配置文件
|
||||
4. **安全注意**:请妥善保管您的密钥信息
|
||||
|
||||
**推荐做法**:
|
||||
|
||||
- 开发环境:使用配置文件
|
||||
- 生产环境:使用环境变量或命令行参数
|
||||
- 临时测试:使用命令行参数
|
||||
|
||||
### WebSocket 配置 (WSConfig)
|
||||
|
||||
WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------ | ------------------ |
|
||||
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
|
||||
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
|
||||
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
|
||||
|
||||
### HTTP 配置 (HTTPConfig)
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | --------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"http://127.0.0.1:48197/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### ROS 配置 (ROSConfig)
|
||||
|
||||
配置 ROS 消息转换器需要加载的模块:
|
||||
|
||||
```python
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg", # 标准消息类型
|
||||
"geometry_msgs.msg", # 几何消息类型
|
||||
"control_msgs.msg", # 控制消息类型
|
||||
"control_msgs.action", # 控制动作类型
|
||||
"nav2_msgs.action", # 导航动作类型
|
||||
"unilabos_msgs.msg", # UniLab 自定义消息类型
|
||||
"unilabos_msgs.action", # UniLab 自定义动作类型
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据需要添加其他ROS模块。
|
||||
您可以根据实际使用的设备和功能添加其他 ROS 模块。
|
||||
|
||||
### 其他配置选项
|
||||
### OSS 上传配置 (OSSUploadConfig)
|
||||
|
||||
- **OSSUploadConfig**: 对象存储上传配置
|
||||
对象存储服务配置,用于文件上传功能:
|
||||
|
||||
## 如何使用配置文件
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------- | ---- | ------ | -------------------- |
|
||||
| `api_host` | str | `""` | OSS API 主机地址 |
|
||||
| `authorization` | str | `""` | 授权认证信息 |
|
||||
| `init_endpoint` | str | `""` | 上传初始化端点 |
|
||||
| `complete_endpoint` | str | `""` | 上传完成端点 |
|
||||
| `max_retries` | int | `3` | 上传失败最大重试次数 |
|
||||
|
||||
启动Uni-Lab时通过`--config`参数指定配置文件路径:
|
||||
## 环境变量支持
|
||||
|
||||
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。环境变量格式为:
|
||||
|
||||
```
|
||||
UNILABOS_{配置类名}_{字段名}
|
||||
```
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```bash
|
||||
unilab --config path/to/your/config.py
|
||||
# 设置基础配置
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
|
||||
|
||||
# 设置WebSocket配置
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
如果您不涉及多环境开发,可以在unilabos的安装路径中手动添加local_config.py的文件
|
||||
### 环境变量类型转换
|
||||
|
||||
# 启动Uni-Lab
|
||||
python -m unilabos.app.main --config path/to/your/config.py
|
||||
- **布尔值**:`"true"`, `"1"`, `"yes"` → `True`;其他 → `False`
|
||||
- **整数**:自动转换为 `int` 类型
|
||||
- **浮点数**:自动转换为 `float` 类型
|
||||
- **字符串**:保持原值
|
||||
|
||||
## 配置文件使用方法
|
||||
|
||||
### 1. 指定配置文件启动
|
||||
|
||||
```bash
|
||||
# 使用指定配置文件启动
|
||||
unilab --config /path/to/your/config.py
|
||||
```
|
||||
|
||||
### 2. 使用默认配置文件
|
||||
|
||||
如果不指定配置文件,系统会按以下顺序查找:
|
||||
|
||||
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
|
||||
2. 工作目录下的 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 3. 配置文件验证
|
||||
|
||||
系统启动时会自动验证配置文件:
|
||||
|
||||
- **语法检查**:确保 Python 语法正确
|
||||
- **类型检查**:验证配置项类型是否匹配
|
||||
- **必需项检查**:确保 `ak` 和 `sk` 已配置
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全配置
|
||||
|
||||
- 不要将包含密钥的配置文件提交到版本控制系统
|
||||
- 使用环境变量或命令行参数在生产环境中配置敏感信息
|
||||
- 定期更换访问密钥
|
||||
- **推荐配置方式**:
|
||||
|
||||
```bash
|
||||
# 生产环境 - 使用环境变量
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
unilab
|
||||
|
||||
# 或使用命令行参数
|
||||
unilab --ak "your_access_key" --sk "your_secret_key"
|
||||
```
|
||||
|
||||
### 2. 多环境配置
|
||||
|
||||
为不同环境创建不同的配置文件并结合命令行参数:
|
||||
|
||||
```
|
||||
configs/
|
||||
├── local_config.py # 本地开发
|
||||
├── test_config.py # 测试环境
|
||||
├── prod_config.py # 生产环境
|
||||
└── example_config.py # 示例配置
|
||||
```
|
||||
|
||||
**环境切换示例**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境
|
||||
unilab --config configs/local_config.py --addr local
|
||||
|
||||
# 测试环境
|
||||
unilab --config configs/test_config.py --addr test --upload_registry
|
||||
|
||||
# 生产环境
|
||||
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK"
|
||||
```
|
||||
|
||||
### 3. 配置管理
|
||||
|
||||
- 保持配置文件简洁,只包含需要修改的配置项
|
||||
- 为配置项添加注释说明其作用
|
||||
- 定期检查和更新配置文件
|
||||
- **命令行参数优先使用场景**:
|
||||
- 临时测试不同配置
|
||||
- CI/CD 流水线中的动态配置
|
||||
- 不同环境间快速切换
|
||||
- 敏感信息的安全传递
|
||||
|
||||
### 4. 灵活配置策略
|
||||
|
||||
**基础配置文件 + 命令行覆盖**的推荐方式:
|
||||
|
||||
```python
|
||||
# base_config.py - 基础配置
|
||||
class BasicConfig:
|
||||
# 非敏感配置写在文件中
|
||||
is_host_mode = True
|
||||
upload_registry = False
|
||||
vis_2d_enable = False
|
||||
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动时通过命令行覆盖关键参数
|
||||
unilab --config base_config.py \
|
||||
--ak "$AK" \
|
||||
--sk "$SK" \
|
||||
--addr "test" \
|
||||
--upload_registry \
|
||||
--2d_vis
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 配置文件加载失败
|
||||
|
||||
**错误信息**:`[ENV] 配置文件 xxx 不存在`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认配置文件路径正确
|
||||
- 检查文件权限是否可读
|
||||
- 确保配置文件是 `.py` 格式
|
||||
|
||||
### 2. 语法错误
|
||||
|
||||
**错误信息**:`[ENV] 加载配置文件 xxx 失败`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 检查 Python 语法是否正确
|
||||
- 确认类名和字段名拼写正确
|
||||
- 验证缩进是否正确(使用空格而非制表符)
|
||||
|
||||
### 3. 认证失败
|
||||
|
||||
**错误信息**:`后续运行必须拥有一个实验室`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认 `ak` 和 `sk` 已正确配置
|
||||
- 检查密钥是否有效
|
||||
- 确认网络连接正常
|
||||
|
||||
### 4. 环境变量不生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认环境变量名格式正确(`UNILABOS_CLASS_FIELD`)
|
||||
- 检查环境变量是否已正确设置
|
||||
- 重启系统或重新加载环境变量
|
||||
|
||||
### 5. 命令行参数不生效
|
||||
|
||||
**错误现象**:设置了命令行参数但配置没有生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`)
|
||||
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
|
||||
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
|
||||
- 查看启动日志确认参数是否被正确解析
|
||||
|
||||
### 6. 配置优先级混淆
|
||||
|
||||
**错误现象**:不确定哪个配置生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 记住优先级:命令行参数 > 环境变量 > 配置文件
|
||||
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息
|
||||
- 检查启动日志中的配置加载信息
|
||||
- 临时移除低优先级配置来测试高优先级配置是否生效
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
# **Uni-Lab 安装**
|
||||
|
||||
请先 `git clone` 本仓库,随后按照以下步骤安装项目:
|
||||
## 快速开始
|
||||
|
||||
`Uni-Lab` 建议您采用 `mamba` 管理环境。若需从头建立 `Uni-Lab` 的运行依赖环境,请执行
|
||||
1. **配置 Conda 环境**
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境:
|
||||
|
||||
```shell
|
||||
mamba env create -f unilabos-<YOUR_OS>.yaml
|
||||
mamba activate unilab
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
其中 `YOUR_OS` 是您的操作系统,可选值 `win64`, `linux-64`, `osx-64`, `osx-arm64`
|
||||
|
||||
若需将依赖安装进当前环境,请执行
|
||||
2. **安装开发版 Uni-Lab-OS**
|
||||
|
||||
```shell
|
||||
conda env update --file unilabos-<YOUR_OS>.yml
|
||||
# 配置好conda环境后,克隆仓库
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
|
||||
cd Uni-Lab-OS
|
||||
|
||||
# 安装 Uni-Lab-OS
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
随后,可在本仓库安装 `unilabos` 的开发版:
|
||||
3. **安装开发版 ros-humble-unilabos-msgs**
|
||||
|
||||
**卸载老版本:**
|
||||
```shell
|
||||
pip install .
|
||||
conda activate unilab
|
||||
conda remove --force ros-humble-unilabos-msgs
|
||||
```
|
||||
有时相同的安装包版本会由于dev构建得到的md5不一样,触发安全检查,可输入 `config set safety_checks disabled` 来关闭安全检查。
|
||||
|
||||
**安装新版本:**
|
||||
|
||||
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件)使用如下指令:
|
||||
```shell
|
||||
conda activate base
|
||||
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
|
||||
```
|
||||
|
||||
4. **启动 Uni-Lab 系统**
|
||||
|
||||
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Uni-Lab 启动
|
||||
# Uni-Lab 启动指南
|
||||
|
||||
安装完毕后,可以通过 `unilab` 命令行启动:
|
||||
|
||||
@@ -8,70 +8,240 @@ Start Uni-Lab Edge server.
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-g GRAPH, --graph GRAPH
|
||||
Physical setup graph.
|
||||
-d DEVICES, --devices DEVICES
|
||||
Devices config file.
|
||||
-r RESOURCES, --resources RESOURCES
|
||||
Resources config file.
|
||||
Physical setup graph file path.
|
||||
-c CONTROLLERS, --controllers CONTROLLERS
|
||||
Controllers config file.
|
||||
Controllers config file path.
|
||||
--registry_path REGISTRY_PATH
|
||||
Path to the registry
|
||||
Path to the registry directory
|
||||
--working_dir WORKING_DIR
|
||||
Path to the working directory
|
||||
--backend {ros,simple,automancer}
|
||||
Choose the backend to run with: 'ros', 'simple', or 'automancer'.
|
||||
--app_bridges APP_BRIDGES [APP_BRIDGES ...]
|
||||
Bridges to connect to. Now support 'mqtt' and 'fastapi'.
|
||||
--without_host Run the backend as slave (without host).
|
||||
--config CONFIG Configuration file path for system settings
|
||||
Bridges to connect to. Now support 'websocket' and 'fastapi'.
|
||||
--is_slave Run the backend as slave node (without host privileges).
|
||||
--slave_no_host Skip waiting for host service in slave mode
|
||||
--upload_registry Upload registry information when starting unilab
|
||||
--use_remote_resource Use remote resources when starting unilab
|
||||
--config CONFIG Configuration file path, supports .py format Python config files
|
||||
--port PORT Port for web service information page
|
||||
--disable_browser Disable opening information page on startup
|
||||
--2d_vis Enable 2D visualization when starting pylabrobot instance
|
||||
--visual {rviz,web,disable}
|
||||
Choose visualization tool: rviz, web, or disable
|
||||
--ak AK Access key for laboratory requests
|
||||
--sk SK Secret key for laboratory requests
|
||||
--addr ADDR Laboratory backend address
|
||||
--skip_env_check Skip environment dependency check on startup
|
||||
--complete_registry Complete registry information
|
||||
```
|
||||
|
||||
## 启动流程详解
|
||||
|
||||
Uni-Lab 的启动过程分为以下几个阶段:
|
||||
|
||||
### 1. 参数解析阶段
|
||||
|
||||
- 解析命令行参数
|
||||
- 处理参数格式转换(支持 dash 和 underscore 格式)
|
||||
|
||||
### 2. 环境检查阶段 (可选)
|
||||
|
||||
- 默认进行环境依赖检查并自动安装必需包
|
||||
- 使用 `--skip_env_check` 可跳过此步骤
|
||||
|
||||
### 3. 配置文件处理阶段
|
||||
|
||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||
|
||||
- **工作目录设置**:
|
||||
|
||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||
- 可通过 `--working_dir` 指定自定义工作目录
|
||||
|
||||
- **配置文件查找顺序**:
|
||||
1. 使用 `--config` 参数指定的配置文件
|
||||
2. 在工作目录中查找 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 4. 服务器地址配置
|
||||
|
||||
支持多种后端环境:
|
||||
|
||||
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
|
||||
- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
|
||||
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
||||
- 自定义地址:直接指定完整 URL
|
||||
|
||||
### 5. 认证配置
|
||||
|
||||
- **必需参数**:`--ak` 和 `--sk` 必须同时提供
|
||||
- 命令行参数优先于配置文件中的设置
|
||||
- 未提供认证信息会导致启动失败并提示注册实验室
|
||||
|
||||
### 6. 设备图谱加载
|
||||
|
||||
支持两种方式:
|
||||
|
||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||
|
||||
### 7. 注册表构建
|
||||
|
||||
- 构建设备和资源注册表
|
||||
- 支持自定义注册表路径 (`--registry_path`)
|
||||
- 可选择补全注册表信息 (`--complete_registry`)
|
||||
|
||||
### 8. 设备验证和注册
|
||||
|
||||
- 验证设备连接和端点配置
|
||||
- 自动注册设备到云端服务
|
||||
|
||||
### 9. 通信桥接配置
|
||||
|
||||
- **WebSocket**:实时通信和任务下发
|
||||
- **FastAPI**:HTTP API 服务和物料更新
|
||||
|
||||
### 10. 可视化和服务启动
|
||||
|
||||
- 可选启动可视化工具 (`--visual`)
|
||||
- 启动 Web 信息服务 (默认端口 8002)
|
||||
- 启动后端通信服务
|
||||
|
||||
## 使用配置文件
|
||||
|
||||
Uni-Lab支持使用Python格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径:
|
||||
Uni-Lab 支持使用 Python 格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径:
|
||||
|
||||
```bash
|
||||
# 使用配置文件启动
|
||||
unilab --config path/to/your/config.py
|
||||
```
|
||||
|
||||
配置文件包含MQTT、HTTP、ROS等系统设置。有关配置文件的详细信息,请参阅[配置指南](configuration.md)。
|
||||
配置文件包含实验室和 WebSocket 连接等设置。有关配置文件的详细信息,请参阅[配置指南](configuration.md)。
|
||||
|
||||
## 初始化信息来源
|
||||
|
||||
启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备、耗材、通信、控制逻辑:
|
||||
启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备:
|
||||
|
||||
### 1. 组态&拓扑图
|
||||
|
||||
使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 graphml 和 node-link json 两种格式。格式可参照 `tests/experiments` 下的启动文件。
|
||||
使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 GraphML 和 node-link JSON 两种格式。格式可参照 `tests/experiments` 下的启动文件。
|
||||
|
||||
### 2. 分别指定设备、耗材、控制逻辑
|
||||
### 2. 分别指定控制逻辑
|
||||
|
||||
分别使用 `-d, -r, -c` 依次传入设备组态配置、耗材列表、控制逻辑。
|
||||
使用 `-c` 传入控制逻辑配置。
|
||||
|
||||
可参照 `devices.json` 和 `resources.json`。
|
||||
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry` 加入 `<your-registry-path>/{devices,device_comms,resources}`。
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`。
|
||||
|
||||
## 通信中间件 `--backend`
|
||||
|
||||
目前 Uni-Lab 仅支持 ros2 作为通信中间件。
|
||||
目前 Uni-Lab 支持以下通信中间件:
|
||||
|
||||
- **ros** (默认):基于 ROS2 的通信
|
||||
- **simple**:简化通信模式
|
||||
- **automancer**:Automancer 兼容模式
|
||||
|
||||
## 端云桥接 `--app_bridges`
|
||||
|
||||
目前 Uni-Lab 提供 FastAPI (http), MQTT 两种端云通信方式。其中默认 MQTT 负责端对云状态同步和云对端任务下发,FastAPI 负责端对云物料更新。
|
||||
目前 Uni-Lab 提供 WebSocket、FastAPI (http) 两种端云通信方式:
|
||||
|
||||
- **WebSocket**:负责实时通信和任务下发
|
||||
- **FastAPI**:负责端对云物料更新和 HTTP API
|
||||
|
||||
## 分布式组网
|
||||
|
||||
启动 Uni-Lab 时,加入 `--without_host` 将作为从站,不加将作为主站,主站 (host) 持有物料修改权以及对云端的通信。局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。
|
||||
启动 Uni-Lab 时,加入 `--is_slave` 将作为从站,不加将作为主站:
|
||||
|
||||
- **主站 (host)**:持有物料修改权以及对云端的通信
|
||||
- **从站 (slave)**:无主机权限,可选择跳过等待主机服务 (`--slave_no_host`)
|
||||
|
||||
局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。
|
||||
|
||||
## 可视化选项
|
||||
|
||||
### 2D 可视化
|
||||
|
||||
使用 `--2d_vis` 在 PyLabRobot 实例启动时同时启动 2D 可视化。
|
||||
|
||||
### 3D 可视化
|
||||
|
||||
通过 `--visual` 参数选择:
|
||||
|
||||
- **rviz**:使用 RViz 进行 3D 可视化
|
||||
- **web**:使用 Web 界面进行可视化
|
||||
- **disable** (默认):禁用可视化
|
||||
|
||||
## 实验室管理
|
||||
|
||||
### 首次使用
|
||||
|
||||
如果是首次使用,系统会:
|
||||
|
||||
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
|
||||
2. 引导创建配置文件
|
||||
3. 设置工作目录
|
||||
|
||||
### 认证设置
|
||||
|
||||
- `--ak`:实验室访问密钥
|
||||
- `--sk`:实验室私钥
|
||||
- 两者必须同时提供才能正常启动
|
||||
|
||||
## 完整启动示例
|
||||
|
||||
以下是一些常用的启动命令示例:
|
||||
|
||||
```bash
|
||||
# 使用配置文件和组态图启动
|
||||
unilab -g path/to/graph.json
|
||||
# 使用组态图启动,上传注册表
|
||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||
|
||||
# 使用配置文件和分离的设备/资源文件启动
|
||||
unilab -d devices.json -r resources.json
|
||||
# 使用远程资源启动
|
||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||
|
||||
# 更新注册表
|
||||
unilab --ak your_ak --sk your_sk --complete_registry
|
||||
|
||||
# 启动从站模式
|
||||
unilab --ak your_ak --sk your_sk --is_slave
|
||||
|
||||
# 启用可视化
|
||||
unilab --ak your_ak --sk your_sk --visual web --2d_vis
|
||||
|
||||
# 指定本地信息网页服务端口和禁用自动跳出浏览器
|
||||
unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 认证失败
|
||||
|
||||
如果提示 "后续运行必须拥有一个实验室",请确保:
|
||||
|
||||
- 已在 https://uni-lab.bohrium.com 注册实验室
|
||||
- 正确设置了 `--ak` 和 `--sk` 参数
|
||||
- 配置文件中包含正确的认证信息
|
||||
|
||||
### 2. 配置文件问题
|
||||
|
||||
如果配置文件加载失败:
|
||||
|
||||
- 确保配置文件是 `.py` 格式
|
||||
- 检查配置文件语法是否正确
|
||||
- 首次使用可让系统自动创建示例配置文件
|
||||
|
||||
### 3. 网络连接问题
|
||||
|
||||
如果无法连接到服务器:
|
||||
|
||||
- 检查网络连接
|
||||
- 确认服务器地址是否正确
|
||||
- 尝试使用不同的环境地址(test、uat、local)
|
||||
|
||||
### 4. 设备图谱问题
|
||||
|
||||
如果设备加载失败:
|
||||
|
||||
- 检查图谱文件格式是否正确
|
||||
- 验证设备连接和端点配置
|
||||
- 确保注册表路径正确
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.4
|
||||
version: 0.10.6
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.4"
|
||||
version: "0.10.6"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
5
setup.py
5
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.4',
|
||||
version='0.10.6',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
@@ -16,8 +16,7 @@ setup(
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
"unilab = unilabos.app.main:main",
|
||||
"unilab-register = unilabos.app.register:main"
|
||||
"unilab = unilabos.app.main:main"
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
60
test/experiments/dispensing_station_bioyond.json
Normal file
60
test/experiments/dispensing_station_bioyond.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "dispensing_station_bioyond",
|
||||
"name": "dispensing_station_bioyond",
|
||||
"children": [
|
||||
"Bioyond_Dispensing_Deck"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "dispensing_station.bioyond",
|
||||
"config": {
|
||||
"config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44388"
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "Bioyond_Dispensing_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
|
||||
}
|
||||
},
|
||||
"station_config": {
|
||||
"station_type": "dispensing_station",
|
||||
"enable_dispensing_station": true,
|
||||
"enable_reaction_station": false,
|
||||
"station_name": "DispensingStation_001",
|
||||
"description": "Bioyond配液工作站"
|
||||
},
|
||||
"protocol_type": []
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "Bioyond_Dispensing_Deck",
|
||||
"name": "Bioyond_Dispensing_Deck",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "dispensing_station_bioyond",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_PolymerPreparationStation_Deck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "BIOYOND_PolymerPreparationStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,9 +21,9 @@
|
||||
"timeout": 10.0,
|
||||
"axis": "Left",
|
||||
"channel_num": 8,
|
||||
"setup": false,
|
||||
"debug": false,
|
||||
"simulator": false,
|
||||
"setup": true,
|
||||
"debug": true,
|
||||
"simulator": true,
|
||||
"matrix_id": "71593"
|
||||
},
|
||||
"data": {},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
69
test/experiments/reaction_station_bioyond.json
Normal file
69
test/experiments/reaction_station_bioyond.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "reaction_station_bioyond",
|
||||
"name": "reaction_station_bioyond",
|
||||
"parent": null,
|
||||
"children": [
|
||||
"Bioyond_Deck"
|
||||
],
|
||||
"type": "device",
|
||||
"class": "reaction_station.bioyond",
|
||||
"config": {
|
||||
"bioyond_config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44402",
|
||||
"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)": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
|
||||
},
|
||||
"material_type_mappings": {
|
||||
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
|
||||
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
|
||||
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
|
||||
}
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
},
|
||||
"protocol_type": []
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "Bioyond_Deck",
|
||||
"name": "Bioyond_Deck",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
],
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
69
test/experiments/reaction_station_bioyond_test.json
Normal file
69
test/experiments/reaction_station_bioyond_test.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "reaction_station_bioyond",
|
||||
"name": "reaction_station_bioyond",
|
||||
"parent": null,
|
||||
"children": [
|
||||
"Bioyond_Deck"
|
||||
],
|
||||
"type": "device",
|
||||
"class": "workstation.bioyond",
|
||||
"config": {
|
||||
"bioyond_config": {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44388",
|
||||
"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)": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
|
||||
},
|
||||
"material_type_mappings": {
|
||||
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
|
||||
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
|
||||
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
|
||||
}
|
||||
},
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
|
||||
}
|
||||
},
|
||||
"protocol_type": []
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "Bioyond_Deck",
|
||||
"name": "Bioyond_Deck",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
],
|
||||
"parent": "reaction_station_bioyond",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "BIOYOND_PolymerReactionStation_Deck",
|
||||
"setup": true,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
588
test/registry/example_devices.py
Normal file
588
test/registry/example_devices.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
示例设备类文件,用于测试注册表编辑器
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
class SmartPumpController:
|
||||
"""
|
||||
智能泵控制器
|
||||
|
||||
支持多种泵送模式,具有高精度流量控制和自动校准功能。
|
||||
适用于实验室自动化系统中的液体处理任务。
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"):
|
||||
"""
|
||||
初始化智能泵控制器
|
||||
|
||||
Args:
|
||||
device_id: 设备唯一标识符
|
||||
port: 通信端口
|
||||
"""
|
||||
self.device_id = device_id
|
||||
self.port = port
|
||||
self.is_connected = False
|
||||
self.current_flow_rate = 0.0
|
||||
self.total_volume_pumped = 0.0
|
||||
self.calibration_factor = 1.0
|
||||
self.pump_mode = "continuous" # continuous, volume, rate
|
||||
|
||||
def connect_device(self, timeout: int = 10) -> bool:
|
||||
"""
|
||||
连接到泵设备
|
||||
|
||||
Args:
|
||||
timeout: 连接超时时间(秒)
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
# 模拟连接过程
|
||||
self.is_connected = True
|
||||
return True
|
||||
|
||||
def disconnect_device(self) -> bool:
|
||||
"""
|
||||
断开设备连接
|
||||
|
||||
Returns:
|
||||
bool: 断开连接是否成功
|
||||
"""
|
||||
self.is_connected = False
|
||||
self.current_flow_rate = 0.0
|
||||
return True
|
||||
|
||||
def set_flow_rate(self, flow_rate: float, units: str = "ml/min") -> bool:
|
||||
"""
|
||||
设置泵流速
|
||||
|
||||
Args:
|
||||
flow_rate: 流速值
|
||||
units: 流速单位
|
||||
|
||||
Returns:
|
||||
bool: 设置是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return False
|
||||
|
||||
self.current_flow_rate = flow_rate
|
||||
return True
|
||||
|
||||
async def pump_volume_async(self, volume: float, flow_rate: float) -> Dict[str, Any]:
|
||||
"""
|
||||
异步泵送指定体积的液体
|
||||
|
||||
Args:
|
||||
volume: 目标体积 (mL)
|
||||
flow_rate: 泵送流速 (mL/min)
|
||||
|
||||
Returns:
|
||||
Dict: 包含操作结果的字典
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return {"success": False, "error": "设备未连接"}
|
||||
|
||||
# 计算泵送时间
|
||||
pump_time = (volume / flow_rate) * 60 # 转换为秒
|
||||
|
||||
self.current_flow_rate = flow_rate
|
||||
await asyncio.sleep(min(pump_time, 3.0)) # 模拟泵送过程
|
||||
|
||||
self.total_volume_pumped += volume
|
||||
self.current_flow_rate = 0.0
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pumped_volume": volume,
|
||||
"actual_time": min(pump_time, 3.0),
|
||||
"total_volume": self.total_volume_pumped,
|
||||
}
|
||||
|
||||
def emergency_stop(self) -> bool:
|
||||
"""
|
||||
紧急停止泵
|
||||
|
||||
Returns:
|
||||
bool: 停止是否成功
|
||||
"""
|
||||
self.current_flow_rate = 0.0
|
||||
return True
|
||||
|
||||
def perform_calibration(self, reference_volume: float, measured_volume: float) -> bool:
|
||||
"""
|
||||
执行泵校准
|
||||
|
||||
Args:
|
||||
reference_volume: 参考体积
|
||||
measured_volume: 实际测量体积
|
||||
|
||||
Returns:
|
||||
bool: 校准是否成功
|
||||
"""
|
||||
if measured_volume > 0:
|
||||
self.calibration_factor = reference_volume / measured_volume
|
||||
return True
|
||||
return False
|
||||
|
||||
# 状态查询方法
|
||||
def get_connection_status(self) -> str:
|
||||
"""获取连接状态"""
|
||||
return "connected" if self.is_connected else "disconnected"
|
||||
|
||||
def get_current_flow_rate(self) -> float:
|
||||
"""获取当前流速 (mL/min)"""
|
||||
return self.current_flow_rate
|
||||
|
||||
def get_total_volume(self) -> float:
|
||||
"""获取累计泵送体积 (mL)"""
|
||||
return self.total_volume_pumped
|
||||
|
||||
def get_calibration_factor(self) -> float:
|
||||
"""获取校准因子"""
|
||||
return self.calibration_factor
|
||||
|
||||
def get_pump_mode(self) -> str:
|
||||
"""获取泵送模式"""
|
||||
return self.pump_mode
|
||||
|
||||
def get_device_status(self) -> Dict[str, Any]:
|
||||
"""获取设备完整状态信息"""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"connected": self.is_connected,
|
||||
"flow_rate": self.current_flow_rate,
|
||||
"total_volume": self.total_volume_pumped,
|
||||
"calibration_factor": self.calibration_factor,
|
||||
"mode": self.pump_mode,
|
||||
"running": self.current_flow_rate > 0,
|
||||
}
|
||||
|
||||
|
||||
class AdvancedTemperatureController:
|
||||
"""
|
||||
高级温度控制器
|
||||
|
||||
支持PID控制、多点温度监控和程序化温度曲线。
|
||||
适用于需要精确温度控制的化学反应和材料处理过程。
|
||||
"""
|
||||
|
||||
def __init__(self, controller_id: str = "temp_controller_01"):
|
||||
"""
|
||||
初始化温度控制器
|
||||
|
||||
Args:
|
||||
controller_id: 控制器ID
|
||||
"""
|
||||
self.controller_id = controller_id
|
||||
self.current_temperature = 25.0
|
||||
self.target_temperature = 25.0
|
||||
self.is_heating = False
|
||||
self.is_cooling = False
|
||||
self.pid_enabled = True
|
||||
self.temperature_history: List[Dict] = []
|
||||
|
||||
def set_target_temperature(self, temperature: float, rate: float = 10.0) -> bool:
|
||||
"""
|
||||
设置目标温度
|
||||
|
||||
Args:
|
||||
temperature: 目标温度 (°C)
|
||||
rate: 升温/降温速率 (°C/min)
|
||||
|
||||
Returns:
|
||||
bool: 设置是否成功
|
||||
"""
|
||||
self.target_temperature = temperature
|
||||
return True
|
||||
|
||||
async def heat_to_temperature_async(
|
||||
self, temperature: float, tolerance: float = 0.5, timeout: int = 600
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
异步加热到指定温度
|
||||
|
||||
Args:
|
||||
temperature: 目标温度 (°C)
|
||||
tolerance: 温度容差 (°C)
|
||||
timeout: 最大等待时间 (秒)
|
||||
|
||||
Returns:
|
||||
Dict: 操作结果
|
||||
"""
|
||||
self.target_temperature = temperature
|
||||
start_temp = self.current_temperature
|
||||
|
||||
if temperature > start_temp:
|
||||
self.is_heating = True
|
||||
elif temperature < start_temp:
|
||||
self.is_cooling = True
|
||||
|
||||
# 模拟温度变化过程
|
||||
steps = min(abs(temperature - start_temp) * 2, 20) # 计算步数
|
||||
step_time = min(timeout / steps if steps > 0 else 1, 2.0) # 每步最多2秒
|
||||
|
||||
for step in range(int(steps)):
|
||||
progress = (step + 1) / steps
|
||||
self.current_temperature = start_temp + (temperature - start_temp) * progress
|
||||
|
||||
# 记录温度历史
|
||||
self.temperature_history.append(
|
||||
{
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
"temperature": self.current_temperature,
|
||||
"target": self.target_temperature,
|
||||
}
|
||||
)
|
||||
|
||||
await asyncio.sleep(step_time)
|
||||
|
||||
# 保持历史记录不超过100条
|
||||
if len(self.temperature_history) > 100:
|
||||
self.temperature_history.pop(0)
|
||||
|
||||
# 最终设置为目标温度
|
||||
self.current_temperature = temperature
|
||||
self.is_heating = False
|
||||
self.is_cooling = False
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"final_temperature": self.current_temperature,
|
||||
"start_temperature": start_temp,
|
||||
"time_taken": steps * step_time,
|
||||
}
|
||||
|
||||
def enable_pid_control(self, kp: float = 1.0, ki: float = 0.1, kd: float = 0.05) -> bool:
|
||||
"""
|
||||
启用PID控制
|
||||
|
||||
Args:
|
||||
kp: 比例增益
|
||||
ki: 积分增益
|
||||
kd: 微分增益
|
||||
|
||||
Returns:
|
||||
bool: 启用是否成功
|
||||
"""
|
||||
self.pid_enabled = True
|
||||
return True
|
||||
|
||||
def run_temperature_program(self, program: List[Dict]) -> bool:
|
||||
"""
|
||||
运行温度程序
|
||||
|
||||
Args:
|
||||
program: 温度程序列表,每个元素包含温度和持续时间
|
||||
|
||||
Returns:
|
||||
bool: 程序启动是否成功
|
||||
"""
|
||||
# 模拟程序启动
|
||||
return True
|
||||
|
||||
# 状态查询方法
|
||||
def get_current_temperature(self) -> float:
|
||||
"""获取当前温度 (°C)"""
|
||||
return round(self.current_temperature, 2)
|
||||
|
||||
def get_target_temperature(self) -> float:
|
||||
"""获取目标温度 (°C)"""
|
||||
return self.target_temperature
|
||||
|
||||
def get_heating_status(self) -> bool:
|
||||
"""获取加热状态"""
|
||||
return self.is_heating
|
||||
|
||||
def get_cooling_status(self) -> bool:
|
||||
"""获取制冷状态"""
|
||||
return self.is_cooling
|
||||
|
||||
def get_pid_status(self) -> bool:
|
||||
"""获取PID控制状态"""
|
||||
return self.pid_enabled
|
||||
|
||||
def get_temperature_history(self) -> List[Dict]:
|
||||
"""获取温度历史记录"""
|
||||
return self.temperature_history[-10:] # 返回最近10条记录
|
||||
|
||||
def get_controller_status(self) -> Dict[str, Any]:
|
||||
"""获取控制器完整状态"""
|
||||
return {
|
||||
"controller_id": self.controller_id,
|
||||
"current_temp": self.current_temperature,
|
||||
"target_temp": self.target_temperature,
|
||||
"is_heating": self.is_heating,
|
||||
"is_cooling": self.is_cooling,
|
||||
"pid_enabled": self.pid_enabled,
|
||||
"history_count": len(self.temperature_history),
|
||||
}
|
||||
|
||||
|
||||
class MultiChannelAnalyzer:
|
||||
"""
|
||||
多通道分析仪
|
||||
|
||||
支持同时监测多个通道的信号,提供实时数据采集和分析功能。
|
||||
常用于光谱分析、电化学测量等应用场景。
|
||||
"""
|
||||
|
||||
def __init__(self, analyzer_id: str = "analyzer_01", channels: int = 8):
|
||||
"""
|
||||
初始化多通道分析仪
|
||||
|
||||
Args:
|
||||
analyzer_id: 分析仪ID
|
||||
channels: 通道数量
|
||||
"""
|
||||
self.analyzer_id = analyzer_id
|
||||
self.channel_count = channels
|
||||
self.channel_data = {i: {"value": 0.0, "unit": "V", "enabled": True} for i in range(channels)}
|
||||
self.is_measuring = False
|
||||
self.sample_rate = 1000 # Hz
|
||||
|
||||
def configure_channel(self, channel: int, enabled: bool = True, unit: str = "V") -> bool:
|
||||
"""
|
||||
配置通道
|
||||
|
||||
Args:
|
||||
channel: 通道编号
|
||||
enabled: 是否启用
|
||||
unit: 测量单位
|
||||
|
||||
Returns:
|
||||
bool: 配置是否成功
|
||||
"""
|
||||
if 0 <= channel < self.channel_count:
|
||||
self.channel_data[channel]["enabled"] = enabled
|
||||
self.channel_data[channel]["unit"] = unit
|
||||
return True
|
||||
return False
|
||||
|
||||
async def start_measurement_async(self, duration: int = 10) -> Dict[str, Any]:
|
||||
"""
|
||||
开始异步测量
|
||||
|
||||
Args:
|
||||
duration: 测量持续时间(秒)
|
||||
|
||||
Returns:
|
||||
Dict: 测量结果
|
||||
"""
|
||||
self.is_measuring = True
|
||||
|
||||
# 模拟数据采集
|
||||
measurements = []
|
||||
for second in range(duration):
|
||||
timestamp = asyncio.get_event_loop().time()
|
||||
frame_data = {}
|
||||
|
||||
for channel in range(self.channel_count):
|
||||
if self.channel_data[channel]["enabled"]:
|
||||
# 模拟传感器数据
|
||||
import random
|
||||
|
||||
value = random.uniform(-5.0, 5.0)
|
||||
frame_data[f"channel_{channel}"] = value
|
||||
self.channel_data[channel]["value"] = value
|
||||
|
||||
measurements.append({"timestamp": timestamp, "data": frame_data})
|
||||
|
||||
await asyncio.sleep(1.0) # 每秒采集一次
|
||||
|
||||
self.is_measuring = False
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"duration": duration,
|
||||
"samples_count": len(measurements),
|
||||
"measurements": measurements[-5:], # 只返回最后5个样本
|
||||
"channels_active": len([ch for ch in self.channel_data.values() if ch["enabled"]]),
|
||||
}
|
||||
|
||||
def stop_measurement(self) -> bool:
|
||||
"""
|
||||
停止测量
|
||||
|
||||
Returns:
|
||||
bool: 停止是否成功
|
||||
"""
|
||||
self.is_measuring = False
|
||||
return True
|
||||
|
||||
def reset_channels(self) -> bool:
|
||||
"""
|
||||
重置所有通道
|
||||
|
||||
Returns:
|
||||
bool: 重置是否成功
|
||||
"""
|
||||
for channel in self.channel_data:
|
||||
self.channel_data[channel]["value"] = 0.0
|
||||
return True
|
||||
|
||||
# 状态查询方法
|
||||
def get_measurement_status(self) -> bool:
|
||||
"""获取测量状态"""
|
||||
return self.is_measuring
|
||||
|
||||
def get_channel_count(self) -> int:
|
||||
"""获取通道数量"""
|
||||
return self.channel_count
|
||||
|
||||
def get_sample_rate(self) -> float:
|
||||
"""获取采样率 (Hz)"""
|
||||
return self.sample_rate
|
||||
|
||||
def get_channel_values(self) -> Dict[int, float]:
|
||||
"""获取所有通道的当前值"""
|
||||
return {ch: data["value"] for ch, data in self.channel_data.items() if data["enabled"]}
|
||||
|
||||
def get_enabled_channels(self) -> List[int]:
|
||||
"""获取已启用的通道列表"""
|
||||
return [ch for ch, data in self.channel_data.items() if data["enabled"]]
|
||||
|
||||
def get_analyzer_status(self) -> Dict[str, Any]:
|
||||
"""获取分析仪完整状态"""
|
||||
return {
|
||||
"analyzer_id": self.analyzer_id,
|
||||
"channel_count": self.channel_count,
|
||||
"is_measuring": self.is_measuring,
|
||||
"sample_rate": self.sample_rate,
|
||||
"active_channels": len(self.get_enabled_channels()),
|
||||
"channel_data": self.channel_data,
|
||||
}
|
||||
|
||||
|
||||
class AutomatedDispenser:
|
||||
"""
|
||||
自动分配器
|
||||
|
||||
精确控制固体和液体材料的分配,支持多种分配模式和容器管理。
|
||||
集成称重功能,确保分配精度和重现性。
|
||||
"""
|
||||
|
||||
def __init__(self, dispenser_id: str = "dispenser_01"):
|
||||
"""
|
||||
初始化自动分配器
|
||||
|
||||
Args:
|
||||
dispenser_id: 分配器ID
|
||||
"""
|
||||
self.dispenser_id = dispenser_id
|
||||
self.is_ready = True
|
||||
self.current_position = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
self.dispensed_total = 0.0
|
||||
self.container_capacity = 1000.0 # mL
|
||||
self.precision_mode = True
|
||||
|
||||
def move_to_position(self, x: float, y: float, z: float) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
x: X坐标 (mm)
|
||||
y: Y坐标 (mm)
|
||||
z: Z坐标 (mm)
|
||||
|
||||
Returns:
|
||||
bool: 移动是否成功
|
||||
"""
|
||||
self.current_position = {"x": x, "y": y, "z": z}
|
||||
return True
|
||||
|
||||
async def dispense_liquid_async(self, volume: float, container_id: str, viscosity: str = "low") -> Dict[str, Any]:
|
||||
"""
|
||||
异步分配液体
|
||||
|
||||
Args:
|
||||
volume: 分配体积 (mL)
|
||||
container_id: 容器ID
|
||||
viscosity: 液体粘度等级
|
||||
|
||||
Returns:
|
||||
Dict: 分配结果
|
||||
"""
|
||||
if not self.is_ready:
|
||||
return {"success": False, "error": "设备未就绪"}
|
||||
|
||||
if volume <= 0:
|
||||
return {"success": False, "error": "体积必须大于0"}
|
||||
|
||||
# 模拟分配过程
|
||||
dispense_time = volume * 0.1 # 每mL需要0.1秒
|
||||
if viscosity == "high":
|
||||
dispense_time *= 2 # 高粘度液体需要更长时间
|
||||
|
||||
await asyncio.sleep(min(dispense_time, 5.0)) # 最多等待5秒
|
||||
|
||||
self.dispensed_total += volume
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"dispensed_volume": volume,
|
||||
"container_id": container_id,
|
||||
"actual_time": min(dispense_time, 5.0),
|
||||
"total_dispensed": self.dispensed_total,
|
||||
}
|
||||
|
||||
def clean_dispenser(self, wash_volume: float = 5.0) -> bool:
|
||||
"""
|
||||
清洗分配器
|
||||
|
||||
Args:
|
||||
wash_volume: 清洗液体积 (mL)
|
||||
|
||||
Returns:
|
||||
bool: 清洗是否成功
|
||||
"""
|
||||
# 模拟清洗过程
|
||||
return True
|
||||
|
||||
def calibrate_volume(self, target_volume: float) -> bool:
|
||||
"""
|
||||
校准分配体积
|
||||
|
||||
Args:
|
||||
target_volume: 校准目标体积 (mL)
|
||||
|
||||
Returns:
|
||||
bool: 校准是否成功
|
||||
"""
|
||||
# 模拟校准过程
|
||||
return True
|
||||
|
||||
# 状态查询方法
|
||||
def get_ready_status(self) -> bool:
|
||||
"""获取就绪状态"""
|
||||
return self.is_ready
|
||||
|
||||
def get_current_position(self) -> Dict[str, float]:
|
||||
"""获取当前位置坐标"""
|
||||
return self.current_position.copy()
|
||||
|
||||
def get_dispensed_total(self) -> float:
|
||||
"""获取累计分配体积 (mL)"""
|
||||
return self.dispensed_total
|
||||
|
||||
def get_container_capacity(self) -> float:
|
||||
"""获取容器容量 (mL)"""
|
||||
return self.container_capacity
|
||||
|
||||
def get_precision_mode(self) -> bool:
|
||||
"""获取精密模式状态"""
|
||||
return self.precision_mode
|
||||
|
||||
def get_dispenser_status(self) -> Dict[str, Any]:
|
||||
"""获取分配器完整状态"""
|
||||
return {
|
||||
"dispenser_id": self.dispenser_id,
|
||||
"ready": self.is_ready,
|
||||
"position": self.current_position,
|
||||
"dispensed_total": self.dispensed_total,
|
||||
"capacity": self.container_capacity,
|
||||
"precision_mode": self.precision_mode,
|
||||
}
|
||||
198
test/resources/bioyond_materials.json
Normal file
198
test/resources/bioyond_materials.json
Normal file
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
|
||||
"typeName": "烧杯",
|
||||
"code": "0006-00160",
|
||||
"barCode": "",
|
||||
"name": "ODA",
|
||||
"quantity": 120000.00000000000000000000000,
|
||||
"lockQuantity": 695374.00000000000000000000000,
|
||||
"unit": "微升",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"whName": "堆栈1",
|
||||
"code": "0001-0001",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"id": "3a1c67a9-aed9-1ade-5fe1-cc04b24b171c",
|
||||
"typeName": "烧杯",
|
||||
"code": "0006-00161",
|
||||
"barCode": "",
|
||||
"name": "MPDA",
|
||||
"quantity": 120000.00000000000000000000000,
|
||||
"lockQuantity": 681618.00000000000000000000000,
|
||||
"unit": "",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"whName": "堆栈1",
|
||||
"code": "0001-0002",
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"id": "3a1c67a9-aed9-2864-6783-2cee4e701ba6",
|
||||
"typeName": "试剂瓶",
|
||||
"code": "0004-00041",
|
||||
"barCode": "",
|
||||
"name": "NMP",
|
||||
"quantity": 300000.00000000000000000000000,
|
||||
"lockQuantity": 380000.00000000000000000000000,
|
||||
"unit": "微升",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||
"whName": "站内试剂存放堆栈",
|
||||
"code": "0003-0001",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"id": "3a1c67a9-aed9-32c7-5809-3ba1b8db1aa1",
|
||||
"typeName": "试剂瓶",
|
||||
"code": "0004-00042",
|
||||
"barCode": "",
|
||||
"name": "PGME",
|
||||
"quantity": 300000.00000000000000000000000,
|
||||
"lockQuantity": 337892.00000000000000000000000,
|
||||
"unit": "",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a14aa3b-9fab-ca72-febc-b7c304476c78",
|
||||
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||
"whName": "站内试剂存放堆栈",
|
||||
"code": "0003-0002",
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"id": "3a1c68c8-0574-d748-725e-97a2e549f085",
|
||||
"typeName": "样品板",
|
||||
"code": "0001-00004",
|
||||
"barCode": "",
|
||||
"name": "0917",
|
||||
"quantity": 1.0000000000000000000000000000,
|
||||
"lockQuantity": 4.0000000000000000000000000000,
|
||||
"unit": "块",
|
||||
"status": 1,
|
||||
"isUse": false,
|
||||
"locations": [
|
||||
{
|
||||
"id": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"whName": "堆栈1",
|
||||
"code": "0001-0009",
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"quantity": 0
|
||||
}
|
||||
],
|
||||
"detail": [
|
||||
{
|
||||
"id": "3a1c68c8-0574-69a1-9858-4637e0193451",
|
||||
"detailMaterialId": "3a1c68c8-0574-3630-bd42-bbf3623c5208",
|
||||
"code": null,
|
||||
"name": "SIDA",
|
||||
"quantity": "300000",
|
||||
"lockQuantity": "4",
|
||||
"unit": "微升",
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"associateId": null
|
||||
},
|
||||
{
|
||||
"id": "3a1c68c8-0574-8d51-3191-a31f5be421e5",
|
||||
"detailMaterialId": "3a1c68c8-0574-3b20-9ad7-90755f123d53",
|
||||
"code": null,
|
||||
"name": "BTDA-2",
|
||||
"quantity": "300000",
|
||||
"lockQuantity": "4",
|
||||
"unit": "微升",
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"z": 1,
|
||||
"associateId": null
|
||||
},
|
||||
{
|
||||
"id": "3a1c68c8-0574-da80-735b-53ae2197a360",
|
||||
"detailMaterialId": "3a1c68c8-0574-f2e4-33b3-90d813567939",
|
||||
"code": null,
|
||||
"name": "BTDA-DD",
|
||||
"quantity": "300000",
|
||||
"lockQuantity": "28",
|
||||
"unit": "微升",
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"associateId": null
|
||||
},
|
||||
{
|
||||
"id": "3a1c68c8-0574-e717-1b1b-99891f875455",
|
||||
"detailMaterialId": "3a1c68c8-0574-a0ef-e636-68cdc98960e2",
|
||||
"code": null,
|
||||
"name": "BTDA-3",
|
||||
"quantity": "300000",
|
||||
"lockQuantity": "4",
|
||||
"unit": "微升",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"z": 1,
|
||||
"associateId": null
|
||||
},
|
||||
{
|
||||
"id": "3a1c68c8-0574-e9bd-6cca-5e261b4f89cb",
|
||||
"detailMaterialId": "3a1c68c8-0574-9d11-5115-283e8e5510b1",
|
||||
"code": null,
|
||||
"name": "BTDA-1",
|
||||
"quantity": "300000",
|
||||
"lockQuantity": "4",
|
||||
"unit": "微升",
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"z": 1,
|
||||
"associateId": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"code": 1,
|
||||
"message": "",
|
||||
"timestamp": 1758560573511
|
||||
}
|
||||
48
test/resources/test_bottle_carrier.py
Normal file
48
test/resources/test_bottle_carrier.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
|
||||
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
|
||||
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
|
||||
|
||||
|
||||
def test_bottle_carrier() -> "BottleCarrier":
|
||||
print("创建载架...")
|
||||
|
||||
# 创建6瓶载架
|
||||
bottle_carrier = BIOYOND_Electrolyte_6VialCarrier("powder_carrier_01")
|
||||
print(f"6瓶载架: {bottle_carrier.name}, 位置数: {len(bottle_carrier.sites)}")
|
||||
|
||||
# 创建1烧杯载架
|
||||
beaker_carrier = BIOYOND_Electrolyte_1BottleCarrier("solution_carrier_01")
|
||||
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
|
||||
|
||||
# 创建瓶子和烧杯
|
||||
powder_bottle = BIOYOND_PolymerStation_Solid_Vial("powder_bottle_01")
|
||||
solution_beaker = BIOYOND_PolymerStation_Solution_Beaker("solution_beaker_01")
|
||||
reagent_bottle = BIOYOND_PolymerStation_Reagent_Bottle("reagent_bottle_01")
|
||||
|
||||
print(f"\n创建的物料:")
|
||||
print(f"粉末瓶: {powder_bottle.name} - {powder_bottle.diameter}mm x {powder_bottle.height}mm, {powder_bottle.max_volume}μL")
|
||||
print(f"溶液烧杯: {solution_beaker.name} - {solution_beaker.diameter}mm x {solution_beaker.height}mm, {solution_beaker.max_volume}μL")
|
||||
print(f"试剂瓶: {reagent_bottle.name} - {reagent_bottle.diameter}mm x {reagent_bottle.height}mm, {reagent_bottle.max_volume}μL")
|
||||
|
||||
# 测试放置容器
|
||||
print(f"\n测试放置容器...")
|
||||
|
||||
# 通过载架的索引操作来放置容器
|
||||
# bottle_carrier[0] = powder_bottle # 放置粉末瓶到第一个位置
|
||||
print(f"粉末瓶已放置到6瓶载架的位置 0")
|
||||
|
||||
# beaker_carrier[0] = solution_beaker # 放置烧杯到第一个位置
|
||||
print(f"溶液烧杯已放置到1烧杯载架的位置 0")
|
||||
|
||||
# 验证放置结果
|
||||
print(f"\n验证放置结果:")
|
||||
bottle_at_0 = bottle_carrier[0].resource
|
||||
beaker_at_0 = beaker_carrier[0].resource
|
||||
|
||||
if bottle_at_0:
|
||||
print(f"位置 0 的瓶子: {bottle_at_0.name}")
|
||||
if beaker_at_0:
|
||||
print(f"位置 0 的烧杯: {beaker_at_0.name}")
|
||||
|
||||
print("\n载架设置完成!")
|
||||
35
test/resources/test_converter_bioyond.py
Normal file
35
test/resources/test_converter_bioyond.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
from unilabos.resources.graphio import resource_bioyond_to_plr
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
|
||||
lab_registry.setup()
|
||||
|
||||
|
||||
type_mapping = {
|
||||
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
|
||||
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
|
||||
"样品板": "BIOYOND_PolymerStation_6VialCarrier",
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def bioyond_materials() -> list[dict]:
|
||||
print("加载 BioYond 物料数据...")
|
||||
print(os.getcwd())
|
||||
with open("bioyond_materials.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)["data"]
|
||||
print(f"加载了 {len(data)} 条物料数据")
|
||||
return data
|
||||
|
||||
|
||||
def test_bioyond_to_plr(bioyond_materials) -> list[dict]:
|
||||
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
|
||||
print("将 BioYond 物料数据转换为 PLR 格式...")
|
||||
output = resource_bioyond_to_plr(bioyond_materials, type_mapping=type_mapping, deck=deck)
|
||||
print(deck.summary())
|
||||
print([resource.serialize() for resource in output])
|
||||
print([resource.serialize_all_state() for resource in output])
|
||||
@@ -34,7 +34,7 @@ dependencies:
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
- websockets
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
@@ -63,6 +63,9 @@ dependencies:
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
- zeep
|
||||
- jinja2
|
||||
- pprp
|
||||
- pip:
|
||||
- paho-mqtt
|
||||
- opentrons_shared_data
|
||||
|
||||
@@ -34,7 +34,7 @@ dependencies:
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
- websockets
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
@@ -62,6 +62,9 @@ dependencies:
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
- zeep
|
||||
- jinja2
|
||||
- pprp
|
||||
- pip:
|
||||
- paho-mqtt
|
||||
- opentrons_shared_data
|
||||
|
||||
@@ -35,8 +35,7 @@ dependencies:
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
- paho-mqtt
|
||||
- websockets
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
@@ -65,6 +64,9 @@ dependencies:
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
- zeep
|
||||
- jinja2
|
||||
- pprp
|
||||
- pip:
|
||||
- paho-mqtt
|
||||
- opentrons_shared_data
|
||||
|
||||
@@ -34,7 +34,7 @@ dependencies:
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
- websockets
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
@@ -65,6 +65,9 @@ dependencies:
|
||||
- uni-lab::ros-humble-unilabos-msgs
|
||||
# driver
|
||||
#- crcmod
|
||||
- zeep
|
||||
- jinja2
|
||||
- pprp
|
||||
- pip:
|
||||
- paho-mqtt
|
||||
- opentrons_shared_data
|
||||
|
||||
@@ -15,16 +15,16 @@ def start_backend(
|
||||
without_host: bool = False,
|
||||
visual: str = "None",
|
||||
resources_mesh_config: dict = {},
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
if backend == "ros":
|
||||
# 假设 ros_main, simple_main, automancer_main 是不同 backend 的启动函数
|
||||
from unilabos.ros.main_slave_run import main, slave # 如果选择 'ros' 作为 backend
|
||||
elif backend == 'simple':
|
||||
elif backend == "simple":
|
||||
# 这里假设 simple_backend 和 automancer_backend 是你定义的其他两个后端
|
||||
# from simple_backend import main as simple_main
|
||||
pass
|
||||
elif backend == 'automancer':
|
||||
elif backend == "automancer":
|
||||
# from automancer_backend import main as automancer_main
|
||||
pass
|
||||
else:
|
||||
@@ -32,7 +32,16 @@ def start_backend(
|
||||
|
||||
backend_thread = threading.Thread(
|
||||
target=main if not without_host else slave,
|
||||
args=(devices_config, resources_config, resources_edge_config, graph, controllers_config, bridges, visual, resources_mesh_config),
|
||||
args=(
|
||||
devices_config,
|
||||
resources_config,
|
||||
resources_edge_config,
|
||||
graph,
|
||||
controllers_config,
|
||||
bridges,
|
||||
visual,
|
||||
resources_mesh_config,
|
||||
),
|
||||
name="backend_thread",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
192
unilabos/app/communication.py
Normal file
192
unilabos/app/communication.py
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""
|
||||
通信模块
|
||||
|
||||
提供WebSocket的统一接口,支持通过配置选择通信协议。
|
||||
包含通信抽象层基类和通信客户端工厂。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
class BaseCommunicationClient(ABC):
|
||||
"""
|
||||
通信客户端抽象基类
|
||||
|
||||
定义了所有通信客户端(WebSocket等)需要实现的接口。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.is_disabled = True
|
||||
self.client_id = ""
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> None:
|
||||
"""
|
||||
启动通信客户端连接
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
停止通信客户端连接
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def publish_device_status(self, device_status: dict, device_id: str, property_name: str) -> None:
|
||||
"""
|
||||
发布设备状态信息
|
||||
|
||||
Args:
|
||||
device_status: 设备状态字典
|
||||
device_id: 设备ID
|
||||
property_name: 属性名称
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def publish_job_status(
|
||||
self, feedback_data: dict, job_id: str, status: str, return_info: Optional[dict] = None
|
||||
) -> None:
|
||||
"""
|
||||
发布作业状态信息
|
||||
|
||||
Args:
|
||||
feedback_data: 反馈数据
|
||||
job_id: 作业ID
|
||||
status: 作业状态
|
||||
return_info: 返回信息
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||
"""
|
||||
发送ping消息
|
||||
|
||||
Args:
|
||||
ping_id: ping ID
|
||||
timestamp: 时间戳
|
||||
"""
|
||||
pass
|
||||
|
||||
def setup_pong_subscription(self) -> None:
|
||||
"""
|
||||
设置pong消息订阅(可选实现)
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
检查是否已连接
|
||||
|
||||
Returns:
|
||||
是否已连接
|
||||
"""
|
||||
return not self.is_disabled
|
||||
|
||||
|
||||
class CommunicationClientFactory:
|
||||
"""
|
||||
通信客户端工厂类
|
||||
|
||||
根据配置文件中的通信协议设置创建相应的客户端实例。
|
||||
"""
|
||||
|
||||
_client_cache: Optional[BaseCommunicationClient] = None
|
||||
|
||||
@classmethod
|
||||
def create_client(cls, protocol: Optional[str] = None) -> BaseCommunicationClient:
|
||||
"""
|
||||
创建通信客户端实例
|
||||
|
||||
Args:
|
||||
protocol: 指定的协议类型,如果为None则使用配置文件中的设置
|
||||
|
||||
Returns:
|
||||
通信客户端实例
|
||||
|
||||
Raises:
|
||||
ValueError: 当协议类型不支持时
|
||||
"""
|
||||
if protocol is None:
|
||||
protocol = BasicConfig.communication_protocol
|
||||
|
||||
protocol = protocol.lower()
|
||||
|
||||
if protocol == "websocket":
|
||||
return cls._create_websocket_client()
|
||||
else:
|
||||
logger.error(f"[CommunicationFactory] Unsupported protocol: {protocol}")
|
||||
logger.warning(f"[CommunicationFactory] Falling back to WebSocket")
|
||||
return cls._create_websocket_client()
|
||||
|
||||
@classmethod
|
||||
def get_client(cls, protocol: Optional[str] = None) -> BaseCommunicationClient:
|
||||
"""
|
||||
获取通信客户端实例(单例模式)
|
||||
|
||||
Args:
|
||||
protocol: 指定的协议类型,如果为None则使用配置文件中的设置
|
||||
|
||||
Returns:
|
||||
通信客户端实例
|
||||
"""
|
||||
if cls._client_cache is None:
|
||||
cls._client_cache = cls.create_client(protocol)
|
||||
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
|
||||
return cls._client_cache
|
||||
|
||||
@classmethod
|
||||
def _create_websocket_client(cls) -> BaseCommunicationClient:
|
||||
"""创建WebSocket客户端"""
|
||||
try:
|
||||
from unilabos.app.ws_client import WebSocketClient
|
||||
|
||||
return WebSocketClient()
|
||||
except Exception as e:
|
||||
logger.error(f"[CommunicationFactory] Failed to create WebSocket client: {str(e)}")
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def reset_client(cls):
|
||||
"""重置客户端缓存(用于测试或重新配置)"""
|
||||
if cls._client_cache:
|
||||
try:
|
||||
cls._client_cache.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"[CommunicationFactory] Error stopping old client: {str(e)}")
|
||||
|
||||
cls._client_cache = None
|
||||
logger.info("[CommunicationFactory] Client cache reset")
|
||||
|
||||
@classmethod
|
||||
def get_supported_protocols(cls) -> list[str]:
|
||||
"""
|
||||
获取支持的协议列表
|
||||
|
||||
Returns:
|
||||
支持的协议列表
|
||||
"""
|
||||
return ["websocket"]
|
||||
|
||||
|
||||
def get_communication_client(protocol: Optional[str] = None) -> BaseCommunicationClient:
|
||||
"""
|
||||
获取通信客户端实例的便捷函数
|
||||
|
||||
Args:
|
||||
protocol: 指定的协议类型,如果为None则使用配置文件中的设置
|
||||
|
||||
Returns:
|
||||
通信客户端实例
|
||||
"""
|
||||
return CommunicationClientFactory.get_client(protocol)
|
||||
@@ -10,7 +10,6 @@ from copy import deepcopy
|
||||
|
||||
import yaml
|
||||
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
|
||||
# 首先添加项目根目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -18,11 +17,12 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if unilabos_dir not in sys.path:
|
||||
sys.path.append(unilabos_dir)
|
||||
|
||||
from unilabos.config.config import load_config, BasicConfig
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
|
||||
|
||||
def load_config_from_file(config_path, override_labid=None):
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
||||
if config_path:
|
||||
@@ -31,10 +31,10 @@ def load_config_from_file(config_path, override_labid=None):
|
||||
elif not config_path.endswith(".py"):
|
||||
print_status(f"配置文件 {config_path} 不是Python文件,必须以.py结尾", "error")
|
||||
else:
|
||||
load_config(config_path, override_labid)
|
||||
load_config(config_path)
|
||||
else:
|
||||
print_status(f"启动 UniLab-OS时,配置文件参数未正确传入 --config '{config_path}' 尝试本地配置...", "warning")
|
||||
load_config(config_path, override_labid)
|
||||
load_config(config_path)
|
||||
|
||||
|
||||
def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
@@ -51,16 +51,14 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||
parser.add_argument("-g", "--graph", help="Physical setup graph.")
|
||||
# parser.add_argument("-d", "--devices", help="Devices config file.")
|
||||
# parser.add_argument("-r", "--resources", help="Resources config file.")
|
||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file.")
|
||||
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(
|
||||
"--registry_path",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to the registry",
|
||||
help="Path to the registry directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--working_dir",
|
||||
@@ -77,67 +75,85 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--app_bridges",
|
||||
nargs="+",
|
||||
default=["mqtt", "fastapi"],
|
||||
help="Bridges to connect to. Now support 'mqtt' and 'fastapi'.",
|
||||
default=["websocket", "fastapi"],
|
||||
help="Bridges to connect to. Now support 'websocket' and 'fastapi'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--without_host",
|
||||
"--is_slave",
|
||||
action="store_true",
|
||||
help="Run the backend as slave (without host).",
|
||||
help="Run the backend as slave node (without host privileges).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slave_no_host",
|
||||
action="store_true",
|
||||
help="Slave模式下跳过等待host服务",
|
||||
help="Skip waiting for host service in slave mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--upload_registry",
|
||||
action="store_true",
|
||||
help="启动unilab时同时报送注册表信息",
|
||||
help="Upload registry information when starting unilab",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use_remote_resource",
|
||||
action="store_true",
|
||||
help="启动unilab时使用远程资源启动",
|
||||
help="Use remote resources when starting unilab",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
default=None,
|
||||
help="配置文件路径,支持.py格式的Python配置文件",
|
||||
help="Configuration file path, supports .py format Python config files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8002,
|
||||
help="信息页web服务的启动端口",
|
||||
help="Port for web service information page",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable_browser",
|
||||
action="store_true",
|
||||
help="是否在启动时关闭信息页",
|
||||
help="Disable opening information page on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--2d_vis",
|
||||
action="store_true",
|
||||
help="是否在pylabrobot实例启动时,同时启动可视化",
|
||||
help="Enable 2D visualization when starting pylabrobot instance",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--visual",
|
||||
choices=["rviz", "web", "disable"],
|
||||
default="disable",
|
||||
help="选择可视化工具: rviz, web",
|
||||
help="Choose visualization tool: rviz, web, or disable",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--labid",
|
||||
"--ak",
|
||||
type=str,
|
||||
default="",
|
||||
help="实验室唯一ID,也可通过环境变量 UNILABOS_MQCONFIG_LABID 设置或传入--config设置",
|
||||
help="Access key for laboratory requests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sk",
|
||||
type=str,
|
||||
default="",
|
||||
help="Secret key for laboratory requests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--addr",
|
||||
type=str,
|
||||
default="https://uni-lab.bohrium.com/api/v1",
|
||||
help="Laboratory backend address",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip_env_check",
|
||||
action="store_true",
|
||||
help="跳过启动时的环境依赖检查",
|
||||
help="Skip environment dependency check on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
return parser
|
||||
|
||||
@@ -167,7 +183,7 @@ def main():
|
||||
else:
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
if args_dict.get("working_dir"):
|
||||
working_dir = args_dict.get("working_dir")
|
||||
working_dir = args_dict.get("working_dir", "")
|
||||
if config_path and not os.path.exists(config_path):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
if not os.path.exists(config_path):
|
||||
@@ -192,17 +208,27 @@ def main():
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
print_status(f"请在文件夹中配置lab_id,放入下载的CA.crt、lab.crt、lab.key重新启动本程序", "info")
|
||||
os._exit(1)
|
||||
else:
|
||||
os._exit(1)
|
||||
# 加载配置文件
|
||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||
load_config_from_file(config_path, args_dict["labid"])
|
||||
load_config_from_file(config_path)
|
||||
if args_dict["addr"] == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||
|
||||
if args_dict["use_remote_resource"]:
|
||||
print_status("使用远程资源启动", "info")
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
res = http_client.resource_get("host_node", False)
|
||||
if str(res.get("code", 0)) == "0" and len(res.get("data", [])) > 0:
|
||||
print_status("远程资源已存在,使用云端物料!", "info")
|
||||
@@ -211,10 +237,17 @@ def main():
|
||||
print_status("远程资源不存在,本地将进行首次上报!", "info")
|
||||
|
||||
# 设置BasicConfig参数
|
||||
if args_dict.get("ak", ""):
|
||||
BasicConfig.ak = args_dict.get("ak", "")
|
||||
print_status("传入了ak参数,优先采用传入参数!", "info")
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.is_host_mode = not args_dict.get("without_host", False)
|
||||
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.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])
|
||||
BasicConfig.machine_name = machine_name
|
||||
@@ -227,17 +260,24 @@ def main():
|
||||
dict_to_nested_dict,
|
||||
initialize_resources,
|
||||
)
|
||||
from unilabos.app.mq import mqtt_client
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.registry.registry import build_registry
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.app.web import http_client
|
||||
from unilabos.app.web import start_server
|
||||
from unilabos.app.register import register_devices_and_resources
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
build_registry(args_dict["registry_path"], False, args_dict["upload_registry"])
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||
)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
if args_dict["graph"] is None:
|
||||
request_startup_json = http_client.request_startup_json()
|
||||
if not request_startup_json:
|
||||
@@ -258,6 +298,37 @@ def main():
|
||||
|
||||
graph_res.physical_setup_graph = graph
|
||||
resource_edge_info = modify_to_backend_format(data["links"])
|
||||
materials = lab_registry.obtain_registry_resource_info()
|
||||
materials.extend(lab_registry.obtain_registry_device_info())
|
||||
materials = {k["id"]: k for k in materials}
|
||||
nodes = {k["id"]: k for k in data["nodes"]}
|
||||
edge_info = len(resource_edge_info)
|
||||
for ind, i in enumerate(resource_edge_info[::-1]):
|
||||
source_node = nodes[i["source"]]
|
||||
target_node = nodes[i["target"]]
|
||||
source_handle = i["sourceHandle"]
|
||||
target_handle = i["targetHandle"]
|
||||
source_handler_keys = [
|
||||
h["handler_key"] for h in materials[source_node["class"]]["handles"] if h["io_type"] == "source"
|
||||
]
|
||||
target_handler_keys = [
|
||||
h["handler_key"] for h in materials[target_node["class"]]["handles"] if h["io_type"] == "target"
|
||||
]
|
||||
if source_handle not in source_handler_keys:
|
||||
print_status(
|
||||
f"节点 {source_node['id']} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
|
||||
"error",
|
||||
)
|
||||
resource_edge_info.pop(edge_info - ind - 1)
|
||||
continue
|
||||
if target_handle not in target_handler_keys:
|
||||
print_status(
|
||||
f"节点 {target_node['id']} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
|
||||
"error",
|
||||
)
|
||||
resource_edge_info.pop(edge_info - ind - 1)
|
||||
continue
|
||||
|
||||
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
|
||||
# args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
|
||||
args_dict["resources_config"] = list(devices_and_resources.values())
|
||||
@@ -268,6 +339,22 @@ def main():
|
||||
for i in args_dict["resources_config"]:
|
||||
print_status(f"DeviceId: {i['id']}, Class: {i['class']}", "info")
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if args_dict.get("ak") and args_dict.get("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:
|
||||
@@ -275,19 +362,22 @@ def main():
|
||||
|
||||
args_dict["bridges"] = []
|
||||
|
||||
if "mqtt" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(mqtt_client)
|
||||
# 获取通信客户端(仅支持WebSocket)
|
||||
comm_client = get_communication_client()
|
||||
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(comm_client)
|
||||
if "fastapi" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(http_client)
|
||||
if "mqtt" in args_dict["app_bridges"]:
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
|
||||
def _exit(signum, frame):
|
||||
mqtt_client.stop()
|
||||
comm_client.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _exit)
|
||||
signal.signal(signal.SIGTERM, _exit)
|
||||
mqtt_client.start()
|
||||
comm_client.start()
|
||||
args_dict["resources_mesh_config"] = {}
|
||||
args_dict["resources_edge_config"] = resource_edge_info
|
||||
# web visiualize 2D
|
||||
|
||||
@@ -50,11 +50,16 @@ class Resp(BaseModel):
|
||||
|
||||
class JobAddReq(BaseModel):
|
||||
device_id: str = Field(examples=["Gripper"], description="device id")
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
|
||||
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
|
||||
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
|
||||
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid")
|
||||
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
|
||||
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
|
||||
|
||||
|
||||
class JobStepFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import ssl
|
||||
import base64
|
||||
import hmac
|
||||
from hashlib import sha1
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from unilabos.config.config import MQConfig
|
||||
from unilabos.app.controler import job_add
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
mqtt_disable = True
|
||||
|
||||
def __init__(self):
|
||||
self.mqtt_disable = not MQConfig.lab_id
|
||||
self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}"
|
||||
logger.info("[MQTT] Client_id: " + self.client_id)
|
||||
self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5)
|
||||
self._setup_callbacks()
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self.client.on_log = self._on_log
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_message = self._on_message
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
|
||||
def _on_log(self, client, userdata, level, buf):
|
||||
# logger.info(f"[MQTT] log: {buf}")
|
||||
pass
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc, properties=None):
|
||||
logger.info("[MQTT] Connected with result code " + str(rc))
|
||||
client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0)
|
||||
client.subscribe(f"labs/{MQConfig.lab_id}/pong/", 0)
|
||||
|
||||
def _on_message(self, client, userdata, msg) -> None:
|
||||
# logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
|
||||
try:
|
||||
payload_str = msg.payload.decode("utf-8")
|
||||
payload_json = json.loads(payload_str)
|
||||
if msg.topic == f"labs/{MQConfig.lab_id}/job/start/":
|
||||
if "data" not in payload_json:
|
||||
payload_json["data"] = {}
|
||||
if "action" in payload_json:
|
||||
payload_json["data"]["action"] = payload_json.pop("action")
|
||||
if "action_type" in payload_json:
|
||||
payload_json["data"]["action_type"] = payload_json.pop("action_type")
|
||||
if "action_args" in payload_json:
|
||||
payload_json["data"]["action_args"] = payload_json.pop("action_args")
|
||||
if "action_kwargs" in payload_json:
|
||||
payload_json["data"]["action_kwargs"] = payload_json.pop("action_kwargs")
|
||||
job_req = JobAddReq.model_validate(payload_json)
|
||||
data = job_add(job_req)
|
||||
return
|
||||
elif msg.topic == f"labs/{MQConfig.lab_id}/pong/":
|
||||
# 处理pong响应,通知HostNode
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
|
||||
host_instance = HostNode.get_instance(0)
|
||||
if host_instance:
|
||||
host_instance.handle_pong_response(payload_json)
|
||||
return
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[MQTT] JSON 解析错误: {e}")
|
||||
logger.error(f"[MQTT] Raw message: {msg.payload}")
|
||||
logger.error(traceback.format_exc())
|
||||
except Exception as e:
|
||||
logger.error(f"[MQTT] 处理消息时出错: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc, reasonCode=None, properties=None):
|
||||
if rc != 0:
|
||||
logger.error(f"[MQTT] Unexpected disconnection {rc}")
|
||||
|
||||
def _setup_ssl_context(self):
|
||||
temp_files = []
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as ca_temp:
|
||||
ca_temp.write(MQConfig.ca_content)
|
||||
temp_files.append(ca_temp.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as cert_temp:
|
||||
cert_temp.write(MQConfig.cert_content)
|
||||
temp_files.append(cert_temp.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as key_temp:
|
||||
key_temp.write(MQConfig.key_content)
|
||||
temp_files.append(key_temp.name)
|
||||
|
||||
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
context.load_verify_locations(cafile=temp_files[0])
|
||||
context.load_cert_chain(certfile=temp_files[1], keyfile=temp_files[2])
|
||||
self.client.tls_set_context(context)
|
||||
finally:
|
||||
for temp_file in temp_files:
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
if self.mqtt_disable:
|
||||
logger.warning("MQTT is disabled, skipping connection.")
|
||||
return
|
||||
userName = f"Signature|{MQConfig.access_key}|{MQConfig.instance_id}"
|
||||
password = base64.b64encode(
|
||||
hmac.new(MQConfig.secret_key.encode(), self.client_id.encode(), sha1).digest()
|
||||
).decode()
|
||||
|
||||
self.client.username_pw_set(userName, password)
|
||||
self._setup_ssl_context()
|
||||
|
||||
# 创建连接线程
|
||||
def connect_thread_func():
|
||||
try:
|
||||
self.client.connect(MQConfig.broker_url, MQConfig.port, 60)
|
||||
self.client.loop_start()
|
||||
|
||||
# 添加连接超时检测
|
||||
max_attempts = 5
|
||||
attempt = 0
|
||||
while not self.client.is_connected() and attempt < max_attempts:
|
||||
logger.info(
|
||||
f"[MQTT] 正在连接到 {MQConfig.broker_url}:{MQConfig.port},尝试 {attempt+1}/{max_attempts}"
|
||||
)
|
||||
time.sleep(3)
|
||||
attempt += 1
|
||||
|
||||
if self.client.is_connected():
|
||||
logger.info(f"[MQTT] 已成功连接到 {MQConfig.broker_url}:{MQConfig.port}")
|
||||
else:
|
||||
logger.error(f"[MQTT] 连接超时,可能是账号密码错误或网络问题")
|
||||
self.client.loop_stop()
|
||||
except Exception as e:
|
||||
logger.error(f"[MQTT] 连接失败: {str(e)}")
|
||||
|
||||
connect_thread_func()
|
||||
# connect_thread = threading.Thread(target=connect_thread_func)
|
||||
# connect_thread.daemon = True
|
||||
# connect_thread.start()
|
||||
|
||||
def stop(self):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
self.client.disconnect()
|
||||
self.client.loop_stop()
|
||||
|
||||
def publish_device_status(self, device_status: dict, device_id, property_name):
|
||||
# status = device_status.get(device_id, {})
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
status = {"data": device_status.get(device_id, {}), "device_id": device_id, "timestamp": time.time()}
|
||||
address = f"labs/{MQConfig.lab_id}/devices/"
|
||||
self.client.publish(address, json.dumps(status), qos=2)
|
||||
# logger.info(f"Device {device_id} status published: address: {address}, {status}")
|
||||
|
||||
def publish_job_status(self, feedback_data: dict, job_id: str, status: str, return_info: Optional[str] = None):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
if return_info is None:
|
||||
return_info = "{}"
|
||||
jobdata = {"job_id": job_id, "data": feedback_data, "status": status, "return_info": return_info}
|
||||
self.client.publish(f"labs/{MQConfig.lab_id}/job/list/", json.dumps(jobdata), qos=2)
|
||||
|
||||
def publish_registry(self, device_id: str, device_info: dict, print_debug: bool = True):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
address = f"labs/{MQConfig.lab_id}/registry/"
|
||||
registry_data = json.dumps({device_id: device_info}, ensure_ascii=False, cls=TypeEncoder)
|
||||
self.client.publish(address, registry_data, qos=2)
|
||||
if print_debug:
|
||||
logger.debug(f"Registry data published: address: {address}, {registry_data}")
|
||||
|
||||
def publish_actions(self, action_id: str, action_info: dict):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
address = f"labs/{MQConfig.lab_id}/actions/"
|
||||
self.client.publish(address, json.dumps(action_info), qos=2)
|
||||
logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}")
|
||||
|
||||
def send_ping(self, ping_id: str, timestamp: float):
|
||||
"""发送ping消息到服务端"""
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
address = f"labs/{MQConfig.lab_id}/ping/"
|
||||
ping_data = {"ping_id": ping_id, "client_timestamp": timestamp, "type": "ping"}
|
||||
self.client.publish(address, json.dumps(ping_data), qos=2)
|
||||
|
||||
def setup_pong_subscription(self):
|
||||
"""设置pong消息订阅"""
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
pong_topic = f"labs/{MQConfig.lab_id}/pong/"
|
||||
self.client.subscribe(pong_topic, 0)
|
||||
logger.debug(f"Subscribed to pong topic: {pong_topic}")
|
||||
|
||||
def handle_pong(self, pong_data: dict):
|
||||
"""处理pong响应(这个方法会在收到pong消息时被调用)"""
|
||||
logger.debug(f"Pong received: {pong_data}")
|
||||
# 这里会被HostNode的ping-pong处理逻辑调用
|
||||
pass
|
||||
|
||||
|
||||
mqtt_client = MQTTClient()
|
||||
|
||||
if __name__ == "__main__":
|
||||
mqtt_client.start()
|
||||
@@ -1,85 +1,62 @@
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.registry.registry import build_registry
|
||||
|
||||
from unilabos.app.main import load_config_from_file
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
|
||||
|
||||
def register_devices_and_resources(mqtt_client, lab_registry):
|
||||
def register_devices_and_resources(lab_registry):
|
||||
"""
|
||||
注册设备和资源到 MQTT
|
||||
注册设备和资源到服务器(仅支持HTTP)
|
||||
"""
|
||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||
|
||||
# 注册设备信息
|
||||
for device_info in lab_registry.obtain_registry_device_info():
|
||||
mqtt_client.publish_registry(device_info["id"], device_info, False)
|
||||
logger.debug(f"[UniLab Register] 注册设备: {device_info['id']}")
|
||||
|
||||
# # 注册资源信息
|
||||
# for resource_info in lab_registry.obtain_registry_resource_info():
|
||||
# mqtt_client.publish_registry(resource_info["id"], resource_info, False)
|
||||
# logger.debug(f"[UniLab Register] 注册资源: {resource_info['id']}")
|
||||
|
||||
# 注册资源信息 - 使用HTTP方式
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||
|
||||
# 注册设备信息
|
||||
devices_to_register = {}
|
||||
for device_info in lab_registry.obtain_registry_device_info():
|
||||
devices_to_register[device_info["id"]] = json.loads(
|
||||
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||
)
|
||||
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
|
||||
resources_to_register = {}
|
||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||
resources_to_register[resource_info["id"]] = resource_info
|
||||
logger.debug(f"[UniLab Register] 准备注册资源: {resource_info['id']}")
|
||||
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
|
||||
# 注册设备
|
||||
if devices_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
# 注册资源
|
||||
if resources_to_register:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry(resources_to_register)
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功通过HTTP注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] HTTP注册资源失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
命令行入口函数
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="注册设备和资源到 MQTT")
|
||||
parser.add_argument(
|
||||
"--registry",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="注册表路径",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
default=None,
|
||||
help="配置文件路径,支持.py格式的Python配置文件",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="是否补全注册表",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
load_config_from_file(args.config)
|
||||
# 构建注册表
|
||||
build_registry(args.registry, args.complete_registry, True)
|
||||
from unilabos.app.mq import mqtt_client
|
||||
|
||||
# 连接mqtt
|
||||
mqtt_client.start()
|
||||
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
# 注册设备和资源
|
||||
register_devices_and_resources(mqtt_client, lab_registry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,14 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import requests
|
||||
from unilabos.utils.log import info
|
||||
from unilabos.config.config import MQConfig, HTTPConfig, BasicConfig
|
||||
from unilabos.config.config import HTTPConfig, BasicConfig
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
@@ -24,14 +25,17 @@ class HTTPClient:
|
||||
remote_addr: 远程服务器地址,如果不提供则从配置中获取
|
||||
auth: 授权信息
|
||||
"""
|
||||
self.initialized = False
|
||||
self.remote_addr = remote_addr or HTTPConfig.remote_addr
|
||||
if auth is not None:
|
||||
self.auth = auth
|
||||
else:
|
||||
self.auth = MQConfig.lab_id
|
||||
auth_secret = BasicConfig.auth_secret()
|
||||
self.auth = auth_secret
|
||||
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
||||
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
||||
|
||||
def resource_edge_add(self, resources: List[Dict[str, Any]], database_process_later: bool) -> requests.Response:
|
||||
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
添加资源
|
||||
|
||||
@@ -41,33 +45,51 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
database_param = 1 if database_process_later else 0
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource/edge/batch_create/?database_process_later={database_param}",
|
||||
json=resources,
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
f"{self.remote_addr}/lab/material/edge",
|
||||
json={
|
||||
"edges": resources,
|
||||
},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"添加物料关系失败: {response.text}")
|
||||
if response.status_code != 200 and response.status_code != 201:
|
||||
logger.error(f"添加物料关系失败: {response.status_code}, {response.text}")
|
||||
return response
|
||||
|
||||
def resource_add(self, resources: List[Dict[str, Any]], database_process_later: bool) -> requests.Response:
|
||||
def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
添加资源
|
||||
|
||||
Args:
|
||||
resources: 要添加的资源列表
|
||||
database_process_later: 后台处理资源
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource/?database_process_later={1 if database_process_later else 0}",
|
||||
json=resources,
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
if response.status_code != 200:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
return response
|
||||
@@ -84,9 +106,9 @@ class HTTPClient:
|
||||
Dict: 返回的资源数据
|
||||
"""
|
||||
response = requests.get(
|
||||
f"{self.remote_addr}/lab/resource/?edge_format=1",
|
||||
f"{self.remote_addr}/lab/material",
|
||||
params={"id": id, "with_children": with_children},
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=20,
|
||||
)
|
||||
return response.json()
|
||||
@@ -104,7 +126,7 @@ class HTTPClient:
|
||||
response = requests.delete(
|
||||
f"{self.remote_addr}/lab/resource/batch_delete/",
|
||||
params={"id": id},
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=20,
|
||||
)
|
||||
return response
|
||||
@@ -119,13 +141,29 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.patch(
|
||||
f"{self.remote_addr}/lab/resource/batch_update/?edge_format=1",
|
||||
json=resources,
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
return response
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
if response.status_code != 200:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
return response.json()
|
||||
|
||||
def upload_file(self, file_path: str, scene: str = "models") -> requests.Response:
|
||||
"""
|
||||
@@ -146,25 +184,25 @@ class HTTPClient:
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||
files=files,
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=30, # 上传文件可能需要更长的超时时间
|
||||
)
|
||||
return response
|
||||
|
||||
def resource_registry(self, registry_data: Dict[str, Any]) -> requests.Response:
|
||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
注册资源到服务器
|
||||
|
||||
Args:
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info}
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/registry/",
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
@@ -183,7 +221,7 @@ class HTTPClient:
|
||||
"""
|
||||
response = requests.get(
|
||||
f"{self.remote_addr}/lab/resource/graph_info/",
|
||||
headers={"Authorization": f"lab {self.auth}"},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=(3, 30),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
|
||||
@@ -78,21 +78,23 @@ def setup_web_pages(router: APIRouter) -> None:
|
||||
HTMLResponse: 渲染后的HTML页面
|
||||
"""
|
||||
try:
|
||||
# 准备设备数据
|
||||
# 准备初始数据结构(这些数据将通过WebSocket实时更新)
|
||||
devices = []
|
||||
resources = []
|
||||
modules = {"names": [], "classes": [], "displayed_count": 0, "total_count": 0}
|
||||
|
||||
# 获取在线设备信息
|
||||
# 获取在线设备信息(用于初始渲染)
|
||||
ros_node_info = get_ros_node_info()
|
||||
# 获取主机节点信息
|
||||
# 获取主机节点信息(用于初始渲染)
|
||||
host_node_info = get_host_node_info()
|
||||
# 获取Registry路径信息
|
||||
# 获取Registry路径信息(静态信息,不需要实时更新)
|
||||
registry_info = get_registry_info()
|
||||
|
||||
# 获取已加载的设备
|
||||
# 获取初始数据用于页面渲染(后续将被WebSocket数据覆盖)
|
||||
if lab_registry:
|
||||
devices = json.loads(json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder))
|
||||
devices = json.loads(
|
||||
json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder)
|
||||
)
|
||||
# 资源类型
|
||||
for resource_id, resource_info in lab_registry.resource_type_registry.items():
|
||||
resources.append(
|
||||
@@ -103,7 +105,7 @@ def setup_web_pages(router: APIRouter) -> None:
|
||||
}
|
||||
)
|
||||
|
||||
# 获取导入的模块
|
||||
# 获取导入的模块(初始数据)
|
||||
if msg_converter_manager:
|
||||
modules["names"] = msg_converter_manager.list_modules()
|
||||
all_classes = [i for i in msg_converter_manager.list_classes() if "." in i]
|
||||
@@ -171,3 +173,20 @@ def setup_web_pages(router: APIRouter) -> None:
|
||||
except Exception as e:
|
||||
error(f"打开文件夹时出错: {str(e)}")
|
||||
return {"status": "error", "message": f"Failed to open folder: {str(e)}"}
|
||||
|
||||
@router.get("/registry-editor", response_class=HTMLResponse, summary="Registry Editor")
|
||||
async def registry_editor_page() -> str:
|
||||
"""
|
||||
注册表编辑页面,用于导入Python文件并生成注册表
|
||||
|
||||
Returns:
|
||||
HTMLResponse: 渲染后的HTML页面
|
||||
"""
|
||||
try:
|
||||
# 使用模板渲染页面
|
||||
template = env.get_template("registry_editor.html")
|
||||
html = template.render()
|
||||
return html
|
||||
except Exception as e:
|
||||
error(f"生成注册表编辑页面时出错: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error generating registry editor page: {str(e)}")
|
||||
|
||||
@@ -162,7 +162,6 @@
|
||||
<body>
|
||||
<h1>{% block header %}UniLab{% endblock %}</h1>
|
||||
{% block nav %}
|
||||
<a href="/unilabos/webtic" class="home-link">Home</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block top_info %}{% endblock %}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}UniLab API{% endblock %}
|
||||
|
||||
{% block header %}UniLab API{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<a href="/status" class="status-link">System Status</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% extends "base.html" %} {% block title %}UniLab API{% endblock %} {% block
|
||||
header %}UniLab API{% endblock %} {% block nav %}
|
||||
<div class="nav-tabs">
|
||||
<a
|
||||
href="/"
|
||||
class="nav-tab"
|
||||
style="background-color: #2196f3; color: white"
|
||||
target="_blank"
|
||||
>主页</a
|
||||
>
|
||||
<a href="/status" class="nav-tab">状态</a>
|
||||
<a href="/registry-editor" class="nav-tab" target="_blank">注册表编辑</a>
|
||||
</div>
|
||||
{% endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h2>Available Endpoints</h2>
|
||||
{% for route in routes %}
|
||||
<div class="endpoint">
|
||||
<span class="method">{{ route.method }}</span>
|
||||
<a href="{{ route.path }}">{{ route.path }}</a>
|
||||
<p>{{ route.summary }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<h2>Available Endpoints</h2>
|
||||
{% for route in routes %}
|
||||
<div class="endpoint">
|
||||
<span class="method">{{ route.method }}</span>
|
||||
<a href="{{ route.path }}">{{ route.path }}</a>
|
||||
<p>{{ route.summary }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
1411
unilabos/app/web/templates/registry_editor.html
Normal file
1411
unilabos/app/web/templates/registry_editor.html
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1166
unilabos/app/ws_client.py
Normal file
1166
unilabos/app/ws_client.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ action_protocol_generators = {
|
||||
HeatChillStopProtocol: generate_heat_chill_stop_protocol,
|
||||
HydrogenateProtocol: generate_hydrogenate_protocol,
|
||||
PumpTransferProtocol: generate_pump_protocol_with_rinsing,
|
||||
TransferProtocol: generate_pump_protocol,
|
||||
RecrystallizeProtocol: generate_recrystallize_protocol,
|
||||
ResetHandlingProtocol: generate_reset_handling_protocol,
|
||||
RunColumnProtocol: generate_run_column_protocol,
|
||||
|
||||
@@ -155,7 +155,7 @@ def generate_add_protocol(
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"准备添加固体 {reagent}"
|
||||
}
|
||||
@@ -169,7 +169,7 @@ def generate_add_protocol(
|
||||
|
||||
# 固体加样
|
||||
add_kwargs = {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"reagent": reagent,
|
||||
"purpose": purpose,
|
||||
"event": event,
|
||||
@@ -232,7 +232,7 @@ def generate_add_protocol(
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"准备添加液体 {reagent}"
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ def generate_adjust_ph_protocol(
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id,
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"pH调节: 启动搅拌,准备添加 {reagent}"
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ def generate_centrifuge_protocol(
|
||||
"device_id": centrifuge_id,
|
||||
"action_name": "centrifuge",
|
||||
"action_kwargs": {
|
||||
"vessel": centrifuge_vessel,
|
||||
"vessel": {"id": centrifuge_vessel},
|
||||
"speed": speed,
|
||||
"time": time,
|
||||
"temp": temp
|
||||
|
||||
@@ -143,7 +143,7 @@ def generate_clean_vessel_protocol(
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"temp": temp,
|
||||
"purpose": f"cleaning with {solvent}"
|
||||
}
|
||||
@@ -295,7 +295,7 @@ def generate_clean_vessel_protocol(
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_stop_action)
|
||||
|
||||
@@ -563,7 +563,7 @@ def generate_dissolve_protocol(
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id,
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": final_temp,
|
||||
"purpose": f"溶解准备 - {event}" if event else "溶解准备"
|
||||
}
|
||||
@@ -587,7 +587,7 @@ def generate_dissolve_protocol(
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id,
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"溶解搅拌 - {event}" if event else "溶解搅拌"
|
||||
}
|
||||
@@ -612,7 +612,7 @@ def generate_dissolve_protocol(
|
||||
|
||||
# 固体加样
|
||||
add_kwargs = {
|
||||
"vessel": vessel_id,
|
||||
"vessel": {"id": vessel_id},
|
||||
"reagent": reagent or amount or "solid reagent",
|
||||
"purpose": f"溶解固体试剂 - {event}" if event else "溶解固体试剂",
|
||||
"event": event
|
||||
@@ -758,7 +758,7 @@ def generate_dissolve_protocol(
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id,
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": final_temp,
|
||||
"time": final_time,
|
||||
"stir": True,
|
||||
@@ -776,7 +776,7 @@ def generate_dissolve_protocol(
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id,
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_time": final_time,
|
||||
"stir_speed": stir_speed,
|
||||
"settling_time": 0,
|
||||
@@ -802,7 +802,7 @@ def generate_dissolve_protocol(
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}
|
||||
action_sequence.append(stop_action)
|
||||
|
||||
@@ -167,7 +167,7 @@ def generate_dry_protocol(
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"temp": dry_temp,
|
||||
"purpose": f"干燥 {compound or '化合物'}"
|
||||
}
|
||||
@@ -191,7 +191,7 @@ def generate_dry_protocol(
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"temp": dry_temp,
|
||||
"time": simulation_time,
|
||||
"purpose": f"干燥 {compound or '化合物'},保持温度 {dry_temp}°C"
|
||||
@@ -251,7 +251,7 @@ def generate_dry_protocol(
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"purpose": f"干燥完成,停止加热"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -452,7 +452,7 @@ def generate_evacuateandrefill_protocol(
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"stir_speed": STIR_SPEED,
|
||||
"purpose": "抽真空充气前预搅拌"
|
||||
}
|
||||
@@ -685,7 +685,7 @@ def generate_evacuateandrefill_protocol(
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {"vessel": vessel_id} # 🔧 使用 vessel_id
|
||||
"action_kwargs": {"vessel": {"id": vessel_id},} # 🔧 使用 vessel_id
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log("跳过搅拌器停止", "⏭️"))
|
||||
|
||||
@@ -329,7 +329,7 @@ def generate_evaporate_protocol(
|
||||
"device_id": rotavap_device,
|
||||
"action_name": "evaporate",
|
||||
"action_kwargs": {
|
||||
"vessel": target_vessel,
|
||||
"vessel": {"id": target_vessel},
|
||||
"pressure": float(pressure),
|
||||
"temp": float(temp),
|
||||
"time": float(final_time), # 🔧 强制转换为float类型
|
||||
|
||||
@@ -220,7 +220,7 @@ def generate_heat_chill_protocol(
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel,
|
||||
"vessel": {"id": vessel},
|
||||
"temp": float(final_temp),
|
||||
"time": float(final_time),
|
||||
"stir": bool(stir),
|
||||
@@ -287,7 +287,8 @@ def generate_heat_chill_start_protocol(
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"temp": temp,
|
||||
"purpose": purpose or f"开始加热到 {temp}°C"
|
||||
"purpose": purpose or f"开始加热到 {temp}°C",
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -403,8 +404,8 @@ def generate_run_column_protocol(
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
|
||||
debug_print("🏛️" * 20)
|
||||
debug_print("🚀 开始生成柱层析协议(支持vessel字典和体积运算)✨")
|
||||
@@ -772,8 +773,8 @@ def generate_gradient_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vesse
|
||||
column: str, start_ratio: str = "10:90",
|
||||
end_ratio: str = "50:50") -> List[Dict[str, Any]]:
|
||||
"""梯度洗脱柱层析(中等比例)"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"📈 梯度柱层析: {from_vessel_id} → {to_vessel_id} ({start_ratio} → {end_ratio})")
|
||||
# 使用中间比例作为近似
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column, ratio="30:70")
|
||||
@@ -781,8 +782,8 @@ def generate_gradient_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vesse
|
||||
def generate_polar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str) -> List[Dict[str, Any]]:
|
||||
"""极性化合物柱层析(高极性溶剂比例)"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"⚡ 极性化合物柱层析: {from_vessel_id} → {to_vessel_id}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio="70:30")
|
||||
@@ -790,8 +791,8 @@ def generate_polar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel:
|
||||
def generate_nonpolar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str) -> List[Dict[str, Any]]:
|
||||
"""非极性化合物柱层析(低极性溶剂比例)"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"🛢️ 非极性化合物柱层析: {from_vessel_id} → {to_vessel_id}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio="5:95")
|
||||
@@ -804,4 +805,3 @@ def test_run_column_protocol():
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_run_column_protocol()
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ def generate_separate_protocol(
|
||||
"device_id": stirrer_device,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": final_vessel_id, # 🔧 使用 final_vessel_id
|
||||
"vessel": {"id": final_vessel_id}, # 🔧 使用 final_vessel_id
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"分离混合 - {purpose}"
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ def generate_stir_protocol(
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": vessel_id, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"time": str(time),
|
||||
"event": event,
|
||||
"time_spec": time_spec,
|
||||
@@ -323,7 +323,7 @@ def generate_start_stir_protocol(
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": vessel_id, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": purpose or f"启动搅拌 {stir_speed} RPM"
|
||||
}
|
||||
@@ -383,7 +383,7 @@ def generate_stop_stir_protocol(
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": vessel_id # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
}
|
||||
}]
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@ def generate_wash_solid_protocol(
|
||||
"device_id": "stirrer_1",
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"time": str(time),
|
||||
"stir_time": final_time,
|
||||
"stir_speed": stir_speed,
|
||||
@@ -377,7 +377,7 @@ def generate_wash_solid_protocol(
|
||||
"device_id": "filter_1",
|
||||
"action_name": "filter",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"filtrate_vessel": actual_filtrate_vessel,
|
||||
"temp": temp,
|
||||
"volume": final_volume
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
# 定义配置变量和加载函数
|
||||
import base64
|
||||
import traceback
|
||||
import os
|
||||
import importlib.util
|
||||
from typing import Optional
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
class BasicConfig:
|
||||
ENV = "pro" # 'test'
|
||||
ak = ""
|
||||
sk = ""
|
||||
working_dir = ""
|
||||
config_path = ""
|
||||
is_host_mode = True
|
||||
@@ -17,25 +17,22 @@ class BasicConfig:
|
||||
machine_name = "undefined"
|
||||
vis_2d_enable = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
|
||||
@classmethod
|
||||
def auth_secret(cls):
|
||||
if not cls.ak or not cls.sk:
|
||||
return ""
|
||||
target = f"{cls.ak}:{cls.sk}"
|
||||
base64_target = base64.b64encode(target.encode("utf-8")).decode("utf-8")
|
||||
return base64_target
|
||||
|
||||
|
||||
# MQTT配置
|
||||
class MQConfig:
|
||||
lab_id = ""
|
||||
instance_id = ""
|
||||
access_key = ""
|
||||
secret_key = ""
|
||||
group_id = ""
|
||||
broker_url = ""
|
||||
port = 1883
|
||||
ca_content = ""
|
||||
cert_content = ""
|
||||
key_content = ""
|
||||
|
||||
# 指定
|
||||
ca_file = "" # 相对config.py所在目录的路径
|
||||
cert_file = "" # 相对config.py所在目录的路径
|
||||
key_file = "" # 相对config.py所在目录的路径
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# OSS上传配置
|
||||
@@ -65,48 +62,13 @@ class ROSConfig:
|
||||
]
|
||||
|
||||
|
||||
def _update_config_from_module(module, override_labid: str):
|
||||
def _update_config_from_module(module):
|
||||
for name, obj in globals().items():
|
||||
if isinstance(obj, type) and name.endswith("Config"):
|
||||
if hasattr(module, name) and isinstance(getattr(module, name), type):
|
||||
for attr in dir(getattr(module, name)):
|
||||
if not attr.startswith("_"):
|
||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||
# 更新OSS认证
|
||||
if len(OSSUploadConfig.authorization) == 0:
|
||||
OSSUploadConfig.authorization = f"lab {MQConfig.lab_id}"
|
||||
# 对 ca_file cert_file key_file 进行初始化
|
||||
if override_labid:
|
||||
MQConfig.lab_id = override_labid
|
||||
logger.warning(f"[ENV] 当前实验室启动的ID被设置为:{override_labid}")
|
||||
if len(MQConfig.ca_content) == 0:
|
||||
# 需要先判断是否为相对路径
|
||||
if MQConfig.ca_file.startswith("."):
|
||||
MQConfig.ca_file = os.path.join(BasicConfig.config_path, MQConfig.ca_file)
|
||||
if len(MQConfig.ca_file) != 0:
|
||||
with open(MQConfig.ca_file, "r", encoding="utf-8") as f:
|
||||
MQConfig.ca_content = f.read()
|
||||
else:
|
||||
logger.warning("Skipping CA file loading, ca_file is empty")
|
||||
if len(MQConfig.cert_content) == 0:
|
||||
# 需要先判断是否为相对路径
|
||||
if MQConfig.cert_file.startswith("."):
|
||||
MQConfig.cert_file = os.path.join(BasicConfig.config_path, MQConfig.cert_file)
|
||||
if len(MQConfig.ca_file) != 0:
|
||||
with open(MQConfig.cert_file, "r", encoding="utf-8") as f:
|
||||
MQConfig.cert_content = f.read()
|
||||
else:
|
||||
logger.warning("Skipping cert file loading, cert_file is empty")
|
||||
if len(MQConfig.key_content) == 0:
|
||||
# 需要先判断是否为相对路径
|
||||
if MQConfig.key_file.startswith("."):
|
||||
MQConfig.key_file = os.path.join(BasicConfig.config_path, MQConfig.key_file)
|
||||
if len(MQConfig.ca_file) != 0:
|
||||
with open(MQConfig.key_file, "r", encoding="utf-8") as f:
|
||||
MQConfig.key_content = f.read()
|
||||
else:
|
||||
logger.warning("Skipping key file loading, key_file is empty")
|
||||
|
||||
|
||||
def _update_config_from_env():
|
||||
prefix = "UNILABOS_"
|
||||
@@ -159,8 +121,7 @@ def _update_config_from_env():
|
||||
logger.warning(f"[ENV] 解析环境变量 {env_key} 失败: {e}")
|
||||
|
||||
|
||||
|
||||
def load_config(config_path=None, override_labid=None):
|
||||
def load_config(config_path=None):
|
||||
# 如果提供了配置文件路径,从该文件导入配置
|
||||
if config_path:
|
||||
env_config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH")
|
||||
@@ -177,7 +138,7 @@ def load_config(config_path=None, override_labid=None):
|
||||
return
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore
|
||||
_update_config_from_module(module, override_labid)
|
||||
_update_config_from_module(module)
|
||||
logger.info(f"[ENV] 配置文件 {config_path} 加载成功")
|
||||
_update_config_from_env()
|
||||
except Exception as e:
|
||||
@@ -186,4 +147,4 @@ def load_config(config_path=None, override_labid=None):
|
||||
exit(1)
|
||||
else:
|
||||
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
||||
load_config(config_path, override_labid)
|
||||
load_config(config_path)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
# MQTT配置
|
||||
class MQConfig:
|
||||
lab_id = ""
|
||||
instance_id = ""
|
||||
access_key = ""
|
||||
secret_key = ""
|
||||
group_id = ""
|
||||
broker_url = ""
|
||||
port = 1883
|
||||
# unilabos的配置文件
|
||||
|
||||
ca_file = "./CA.crt"
|
||||
cert_file = "./lab.crt"
|
||||
key_file = "./lab.key"
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
|
||||
sk = "" # 实验室网页给您提供的sk代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
@@ -1 +0,0 @@
|
||||
from .eis_model import EISModelBasedController
|
||||
@@ -1,5 +0,0 @@
|
||||
import numpy as np
|
||||
|
||||
|
||||
def EISModelBasedController(eis: np.array) -> float:
|
||||
return 0.0
|
||||
6
unilabos/devices/balance/__init__.py
Normal file
6
unilabos/devices/balance/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# Balance devices module
|
||||
|
||||
# Import balance device modules
|
||||
from . import mettler_toledo_xpr
|
||||
|
||||
__all__ = ['mettler_toledo_xpr']
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
WSDL Template for Mettler Toledo XPR/XSR Balance
|
||||
|
||||
IMPORTANT: This is a template file. You need to obtain the actual WSDL file
|
||||
from Mettler Toledo for your specific balance model.
|
||||
|
||||
To use this driver:
|
||||
1. Contact Mettler Toledo support to obtain the official WSDL file
|
||||
2. Replace this template with the actual WSDL file
|
||||
3. Rename it to: MT.Laboratory.Balance.XprXsr.V03.wsdl
|
||||
|
||||
The WSDL file contains proprietary information and cannot be distributed
|
||||
with this open-source project.
|
||||
-->
|
||||
<wsdl:definitions xmlns:wsx="http://schemas.xmlsoap.org/ws/2004/09/mex"
|
||||
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
|
||||
xmlns:wsa10="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"
|
||||
xmlns:wsap="http://schemas.xmlsoap.org/ws/2004/08/addressing/policy"
|
||||
xmlns:msc="http://schemas.microsoft.com/ws/2005/12/wsdl/contract"
|
||||
xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
|
||||
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||
xmlns:wsam="http://www.w3.org/2007/05/addressing/metadata"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:tns="http://MT/Laboratory/Balance/XprXsr/V03"
|
||||
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
|
||||
xmlns:wsaw="http://www.w3.org/2006/05/addressing/wsdl"
|
||||
xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/"
|
||||
targetNamespace="http://MT/Laboratory/Balance/XprXsr/V03"
|
||||
xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">
|
||||
|
||||
<!--
|
||||
PLACEHOLDER CONTENT
|
||||
|
||||
This template contains only the basic structure.
|
||||
The actual WSDL file should contain:
|
||||
- Service definitions
|
||||
- Port types
|
||||
- Message definitions
|
||||
- Binding information
|
||||
- Endpoint addresses with template variables: {{host}}, {{port}}, {{api_path}}
|
||||
-->
|
||||
|
||||
<wsdl:types>
|
||||
<!-- Schema definitions will be here in the actual WSDL -->
|
||||
</wsdl:types>
|
||||
|
||||
<!-- Service definitions will be here in the actual WSDL -->
|
||||
|
||||
</wsdl:definitions>
|
||||
@@ -0,0 +1,255 @@
|
||||
# 梅特勒天平 ROS2 使用指南 / Mettler Toledo Balance ROS2 User Guide
|
||||
|
||||
## 概述 / Overview
|
||||
|
||||
梅特勒托利多XPR/XSR天平驱动支持通过ROS2动作进行操作,包括去皮、清零、读取重量等功能。
|
||||
|
||||
The Mettler Toledo XPR/XSR balance driver supports operations through ROS2 actions, including tare, zero, weight reading, and other functions.
|
||||
|
||||
## 主要功能 / Main Features
|
||||
|
||||
### 1. 去皮操作 / Tare Operation (`tare`)
|
||||
|
||||
- **功能 / Function**: 执行天平去皮操作 / Perform balance tare operation
|
||||
- **输入 / Input**: `{"immediate": bool}` - 是否立即去皮 / Whether to tare immediately
|
||||
- **输出 / Output**: `{"return_info": str, "success": bool}`
|
||||
|
||||
### 2. 清零操作 / Zero Operation (`zero`)
|
||||
|
||||
- **功能 / Function**: 执行天平清零操作 / Perform balance zero operation
|
||||
- **输入 / Input**: `{"immediate": bool}` - 是否立即清零 / Whether to zero immediately
|
||||
- **输出 / Output**: `{"return_info": str, "success": bool}`
|
||||
|
||||
### 3. 读取重量 / Read Weight (`read` / `get_weight`)
|
||||
|
||||
- **功能 / Function**: 读取当前天平重量 / Read current balance weight
|
||||
- **输入 / Input**: 无参数 / No parameters
|
||||
- **输出 / Output**: `{"return_info": str, "success": bool}` - 包含重量信息 / Contains weight information
|
||||
|
||||
|
||||
|
||||
## 使用方法 / Usage Methods
|
||||
|
||||
### ROS2命令行使用 / ROS2 Command Line Usage
|
||||
|
||||
### 1. 去皮操作 / Tare Operation
|
||||
|
||||
```bash
|
||||
ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{
|
||||
command: '{\"command\": \"tare\", \"params\": {\"immediate\": false}}'
|
||||
}"
|
||||
```
|
||||
|
||||
### 2. 清零操作 / Zero Operation
|
||||
|
||||
```bash
|
||||
ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{
|
||||
command: '{\"command\": \"zero\", \"params\": {\"immediate\": false}}'
|
||||
}"
|
||||
```
|
||||
|
||||
### 3. 读取重量 / Read Weight
|
||||
|
||||
```bash
|
||||
ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{
|
||||
command: '{\"command\": \"read\"}'
|
||||
}"
|
||||
```
|
||||
|
||||
|
||||
### 4. 推荐的去皮读取流程 / Recommended Tare and Read Workflow
|
||||
|
||||
**步骤1: 去皮操作 / Step 1: Tare Operation**
|
||||
```bash
|
||||
# 放置空容器后执行去皮 / Execute tare after placing empty container
|
||||
ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{
|
||||
command: '{\"command\": \"tare\", \"params\": {\"immediate\": false}}'
|
||||
}"
|
||||
```
|
||||
|
||||
**步骤2: 读取净重 / Step 2: Read Net Weight**
|
||||
```bash
|
||||
# 添加物质后读取净重 / Read net weight after adding substance
|
||||
ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{
|
||||
command: '{\"command\": \"read\"}'
|
||||
}"
|
||||
```
|
||||
|
||||
**优势 / Advantages**:
|
||||
- 可以在去皮和读取之间进行确认 / Can confirm between taring and reading
|
||||
- 更好的错误处理和调试 / Better error handling and debugging
|
||||
- 操作流程更加清晰 / Clearer operation workflow
|
||||
|
||||
|
||||
|
||||
## 命令格式说明 / Command Format Description
|
||||
|
||||
所有命令都使用JSON格式,包含以下字段 / All commands use JSON format with the following fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "命令名称 / Command name",
|
||||
"params": {
|
||||
"参数名 / Parameter name": "参数值 / Parameter value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意事项 / Notes:**
|
||||
1. JSON字符串需要正确转义引号 / JSON strings need proper quote escaping
|
||||
2. 布尔值使用小写(true/false)/ Boolean values use lowercase (true/false)
|
||||
3. 如果命令不需要参数,可以省略`params`字段 / If command doesn't need parameters, `params` field can be omitted
|
||||
|
||||
## 返回结果 / Return Results
|
||||
|
||||
所有命令都会返回包含以下字段的结果 / All commands return results with the following fields:
|
||||
|
||||
- `success`: 布尔值,表示操作是否成功 / Boolean value indicating operation success
|
||||
- `return_info`: 字符串,包含操作结果的详细信息 / String containing detailed operation result information
|
||||
|
||||
## 成功执行示例 / Successful Execution Example
|
||||
|
||||
以下是一个成功执行读取重量命令的示例 / Here is an example of successfully executing a weight reading command:
|
||||
|
||||
```bash
|
||||
ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{
|
||||
command: '{\"command\": \"read\"}'
|
||||
}"
|
||||
```
|
||||
|
||||
**成功返回结果 / Successful Return Result:**
|
||||
```
|
||||
Waiting for an action server to become available...
|
||||
Sending goal:
|
||||
command: '{"command": "read"}'
|
||||
|
||||
Goal accepted :)
|
||||
|
||||
Result:
|
||||
success: True
|
||||
return_info: Weight: 0.24866 Milligram
|
||||
|
||||
Goal finished with status: SUCCEEDED
|
||||
```
|
||||
|
||||
### Python代码使用 / Python Code Usage
|
||||
|
||||
```python
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.action import ActionClient
|
||||
from unilabos_msgs.action import SendCmd
|
||||
import json
|
||||
|
||||
class BalanceController(Node):
|
||||
"""梅特勒天平控制器 / Mettler Balance Controller"""
|
||||
def __init__(self):
|
||||
super().__init__('balance_controller')
|
||||
self._action_client = ActionClient(self, SendCmd, '/devices/BALANCE_STATION/send_cmd')
|
||||
|
||||
def send_command(self, command, params=None):
|
||||
"""发送命令到天平 / Send command to balance"""
|
||||
goal_msg = SendCmd.Goal()
|
||||
|
||||
cmd_data = {'command': command}
|
||||
if params:
|
||||
cmd_data['params'] = params
|
||||
|
||||
goal_msg.command = json.dumps(cmd_data)
|
||||
|
||||
self._action_client.wait_for_server()
|
||||
future = self._action_client.send_goal_async(goal_msg)
|
||||
|
||||
return future
|
||||
|
||||
def tare_balance(self, immediate=False):
|
||||
"""去皮操作 / Tare operation"""
|
||||
return self.send_command('tare', {'immediate': immediate})
|
||||
|
||||
def zero_balance(self, immediate=False):
|
||||
"""清零操作 / Zero operation"""
|
||||
return self.send_command('zero', {'immediate': immediate})
|
||||
|
||||
def read_weight(self):
|
||||
"""读取重量 / Read weight"""
|
||||
return self.send_command('read')
|
||||
|
||||
|
||||
|
||||
|
||||
# 使用示例 / Usage Example
|
||||
def main():
|
||||
rclpy.init()
|
||||
controller = BalanceController()
|
||||
|
||||
# 去皮操作 / Tare operation
|
||||
future = controller.tare_balance(immediate=False)
|
||||
rclpy.spin_until_future_complete(controller, future)
|
||||
result = future.result().result
|
||||
print(f"去皮结果 / Tare result: {result.success}, 信息 / Info: {result.return_info}")
|
||||
|
||||
# 读取重量 / Read weight
|
||||
future = controller.read_weight()
|
||||
rclpy.spin_until_future_complete(controller, future)
|
||||
result = future.result().result
|
||||
print(f"读取结果 / Read result: {result.success}, 信息 / Info: {result.return_info}")
|
||||
|
||||
controller.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
## 使用注意事项 / Usage Notes
|
||||
|
||||
1. **设备连接 / Device Connection**: 确保梅特勒天平设备已连接并可访问 / Ensure Mettler balance device is connected and accessible
|
||||
2. **命令格式 / Command Format**: JSON字符串需要正确转义引号 / JSON strings need proper quote escaping
|
||||
3. **参数类型 / Parameter Types**: 布尔值使用小写(true/false)/ Boolean values use lowercase (true/false)
|
||||
4. **权限 / Permissions**: 确保有操作天平的权限 / Ensure you have permission to operate the balance
|
||||
|
||||
## 故障排除 / Troubleshooting
|
||||
|
||||
### 常见问题 / Common Issues
|
||||
|
||||
1. **JSON格式错误 / JSON Format Error**: 确保JSON字符串格式正确且引号已转义 / Ensure JSON string format is correct and quotes are escaped
|
||||
2. **未知命令名称 / Unknown Command Name**: 检查命令名称是否正确 / Check if command name is correct
|
||||
3. **设备连接失败 / Device Connection Failed**: 检查网络连接和设备状态 / Check network connection and device status
|
||||
4. **操作超时 / Operation Timeout**: 检查设备是否响应正常 / Check if device is responding normally
|
||||
|
||||
### 错误处理 / Error Handling
|
||||
|
||||
如果命令执行失败,返回结果中的`success`字段将为`false`,`return_info`字段将包含错误信息。
|
||||
|
||||
If command execution fails, the `success` field in the return result will be `false`, and the `return_info` field will contain error information.
|
||||
|
||||
### 调试技巧 / Debugging Tips
|
||||
|
||||
1. 检查设备节点是否正在运行 / Check if device node is running:
|
||||
```bash
|
||||
ros2 node list | grep BALANCE
|
||||
```
|
||||
|
||||
2. 查看可用的action / View available actions:
|
||||
```bash
|
||||
ros2 action list | grep BALANCE
|
||||
```
|
||||
|
||||
3. 检查action接口 / Check action interface:
|
||||
```bash
|
||||
ros2 action info /devices/BALANCE_STATION/send_cmd
|
||||
```
|
||||
|
||||
4. 查看节点日志 / View node logs:
|
||||
```bash
|
||||
ros2 topic echo /rosout
|
||||
```
|
||||
|
||||
## 总结 / Summary
|
||||
|
||||
梅特勒托利多天平设备现在支持 / Mettler Toledo balance device now supports:
|
||||
|
||||
1. 通过ROS2 SendCmd动作进行统一操作 / Unified operations through ROS2 SendCmd actions
|
||||
2. 完整的天平功能支持(去皮、清零、读重等)/ Complete balance function support (tare, zero, weight reading, etc.)
|
||||
3. 完善的错误处理和日志记录 / Comprehensive error handling and logging
|
||||
4. 简化的操作流程和调试方法 / Simplified operation workflow and debugging methods
|
||||
123
unilabos/devices/balance/mettler_toledo_xpr/README.md
Normal file
123
unilabos/devices/balance/mettler_toledo_xpr/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Mettler Toledo XPR/XSR Balance Driver
|
||||
|
||||
## 概述
|
||||
|
||||
本驱动程序为梅特勒托利多XPR/XSR系列天平提供标准接口,支持去皮、清零和重量读取等操作。
|
||||
|
||||
## ⚠️ 重要说明 - WSDL文件配置
|
||||
|
||||
### 问题说明
|
||||
|
||||
本驱动程序需要使用梅特勒托利多官方提供的WSDL文件来与天平通信。由于该WSDL文件包含专有信息,不能随开源项目一起分发。
|
||||
|
||||
### 配置步骤
|
||||
|
||||
1. **获取WSDL文件**
|
||||
- 联系梅特勒托利多技术支持
|
||||
- 或从您的天平设备Web界面下载
|
||||
- 或从梅特勒托利多官方SDK获取
|
||||
|
||||
2. **安装WSDL文件**
|
||||
```bash
|
||||
# 将获取的WSDL文件复制到驱动目录
|
||||
cp /path/to/your/MT.Laboratory.Balance.XprXsr.V03.wsdl \
|
||||
unilabos/devices/balance/mettler_toledo_xpr/
|
||||
```
|
||||
|
||||
3. **验证安装**
|
||||
- 确保文件名为:`MT.Laboratory.Balance.XprXsr.V03.wsdl`
|
||||
- 确保文件包含Jinja2模板变量:`{{host}}`、`{{port}}`、`{{api_path}}`
|
||||
|
||||
### WSDL文件要求
|
||||
|
||||
- 文件必须是有效的WSDL格式
|
||||
- 必须包含SessionService和WeighingService的定义
|
||||
- 端点地址应使用模板变量以支持动态IP配置:
|
||||
```xml
|
||||
<soap:address location="http://{{host}}:{{port}}/{{api_path}}/SessionService" />
|
||||
<soap:address location="http://{{host}}:{{port}}/{{api_path}}/WeighingService" />
|
||||
```
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
mettler_toledo_xpr/
|
||||
├── MT.Laboratory.Balance.XprXsr.V03.wsdl # 实际WSDL文件(用户提供)
|
||||
├── MT.Laboratory.Balance.XprXsr.V03.wsdl.template # 模板文件(仅供参考)
|
||||
├── mettler_toledo_xpr.py # 驱动程序
|
||||
├── balance.yaml # 设备配置
|
||||
├── SendCmd_Usage_Guide.md # 使用指南
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本配置
|
||||
|
||||
```python
|
||||
from unilabos.devices.balance.mettler_toledo_xpr import MettlerToledoXPR
|
||||
|
||||
# 创建天平实例
|
||||
balance = MettlerToledoXPR(
|
||||
ip="192.168.1.10", # 天平IP地址
|
||||
port=81, # 天平端口
|
||||
password="123456", # 天平密码
|
||||
timeout=10 # 连接超时时间
|
||||
)
|
||||
|
||||
# 执行操作
|
||||
balance.tare() # 去皮
|
||||
balance.zero() # 清零
|
||||
weight = balance.get_weight() # 读取重量
|
||||
```
|
||||
|
||||
### ROS2 SendCmd Action
|
||||
|
||||
详细的ROS2使用方法请参考 [SendCmd_Usage_Guide.md](SendCmd_Usage_Guide.md)
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见错误
|
||||
|
||||
1. **FileNotFoundError: WSDL template not found**
|
||||
- 确保WSDL文件已正确放置在驱动目录中
|
||||
- 检查文件名是否正确
|
||||
|
||||
2. **连接失败**
|
||||
- 检查天平IP地址和端口配置
|
||||
- 确保天平Web服务已启用
|
||||
- 验证网络连接
|
||||
|
||||
3. **认证失败**
|
||||
- 检查天平密码是否正确
|
||||
- 确保天平允许Web服务访问
|
||||
|
||||
### 调试模式
|
||||
|
||||
```python
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# 创建天平实例,将显示详细日志
|
||||
balance = MettlerToledoXPR(ip="192.168.1.10")
|
||||
```
|
||||
|
||||
## 支持的操作
|
||||
|
||||
- **去皮 (Tare)**: 将当前重量设为零点
|
||||
- **清零 (Zero)**: 重新校准零点
|
||||
- **读取重量 (Get Weight)**: 获取当前重量值
|
||||
- **带去皮读取**: 先去皮再读取重量
|
||||
- **连接管理**: 自动连接和断开
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果您在配置WSDL文件时遇到问题,请:
|
||||
|
||||
1. 查看梅特勒托利多官方文档
|
||||
2. 联系梅特勒托利多技术支持
|
||||
3. 在项目GitHub页面提交Issue
|
||||
|
||||
## 许可证
|
||||
|
||||
本驱动程序遵循项目主许可证。WSDL文件的使用需遵循梅特勒托利多的许可条款。
|
||||
5
unilabos/devices/balance/mettler_toledo_xpr/__init__.py
Normal file
5
unilabos/devices/balance/mettler_toledo_xpr/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# Mettler Toledo XPR Balance Driver Module
|
||||
|
||||
from .mettler_toledo_xpr import MettlerToledoXPR
|
||||
|
||||
__all__ = ['MettlerToledoXPR']
|
||||
256
unilabos/devices/balance/mettler_toledo_xpr/balance.yaml
Normal file
256
unilabos/devices/balance/mettler_toledo_xpr/balance.yaml
Normal file
@@ -0,0 +1,256 @@
|
||||
balance.mettler_toledo_xpr:
|
||||
category:
|
||||
- balance
|
||||
class:
|
||||
action_value_mappings:
|
||||
disconnect:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: []
|
||||
result:
|
||||
success: success
|
||||
schema:
|
||||
description: Disconnect from balance
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
success:
|
||||
description: Whether disconnect was successful
|
||||
type: boolean
|
||||
required:
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
get_weight:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: []
|
||||
result:
|
||||
unit: unit
|
||||
weight: weight
|
||||
schema:
|
||||
description: Get current weight reading
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
unit:
|
||||
description: Weight unit (e.g., g, kg)
|
||||
type: string
|
||||
weight:
|
||||
description: Weight value
|
||||
type: number
|
||||
required:
|
||||
- weight
|
||||
- unit
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
read_with_tare:
|
||||
feedback: {}
|
||||
goal:
|
||||
immediate_tare: immediate_tare
|
||||
goal_default:
|
||||
immediate_tare: true
|
||||
handles: []
|
||||
result:
|
||||
unit: unit
|
||||
weight: weight
|
||||
schema:
|
||||
description: Perform tare then read weight (standard read operation)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
immediate_tare:
|
||||
default: true
|
||||
description: Whether to use immediate tare
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
unit:
|
||||
description: Weight unit (e.g., g, kg)
|
||||
type: string
|
||||
weight:
|
||||
description: Weight value after tare
|
||||
type: number
|
||||
required:
|
||||
- weight
|
||||
- unit
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
send_cmd:
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: []
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: SendCmd
|
||||
type: object
|
||||
type: SendCmd
|
||||
tare:
|
||||
feedback: {}
|
||||
goal:
|
||||
immediate: immediate
|
||||
goal_default:
|
||||
immediate: false
|
||||
handles: []
|
||||
result:
|
||||
success: success
|
||||
schema:
|
||||
description: Tare operation for balance
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
immediate:
|
||||
default: false
|
||||
description: Whether to perform immediate tare
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
success:
|
||||
description: Whether tare operation was successful
|
||||
type: boolean
|
||||
required:
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
zero:
|
||||
feedback: {}
|
||||
goal:
|
||||
immediate: immediate
|
||||
goal_default:
|
||||
immediate: false
|
||||
handles: []
|
||||
result:
|
||||
success: success
|
||||
schema:
|
||||
description: Zero operation for balance
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
immediate:
|
||||
default: false
|
||||
description: Whether to perform immediate zero
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
success:
|
||||
description: Whether zero operation was successful
|
||||
type: boolean
|
||||
required:
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.balance.mettler_toledo_xpr.mettler_toledo_xpr:MettlerToledoXPR
|
||||
status_types:
|
||||
error_message: str
|
||||
is_stable: bool
|
||||
status: str
|
||||
unit: str
|
||||
weight: float
|
||||
type: python
|
||||
config_info: []
|
||||
description: Mettler Toledo XPR/XSR Balance Driver
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
description: MettlerToledoXPR __init__ parameters
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
description: Initialization parameters for Mettler Toledo XPR balance
|
||||
properties:
|
||||
ip:
|
||||
default: 192.168.1.10
|
||||
description: Balance IP address
|
||||
type: string
|
||||
password:
|
||||
default: '123456'
|
||||
description: Balance password
|
||||
type: string
|
||||
port:
|
||||
default: 81
|
||||
description: Balance port number
|
||||
type: integer
|
||||
timeout:
|
||||
default: 10
|
||||
description: Connection timeout in seconds
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: __init__ command parameters
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "BALANCE_STATION",
|
||||
"name": "METTLER_TOLEDO_XPR",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "balance.mettler_toledo_xpr",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"ip": "192.168.1.10",
|
||||
"port": 81,
|
||||
"password": "123456",
|
||||
"timeout": 10
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Mettler Toledo XPR/XSR Balance Driver for Uni-Lab OS
|
||||
|
||||
This driver provides standard interface for Mettler Toledo XPR/XSR balance operations
|
||||
including tare, zero, and weight reading functions.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from jinja2 import Template
|
||||
from requests import Session
|
||||
from zeep import Client
|
||||
from zeep.transports import Transport
|
||||
import pprp
|
||||
|
||||
# Import UniversalDriver - handle import error gracefully
|
||||
try:
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
except ImportError:
|
||||
# Fallback for standalone testing
|
||||
class UniversalDriver:
|
||||
"""Fallback UniversalDriver for standalone testing"""
|
||||
def __init__(self):
|
||||
self.success = False
|
||||
|
||||
|
||||
class Outcome(enum.Enum):
|
||||
"""Balance operation outcome enumeration"""
|
||||
SUCCESS = "Success"
|
||||
ERROR = "Error"
|
||||
|
||||
|
||||
class MettlerToledoXPR(UniversalDriver):
|
||||
"""Mettler Toledo XPR/XSR Balance Driver
|
||||
|
||||
Provides standard interface for balance operations including:
|
||||
- Tare (去皮)
|
||||
- Zero (清零)
|
||||
- Weight reading (读数)
|
||||
"""
|
||||
|
||||
def __init__(self, ip: str = "192.168.1.10", port: int = 81,
|
||||
password: str = "123456", timeout: int = 10):
|
||||
"""Initialize the balance driver
|
||||
|
||||
Args:
|
||||
ip: Balance IP address
|
||||
port: Balance port number
|
||||
password: Balance password
|
||||
timeout: Connection timeout in seconds
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.ip = ip
|
||||
self.port = port
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.api_path = "MT/Laboratory/Balance/XprXsr/V03"
|
||||
|
||||
# Status properties
|
||||
self._status = "Disconnected"
|
||||
self._last_weight = 0.0
|
||||
self._last_unit = "g"
|
||||
self._is_stable = False
|
||||
self._error_message = ""
|
||||
|
||||
# ROS2 action result properties
|
||||
self.success = False
|
||||
self.return_info = ""
|
||||
|
||||
# Service objects
|
||||
self.client = None
|
||||
self.session_svc = None
|
||||
self.weighing_svc = None
|
||||
self.session_id = None
|
||||
|
||||
# WSDL template path
|
||||
self.wsdl_template = Path(__file__).parent / "MT.Laboratory.Balance.XprXsr.V03.wsdl"
|
||||
|
||||
# Bindings
|
||||
self.bindings = {
|
||||
"session": "{http://MT/Laboratory/Balance/XprXsr/V03}BasicHttpBinding_ISessionService",
|
||||
"weigh": "{http://MT/Laboratory/Balance/XprXsr/V03}BasicHttpBinding_IWeighingService",
|
||||
}
|
||||
|
||||
# Setup logging
|
||||
self.logger = logging.getLogger(f"MettlerToledoXPR-{ip}")
|
||||
|
||||
# Initialize connection
|
||||
self._connect()
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Current device status"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def weight(self) -> float:
|
||||
"""Last measured weight value"""
|
||||
return self._last_weight
|
||||
|
||||
@property
|
||||
def unit(self) -> str:
|
||||
"""Weight unit (e.g., 'g', 'kg')"""
|
||||
return self._last_unit
|
||||
|
||||
@property
|
||||
def is_stable(self) -> bool:
|
||||
"""Whether the weight reading is stable"""
|
||||
return self._is_stable
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
"""Last error message"""
|
||||
return self._error_message
|
||||
|
||||
def _decrypt_session_id(self, pw: str, enc_sid: str, salt: str) -> str:
|
||||
"""Decrypt session ID using password and salt"""
|
||||
key = hashlib.pbkdf2_hmac("sha1", pw.encode(),
|
||||
base64.b64decode(salt), 1000, dklen=32)
|
||||
plain = pprp.decrypt_sink(
|
||||
pprp.rijndael_decrypt_gen(
|
||||
key, pprp.data_source_gen(base64.b64decode(enc_sid))))
|
||||
return plain.decode()
|
||||
|
||||
def _render_wsdl(self) -> Path:
|
||||
"""Render WSDL template with current connection parameters"""
|
||||
if not self.wsdl_template.exists():
|
||||
raise FileNotFoundError(f"WSDL template not found: {self.wsdl_template}")
|
||||
|
||||
text = Template(self.wsdl_template.read_text(encoding="utf-8")).render(
|
||||
host=self.ip, port=self.port, api_path=self.api_path)
|
||||
|
||||
wsdl_path = self.wsdl_template.parent / f"rendered_{self.ip}_{self.port}.wsdl"
|
||||
wsdl_path.write_text(text, encoding="utf-8")
|
||||
|
||||
return wsdl_path
|
||||
|
||||
def _connect(self):
|
||||
"""Establish connection to the balance"""
|
||||
try:
|
||||
self._status = "Connecting"
|
||||
|
||||
# Render WSDL
|
||||
wsdl_path = self._render_wsdl()
|
||||
self.logger.info(f"WSDL rendered to {wsdl_path}")
|
||||
|
||||
# Create SOAP client
|
||||
transport = Transport(session=Session(), timeout=self.timeout)
|
||||
self.client = Client(wsdl=str(wsdl_path), transport=transport)
|
||||
|
||||
# Create service proxies
|
||||
base_url = f"http://{self.ip}:{self.port}/{self.api_path}"
|
||||
self.session_svc = self.client.create_service(
|
||||
self.bindings["session"], f"{base_url}/SessionService")
|
||||
self.weighing_svc = self.client.create_service(
|
||||
self.bindings["weigh"], f"{base_url}/WeighingService")
|
||||
|
||||
self.logger.info("Zeep service proxies created")
|
||||
|
||||
# Open session
|
||||
self.logger.info("Opening session...")
|
||||
reply = self.session_svc.OpenSession()
|
||||
if reply.Outcome != Outcome.SUCCESS.value:
|
||||
raise RuntimeError(f"OpenSession failed: {getattr(reply, 'ErrorMessage', '')}")
|
||||
|
||||
self.session_id = self._decrypt_session_id(
|
||||
self.password, reply.SessionId, reply.Salt)
|
||||
|
||||
self.logger.info(f"Session established successfully, SessionId={self.session_id}")
|
||||
self._status = "Connected"
|
||||
self._error_message = ""
|
||||
|
||||
except Exception as e:
|
||||
self._status = "Error"
|
||||
self._error_message = str(e)
|
||||
self.logger.error(f"Connection failed: {e}")
|
||||
raise
|
||||
|
||||
def _ensure_connected(self):
|
||||
"""Ensure the device is connected"""
|
||||
if self._status != "Connected" or self.session_id is None:
|
||||
self._connect()
|
||||
|
||||
def tare(self, immediate: bool = False) -> bool:
|
||||
"""Perform tare operation (去皮)
|
||||
|
||||
Args:
|
||||
immediate: Whether to perform immediate tare
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
self._status = "Taring"
|
||||
|
||||
self.logger.info(f"Performing tare (immediate={immediate})...")
|
||||
reply = self.weighing_svc.Tare(self.session_id, immediate)
|
||||
|
||||
if reply.Outcome != Outcome.SUCCESS.value:
|
||||
error_msg = getattr(reply, 'ErrorMessage', 'Unknown error')
|
||||
self.logger.error(f"Tare failed: {error_msg}")
|
||||
self._error_message = f"Tare failed: {error_msg}"
|
||||
self._status = "Error"
|
||||
return False
|
||||
|
||||
self.logger.info("Tare completed successfully")
|
||||
self._status = "Connected"
|
||||
self._error_message = ""
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Tare operation failed: {e}")
|
||||
self._error_message = str(e)
|
||||
self._status = "Error"
|
||||
return False
|
||||
|
||||
def zero(self, immediate: bool = False) -> bool:
|
||||
"""Perform zero operation (清零)
|
||||
|
||||
Args:
|
||||
immediate: Whether to perform immediate zero
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
self._status = "Zeroing"
|
||||
|
||||
self.logger.info(f"Performing zero (immediate={immediate})...")
|
||||
reply = self.weighing_svc.Zero(self.session_id, immediate)
|
||||
|
||||
if reply.Outcome != Outcome.SUCCESS.value:
|
||||
error_msg = getattr(reply, 'ErrorMessage', 'Unknown error')
|
||||
self.logger.error(f"Zero failed: {error_msg}")
|
||||
self._error_message = f"Zero failed: {error_msg}"
|
||||
self._status = "Error"
|
||||
return False
|
||||
|
||||
self.logger.info("Zero completed successfully")
|
||||
self._status = "Connected"
|
||||
self._error_message = ""
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Zero operation failed: {e}")
|
||||
self._error_message = str(e)
|
||||
self._status = "Error"
|
||||
return False
|
||||
|
||||
def get_weight(self) -> float:
|
||||
"""Get current weight reading (读数)
|
||||
|
||||
Returns:
|
||||
float: Weight value
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
self._status = "Reading"
|
||||
|
||||
self.logger.info("Getting weight...")
|
||||
reply = self.weighing_svc.GetWeight(self.session_id)
|
||||
|
||||
if reply.Outcome != Outcome.SUCCESS.value:
|
||||
error_msg = getattr(reply, 'ErrorMessage', 'Unknown error')
|
||||
self.logger.error(f"GetWeight failed: {error_msg}")
|
||||
self._error_message = f"GetWeight failed: {error_msg}"
|
||||
self._status = "Error"
|
||||
return 0.0
|
||||
|
||||
# Handle different response structures
|
||||
if hasattr(reply, 'WeightSample'):
|
||||
# Handle WeightSample structure (most common for XPR)
|
||||
weight_sample = reply.WeightSample
|
||||
if hasattr(weight_sample, 'NetWeight'):
|
||||
weight_val = float(Decimal(weight_sample.NetWeight.Value))
|
||||
weight_unit = weight_sample.NetWeight.Unit
|
||||
elif hasattr(weight_sample, 'GrossWeight'):
|
||||
weight_val = float(Decimal(weight_sample.GrossWeight.Value))
|
||||
weight_unit = weight_sample.GrossWeight.Unit
|
||||
else:
|
||||
weight_val = 0.0
|
||||
weight_unit = 'g'
|
||||
is_stable = getattr(weight_sample, 'Stable', True)
|
||||
elif hasattr(reply, 'Weight'):
|
||||
weight_val = float(Decimal(reply.Weight.Value))
|
||||
weight_unit = reply.Weight.Unit
|
||||
is_stable = getattr(reply.Weight, 'IsStable', True)
|
||||
elif hasattr(reply, 'Value'):
|
||||
weight_val = float(Decimal(reply.Value))
|
||||
weight_unit = getattr(reply, 'Unit', 'g')
|
||||
is_stable = getattr(reply, 'IsStable', True)
|
||||
else:
|
||||
# Try to extract from reply attributes
|
||||
weight_val = float(Decimal(getattr(reply, 'WeightValue', getattr(reply, 'Value', '0'))))
|
||||
weight_unit = getattr(reply, 'WeightUnit', getattr(reply, 'Unit', 'g'))
|
||||
is_stable = getattr(reply, 'IsStable', True)
|
||||
|
||||
# Convert to grams for consistent output (ROS2 requirement)
|
||||
if weight_unit.lower() in ['milligram', 'mg']:
|
||||
weight_val_grams = weight_val / 1000.0
|
||||
elif weight_unit.lower() in ['kilogram', 'kg']:
|
||||
weight_val_grams = weight_val * 1000.0
|
||||
elif weight_unit.lower() in ['gram', 'g']:
|
||||
weight_val_grams = weight_val
|
||||
else:
|
||||
# Default to assuming grams if unit is unknown
|
||||
weight_val_grams = weight_val
|
||||
self.logger.warning(f"Unknown weight unit: {weight_unit}, assuming grams")
|
||||
|
||||
# Update internal state (keep original values for reference)
|
||||
self._last_weight = weight_val
|
||||
self._last_unit = weight_unit
|
||||
self._is_stable = is_stable
|
||||
|
||||
self.logger.info(f"Weight: {weight_val_grams} g (original: {weight_val} {weight_unit})")
|
||||
self._status = "Connected"
|
||||
self._error_message = ""
|
||||
|
||||
return weight_val_grams
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get weight failed: {e}")
|
||||
self._error_message = str(e)
|
||||
self._status = "Error"
|
||||
return 0.0
|
||||
|
||||
def get_weight_with_unit(self) -> Tuple[float, str]:
|
||||
"""Get current weight reading with unit (读数含单位)
|
||||
|
||||
Returns:
|
||||
Tuple[float, str]: Weight value and unit
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
self._status = "Reading"
|
||||
|
||||
self.logger.info("Getting weight with unit...")
|
||||
reply = self.weighing_svc.GetWeight(self.session_id)
|
||||
|
||||
if reply.Outcome != Outcome.SUCCESS.value:
|
||||
error_msg = getattr(reply, 'ErrorMessage', 'Unknown error')
|
||||
self.logger.error(f"GetWeight failed: {error_msg}")
|
||||
self._error_message = f"GetWeight failed: {error_msg}"
|
||||
self._status = "Error"
|
||||
return 0.0, ""
|
||||
|
||||
# Handle different response structures
|
||||
if hasattr(reply, 'WeightSample'):
|
||||
# Handle WeightSample structure (most common for XPR)
|
||||
weight_sample = reply.WeightSample
|
||||
if hasattr(weight_sample, 'NetWeight'):
|
||||
weight_val = float(Decimal(weight_sample.NetWeight.Value))
|
||||
weight_unit = weight_sample.NetWeight.Unit
|
||||
elif hasattr(weight_sample, 'GrossWeight'):
|
||||
weight_val = float(Decimal(weight_sample.GrossWeight.Value))
|
||||
weight_unit = weight_sample.GrossWeight.Unit
|
||||
else:
|
||||
weight_val = 0.0
|
||||
weight_unit = 'g'
|
||||
is_stable = getattr(weight_sample, 'Stable', True)
|
||||
elif hasattr(reply, 'Weight'):
|
||||
weight_val = float(Decimal(reply.Weight.Value))
|
||||
weight_unit = reply.Weight.Unit
|
||||
is_stable = getattr(reply.Weight, 'IsStable', True)
|
||||
elif hasattr(reply, 'Value'):
|
||||
weight_val = float(Decimal(reply.Value))
|
||||
weight_unit = getattr(reply, 'Unit', 'g')
|
||||
is_stable = getattr(reply, 'IsStable', True)
|
||||
else:
|
||||
# Try to extract from reply attributes
|
||||
weight_val = float(Decimal(getattr(reply, 'WeightValue', getattr(reply, 'Value', '0'))))
|
||||
weight_unit = getattr(reply, 'WeightUnit', getattr(reply, 'Unit', 'g'))
|
||||
is_stable = getattr(reply, 'IsStable', True)
|
||||
|
||||
# Update internal state
|
||||
self._last_weight = weight_val
|
||||
self._last_unit = weight_unit
|
||||
self._is_stable = is_stable
|
||||
|
||||
self.logger.info(f"Weight: {weight_val} {weight_unit}")
|
||||
self._status = "Connected"
|
||||
self._error_message = ""
|
||||
|
||||
return weight_val, weight_unit
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get weight with unit failed: {e}")
|
||||
self._error_message = str(e)
|
||||
self._status = "Error"
|
||||
return 0.0, ""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def send_cmd(self, command: str) -> dict:
|
||||
"""ROS2 SendCmd action handler
|
||||
|
||||
Args:
|
||||
command: JSON string containing command and parameters
|
||||
|
||||
Returns:
|
||||
dict: Result containing success status and return_info
|
||||
"""
|
||||
return self.execute_command_from_outer(command)
|
||||
|
||||
def execute_command_from_outer(self, command: str) -> dict:
|
||||
"""Execute command from ROS2 SendCmd action
|
||||
|
||||
Args:
|
||||
command: JSON string containing command and parameters
|
||||
|
||||
Returns:
|
||||
dict: Result containing success status and return_info
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
# Parse JSON command
|
||||
cmd_data = json.loads(command.replace("'", '"').replace("False", "false").replace("True", "true"))
|
||||
|
||||
# Extract command name and parameters
|
||||
cmd_name = cmd_data.get('command', '')
|
||||
params = cmd_data.get('params', {})
|
||||
|
||||
self.logger.info(f"Executing command: {cmd_name} with params: {params}")
|
||||
|
||||
# Execute different commands
|
||||
if cmd_name == 'tare':
|
||||
immediate = params.get('immediate', False)
|
||||
success = self.tare(immediate)
|
||||
result = {
|
||||
'success': success,
|
||||
'return_info': f"Tare operation {'successful' if success else 'failed'}"
|
||||
}
|
||||
# Update instance attributes for ROS2 action system
|
||||
self.success = result['success']
|
||||
self.return_info = result['return_info']
|
||||
return result
|
||||
|
||||
elif cmd_name == 'zero':
|
||||
immediate = params.get('immediate', False)
|
||||
success = self.zero(immediate)
|
||||
result = {
|
||||
'success': success,
|
||||
'return_info': f"Zero operation {'successful' if success else 'failed'}"
|
||||
}
|
||||
# Update instance attributes for ROS2 action system
|
||||
self.success = result['success']
|
||||
self.return_info = result['return_info']
|
||||
return result
|
||||
|
||||
elif cmd_name == 'read' or cmd_name == 'get_weight':
|
||||
try:
|
||||
self.logger.info(f"Executing {cmd_name} command via ROS2...")
|
||||
self.logger.info(f"Current status: {self._status}")
|
||||
|
||||
# Use get_weight to get weight value (returns float in grams)
|
||||
weight_grams = self.get_weight()
|
||||
self.logger.info(f"get_weight() returned: {weight_grams} g")
|
||||
|
||||
# Get the original weight and unit for display
|
||||
original_weight = getattr(self, '_last_weight', weight_grams)
|
||||
original_unit = getattr(self, '_last_unit', 'g')
|
||||
self.logger.info(f"Original reading: {original_weight} {original_unit}")
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'return_info': f"Weight: {original_weight} {original_unit}"
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exception in {cmd_name}: {str(e)}")
|
||||
self.logger.error(f"Exception type: {type(e).__name__}")
|
||||
import traceback
|
||||
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
result = {
|
||||
'success': False,
|
||||
'return_info': f"Failed to read weight: {str(e)}"
|
||||
}
|
||||
# Update instance attributes for ROS2 action system
|
||||
self.success = result['success']
|
||||
self.return_info = result['return_info']
|
||||
return result
|
||||
|
||||
|
||||
|
||||
else:
|
||||
result = {
|
||||
'success': False,
|
||||
'return_info': f"Unknown command: {cmd_name}. Available commands: tare, zero, read"
|
||||
}
|
||||
# Update instance attributes for ROS2 action system
|
||||
self.success = result['success']
|
||||
self.return_info = result['return_info']
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"JSON parsing failed: {e}")
|
||||
result = {
|
||||
'success': False,
|
||||
'return_info': f"JSON parsing failed: {str(e)}"
|
||||
}
|
||||
# Update instance attributes for ROS2 action system
|
||||
self.success = result['success']
|
||||
self.return_info = result['return_info']
|
||||
return result
|
||||
except Exception as e:
|
||||
self.logger.error(f"Command execution failed: {e}")
|
||||
result = {
|
||||
'success': False,
|
||||
'return_info': f"Command execution failed: {str(e)}"
|
||||
}
|
||||
# Update instance attributes for ROS2 action system
|
||||
self.success = result['success']
|
||||
self.return_info = result['return_info']
|
||||
return result
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup when object is destroyed"""
|
||||
self.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the driver
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Mettler Toledo XPR Balance Driver Test")
|
||||
parser.add_argument("--ip", default="192.168.1.10", help="Balance IP address")
|
||||
parser.add_argument("--port", type=int, default=81, help="Balance port")
|
||||
parser.add_argument("--password", default="123456", help="Balance password")
|
||||
parser.add_argument("action", choices=["tare", "zero", "read"],
|
||||
nargs="?", default="read", help="Action to perform")
|
||||
parser.add_argument("--immediate", action="store_true", help="Use immediate mode")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s: %(message)s")
|
||||
|
||||
# Create driver instance
|
||||
balance = MettlerToledoXPR(ip=args.ip, port=args.port, password=args.password)
|
||||
|
||||
try:
|
||||
if args.action == "tare":
|
||||
success = balance.tare(args.immediate)
|
||||
print(f"Tare {'successful' if success else 'failed'}")
|
||||
elif args.action == "zero":
|
||||
success = balance.zero(args.immediate)
|
||||
print(f"Zero {'successful' if success else 'failed'}")
|
||||
else: # read
|
||||
# Perform tare first, then read weight
|
||||
if balance.tare(args.immediate):
|
||||
weight, unit = balance.get_weight_with_unit()
|
||||
print(f"Weight: {weight} {unit}")
|
||||
else:
|
||||
print("Tare operation failed, cannot read weight")
|
||||
|
||||
finally:
|
||||
balance.disconnect()
|
||||
29
unilabos/devices/battery/battery.json
Normal file
29
unilabos/devices/battery/battery.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "NEWARE_BATTERY_TEST_SYSTEM",
|
||||
"name": "Neware Battery Test System",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "neware_battery_test_system",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_id": 1,
|
||||
"devtype": "27",
|
||||
"timeout": 20,
|
||||
"size_x": 500.0,
|
||||
"size_y": 500.0,
|
||||
"size_z": 2000.0
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
1042
unilabos/devices/battery/neware_battery_test_system.py
Normal file
1042
unilabos/devices/battery/neware_battery_test_system.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import traceback
|
||||
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set
|
||||
|
||||
from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast
|
||||
from collections import Counter
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pprint as pp
|
||||
from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness
|
||||
from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod
|
||||
from pylabrobot.liquid_handling.standard import GripDirection
|
||||
@@ -29,6 +29,7 @@ from pylabrobot.resources import (
|
||||
class LiquidHandlerMiddleware(LiquidHandler):
|
||||
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8):
|
||||
self._simulator = simulator
|
||||
self.channel_num = channel_num
|
||||
if simulator:
|
||||
self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num)
|
||||
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
|
||||
@@ -104,8 +105,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
offsets: Optional[List[Coordinate]] = None,
|
||||
**backend_kwargs,
|
||||
):
|
||||
print('222'*200)
|
||||
print(tip_spots)
|
||||
|
||||
if self._simulator:
|
||||
return await self._simulate_handler.pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
|
||||
return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
|
||||
@@ -545,6 +545,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
deck: Deck to use.
|
||||
"""
|
||||
self._simulator = simulator
|
||||
self.group_info = dict()
|
||||
super().__init__(backend, deck, simulator, channel_num)
|
||||
|
||||
@classmethod
|
||||
@@ -556,6 +557,77 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
# REMOVE LIQUID --------------------------------------------------
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
|
||||
if self.channel_num == 8 and len(wells) != 8:
|
||||
raise RuntimeError(f"Expected 8 wells, got {len(wells)}")
|
||||
self.group_info[group_name] = wells
|
||||
self.set_liquid(wells, [group_name] * len(wells), volumes)
|
||||
|
||||
async def transfer_group(self, source_group_name: str, target_group_name: str, unit_volume: float):
|
||||
|
||||
source_wells = self.group_info.get(source_group_name, [])
|
||||
target_wells = self.group_info.get(target_group_name, [])
|
||||
|
||||
rack_info = dict()
|
||||
for child in self.deck.children:
|
||||
if issubclass(child.__class__, TipRack):
|
||||
rack: TipRack = cast(TipRack, child)
|
||||
if "plate" not in rack.name.lower():
|
||||
for tip in rack.get_all_tips():
|
||||
if unit_volume > tip.maximal_volume:
|
||||
break
|
||||
else:
|
||||
rack_info[rack.name] = (rack, tip.maximal_volume - unit_volume)
|
||||
|
||||
if len(rack_info) == 0:
|
||||
raise ValueError(f"No tip rack can support volume {unit_volume}.")
|
||||
|
||||
rack_info = sorted(rack_info.items(), key=lambda x: x[1][1])
|
||||
for child in self.deck.children:
|
||||
if child.name == rack_info[0][0]:
|
||||
target_rack = child
|
||||
target_rack = cast(TipRack, target_rack)
|
||||
available_tips = {}
|
||||
for (idx, tipSpot) in enumerate(target_rack.get_all_items()):
|
||||
if tipSpot.has_tip():
|
||||
available_tips[idx] = tipSpot
|
||||
continue
|
||||
# 一般移动液体有两种方式,一对多和多对多
|
||||
print("channel_num", self.channel_num)
|
||||
if self.channel_num == 8:
|
||||
|
||||
tip_prefix = list(available_tips.values())[0].name.split('_')[0]
|
||||
colnum_list = [int(tip.name.split('_')[-1][1:]) for tip in available_tips.values()]
|
||||
available_cols = [colnum for colnum, count in dict(Counter(colnum_list)).items() if count == 8]
|
||||
available_cols.sort()
|
||||
available_tips_dict = {tip.name: tip for tip in available_tips.values()}
|
||||
tips_to_use = [available_tips_dict[f"{tip_prefix}_{chr(65 + i)}{available_cols[0]}"] for i in range(8)]
|
||||
print("tips_to_use", tips_to_use)
|
||||
await self.pick_up_tips(tips_to_use, use_channels=list(range(0, 8)))
|
||||
print("source_wells", source_wells)
|
||||
await self.aspirate(source_wells, [unit_volume] * 8, use_channels=list(range(0, 8)))
|
||||
print("target_wells", target_wells)
|
||||
await self.dispense(target_wells, [unit_volume] * 8, use_channels=list(range(0, 8)))
|
||||
await self.discard_tips(use_channels=list(range(0, 8)))
|
||||
|
||||
elif self.channel_num == 1:
|
||||
|
||||
for num_well in range(len(target_wells)):
|
||||
tip_to_use = available_tips[list(available_tips.keys())[num_well]]
|
||||
print("tip_to_use", tip_to_use)
|
||||
await self.pick_up_tips([tip_to_use], use_channels=[0])
|
||||
print("source_wells", source_wells)
|
||||
print("target_wells", target_wells)
|
||||
if len(source_wells) == 1:
|
||||
await self.aspirate([source_wells[0]], [unit_volume], use_channels=[0])
|
||||
else:
|
||||
await self.aspirate([source_wells[num_well]], [unit_volume], use_channels=[0])
|
||||
await self.dispense([target_wells[num_well]], [unit_volume], use_channels=[0])
|
||||
await self.discard_tips(use_channels=[0])
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported channel number {self.channel_num}.")
|
||||
|
||||
async def create_protocol(
|
||||
self,
|
||||
protocol_name: str,
|
||||
@@ -569,6 +641,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
"""Create a new protocol with the given metadata."""
|
||||
pass
|
||||
|
||||
|
||||
async def remove_liquid(
|
||||
self,
|
||||
vols: List[float],
|
||||
@@ -987,7 +1060,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
await self.touch_tip(current_targets)
|
||||
await self.discard_tips()
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# except Exception as e:
|
||||
# traceback.print_exc()
|
||||
|
||||
621
unilabos/devices/liquid_handling/prcxi/abstract_protocol.py
Normal file
621
unilabos/devices/liquid_handling/prcxi/abstract_protocol.py
Normal file
@@ -0,0 +1,621 @@
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, List, Dict, Optional, TypedDict, Union, Sequence, Iterator, Literal
|
||||
import pprint as pp
|
||||
from pylabrobot.liquid_handling import (
|
||||
LiquidHandlerBackend,
|
||||
Pickup,
|
||||
SingleChannelAspiration,
|
||||
Drop,
|
||||
SingleChannelDispense,
|
||||
PickupTipRack,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
|
||||
)
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
ResourcePickup,
|
||||
ResourceMove,
|
||||
ResourceDrop,
|
||||
)
|
||||
from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash
|
||||
from traitlets import Int
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
|
||||
|
||||
class MaterialResource:
|
||||
"""统一的液体/反应器资源,支持多孔(wells)场景:
|
||||
- wells: 列表,每个元素代表一个物料孔(unit);
|
||||
- units: 与 wells 对齐的列表,每个元素是 {liquid_id: volume};
|
||||
- 若传入 liquid_id + volume 或 composition,总量将**等分**到各 unit;
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
resource_name: str,
|
||||
slot: int,
|
||||
well: List[int],
|
||||
composition: Optional[Dict[str, float]] = None,
|
||||
liquid_id: Optional[str] = None,
|
||||
volume: Union[float, int] = 0.0,
|
||||
is_supply: Optional[bool] = None,
|
||||
):
|
||||
self.resource_name = resource_name
|
||||
self.slot = int(slot)
|
||||
self.well = list(well or [])
|
||||
self.is_supply = bool(is_supply) if is_supply is not None else (bool(composition) or (liquid_id is not None))
|
||||
|
||||
# 规范化:至少有 1 个 unit
|
||||
n = max(1, len(self.well))
|
||||
self.units: List[Dict[str, float]] = [dict() for _ in range(n)]
|
||||
|
||||
# 初始化内容:等分到各 unit
|
||||
if composition:
|
||||
for k, v in composition.items():
|
||||
share = float(v) / n
|
||||
for u in self.units:
|
||||
if share > 0:
|
||||
u[k] = u.get(k, 0.0) + share
|
||||
elif liquid_id is not None and float(volume) > 0:
|
||||
share = float(volume) / n
|
||||
for u in self.units:
|
||||
u[liquid_id] = u.get(liquid_id, 0.0) + share
|
||||
|
||||
# 位置描述
|
||||
def location(self) -> Dict[str, Any]:
|
||||
return {"slot": self.slot, "well": self.well}
|
||||
|
||||
def unit_count(self) -> int:
|
||||
return len(self.units)
|
||||
|
||||
def unit_volume(self, idx: int) -> float:
|
||||
return float(sum(self.units[idx].values()))
|
||||
|
||||
def total_volume(self) -> float:
|
||||
return float(sum(self.unit_volume(i) for i in range(self.unit_count())))
|
||||
|
||||
def add_to_unit(self, idx: int, liquid_id: str, vol: Union[float, int]):
|
||||
v = float(vol)
|
||||
if v < 0:
|
||||
return
|
||||
u = self.units[idx]
|
||||
if liquid_id not in u:
|
||||
u[liquid_id] = 0.0
|
||||
if v > 0:
|
||||
u[liquid_id] += v
|
||||
|
||||
def remove_from_unit(self, idx: int, total: Union[float, int]) -> Dict[str, float]:
|
||||
take = float(total)
|
||||
if take <= 0: return {}
|
||||
u = self.units[idx]
|
||||
avail = sum(u.values())
|
||||
if avail <= 0: return {}
|
||||
take = min(take, avail)
|
||||
ratio = take / avail
|
||||
removed: Dict[str, float] = {}
|
||||
for k, v in list(u.items()):
|
||||
dv = v * ratio
|
||||
nv = v - dv
|
||||
if nv < 1e-9: nv = 0.0
|
||||
u[k] = nv
|
||||
removed[k] = dv
|
||||
|
||||
self.units[idx] = {k: v for k, v in u.items() if v > 0}
|
||||
return removed
|
||||
|
||||
def transfer_unit_to(self, src_idx: int, other: "MaterialResource", dst_idx: int, total: Union[float, int]):
|
||||
moved = self.remove_from_unit(src_idx, total)
|
||||
for k, v in moved.items():
|
||||
other.add_to_unit(dst_idx, k, v)
|
||||
|
||||
def get_resource(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"resource_name": self.resource_name,
|
||||
"slot": self.slot,
|
||||
"well": self.well,
|
||||
"units": [dict(u) for u in self.units],
|
||||
"total_volume": self.total_volume(),
|
||||
"is_supply": self.is_supply,
|
||||
}
|
||||
|
||||
def transfer_liquid(
|
||||
sources: MaterialResource,
|
||||
targets: MaterialResource,
|
||||
unit_volume: Optional[Union[float, int]] = None,
|
||||
tip: Optional[str] = None, #这里应该是指定种类的
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
vol_each = float(unit_volume)
|
||||
except (TypeError, ValueError):
|
||||
return {"action": "transfer_liquid", "error": "invalid unit_volume"}
|
||||
if vol_each <= 0:
|
||||
return {"action": "transfer_liquid", "error": "non-positive volume"}
|
||||
|
||||
ns, nt = sources.unit_count(), targets.unit_count()
|
||||
# one-to-many: 从单个 source unit(0) 扇出到目标各 unit
|
||||
if ns == 1 and nt >= 1:
|
||||
for j in range(nt):
|
||||
sources.transfer_unit_to(0, targets, j, vol_each)
|
||||
# many-to-many: 数量相同,逐一对应
|
||||
elif ns == nt and ns > 0:
|
||||
for i in range(ns):
|
||||
sources.transfer_unit_to(i, targets, i, vol_each)
|
||||
else:
|
||||
raise ValueError(f"Unsupported mapping: sources={ns} units, targets={nt} units. Only 1->N or N->N are allowed.")
|
||||
|
||||
return {
|
||||
"action": "transfer_liquid",
|
||||
"sources": sources.get_resource(),
|
||||
"targets": targets.get_resource(),
|
||||
"unit_volume": unit_volume,
|
||||
"tip": tip,
|
||||
}
|
||||
|
||||
def plan_transfer(pm: "ProtocolManager", **kwargs) -> Dict[str, Any]:
|
||||
"""Shorthand to add a non-committing transfer to a ProtocolManager.
|
||||
Accepts the same kwargs as ProtocolManager.add_transfer.
|
||||
"""
|
||||
return pm.add_transfer(**kwargs)
|
||||
|
||||
class ProtocolManager:
|
||||
"""Plan/track transfers and back‑solve minimum initial volumes.
|
||||
|
||||
Use add_transfer(...) to register steps (no mutation).
|
||||
Use compute_min_initials(...) to infer the minimal starting volume of each liquid
|
||||
per resource required to execute the plan in order.
|
||||
"""
|
||||
|
||||
# ---------- lifecycle ----------
|
||||
def __init__(self):
|
||||
# queued logical steps (keep live refs to MaterialResource)
|
||||
self.steps: List[Dict[str, Any]] = []
|
||||
|
||||
# simple tip catalog; choose the smallest that meets min_aspirate and capacity*safety
|
||||
self.tip_catalog = [
|
||||
{"name": "TIP_10uL", "capacity": 10.0, "min_aspirate": 0.5},
|
||||
{"name": "TIP_20uL", "capacity": 20.0, "min_aspirate": 1.0},
|
||||
{"name": "TIP_50uL", "capacity": 50.0, "min_aspirate": 2.0},
|
||||
{"name": "TIP_200uL", "capacity": 200.0, "min_aspirate": 5.0},
|
||||
{"name": "TIP_300uL", "capacity": 300.0, "min_aspirate": 10.0},
|
||||
{"name": "TIP_1000uL", "capacity": 1000.0, "min_aspirate": 20.0},
|
||||
]
|
||||
|
||||
# stable labels for unknown liquids per resource (A, B, C, ..., AA, AB, ...)
|
||||
self._unknown_labels: Dict[MaterialResource, str] = {}
|
||||
self._unknown_label_counter: int = 0
|
||||
|
||||
# ---------- public API ----------
|
||||
def recommend_tip(self, unit_volume: float, safety: float = 1.10) -> str:
|
||||
v = float(unit_volume)
|
||||
# prefer: meets min_aspirate and capacity with safety margin; else fallback to capacity-only; else max capacity
|
||||
eligible = [t for t in self.tip_catalog if t["min_aspirate"] <= v and t["capacity"] >= v * safety]
|
||||
if not eligible:
|
||||
eligible = [t for t in self.tip_catalog if t["capacity"] >= v]
|
||||
return min(eligible or self.tip_catalog, key=lambda t: t["capacity"]) ["name"]
|
||||
|
||||
def get_tip_capacity(self, tip_name: str) -> Optional[float]:
|
||||
for t in self.tip_catalog:
|
||||
if t["name"] == tip_name:
|
||||
return t["capacity"]
|
||||
return None
|
||||
|
||||
def add_transfer(
|
||||
self,
|
||||
sources: MaterialResource,
|
||||
targets: MaterialResource,
|
||||
unit_volume: Union[float, int],
|
||||
tip: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
step = {
|
||||
"action": "transfer_liquid",
|
||||
"sources": sources,
|
||||
"targets": targets,
|
||||
"unit_volume": float(unit_volume),
|
||||
"tip": tip or self.recommend_tip(unit_volume),
|
||||
}
|
||||
self.steps.append(step)
|
||||
# return a serializable shadow (no mutation)
|
||||
return {
|
||||
"action": "transfer_liquid",
|
||||
"sources": sources.get_resource(),
|
||||
"targets": targets.get_resource(),
|
||||
"unit_volume": step["unit_volume"],
|
||||
"tip": step["tip"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _liquid_keys_of(resource: MaterialResource) -> List[str]:
|
||||
keys: set[str] = set()
|
||||
for u in resource.units:
|
||||
keys.update(u.keys())
|
||||
return sorted(keys)
|
||||
|
||||
@staticmethod
|
||||
def _fanout_multiplier(ns: int, nt: int) -> Optional[int]:
|
||||
"""Return the number of liquid movements for a mapping shape.
|
||||
1->N: N moves; N->N: N moves; otherwise unsupported (None).
|
||||
"""
|
||||
if ns == 1 and nt >= 1:
|
||||
return nt
|
||||
if ns == nt and ns > 0:
|
||||
return ns
|
||||
return None
|
||||
|
||||
# ---------- planning core ----------
|
||||
def compute_min_initials(
|
||||
self,
|
||||
use_initial: bool = False,
|
||||
external_only: bool = True,
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
"""Simulate the plan (non‑mutating) and return minimal starting volumes per resource/liquid."""
|
||||
ledger: Dict[MaterialResource, Dict[str, float]] = {}
|
||||
min_seen: Dict[MaterialResource, Dict[str, float]] = {}
|
||||
|
||||
def _ensure(res: MaterialResource) -> None:
|
||||
if res in ledger:
|
||||
return
|
||||
declared = self._liquid_keys_of(res)
|
||||
if use_initial:
|
||||
# sum actual held amounts across units
|
||||
totals = {k: 0.0 for k in declared}
|
||||
for u in res.units:
|
||||
for k, v in u.items():
|
||||
totals[k] = totals.get(k, 0.0) + float(v)
|
||||
ledger[res] = totals
|
||||
else:
|
||||
ledger[res] = {k: 0.0 for k in declared}
|
||||
min_seen[res] = {k: ledger[res].get(k, 0.0) for k in ledger[res]}
|
||||
|
||||
def _proportions(src: MaterialResource, src_bal: Dict[str, float]) -> tuple[List[str], Dict[str, float]]:
|
||||
keys = list(src_bal.keys())
|
||||
total_pos = sum(x for x in src_bal.values() if x > 0)
|
||||
|
||||
# if ledger has no keys yet, seed from declared types on the resource
|
||||
if not keys:
|
||||
keys = self._liquid_keys_of(src)
|
||||
for k in keys:
|
||||
src_bal.setdefault(k, 0.0)
|
||||
min_seen[src].setdefault(k, 0.0)
|
||||
|
||||
if total_pos > 0:
|
||||
# proportional to current positive balances
|
||||
props = {k: (src_bal.get(k, 0.0) / total_pos) for k in keys}
|
||||
return keys, props
|
||||
|
||||
# no material currently: evenly from known keys, or assign an unknown label
|
||||
if keys:
|
||||
eq = 1.0 / len(keys)
|
||||
return keys, {k: eq for k in keys}
|
||||
|
||||
unk = self._label_for_unknown(src)
|
||||
keys = [unk]
|
||||
src_bal.setdefault(unk, 0.0)
|
||||
min_seen[src].setdefault(unk, 0.0)
|
||||
return keys, {unk: 1.0}
|
||||
|
||||
for step in self.steps:
|
||||
if step.get("action") != "transfer_liquid":
|
||||
continue
|
||||
|
||||
src: MaterialResource = step["sources"]
|
||||
dst: MaterialResource = step["targets"]
|
||||
vol = float(step["unit_volume"])
|
||||
if vol <= 0:
|
||||
continue
|
||||
|
||||
_ensure(src)
|
||||
_ensure(dst)
|
||||
|
||||
mult = self._fanout_multiplier(src.unit_count(), dst.unit_count())
|
||||
if not mult:
|
||||
continue # unsupported mapping shape for this planner
|
||||
|
||||
eff_vol = vol * mult
|
||||
src_bal = ledger[src]
|
||||
keys, props = _proportions(src, src_bal)
|
||||
|
||||
# subtract from src; track minima; accumulate to dst
|
||||
moved: Dict[str, float] = {}
|
||||
for k in keys:
|
||||
dv = eff_vol * props[k]
|
||||
src_bal[k] = src_bal.get(k, 0.0) - dv
|
||||
moved[k] = dv
|
||||
prev_min = min_seen[src].get(k, 0.0)
|
||||
if src_bal[k] < prev_min:
|
||||
min_seen[src][k] = src_bal[k]
|
||||
|
||||
dst_bal = ledger[dst]
|
||||
for k, dv in moved.items():
|
||||
dst_bal[k] = dst_bal.get(k, 0.0) + dv
|
||||
min_seen[dst].setdefault(k, dst_bal[k])
|
||||
|
||||
# convert minima (negative) to required initials
|
||||
result: Dict[str, Dict[str, float]] = {}
|
||||
for res, mins in min_seen.items():
|
||||
if external_only and not getattr(res, "is_supply", False):
|
||||
continue
|
||||
need = {liq: max(0.0, -mn) for liq, mn in mins.items() if mn < 0.0}
|
||||
if need:
|
||||
result[res.resource_name] = need
|
||||
return result
|
||||
|
||||
def compute_tip_consumption(self) -> Dict[str, Any]:
|
||||
"""Compute how many tips are consumed at each transfer step, and aggregate by tip type.
|
||||
Rule: each liquid movement (source unit -> target unit) consumes one tip.
|
||||
For supported shapes: 1->N uses N tips; N->N uses N tips.
|
||||
"""
|
||||
per_step: List[Dict[str, Any]] = []
|
||||
totals_by_tip: Dict[str, int] = {}
|
||||
|
||||
for i, s in enumerate(self.steps):
|
||||
if s.get("action") != "transfer_liquid":
|
||||
continue
|
||||
ns = s["sources"].unit_count()
|
||||
nt = s["targets"].unit_count()
|
||||
moves = self._fanout_multiplier(ns, nt) or 0
|
||||
tip_name = s.get("tip") or self.recommend_tip(s["unit_volume"]) # per-step tip may vary
|
||||
per_step.append({
|
||||
"idx": i,
|
||||
"tip": tip_name,
|
||||
"tips_used": moves,
|
||||
"moves": moves,
|
||||
})
|
||||
totals_by_tip[tip_name] = totals_by_tip.get(tip_name, 0) + int(moves)
|
||||
|
||||
return {"per_step": per_step, "totals_by_tip": totals_by_tip}
|
||||
|
||||
def compute_min_initials_with_tips(
|
||||
self,
|
||||
use_initial: bool = False,
|
||||
external_only: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
needs = self.compute_min_initials(use_initial=use_initial, external_only=external_only)
|
||||
step_tips: List[Dict[str, Any]] = []
|
||||
totals_by_tip: Dict[str, int] = {}
|
||||
|
||||
for i, s in enumerate(self.steps):
|
||||
if s.get("action") != "transfer_liquid":
|
||||
continue
|
||||
ns = s["sources"].unit_count()
|
||||
nt = s["targets"].unit_count()
|
||||
moves = self._fanout_multiplier(ns, nt) or 0
|
||||
tip_name = s.get("tip") or self.recommend_tip(s["unit_volume"]) # step-specific tip
|
||||
totals_by_tip[self.get_tip_capacity(tip_name)] = totals_by_tip.get(tip_name, 0) + int(moves)
|
||||
|
||||
step_tips.append({
|
||||
"idx": i,
|
||||
"tip": tip_name,
|
||||
"tip_capacity": self.get_tip_capacity(tip_name),
|
||||
"unit_volume": s["unit_volume"],
|
||||
"tips_used": moves,
|
||||
})
|
||||
return {"liquid_setup": needs, "step_tips": step_tips, "totals_by_tip": totals_by_tip}
|
||||
|
||||
# ---------- unknown labels ----------
|
||||
def _index_to_letters(self, idx: int) -> str:
|
||||
"""0->A, 1->B, ... 25->Z, 26->AA, 27->AB ... (Excel-like)"""
|
||||
s: List[str] = []
|
||||
idx = int(idx)
|
||||
while True:
|
||||
idx, r = divmod(idx, 26)
|
||||
s.append(chr(ord('A') + r))
|
||||
if idx == 0:
|
||||
break
|
||||
idx -= 1 # Excel-style carry
|
||||
return "".join(reversed(s))
|
||||
|
||||
def _label_for_unknown(self, res: MaterialResource) -> str:
|
||||
"""Assign a stable unknown-liquid label (A/B/C/...) per resource."""
|
||||
if res not in self._unknown_labels:
|
||||
lab = self._index_to_letters(self._unknown_label_counter)
|
||||
self._unknown_label_counter += 1
|
||||
self._unknown_labels[res] = lab
|
||||
return self._unknown_labels[res]
|
||||
|
||||
|
||||
# 在这一步传输目前有的物料
|
||||
class LabResource:
|
||||
def __init__(self):
|
||||
self.tipracks = []
|
||||
self.plates = []
|
||||
self.trash = []
|
||||
|
||||
def add_tipracks(self, tiprack: List[TipRack]):
|
||||
self.tipracks.extend(tiprack)
|
||||
def add_plates(self, plate: List[Plate]):
|
||||
self.plates.extend(plate)
|
||||
def add_trash(self, trash: List[Plate]):
|
||||
self.trash.extend(trash)
|
||||
def get_resources_info(self) -> Dict[str, Any]:
|
||||
tipracks = [{"name": tr.name, "max_volume": tr.children[0].tracker._tip.maximal_volume, "count": len(tr.children)} for tr in self.tipracks]
|
||||
plates = [{"name": pl.name, "max_volume": pl.children[0].max_volume, "count": len(pl.children)} for pl in self.plates]
|
||||
trash = [{"name": t.name, "max_volume": t.children[0].max_volume, "count": len(t.children)} for t in self.trash]
|
||||
return {
|
||||
"tipracks": tipracks,
|
||||
"plates": plates,
|
||||
"trash": trash
|
||||
}
|
||||
|
||||
from typing import Dict, Any
|
||||
|
||||
import time
|
||||
class DefaultLayout:
|
||||
|
||||
def __init__(self, product_name: str = "PRCXI9300"):
|
||||
self.labresource = {}
|
||||
if product_name not in ["PRCXI9300", "PRCXI9320"]:
|
||||
raise ValueError(f"Unsupported product_name: {product_name}. Only 'PRCXI9300' and 'PRCXI9320' are supported.")
|
||||
|
||||
if product_name == "PRCXI9300":
|
||||
self.rows = 2
|
||||
self.columns = 3
|
||||
self.layout = [1, 2, 3, 4, 5, 6]
|
||||
self.trash_slot = 3
|
||||
self.waste_liquid_slot = 6
|
||||
|
||||
elif product_name == "PRCXI9320":
|
||||
self.rows = 3
|
||||
self.columns = 4
|
||||
self.layout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
self.trash_slot = 16
|
||||
self.waste_liquid_slot = 12
|
||||
self.default_layout = {"MatrixId":f"{time.time()}","MatrixName":f"{time.time()}","MatrixCount":16,"WorkTablets":
|
||||
[{"Number": 1, "Code": "T1", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 2, "Code": "T2", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 3, "Code": "T3", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 4, "Code": "T4", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 5, "Code": "T5", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 6, "Code": "T6", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 7, "Code": "T7", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 8, "Code": "T8", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 9, "Code": "T9", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 10, "Code": "T10", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 11, "Code": "T11", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 12, "Code": "T12", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}}, # 这个设置成废液槽,用储液槽表示
|
||||
{"Number": 13, "Code": "T13", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 14, "Code": "T14", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 15, "Code": "T15", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||
{"Number": 16, "Code": "T16", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}} # 这个设置成垃圾桶,用储液槽表示
|
||||
]
|
||||
}
|
||||
|
||||
def get_layout(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"rows": self.rows,
|
||||
"columns": self.columns,
|
||||
"layout": self.layout,
|
||||
"trash_slot": self.trash_slot,
|
||||
"waste_liquid_slot": self.waste_liquid_slot
|
||||
}
|
||||
|
||||
def get_trash_slot(self) -> int:
|
||||
return self.trash_slot
|
||||
|
||||
def get_waste_liquid_slot(self) -> int:
|
||||
return self.waste_liquid_slot
|
||||
|
||||
def add_lab_resource(self, material_info):
|
||||
self.labresource = material_info
|
||||
|
||||
def recommend_layout(self, needs: Dict[str, int]) -> Dict[str, Any]:
|
||||
"""根据 needs 推荐布局"""
|
||||
for k, v in needs.items():
|
||||
if k not in self.labresource:
|
||||
raise ValueError(f"Material {k} not found in lab resources.")
|
||||
|
||||
# 预留位置12和16不动
|
||||
reserved_positions = {12, 16}
|
||||
available_positions = [i for i in range(1, 17) if i not in reserved_positions]
|
||||
|
||||
# 计算总需求
|
||||
total_needed = sum(needs.values())
|
||||
if total_needed > len(available_positions):
|
||||
raise ValueError(f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置(排除位置12和16)")
|
||||
|
||||
# 依次分配位置
|
||||
current_pos = 0
|
||||
for material_name, count in needs.items():
|
||||
material_uuid = self.labresource[material_name]['uuid']
|
||||
material_enum = self.labresource[material_name]['materialEnum']
|
||||
|
||||
for _ in range(count):
|
||||
if current_pos >= len(available_positions):
|
||||
raise ValueError("位置不足,无法分配更多物料")
|
||||
|
||||
position = available_positions[current_pos]
|
||||
# 找到对应的tablet并更新
|
||||
for tablet in self.default_layout['WorkTablets']:
|
||||
if tablet['Number'] == position:
|
||||
tablet['Material']['uuid'] = material_uuid
|
||||
tablet['Material']['materialEnum'] = material_enum
|
||||
break
|
||||
|
||||
current_pos += 1
|
||||
|
||||
return self.default_layout
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open("prcxi_material.json", "r") as f:
|
||||
material_info = json.load(f)
|
||||
|
||||
layout = DefaultLayout("PRCXI9320")
|
||||
layout.add_lab_resource(material_info)
|
||||
plan = layout.recommend_layout({
|
||||
"10μL加长 Tip头": 2,
|
||||
"300μL Tip头": 2,
|
||||
"96深孔板": 2,
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# # ---- 资源:SUP 供液(X),中间板 R1(4 孔空),目标板 R2(4 孔空)----
|
||||
# # sup = MaterialResource("SUP", slot=5, well=[1], liquid_id="X", volume=10000)
|
||||
# # r1 = MaterialResource("R1", slot=6, well=[1,2,3,4,5,6,7,8])
|
||||
# # r2 = MaterialResource("R2", slot=7, well=[1,2,3,4,5,6,7,8])
|
||||
|
||||
# # pm = ProtocolManager()
|
||||
# # # 步骤1:SUP -> R1,1->N 扇出,每孔 50 uL(总 200 uL)
|
||||
# # pm.add_transfer(sup, r1, unit_volume=10.0)
|
||||
# # # 步骤2:R1 -> R2,N->N 对应,每对 25 uL(总 100 uL;来自 R1 中已存在的混合物 X)
|
||||
# # pm.add_transfer(r1, r2, unit_volume=120.0)
|
||||
|
||||
# # out = pm.compute_min_initials_with_tips()
|
||||
# # # layout_planer = DefaultLayout('PRCXI9320')
|
||||
# # # print(layout_planer.get_layout())
|
||||
# # # print("回推最小需求:", out["liquid_setup"]) # {'SUP': {'X': 200.0}}
|
||||
# # # print("步骤枪头建议:", out["step_tips"]) # [{'idx':0,'tip':'TIP_200uL','unit_volume':50.0}, {'idx':1,'tip':'TIP_50uL','unit_volume':25.0}]
|
||||
|
||||
# # # # 实际执行(可选)
|
||||
# # # transfer_liquid(sup, r1, unit_volume=50.0)
|
||||
# # # transfer_liquid(r1, r2, unit_volume=25.0)
|
||||
# # # print("执行后 SUP:", sup.get_resource()) # 总体积 -200
|
||||
# # # print("执行后 R1:", r1.get_resource()) # 每孔 25 uL(50 进 -25 出)
|
||||
# # # print("执行后 R2:", r2.get_resource()) # 每孔 25 uL
|
||||
|
||||
|
||||
# # from pylabrobot.resources.opentrons.tube_racks import *
|
||||
# # from pylabrobot.resources.opentrons.plates import *
|
||||
# # from pylabrobot.resources.opentrons.tip_racks import *
|
||||
# # from pylabrobot.resources.opentrons.reservoirs import *
|
||||
|
||||
# # plate = [locals()['nest_96_wellplate_2ml_deep'](name="thermoscientificnunc_96_wellplate_2000ul"), locals()['corning_96_wellplate_360ul_flat'](name="corning_96_wellplate_360ul_flat")]
|
||||
# # tiprack = [locals()['opentrons_96_tiprack_300ul'](name="opentrons_96_tiprack_300ul"), locals()['opentrons_96_tiprack_1000ul'](name="opentrons_96_tiprack_1000ul")]
|
||||
# # trash = [locals()['axygen_1_reservoir_90ml'](name="axygen_1_reservoir_90ml")]
|
||||
|
||||
# # from pprint import pprint
|
||||
|
||||
# # lab_resource = LabResource()
|
||||
# # lab_resource.add_tipracks(tiprack)
|
||||
# # lab_resource.add_plates(plate)
|
||||
# # lab_resource.add_trash(trash)
|
||||
|
||||
# # layout_planer = DefaultLayout('PRCXI9300')
|
||||
# # layout_planer.add_lab_resource(lab_resource)
|
||||
# # layout_planer.recommend_layout(out)
|
||||
|
||||
# with open("prcxi_material.json", "r") as f:
|
||||
# material_info = json.load(f)
|
||||
# # print("当前实验物料信息:", material_info)
|
||||
|
||||
# layout = DefaultLayout("PRCXI9320")
|
||||
# layout.add_lab_resource(material_info)
|
||||
# print(layout.default_layout['WorkTablets'])
|
||||
# # plan = layout.recommend_layout({
|
||||
# # "10μL加长 Tip头": 2,
|
||||
# # "300μL Tip头": 2,
|
||||
# # "96深孔板": 2,
|
||||
# # })
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
31
unilabos/devices/liquid_handling/prcxi/prcxi_material.json
Normal file
31
unilabos/devices/liquid_handling/prcxi/prcxi_material.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Tip头适配器 1250uL": {"uuid": "3b6f33ffbf734014bcc20e3c63e124d4", "materialEnum": "0"},
|
||||
"ZHONGXI 适配器 300uL": {"uuid": "7c822592b360451fb59690e49ac6b181", "materialEnum": "0"},
|
||||
"吸头10ul 适配器": {"uuid": "8cc3dce884ac41c09f4570d0bcbfb01c", "materialEnum": "0"},
|
||||
"1250μL Tip头": {"uuid": "7960f49ddfe9448abadda89bd1556936", "materialEnum": "0"},
|
||||
"10μL Tip头": {"uuid": "45f2ed3ad925484d96463d675a0ebf66", "materialEnum": "0"},
|
||||
"10μL加长 Tip头": {"uuid": "068b3815e36b4a72a59bae017011b29f", "materialEnum": "1"},
|
||||
"1000μL Tip头": {"uuid": "80652665f6a54402b2408d50b40398df", "materialEnum": "1"},
|
||||
"300μL Tip头": {"uuid": "076250742950465b9d6ea29a225dfb00", "materialEnum": "1"},
|
||||
"200μL Tip头": {"uuid": "7a73bb9e5c264515a8fcbe88aed0e6f7", "materialEnum": "0"},
|
||||
"0.2ml PCR板": {"uuid": "73bb9b10bc394978b70e027bf45ce2d3", "materialEnum": "0"},
|
||||
"2.2ml 深孔板": {"uuid": "ca877b8b114a4310b429d1de4aae96ee", "materialEnum": "0"},
|
||||
"储液槽": {"uuid": "04211a2dc93547fe9bf6121eac533650", "materialEnum": "0"},
|
||||
"全裙边 PCR适配器": {"uuid": "4a043a07c65a4f9bb97745e1f129b165", "materialEnum": "3"},
|
||||
"储液槽 适配器": {"uuid": "6bdfdd7069df453896b0806df50f2f4d", "materialEnum": "0"},
|
||||
"300ul深孔板适配器": {"uuid": "9a439bed8f3344549643d6b3bc5a5eb4", "materialEnum": "0"},
|
||||
"10ul专用深孔板适配器": {"uuid": "4dc8d6ecfd0449549683b8ef815a861b", "materialEnum": "0"},
|
||||
"爱津": {"uuid": "b01627718d3341aba649baa81c2c083c", "materialEnum": "0"},
|
||||
"适配器": {"uuid": "adfabfffa8f24af5abfbba67b8d0f973", "materialEnum": "0"},
|
||||
"废弃槽": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": "0"},
|
||||
"96深孔板": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": "0"},
|
||||
"384板": {"uuid": "853dcfb6226f476e8b23c250217dc7da", "materialEnum": "0"},
|
||||
"4道储液槽": {"uuid": "01953864f6f140ccaa8ddffd4f3e46f5", "materialEnum": "0"},
|
||||
"48孔深孔板": {"uuid": "026c5d5cf3d94e56b4e16b7fb53a995b", "materialEnum": "2"},
|
||||
"12道储液槽": {"uuid": "0f1639987b154e1fac78f4fb29a1f7c1", "materialEnum": "0"},
|
||||
"HPLC料盘": {"uuid": "548bbc3df0d4447586f2c19d2c0c0c55", "materialEnum": "0"},
|
||||
"ep适配器": {"uuid": "e146697c395e4eabb3d6b74f0dd6aaf7", "materialEnum": "0"},
|
||||
"30mm适配器": {"uuid": "a0757a90d8e44e81a68f306a608694f2", "materialEnum": "0"},
|
||||
"细菌培养皿": {"uuid": "b05b3b2aafd94ec38ea0cd3215ecea8f", "materialEnum": "4"},
|
||||
"96 细胞培养皿":{ "uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": "0"}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import asyncio
|
||||
from threading import Lock, Event
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Union, Optional, overload
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from threading import Lock, Event
|
||||
from typing import Union, Optional
|
||||
|
||||
import serial.tools.list_ports
|
||||
from serial import Serial
|
||||
@@ -18,47 +17,47 @@ class RunzeSyringePumpMode(Enum):
|
||||
|
||||
|
||||
pulse_freq_grades = {
|
||||
6000: "0" ,
|
||||
5600: "1" ,
|
||||
5000: "2" ,
|
||||
4400: "3" ,
|
||||
3800: "4" ,
|
||||
3200: "5" ,
|
||||
2600: "6" ,
|
||||
2200: "7" ,
|
||||
2000: "8" ,
|
||||
1800: "9" ,
|
||||
6000: "0",
|
||||
5600: "1",
|
||||
5000: "2",
|
||||
4400: "3",
|
||||
3800: "4",
|
||||
3200: "5",
|
||||
2600: "6",
|
||||
2200: "7",
|
||||
2000: "8",
|
||||
1800: "9",
|
||||
1600: "10",
|
||||
1400: "11",
|
||||
1200: "12",
|
||||
1000: "13",
|
||||
800 : "14",
|
||||
600 : "15",
|
||||
400 : "16",
|
||||
200 : "17",
|
||||
190 : "18",
|
||||
180 : "19",
|
||||
170 : "20",
|
||||
160 : "21",
|
||||
150 : "22",
|
||||
140 : "23",
|
||||
130 : "24",
|
||||
120 : "25",
|
||||
110 : "26",
|
||||
100 : "27",
|
||||
90 : "28",
|
||||
80 : "29",
|
||||
70 : "30",
|
||||
60 : "31",
|
||||
50 : "32",
|
||||
40 : "33",
|
||||
30 : "34",
|
||||
20 : "35",
|
||||
18 : "36",
|
||||
16 : "37",
|
||||
14 : "38",
|
||||
12 : "39",
|
||||
10 : "40",
|
||||
800: "14",
|
||||
600: "15",
|
||||
400: "16",
|
||||
200: "17",
|
||||
190: "18",
|
||||
180: "19",
|
||||
170: "20",
|
||||
160: "21",
|
||||
150: "22",
|
||||
140: "23",
|
||||
130: "24",
|
||||
120: "25",
|
||||
110: "26",
|
||||
100: "27",
|
||||
90: "28",
|
||||
80: "29",
|
||||
70: "30",
|
||||
60: "31",
|
||||
50: "32",
|
||||
40: "33",
|
||||
30: "34",
|
||||
20: "35",
|
||||
18: "36",
|
||||
16: "37",
|
||||
14: "38",
|
||||
12: "39",
|
||||
10: "40",
|
||||
}
|
||||
|
||||
|
||||
@@ -100,10 +99,7 @@ class RunzeSyringePump:
|
||||
# baudrate=9600,
|
||||
# port=port
|
||||
# )
|
||||
self.hardware_interface = Serial(
|
||||
baudrate=9600,
|
||||
port=port
|
||||
)
|
||||
self.hardware_interface = Serial(baudrate=9600, port=port)
|
||||
|
||||
except (OSError, SerialException) as e:
|
||||
# raise RunzeSyringePumpConnectionError from e
|
||||
@@ -120,7 +116,7 @@ class RunzeSyringePump:
|
||||
self.total_steps_vel = 48000 if self.mode == RunzeSyringePumpMode.AccuratePosVel else 6000
|
||||
|
||||
def send_command(self, full_command: str):
|
||||
full_command_data = bytearray(full_command, 'ascii')
|
||||
full_command_data = bytearray(full_command, "ascii")
|
||||
response = self.hardware_interface.write(full_command_data)
|
||||
time.sleep(0.05)
|
||||
output = self._receive(self.hardware_interface.read_until(b"\n"))
|
||||
@@ -131,7 +127,7 @@ class RunzeSyringePump:
|
||||
if self._closing:
|
||||
raise RunzeSyringePumpConnectionError
|
||||
|
||||
run = 'R' if not "?" in command else ''
|
||||
run = "R" if "?" not in command else ""
|
||||
full_command = f"/{self.address}{command}{run}\r\n"
|
||||
|
||||
output = self.send_command(full_command)[3:-3]
|
||||
@@ -161,7 +157,7 @@ class RunzeSyringePump:
|
||||
time.sleep(0.5) # Wait for 0.5 seconds before polling again
|
||||
|
||||
status = self.get_status()
|
||||
if status == 'Idle':
|
||||
if status == "Idle":
|
||||
break
|
||||
finally:
|
||||
pass
|
||||
@@ -275,11 +271,11 @@ class RunzeSyringePump:
|
||||
return self._valve_position
|
||||
|
||||
def set_valve_position(self, position: Union[int, str, float]):
|
||||
if type(position) == float:
|
||||
if isinstance(position, float):
|
||||
position = round(position / 120)
|
||||
command = f"I{position}" if type(position) == int or ord(position) <= 57 else position.upper()
|
||||
command = f"I{position}" if isinstance(position, int) or ord(position) <= 57 else position.upper()
|
||||
response = self._run(command)
|
||||
self._valve_position = f"{position}" if type(position) == int or ord(position) <= 57 else position.upper()
|
||||
self._valve_position = f"{position}" if isinstance(position, int) or ord(position) <= 57 else position.upper()
|
||||
return response
|
||||
|
||||
def get_valve_position(self) -> str:
|
||||
|
||||
391
unilabos/devices/pump_and_valve/runze_multiple_backbone.py
Normal file
391
unilabos/devices/pump_and_valve/runze_multiple_backbone.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Runze Syringe Pump Controller (SY-03B-T08)
|
||||
|
||||
本模块用于控制润泽注射泵 SY-03B-T08 型号的多泵系统。
|
||||
支持通过串口同时控制多个具有不同地址的泵。
|
||||
泵每次连接前要先进行初始化。
|
||||
|
||||
基础用法:
|
||||
# 创建控制器实例
|
||||
pump_controller = RunzeMultiplePump("COM3") # 或 "/dev/ttyUSB0" (Linux)
|
||||
|
||||
# 初始化特定地址的泵
|
||||
pump_controller.initialize("1")
|
||||
|
||||
# 设置阀门位置
|
||||
pump_controller.set_valve_position("1", 1) # 设置到位置1
|
||||
|
||||
# 移动到绝对位置
|
||||
pump_controller.set_position("1", 10.0) # 移动到10ml位置
|
||||
|
||||
# 推拉柱塞操作
|
||||
pump_controller.pull_plunger("1", 5.0) # 吸取5ml
|
||||
pump_controller.push_plunger("1", 5.0) # 推出5ml
|
||||
|
||||
# 关闭连接
|
||||
pump_controller.close()
|
||||
|
||||
支持的泵地址: 1-8 (字符串格式,如 "1", "2", "3" 等)
|
||||
默认最大容量: 25.0 ml
|
||||
通信协议: RS485, 9600波特率
|
||||
"""
|
||||
|
||||
from threading import Lock, Event
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Union, Optional, List, Dict
|
||||
|
||||
import serial.tools.list_ports
|
||||
from serial import Serial
|
||||
from serial.serialutil import SerialException
|
||||
|
||||
|
||||
class RunzeSyringePumpMode(Enum):
|
||||
Normal = 0
|
||||
AccuratePos = 1
|
||||
AccuratePosVel = 2
|
||||
|
||||
|
||||
pulse_freq_grades = {
|
||||
6000: "0",
|
||||
5600: "1",
|
||||
5000: "2",
|
||||
4400: "3",
|
||||
3800: "4",
|
||||
3200: "5",
|
||||
2600: "6",
|
||||
2200: "7",
|
||||
2000: "8",
|
||||
1800: "9",
|
||||
1600: "10",
|
||||
1400: "11",
|
||||
1200: "12",
|
||||
1000: "13",
|
||||
800: "14",
|
||||
600: "15",
|
||||
400: "16",
|
||||
200: "17",
|
||||
190: "18",
|
||||
180: "19",
|
||||
170: "20",
|
||||
160: "21",
|
||||
150: "22",
|
||||
140: "23",
|
||||
130: "24",
|
||||
120: "25",
|
||||
110: "26",
|
||||
100: "27",
|
||||
90: "28",
|
||||
80: "29",
|
||||
70: "30",
|
||||
60: "31",
|
||||
50: "32",
|
||||
40: "33",
|
||||
30: "34",
|
||||
20: "35",
|
||||
18: "36",
|
||||
16: "37",
|
||||
14: "38",
|
||||
12: "39",
|
||||
10: "40",
|
||||
}
|
||||
|
||||
|
||||
class RunzeSyringePumpConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PumpConfig:
|
||||
address: str
|
||||
max_volume: float = 25.0
|
||||
mode: RunzeSyringePumpMode = RunzeSyringePumpMode.Normal
|
||||
|
||||
|
||||
class RunzeMultiplePump:
|
||||
"""
|
||||
Multi-address Runze Syringe Pump Controller
|
||||
|
||||
Supports controlling multiple pumps on the same serial port with different addresses.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str):
|
||||
"""
|
||||
Initialize multiple pump controller
|
||||
|
||||
Args:
|
||||
port (str): Serial port path
|
||||
"""
|
||||
self.port = port
|
||||
|
||||
# Default pump parameters
|
||||
self.max_volume = 25.0
|
||||
self.total_steps = 6000
|
||||
self.total_steps_vel = 6000
|
||||
|
||||
# Connection management
|
||||
try:
|
||||
self.hardware_interface = Serial(baudrate=9600, port=port, timeout=1.0)
|
||||
print(f"✓ 成功连接到串口: {port}")
|
||||
except (OSError, SerialException) as e:
|
||||
print(f"✗ 串口连接失败: {e}")
|
||||
raise RunzeSyringePumpConnectionError(f"无法连接到串口 {port}: {e}") from e
|
||||
|
||||
# Thread safety
|
||||
self._query_lock = Lock()
|
||||
self._run_lock = Lock()
|
||||
self._closing = False
|
||||
|
||||
# Pump status tracking
|
||||
self._pump_status: Dict[str, str] = {} # address -> status
|
||||
|
||||
def _adjust_total_steps(self, mode: RunzeSyringePumpMode):
|
||||
total_steps = 6000 if mode == RunzeSyringePumpMode.Normal else 48000
|
||||
total_steps_vel = 48000 if mode == RunzeSyringePumpMode.AccuratePosVel else 6000
|
||||
return total_steps, total_steps_vel
|
||||
|
||||
def _receive(self, data: bytes) -> str:
|
||||
"""
|
||||
Keep this method as original. Always use chr to decode, avoid "/0"
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
# **Do not use decode method
|
||||
ascii_string = "".join(chr(byte) for byte in data)
|
||||
return ascii_string
|
||||
|
||||
def send_command(self, full_command: str) -> str:
|
||||
"""Send command to hardware and get response"""
|
||||
full_command_data = bytearray(full_command, "ascii")
|
||||
self.hardware_interface.write(full_command_data)
|
||||
time.sleep(0.05)
|
||||
response = self.hardware_interface.read_until(b"\n") # \n should direct use, not \\n
|
||||
output = self._receive(response)
|
||||
return output
|
||||
|
||||
def _query(self, address: str, command: str) -> str:
|
||||
"""
|
||||
Send query command to specific pump
|
||||
|
||||
Args:
|
||||
address (str): Pump address (e.g., "1", "2", "3")
|
||||
command (str): Command to send
|
||||
|
||||
Returns:
|
||||
str: Response from pump
|
||||
"""
|
||||
with self._query_lock:
|
||||
if self._closing:
|
||||
raise RunzeSyringePumpConnectionError("Connection is closing")
|
||||
|
||||
run = "R" if "?" not in command else ""
|
||||
full_command = f"/{address}{command}{run}\r\n" # \r\n should direct use, not \\r\\n
|
||||
|
||||
output = self.send_command(full_command)[3:-3]
|
||||
return output
|
||||
|
||||
|
||||
def _run(self, address: str, command: str) -> str:
|
||||
"""
|
||||
Run command and wait for completion
|
||||
|
||||
Args:
|
||||
address (str): Pump address
|
||||
command (str): Command to execute
|
||||
|
||||
Returns:
|
||||
str: Command response
|
||||
"""
|
||||
with self._run_lock:
|
||||
try:
|
||||
print(f"[泵 {address}] 执行命令: {command}")
|
||||
response = self._query(address, command)
|
||||
|
||||
# Wait for operation completion
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
status = self.get_status(address)
|
||||
if status == "Idle":
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[泵 {address}] 命令执行错误: {e}")
|
||||
response = ""
|
||||
|
||||
return response
|
||||
|
||||
def _standardize_status(self, status_raw: str) -> str:
|
||||
"""Convert raw status to standard format"""
|
||||
return "Idle" if status_raw == "`" else "Busy"
|
||||
|
||||
# === Core Operations ===
|
||||
|
||||
def initialize(self, address: str) -> str:
|
||||
"""Initialize specific pump"""
|
||||
print(f"[泵 {address}] 正在初始化...")
|
||||
response = self._run(address, "Z")
|
||||
print(f"[泵 {address}] 初始化完成")
|
||||
return response
|
||||
|
||||
# === Status Queries ===
|
||||
|
||||
def get_status(self, address: str) -> str:
|
||||
"""Get pump status"""
|
||||
try:
|
||||
status_raw = self._query(address, "Q")
|
||||
status = self._standardize_status(status_raw)
|
||||
self._pump_status[address] = status
|
||||
return status
|
||||
except Exception:
|
||||
return "Error"
|
||||
|
||||
# === Velocity Control ===
|
||||
|
||||
def set_max_velocity(self, address: str, velocity: float, max_volume: float = None) -> str:
|
||||
"""Set maximum velocity for pump"""
|
||||
if max_volume is None:
|
||||
max_volume = self.max_volume
|
||||
|
||||
pulse_freq = int(velocity / max_volume * self.total_steps_vel)
|
||||
pulse_freq = min(6000, pulse_freq)
|
||||
return self._run(address, f"V{pulse_freq}")
|
||||
|
||||
def get_max_velocity(self, address: str, max_volume: float = None) -> float:
|
||||
"""Get maximum velocity of pump"""
|
||||
if max_volume is None:
|
||||
max_volume = self.max_volume
|
||||
|
||||
response = self._query(address, "?2")
|
||||
status_raw, pulse_freq = response[0], int(response[1:])
|
||||
velocity = pulse_freq / self.total_steps_vel * max_volume
|
||||
return velocity
|
||||
|
||||
def set_velocity_grade(self, address: str, velocity: Union[int, str]) -> str:
|
||||
"""Set velocity grade"""
|
||||
return self._run(address, f"S{velocity}")
|
||||
|
||||
# === Position Control ===
|
||||
|
||||
def get_position(self, address: str, max_volume: float = None) -> float:
|
||||
"""Get current plunger position in ml"""
|
||||
if max_volume is None:
|
||||
max_volume = self.max_volume
|
||||
|
||||
response = self._query(address, "?0")
|
||||
status_raw, pos_step = response[0], int(response[1:])
|
||||
position = pos_step / self.total_steps * max_volume
|
||||
return position
|
||||
|
||||
def set_position(self, address: str, position: float, max_velocity: float = None, max_volume: float = None) -> str:
|
||||
"""
|
||||
Move to absolute volume position
|
||||
|
||||
Args:
|
||||
address (str): Pump address
|
||||
position (float): Target position in ml
|
||||
max_velocity (float): Maximum velocity in ml/s
|
||||
max_volume (float): Maximum syringe volume in ml
|
||||
"""
|
||||
if max_volume is None:
|
||||
max_volume = self.max_volume
|
||||
|
||||
velocity_cmd = ""
|
||||
if max_velocity is not None:
|
||||
pulse_freq = int(max_velocity / max_volume * self.total_steps_vel)
|
||||
pulse_freq = min(6000, pulse_freq)
|
||||
velocity_cmd = f"V{pulse_freq}"
|
||||
|
||||
pos_step = int(position / max_volume * self.total_steps)
|
||||
return self._run(address, f"{velocity_cmd}A{pos_step}")
|
||||
|
||||
def pull_plunger(self, address: str, volume: float, max_volume: float = None) -> str:
|
||||
"""Pull plunger by specified volume"""
|
||||
if max_volume is None:
|
||||
max_volume = self.max_volume
|
||||
|
||||
pos_step = int(volume / max_volume * self.total_steps)
|
||||
return self._run(address, f"P{pos_step}")
|
||||
|
||||
def push_plunger(self, address: str, volume: float, max_volume: float = None) -> str:
|
||||
"""Push plunger by specified volume"""
|
||||
if max_volume is None:
|
||||
max_volume = self.max_volume
|
||||
|
||||
pos_step = int(volume / max_volume * self.total_steps)
|
||||
return self._run(address, f"D{pos_step}")
|
||||
|
||||
# === Valve Control ===
|
||||
|
||||
def set_valve_position(self, address: str, position: Union[int, str, float]) -> str:
|
||||
"""Set valve position"""
|
||||
if isinstance(position, float):
|
||||
position = round(position / 120)
|
||||
command = f"I{position}" if isinstance(position, int) or ord(str(position)) <= 57 else str(position).upper()
|
||||
return self._run(address, command)
|
||||
|
||||
def get_valve_position(self, address: str) -> str:
|
||||
"""Get current valve position"""
|
||||
response = self._query(address, "?6")
|
||||
status_raw, pos_valve = response[0], response[1].upper()
|
||||
return pos_valve
|
||||
|
||||
# === Utility Functions ===
|
||||
|
||||
def stop_operation(self, address: str) -> str:
|
||||
"""Stop current operation"""
|
||||
return self._run(address, "T")
|
||||
|
||||
def close(self):
|
||||
"""Close connection"""
|
||||
if self._closing:
|
||||
raise RunzeSyringePumpConnectionError("Already closing")
|
||||
|
||||
self._closing = True
|
||||
self.hardware_interface.close()
|
||||
print("✓ 串口连接已关闭")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
示例:初始化3个泵(地址1、2、3),然后断开连接
|
||||
"""
|
||||
try:
|
||||
# 请根据实际串口修改端口号
|
||||
# Windows: "COM3", "COM4", 等
|
||||
# Linux/Mac: "/dev/ttyUSB0", "/dev/ttyACM0", 等
|
||||
port = "/dev/cn." # 修改为实际使用的串口
|
||||
|
||||
print("正在创建泵控制器...")
|
||||
pump_controller = RunzeMultiplePump(port)
|
||||
|
||||
# 初始化3个泵 (地址: 1, 2, 3)
|
||||
pump_addresses = ["1", "2", "3"]
|
||||
|
||||
for address in pump_addresses:
|
||||
try:
|
||||
print(f"\n正在初始化泵 {address}...")
|
||||
pump_controller.initialize(address)
|
||||
|
||||
# 检查泵状态
|
||||
status = pump_controller.get_status(address)
|
||||
print(f"泵 {address} 状态: {status}")
|
||||
except Exception as e:
|
||||
print(f"泵 {address} 初始化失败: {e}")
|
||||
|
||||
print("\n所有泵初始化完成!")
|
||||
|
||||
# 断开连接
|
||||
print("\n正在断开连接...")
|
||||
pump_controller.close()
|
||||
print("程序结束")
|
||||
|
||||
except RunzeSyringePumpConnectionError as e:
|
||||
print(f"连接错误: {e}")
|
||||
print("请检查:")
|
||||
print("1. 串口是否正确")
|
||||
print("2. 设备是否已连接")
|
||||
print("3. 串口是否被其他程序占用")
|
||||
|
||||
except Exception as e:
|
||||
print(f"未知错误: {e}")
|
||||
@@ -288,7 +288,7 @@ class VirtualSolidDispenser:
|
||||
"return_info": f"dispensed_{actual_amount:.6f}g",
|
||||
"dispensed_amount": actual_amount,
|
||||
"reagent": reagent,
|
||||
"vessel": vessel
|
||||
"vessel": {"id": vessel},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
184
unilabos/devices/workstation/README.md
Normal file
184
unilabos/devices/workstation/README.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 工作站抽象基类物料系统架构说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||
2. **Graphio转换函数集成**:使用graphio中的`resource_ulab_to_plr`等转换函数实现UniLab与PLR格式的无缝转换
|
||||
3. **关注点分离**:基类专注核心物料系统,HTTP服务等功能在子类中实现
|
||||
4. **外部系统集成模式**:通过ResourceSynchronizer抽象类提供外部物料系统对接模式
|
||||
|
||||
## 架构组成
|
||||
|
||||
### 1. WorkstationBase(基类)
|
||||
**文件**: `workstation_base.py`
|
||||
|
||||
**核心功能**:
|
||||
- 使用deck_config和children通过`resource_ulab_to_plr`转换为PLR物料self.deck
|
||||
- 基础的资源查找和管理功能
|
||||
- 抽象的工作流执行接口
|
||||
- ResourceSynchronizer集成点
|
||||
|
||||
**关键代码**:
|
||||
```python
|
||||
def _initialize_material_system(self, deck_config: Dict[str, Any], children_config: Dict[str, Any] = None):
|
||||
"""初始化基于PLR的物料系统"""
|
||||
# 合并deck_config和children
|
||||
complete_config = self._merge_deck_and_children_config(deck_config, children_config)
|
||||
|
||||
# 使用graphio转换函数转换为PLR资源
|
||||
self.deck = resource_ulab_to_plr(complete_config)
|
||||
```
|
||||
|
||||
### 2. ResourceSynchronizer(外部系统集成抽象类)
|
||||
**定义在**: `workstation_base.py`
|
||||
|
||||
**设计目的**:
|
||||
- 提供外部物料系统(如Bioyong、LIMS等)集成的标准接口
|
||||
- 双向同步:从外部系统同步到本地deck,以及将本地变更同步到外部系统
|
||||
- 处理外部系统的变更通知
|
||||
|
||||
**核心方法**:
|
||||
```python
|
||||
async def sync_from_external(self) -> bool:
|
||||
"""从外部系统同步物料到本地deck"""
|
||||
|
||||
async def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将本地物料同步到外部系统"""
|
||||
|
||||
async def handle_external_change(self, change_info: Dict[str, Any]) -> bool:
|
||||
"""处理外部系统的变更通知"""
|
||||
```
|
||||
|
||||
### 3. WorkstationWithHTTP(子类示例)
|
||||
**文件**: `workstation_with_http_example.py`
|
||||
|
||||
**扩展功能**:
|
||||
- HTTP报送接收服务集成
|
||||
- 具体工作流实现(液体转移、板洗等)
|
||||
- Bioyong物料系统同步器示例
|
||||
- 外部报送处理方法
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 核心依赖
|
||||
- **PyLabRobot**: 物料资源管理核心(Deck, Resource, Coordinate)
|
||||
- **GraphIO转换函数**: UniLab ↔ PLR格式转换
|
||||
- `resource_ulab_to_plr`: UniLab格式转PLR格式
|
||||
- `resource_plr_to_ulab`: PLR格式转UniLab格式
|
||||
- `convert_resources_to_type`: 通用资源类型转换
|
||||
- **ROS2**: 基础设备节点通信(BaseROS2DeviceNode)
|
||||
|
||||
### 可选依赖
|
||||
- **HTTP服务**: 仅在需要外部报送接收的子类中使用
|
||||
- **外部系统API**: 根据具体集成需求添加
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 简单工作站(仅PLR物料系统)
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
# Deck配置
|
||||
deck_config = {
|
||||
"size_x": 1200.0,
|
||||
"size_y": 800.0,
|
||||
"size_z": 100.0
|
||||
}
|
||||
|
||||
# 子资源配置
|
||||
children_config = {
|
||||
"source_plate": {
|
||||
"name": "source_plate",
|
||||
"type": "plate",
|
||||
"position": {"x": 100, "y": 100, "z": 10},
|
||||
"config": {"size_x": 127.8, "size_y": 85.5, "size_z": 14.4}
|
||||
}
|
||||
}
|
||||
|
||||
# 创建工作站
|
||||
workstation = WorkstationBase(
|
||||
device_id="simple_workstation",
|
||||
deck_config=deck_config,
|
||||
children_config=children_config
|
||||
)
|
||||
|
||||
# 查找资源
|
||||
plate = workstation.find_resource_by_name("source_plate")
|
||||
```
|
||||
|
||||
### 2. 带HTTP服务的工作站
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_with_http_example import WorkstationWithHTTP
|
||||
|
||||
# HTTP服务配置
|
||||
http_service_config = {
|
||||
"enabled": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8081
|
||||
}
|
||||
|
||||
# 创建带HTTP服务的工作站
|
||||
workstation = WorkstationWithHTTP(
|
||||
device_id="http_workstation",
|
||||
deck_config=deck_config,
|
||||
children_config=children_config,
|
||||
http_service_config=http_service_config
|
||||
)
|
||||
|
||||
# 执行工作流
|
||||
success = workstation.execute_workflow("liquid_transfer", {
|
||||
"volume": 100.0,
|
||||
"source_wells": ["A1", "A2"],
|
||||
"dest_wells": ["B1", "B2"]
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 外部系统集成
|
||||
|
||||
```python
|
||||
class BioyongResourceSynchronizer(ResourceSynchronizer):
|
||||
"""Bioyong系统同步器"""
|
||||
|
||||
async def sync_from_external(self) -> bool:
|
||||
# 从Bioyong API获取物料
|
||||
external_materials = await self._fetch_bioyong_materials()
|
||||
|
||||
# 转换并添加到本地deck
|
||||
for material in external_materials:
|
||||
await self._add_material_to_deck(material)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## 设计优势
|
||||
|
||||
### 1. **简洁性**
|
||||
- 基类只专注核心物料管理,没有冗余功能
|
||||
- 使用成熟的PyLabRobot作为物料管理基础
|
||||
|
||||
### 2. **可扩展性**
|
||||
- 通过子类添加HTTP服务、特定工作流等功能
|
||||
- ResourceSynchronizer模式支持任意外部系统集成
|
||||
|
||||
### 3. **标准化**
|
||||
- PLR Deck提供标准的资源管理接口
|
||||
- Graphio转换函数确保格式一致性
|
||||
|
||||
### 4. **灵活性**
|
||||
- 可选择性使用HTTP服务和外部系统集成
|
||||
- 支持不同类型的工作站需求
|
||||
|
||||
## 发展历程
|
||||
|
||||
1. **初始设计**: 复杂的统一物料系统,包含HTTP服务和多种功能
|
||||
2. **PyLabRobot集成**: 引入PLR Deck管理,但保留了ResourceTracker复杂性
|
||||
3. **Graphio转换**: 使用graphio转换函数简化初始化
|
||||
4. **最终简化**: 专注核心PLR物料系统,HTTP服务移至子类
|
||||
|
||||
这个架构体现了"用PyLabRobot Deck来管理物料会更好;但是要做好和外部物料系统的对接"的设计理念,以及"现在我只需要在工作站创建的时候,整体使用deck_config和children,一起通过resource_ulab_to_plr转换为plr物料self.deck即可"的简化要求。
|
||||
0
unilabos/devices/workstation/__init__.py
Normal file
0
unilabos/devices/workstation/__init__.py
Normal file
BIN
unilabos/devices/workstation/bioyond_cell/2025092702.xlsx
Normal file
BIN
unilabos/devices/workstation/bioyond_cell/2025092702.xlsx
Normal file
Binary file not shown.
BIN
unilabos/devices/workstation/bioyond_cell/2025101301.xlsx
Normal file
BIN
unilabos/devices/workstation/bioyond_cell/2025101301.xlsx
Normal file
Binary file not shown.
49
unilabos/devices/workstation/bioyond_cell/benyao_test.py
Normal file
49
unilabos/devices/workstation/bioyond_cell/benyao_test.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
def test_benyao_api():
|
||||
# 配置信息
|
||||
ip_addr = "192.168.1.200"
|
||||
port = 44386
|
||||
#url = f"http://{ip_addr}:{port}/api/lims/scheduler/scheduler-status"
|
||||
#url = f"http://{ip_addr}:{port}/api/lims/order/order-list-status"
|
||||
url = f"http://{ip_addr}:{port}/api/lims/storage/stock-material"
|
||||
apiKey = "8A819E5C" # 请替换为实际apiKey
|
||||
|
||||
# 构造请求体
|
||||
request_data = {
|
||||
"apiKey": apiKey,
|
||||
"requestTime": datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), # 示例:2025-08-15T10:00:00.000Z
|
||||
"data": {
|
||||
"typeMode": 1,
|
||||
"includeDetail": True
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#request_data = {
|
||||
# "apiKey": apiKey,
|
||||
# "requestTime": datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), # 示例:2025-08-15T10:00:00.000Z
|
||||
# "data":
|
||||
#}
|
||||
|
||||
|
||||
print(request_data)
|
||||
# 发送POST请求
|
||||
try:
|
||||
response = requests.post(url, json=request_data, timeout=10)
|
||||
response.raise_for_status() # 检查HTTP状态码
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
print("响应状态码:", response.status_code)
|
||||
print("响应内容:")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print("请求失败:", e)
|
||||
except json.JSONDecodeError as e:
|
||||
print("JSON解析失败:", e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_benyao_api()
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
Bioyond物料管理实现
|
||||
Bioyond Material Management Implementation
|
||||
|
||||
基于Bioyond系统的物料管理,支持从Bioyond系统同步物料到UniLab工作站
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pylabrobot.resources import (
|
||||
Resource as PLRResource,
|
||||
Container,
|
||||
Deck,
|
||||
Coordinate as PLRCoordinate,
|
||||
)
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.resources.graphio import (
|
||||
resource_plr_to_ulab,
|
||||
resource_ulab_to_plr,
|
||||
resource_bioyond_to_ulab,
|
||||
resource_bioyond_container_to_ulab,
|
||||
resource_ulab_to_bioyond
|
||||
)
|
||||
from .workstation_material_management import MaterialManagementBase
|
||||
|
||||
|
||||
class BioyondMaterialManagement(MaterialManagementBase):
|
||||
"""Bioyond物料管理类
|
||||
|
||||
实现从Bioyond系统同步物料到UniLab工作站的功能:
|
||||
1. 从Bioyond系统获取物料数据
|
||||
2. 转换为UniLab格式
|
||||
3. 同步到PyLabRobot Deck
|
||||
4. 支持双向同步
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
deck_config: Dict[str, Any],
|
||||
resource_tracker: DeviceNodeResourceTracker,
|
||||
children_config: Dict[str, Dict[str, Any]] = None,
|
||||
bioyond_config: Dict[str, Any] = None
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {}
|
||||
self.bioyond_api_client = None
|
||||
self.sync_interval = self.bioyond_config.get("sync_interval", 30) # 同步间隔(秒)
|
||||
|
||||
# 初始化父类
|
||||
super().__init__(device_id, deck_config, resource_tracker, children_config)
|
||||
|
||||
# 初始化Bioyond API客户端
|
||||
self._initialize_bioyond_client()
|
||||
|
||||
# 启动同步任务
|
||||
self._start_sync_task()
|
||||
|
||||
def _initialize_bioyond_client(self):
|
||||
"""初始化Bioyond API客户端"""
|
||||
try:
|
||||
# 这里应该根据实际的Bioyond API实现
|
||||
# 暂时使用模拟客户端
|
||||
self.bioyond_api_client = BioyondAPIClient(self.bioyond_config)
|
||||
logger.info(f"Bioyond API客户端初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API客户端初始化失败: {e}")
|
||||
self.bioyond_api_client = None
|
||||
|
||||
def _start_sync_task(self):
|
||||
"""启动同步任务"""
|
||||
if self.bioyond_api_client:
|
||||
# 创建异步同步任务
|
||||
asyncio.create_task(self._periodic_sync())
|
||||
logger.info(f"Bioyond同步任务已启动,间隔: {self.sync_interval}秒")
|
||||
|
||||
async def _periodic_sync(self):
|
||||
"""定期同步任务"""
|
||||
while True:
|
||||
try:
|
||||
await self.sync_from_bioyond()
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond同步任务出错: {e}")
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
|
||||
async def sync_from_bioyond(self) -> bool:
|
||||
"""从Bioyond系统同步物料"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 从Bioyond获取物料数据
|
||||
bioyond_data = await self.bioyond_api_client.get_materials()
|
||||
if not bioyond_data:
|
||||
logger.warning("从Bioyond获取物料数据为空")
|
||||
return False
|
||||
|
||||
# 2. 转换为UniLab格式
|
||||
if isinstance(bioyond_data, dict) and "data" in bioyond_data:
|
||||
# 容器格式数据
|
||||
unilab_resources = resource_bioyond_container_to_ulab(bioyond_data)
|
||||
else:
|
||||
# 物料列表格式数据
|
||||
unilab_resources = resource_bioyond_to_ulab(bioyond_data)
|
||||
|
||||
# 3. 转换为PLR格式并分配到Deck
|
||||
await self._assign_resources_to_deck(unilab_resources)
|
||||
|
||||
logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从Bioyond同步物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def sync_to_bioyond(self, plr_resource: PLRResource) -> bool:
|
||||
"""将本地物料变更同步到Bioyond系统"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 转换为UniLab格式
|
||||
unilab_resource = resource_plr_to_ulab(plr_resource)
|
||||
|
||||
# 2. 转换为Bioyond格式
|
||||
bioyond_materials = resource_ulab_to_bioyond([unilab_resource])
|
||||
|
||||
# 3. 发送到Bioyond系统
|
||||
success = await self.bioyond_api_client.update_materials(bioyond_materials)
|
||||
|
||||
if success:
|
||||
logger.info(f"成功同步物料 {plr_resource.name} 到Bioyond")
|
||||
else:
|
||||
logger.warning(f"同步物料 {plr_resource.name} 到Bioyond失败")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"同步物料到Bioyond失败: {e}")
|
||||
return False
|
||||
|
||||
async def _assign_resources_to_deck(self, unilab_resources: List[Dict[str, Any]]):
|
||||
"""将UniLab资源分配到Deck"""
|
||||
try:
|
||||
# 转换为PLR格式
|
||||
from unilabos.resources.graphio import list_to_nested_dict
|
||||
nested_resources = list_to_nested_dict(unilab_resources)
|
||||
plr_resources = resource_ulab_to_plr(nested_resources)
|
||||
|
||||
# 分配资源到Deck
|
||||
if hasattr(plr_resources, 'children'):
|
||||
resources_to_assign = plr_resources.children
|
||||
elif isinstance(plr_resources, list):
|
||||
resources_to_assign = plr_resources
|
||||
else:
|
||||
resources_to_assign = [plr_resources]
|
||||
|
||||
for resource in resources_to_assign:
|
||||
try:
|
||||
# 获取资源位置
|
||||
if hasattr(resource, 'location') and resource.location:
|
||||
location = PLRCoordinate(resource.location.x, resource.location.y, resource.location.z)
|
||||
else:
|
||||
location = PLRCoordinate(0, 0, 0)
|
||||
|
||||
# 分配资源到Deck
|
||||
self.plr_deck.assign_child_resource(resource, location)
|
||||
|
||||
# 注册到resource tracker
|
||||
self.resource_tracker.add_resource(resource)
|
||||
|
||||
# 保存资源引用
|
||||
self.plr_resources[resource.name] = resource
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源 {resource.name} 到Deck失败: {e}")
|
||||
|
||||
logger.info(f"成功分配了 {len(resources_to_assign)} 个资源到Deck")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源到Deck失败: {e}")
|
||||
|
||||
def _create_resource_by_type(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""根据类型创建Bioyond相关资源"""
|
||||
try:
|
||||
# 这里可以根据需要实现特定的Bioyond资源类型
|
||||
# 目前使用通用的容器类型
|
||||
if resource_type in ["container", "plate", "well"]:
|
||||
return self._create_generic_container(resource_id, resource_type, config, data, location)
|
||||
else:
|
||||
logger.warning(f"未知的Bioyond资源类型: {resource_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建Bioyond资源失败 {resource_id} ({resource_type}): {e}")
|
||||
return None
|
||||
|
||||
def _create_generic_container(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""创建通用容器资源"""
|
||||
try:
|
||||
from pylabrobot.resources import Plate, Well
|
||||
|
||||
if resource_type == "plate":
|
||||
return Plate(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 127.76),
|
||||
size_y=config.get("size_y", 85.48),
|
||||
size_z=config.get("size_z", 14.35),
|
||||
location=location,
|
||||
category="plate"
|
||||
)
|
||||
elif resource_type == "well":
|
||||
return Well(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 9.0),
|
||||
size_y=config.get("size_y", 9.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="well"
|
||||
)
|
||||
else:
|
||||
return Container(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 50.0),
|
||||
size_y=config.get("size_y", 50.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="container"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建通用容器失败 {resource_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_bioyond_materials(self) -> List[Dict[str, Any]]:
|
||||
"""获取当前Bioyond物料列表"""
|
||||
try:
|
||||
# 将当前PLR资源转换为Bioyond格式
|
||||
bioyond_materials = []
|
||||
for resource in self.plr_resources.values():
|
||||
unilab_resource = resource_plr_to_ulab(resource)
|
||||
bioyond_materials.extend(resource_ulab_to_bioyond([unilab_resource]))
|
||||
return bioyond_materials
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料列表失败: {e}")
|
||||
return []
|
||||
|
||||
def update_material_from_bioyond(self, material_id: str, bioyond_data: Dict[str, Any]) -> bool:
|
||||
"""从Bioyond数据更新指定物料"""
|
||||
try:
|
||||
# 查找现有物料
|
||||
material = self.find_material_by_id(material_id)
|
||||
if not material:
|
||||
logger.warning(f"未找到物料: {material_id}")
|
||||
return False
|
||||
|
||||
# 转换Bioyond数据为UniLab格式
|
||||
unilab_resources = resource_bioyond_to_ulab([bioyond_data])
|
||||
if not unilab_resources:
|
||||
logger.warning(f"转换Bioyond数据失败: {material_id}")
|
||||
return False
|
||||
|
||||
# 更新物料属性
|
||||
unilab_resource = unilab_resources[0]
|
||||
material.name = unilab_resource.get("name", material.name)
|
||||
|
||||
# 更新位置
|
||||
position = unilab_resource.get("position", {})
|
||||
if position:
|
||||
material.location = PLRCoordinate(
|
||||
position.get("x", 0),
|
||||
position.get("y", 0),
|
||||
position.get("z", 0)
|
||||
)
|
||||
|
||||
logger.info(f"成功更新物料: {material_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新物料失败 {material_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class BioyondAPIClient:
|
||||
"""Bioyond API客户端(模拟实现)
|
||||
|
||||
实际使用时需要根据Bioyond系统的API接口实现
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.base_url = config.get("base_url", "http://localhost:8080")
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.timeout = config.get("timeout", 30)
|
||||
|
||||
async def get_materials(self) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
|
||||
"""从Bioyond系统获取物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
# 暂时返回模拟数据
|
||||
logger.info("从Bioyond API获取物料数据")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据(实际应该从API获取)
|
||||
return {
|
||||
"data": [],
|
||||
"code": 1,
|
||||
"message": "success",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API调用失败: {e}")
|
||||
return None
|
||||
|
||||
async def update_materials(self, materials: List[Dict[str, Any]]) -> bool:
|
||||
"""更新Bioyond系统中的物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"更新Bioyond系统中的 {len(materials)} 个物料")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 模拟成功响应
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新Bioyond物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_material_by_id(self, material_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""根据ID获取单个物料"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"从Bioyond API获取物料: {material_id}")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据
|
||||
return {
|
||||
"id": material_id,
|
||||
"name": f"material_{material_id}",
|
||||
"type": "container",
|
||||
"quantity": 1.0,
|
||||
"unit": "个"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料失败 {material_id}: {e}")
|
||||
return None
|
||||
796
unilabos/devices/workstation/bioyond_cell/bioyond_workstation.py
Normal file
796
unilabos/devices/workstation/bioyond_cell/bioyond_workstation.py
Normal file
@@ -0,0 +1,796 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import re
|
||||
import threading
|
||||
|
||||
from urllib3 import response
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
from unilabos.utils.log import logger
|
||||
from pylabrobot.resources.deck import Deck
|
||||
|
||||
def _iso_utc_now_ms() -> str:
|
||||
# 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z
|
||||
dt = datetime.now()
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
|
||||
|
||||
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
"""
|
||||
集成 Bioyond LIMS 的工作站示例,
|
||||
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
|
||||
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
|
||||
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
station_resource: Optional[Dict[str, Any]] = None,
|
||||
debug_mode: bool = False,
|
||||
*args, **kwargs,
|
||||
):
|
||||
default_config = {
|
||||
#"base_url": "http://192.168.1.200:44388",
|
||||
"base_url": "http://61.169.57.196:44422",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"report_token": "CHANGE_ME_TOKEN"
|
||||
}
|
||||
self.bioyond_config = {**default_config, **(bioyond_config or {})}
|
||||
|
||||
self.http_service_started = False
|
||||
self.debug_mode = debug_mode
|
||||
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
|
||||
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
|
||||
|
||||
# 实例化并在后台线程启动 HTTP 报送服务
|
||||
# self.order_status = {}
|
||||
# try:
|
||||
# t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
|
||||
# t.start()
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"unilab-server后台启动报送服务失败: {e}")
|
||||
|
||||
# @property
|
||||
# def device_id(self) -> str:
|
||||
# try:
|
||||
# return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
|
||||
# except Exception:
|
||||
# return "bioyond_workstation"
|
||||
|
||||
# def _start_http_service_bg(self, host: str = "192.168.1.104", port: int = 8080) -> None:
|
||||
# logger.info("进入 _start_http_service_bg 函数")
|
||||
# try:
|
||||
# self.service = WorkstationHTTPService(self, host=host, port=port)
|
||||
# logger.info("WorkstationHTTPService 实例化完成")
|
||||
# self.service.start()
|
||||
# self.http_service_started = True
|
||||
# logger.info(f"unilab_HTTP 服务成功启动: {host}:{port}")
|
||||
|
||||
# 一直挂着,直到进程退出
|
||||
# while True:
|
||||
# time.sleep(1)
|
||||
|
||||
# except Exception as e:
|
||||
# self.http_service_started = False
|
||||
# logger.error(f"启动unilab_HTTP服务失败: {e}", exc_info=True)
|
||||
|
||||
# -------------------- 基础HTTP封装 --------------------
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
|
||||
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""LIMS API:大多数接口用 {apiKey/requestTime,data} 包装"""
|
||||
payload = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": _iso_utc_now_ms()
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
|
||||
if self.debug_mode:
|
||||
# 模拟返回,不发真实请求
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(
|
||||
self._url(path),
|
||||
json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r.raise_for_status()
|
||||
#print(r.json())
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 修正:_post_report / _post_report_raw 同样走 debug_mode ---
|
||||
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"token": self.bioyond_config.get("report_token", ""),
|
||||
"request_time": _iso_utc_now_ms(),
|
||||
"data": data
|
||||
}
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with body={body}")
|
||||
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=body,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# -------------------- 单点接口封装 --------------------
|
||||
# 2.17 入库物料(单个)
|
||||
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/storage/inbound", {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id
|
||||
})
|
||||
|
||||
# 2.18 批量入库(多个)
|
||||
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
items = [{"materialId": "...", "locationId": "..."}, ...]
|
||||
"""
|
||||
return self._post_lims("/api/lims/storage/batch-inbound", items)
|
||||
|
||||
# 3.30 自动化上料(Excel -> JSON -> POST /api/lims/order/auto-feeding4to3)
|
||||
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据固定模板解析 Excel:
|
||||
- 四号手套箱加样头面 (2-13行, 3-7列)
|
||||
- 四号手套箱原液瓶面 (15-23行, 3-9列)
|
||||
- 三号手套箱人工堆栈 (26-40行, 3-7列)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
# 四号手套箱 - 加样头面(2-13行, 3-7列)
|
||||
for _, row in df.iloc[1:13, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 四号手套箱 - 原液瓶面(15-23行, 3-9列)
|
||||
for _, row in df.iloc[14:23, 2:9].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
|
||||
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 三号手套箱人工堆栈(26-40行, 3-7列)
|
||||
for _, row in df.iloc[25:40, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "三号手套箱人工堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
|
||||
"quantity": 1 # 默认数量1
|
||||
}
|
||||
if item["materialId"] or item["materialType"]:
|
||||
items.append(item)
|
||||
|
||||
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
|
||||
|
||||
|
||||
|
||||
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
def pick(names: List[str]) -> Optional[str]:
|
||||
for n in names:
|
||||
if n in df.columns:
|
||||
return n
|
||||
return None
|
||||
|
||||
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
|
||||
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
|
||||
c_qty = pick(["数量", "quantity"])
|
||||
c_x = pick(["x", "X", "posX", "坐标X"])
|
||||
c_y = pick(["y", "Y", "posY", "坐标Y"])
|
||||
c_z = pick(["z", "Z", "posZ", "坐标Z"])
|
||||
|
||||
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
|
||||
if any(c is None for c in required):
|
||||
raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。")
|
||||
|
||||
def as_int(v, d=0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_float(v, d=0.0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return float(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_str(v, d=""):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)): return d
|
||||
s = str(v).strip()
|
||||
return s if s else d
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
"locationId": as_str(row[c_loc]),
|
||||
"warehouseId": as_str(row[c_wh]),
|
||||
"quantity": as_float(row[c_qty]),
|
||||
"x": as_int(row[c_x]),
|
||||
"y": as_int(row[c_y]),
|
||||
"z": as_int(row[c_z]),
|
||||
})
|
||||
|
||||
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
|
||||
|
||||
# 2.14 新建实验
|
||||
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Excel 解析并创建实验(2.14)
|
||||
约定:
|
||||
- batchId = Excel 文件名(不含扩展名)
|
||||
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
|
||||
- totalMass 自动计算为所有物料质量之和
|
||||
- createTime 缺失或为空时自动填充为当前日期(YYYY/M/D)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
# 列名容错:返回可选列名,找不到则返回 None
|
||||
def _pick(col_names: List[str]) -> Optional[str]:
|
||||
for c in col_names:
|
||||
if c in df.columns:
|
||||
return c
|
||||
return None
|
||||
|
||||
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
|
||||
col_create_time = _pick(["创建日期", "createTime"])
|
||||
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
|
||||
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
|
||||
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
|
||||
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
|
||||
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
|
||||
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
|
||||
|
||||
# 物料列:所有以 (g) 结尾
|
||||
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
|
||||
if not material_cols:
|
||||
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
|
||||
|
||||
batch_id = path.stem
|
||||
|
||||
def _to_ymd_slash(v) -> str:
|
||||
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
|
||||
ts = datetime.now()
|
||||
else:
|
||||
try:
|
||||
ts = pd.to_datetime(v)
|
||||
except Exception:
|
||||
ts = datetime.now()
|
||||
return f"{ts.year}/{ts.month}/{ts.day}"
|
||||
|
||||
def _as_int(val, default=0) -> int:
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(val)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(val, default="") -> str:
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
s = str(val).strip()
|
||||
return s if s else default
|
||||
|
||||
orders: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
mats: List[Dict[str, Any]] = []
|
||||
total_mass = 0.0
|
||||
|
||||
for mcol in material_cols:
|
||||
val = row.get(mcol, None)
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
continue
|
||||
try:
|
||||
mass = float(val)
|
||||
except Exception:
|
||||
continue
|
||||
if mass > 0:
|
||||
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
|
||||
total_mass += mass
|
||||
|
||||
order_data = {
|
||||
"batchId": batch_id,
|
||||
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
|
||||
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
|
||||
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
|
||||
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
|
||||
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
|
||||
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
|
||||
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
|
||||
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
|
||||
"materialInfos": mats,
|
||||
"totalMass": round(total_mass, 4) # 自动汇总
|
||||
}
|
||||
orders.append(order_data)
|
||||
|
||||
# print(orders)
|
||||
while True:
|
||||
time.sleep(5)
|
||||
response = self._post_lims("/api/lims/order/orders", orders)
|
||||
if response.get("data", []):
|
||||
break
|
||||
logger.info(f"等待配液实验创建完成")
|
||||
|
||||
|
||||
|
||||
# self.order_status[response["data"]["orderCode"]] = "running"
|
||||
|
||||
# while True:
|
||||
# time.sleep(5)
|
||||
# if self.order_status.get(response["data"]["orderCode"], None) == "finished":
|
||||
# logger.info(f"配液实验已完成 ,即将执行 3-2-1 转运")
|
||||
# break
|
||||
# logger.info(f"等待配液实验完成")
|
||||
|
||||
# self.transfer_3_to_2_to_1()
|
||||
# self.wait_for_transfer_task()
|
||||
# logger.info(f"3-2-1 转运完成,返回结果")
|
||||
# return r321
|
||||
return response
|
||||
|
||||
# 2.7 启动调度
|
||||
def scheduler_start(self) -> Dict[str, Any]:
|
||||
response = self._post_lims("/api/lims/scheduler/start")
|
||||
print(response)
|
||||
return response
|
||||
# 3.10 停止调度
|
||||
def scheduler_stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止调度 (3.10)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/stop")
|
||||
# 2.9 继续调度
|
||||
def scheduler_continue(self) -> Dict[str, Any]:
|
||||
"""
|
||||
继续调度 (2.9)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/continue")
|
||||
|
||||
|
||||
|
||||
# 2.24 物料变更推送
|
||||
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
|
||||
"""
|
||||
return self._post_report_raw("/report/material_change", material_obj)
|
||||
|
||||
# 2.21 步骤完成推送(BS → LIMS)
|
||||
def report_step_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
step_name: str,
|
||||
step_id: str,
|
||||
sample_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
execution_status: str = "completed") -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"stepName": step_name,
|
||||
"stepId": step_id,
|
||||
"sampleId": sample_id,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"executionStatus": execution_status
|
||||
}
|
||||
return self._post_report("/report/step_finish", data)
|
||||
|
||||
# 2.23 订单完成推送(BS → LIMS)
|
||||
def report_order_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
|
||||
workflow_status: str = "Finished",
|
||||
completion_time: Optional[str] = None,
|
||||
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"status": status,
|
||||
"workflowStatus": workflow_status,
|
||||
"completionTime": completion_time or end_time,
|
||||
"usedMaterials": used_materials or []
|
||||
}
|
||||
return self._post_report("/report/order_finish", data)
|
||||
|
||||
# 2.5 批量查询实验报告(用于轮询是否完成)
|
||||
def order_list(self,
|
||||
status: Optional[str] = None,
|
||||
begin_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
filter_text: Optional[str] = None,
|
||||
skip: int = 0, page: int = 10) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
|
||||
if status is not None: # 80 成功 / 90 失败 / 100 执行中
|
||||
data["status"] = status
|
||||
if begin_time:
|
||||
data["timeType"] = "CreationTime"
|
||||
data["beginTime"] = begin_time
|
||||
if end_time:
|
||||
data["endTime"] = end_time
|
||||
if filter_text:
|
||||
data["filter"] = filter_text
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
# 2.6 实验报告查询(根据任务ID拿详情)
|
||||
def order_report(self, order_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/order/order-report", order_id)
|
||||
|
||||
# 2.32 3-2-1 物料转运
|
||||
def transfer_3_to_2_to_1(self,
|
||||
# source_wh_id: Optional[str] = None,
|
||||
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
|
||||
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
|
||||
}
|
||||
if source_wh_id:
|
||||
payload["sourceWHID"] = source_wh_id
|
||||
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
|
||||
|
||||
# 2.28 样品/废料取出
|
||||
def take_out(self,
|
||||
order_id: str,
|
||||
preintake_ids: Optional[List[str]] = None,
|
||||
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": preintake_ids or [],
|
||||
"materialIds": material_ids or []
|
||||
}
|
||||
return self._post_lims("/api/lims/order/take-out", data)
|
||||
|
||||
# --------(可选)占位方法:文档未定义的“1号站内部流程 / 1-2转运”--------
|
||||
def start_station1_internal_flow(self, **kwargs) -> None:
|
||||
logger.info("启动1号站内部流程(占位,按现场系统填充具体指令)")
|
||||
|
||||
|
||||
# 3.x 1→2 物料转运
|
||||
def transfer_1_to_2(self) -> Dict[str, Any]:
|
||||
"""
|
||||
1→2 物料转运
|
||||
URL: /api/lims/order/transfer-task1To2
|
||||
只需要 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/order/transfer-task1To2")
|
||||
|
||||
|
||||
# -------------------- 整体编排 --------------------
|
||||
def run_full_workflow(self,
|
||||
inbound_items: List[Dict[str, str]],
|
||||
orders: List[Dict[str, Any]],
|
||||
poll_filter_code: Optional[str] = None,
|
||||
poll_timeout_s: int = 600,
|
||||
poll_interval_s: int = 5,
|
||||
transfer_source: Optional[Dict[str, Any]] = None,
|
||||
takeout_order_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
一键串联:
|
||||
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
|
||||
运行中(如需):4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
|
||||
完成后:查询实验(2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
|
||||
→ 9) 1-2 转运 → 10) 样品/废料取出
|
||||
"""
|
||||
# 1. 入库(多于1个就用批量接口 2.18)
|
||||
if len(inbound_items) == 1:
|
||||
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
|
||||
logger.info(f"单个入库结果: {r}")
|
||||
else:
|
||||
r = self.storage_batch_inbound(inbound_items)
|
||||
logger.info(f"批量入库结果: {r}")
|
||||
|
||||
# 2. 新建实验(2.14)
|
||||
r = self.create_orders(orders)
|
||||
logger.info(f"新建实验结果: {r}")
|
||||
|
||||
# 3. 启动调度(2.7)
|
||||
r = self.scheduler_start()
|
||||
logger.info(f"启动调度结果: {r}")
|
||||
|
||||
# —— 运行中各类推送(2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
|
||||
# self.report_material_change({...})
|
||||
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
|
||||
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
|
||||
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
|
||||
|
||||
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80(成功)
|
||||
if poll_filter_code:
|
||||
import time
|
||||
deadline = time.time() + poll_timeout_s
|
||||
while time.time() < deadline:
|
||||
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
|
||||
if isinstance(res, dict) and res.get("data", {}).get("items"):
|
||||
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
|
||||
break
|
||||
time.sleep(poll_interval_s)
|
||||
else:
|
||||
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80)")
|
||||
|
||||
# 7. 启动 3-2-1 转运(2.32)
|
||||
if transfer_source:
|
||||
r = self.transfer_3_to_2_to_1(
|
||||
source_wh_id=transfer_source.get("sourceWHID"),
|
||||
source_x=transfer_source.get("sourcePosX", 1),
|
||||
source_y=transfer_source.get("sourcePosY", 1),
|
||||
source_z=transfer_source.get("sourcePosZ", 1),
|
||||
)
|
||||
logger.info(f"3-2-1 转运结果: {r}")
|
||||
|
||||
# 8. 1号站内部流程(占位)
|
||||
self.start_station1_internal_flow()
|
||||
|
||||
# 9. 1→2 转运(占位)
|
||||
self.transfer_1_to_2()
|
||||
|
||||
# 10. 样品/废料取出(2.28)
|
||||
if takeout_order_id:
|
||||
r = self.take_out(order_id=takeout_order_id)
|
||||
logger.info(f"样品/废料取出结果: {r}")
|
||||
|
||||
# 2.5 批量查询实验报告
|
||||
def order_list_v2(self,
|
||||
timeType: str = "string",
|
||||
beginTime: str = "",
|
||||
endTime: str = "",
|
||||
status: str = "",
|
||||
filter: str = "",
|
||||
skipCount: int = 0,
|
||||
pageCount: int = 1,
|
||||
sorting: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
批量查询实验报告的详细信息 (2.5)
|
||||
URL: /api/lims/order/order-list
|
||||
参数默认值和接口文档保持一致
|
||||
"""
|
||||
data: Dict[str, Any] = {
|
||||
"timeType": timeType,
|
||||
"beginTime": beginTime,
|
||||
"endTime": endTime,
|
||||
"status": status,
|
||||
"filter": filter,
|
||||
"skipCount": skipCount,
|
||||
"pageCount": pageCount,
|
||||
"sorting": sorting
|
||||
}
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
|
||||
def wait_for_transfer_task(self, timeout: int = 3000, interval: int = 5, filter_text: Optional[str] = None) -> bool:
|
||||
"""
|
||||
轮询查询物料转移任务是否成功完成 (status=80)
|
||||
- timeout: 最大等待秒数 (默认600秒)
|
||||
- interval: 轮询间隔秒数 (默认3秒)
|
||||
返回 True 表示找到并成功完成,False 表示超时未找到
|
||||
"""
|
||||
now = datetime.now()
|
||||
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(beginTime, endTime)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
result = self.order_list_v2(
|
||||
timeType="string",
|
||||
beginTime=beginTime,
|
||||
endTime=endTime,
|
||||
status="",
|
||||
filter=filter_text,
|
||||
skipCount=0,
|
||||
pageCount=1,
|
||||
sorting=""
|
||||
)
|
||||
print(result)
|
||||
|
||||
items = result.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
status = item.get("status")
|
||||
# 改成用 filter_text 判断
|
||||
if (not filter_text or filter_text in name) and status == 80:
|
||||
logger.info(f"硬件转移动作完成: {name}, status={status}")
|
||||
return True
|
||||
|
||||
logger.info(f"等待中: {name}, status={status}")
|
||||
time.sleep(interval)
|
||||
|
||||
logger.warning("超时未找到成功的物料转移任务")
|
||||
return False
|
||||
|
||||
|
||||
def Bioystation_scheduler_start_task(self) -> bool:
|
||||
logger.info("开始调度")
|
||||
self.scheduler_start()
|
||||
logger.info("调度已启动")
|
||||
|
||||
def Bioystation_scheduler_stop_task(self) -> bool:
|
||||
logger.info("停止调度")
|
||||
self.scheduler_stop()
|
||||
logger.info("调度已停止")
|
||||
|
||||
def Bioystation_scheduler_continue_task(self) -> bool:
|
||||
logger.info("继续调度")
|
||||
self.scheduler_continue()
|
||||
logger.info("调度已继续")
|
||||
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
def Bioystation_feeding4to3_from_xlsx_task(self) -> bool:
|
||||
logger.info("4号箱自动上料开始")
|
||||
r1 = self.auto_feeding4to3_from_xlsx(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板.xlsx")
|
||||
self.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
logger.info("4号箱向3号箱转运物料转移任务已完成")
|
||||
return True
|
||||
|
||||
# # 新建实验
|
||||
def Bioystation_start_experiment_task(self) -> bool:
|
||||
logger.info("3号箱内实验开始")
|
||||
response = self.create_orders(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\2025101301.xlsx")
|
||||
logger.info(response)
|
||||
data_list = response.get("data", [])
|
||||
order_name = data_list[0].get("orderName", "")
|
||||
self.wait_for_transfer_task(filter_text=order_name)
|
||||
logger.info("3号站内实验完成")
|
||||
return True
|
||||
|
||||
def Bioystation_3_to_2_task(self) -> bool:
|
||||
self.transfer_3_to_2_to_1()
|
||||
self.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
logger.info("3号站向2号站向1号站转移任务完成")
|
||||
return True
|
||||
|
||||
def Bioystation_1_to_2_task(self) -> bool:
|
||||
self.transfer_1_to_2()
|
||||
self.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
logger.info("1号站向2号站转移任务完成")
|
||||
logger.info("全流程结束")
|
||||
return True
|
||||
|
||||
def test_benyao_workstation(self, num1, num2):
|
||||
num1 = int(num1)
|
||||
num2 = int(num2)
|
||||
for i in range(num1):
|
||||
print(f"num1 = {num1}")
|
||||
for j in range(num2):
|
||||
print(f"num1 = {num2}")
|
||||
|
||||
# --------------------------------
|
||||
if __name__ == "__main__":
|
||||
ws = BioyondWorkstation()
|
||||
#ws.scheduler_stop()
|
||||
#ws.Bioystation_scheduler_start_task()
|
||||
ws.scheduler_start()
|
||||
# ws.scheduler_start()
|
||||
# logger.info("调度启动完成")
|
||||
|
||||
# ws.scheduler_continue()
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
# r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
|
||||
# ws.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
# logger.info("4号箱向3号箱转运物料转移任务已完成")
|
||||
|
||||
# ws.scheduler_start()
|
||||
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
|
||||
|
||||
# # 新建实验
|
||||
# response = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092701.xlsx")
|
||||
# logger.info(response)
|
||||
# data_list = response.get("data", [])
|
||||
# order_name = data_list[0].get("orderName", "")
|
||||
|
||||
# ws.wait_for_transfer_task(filter_text=order_name)
|
||||
# ws.wait_for_transfer_task(filter_text='DP20250927001')
|
||||
# logger.info("3号站内实验完成")
|
||||
# # ws.scheduler_start()
|
||||
# # print(res)
|
||||
# ws.transfer_3_to_2_to_1()
|
||||
# ws.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
# logger.info("3号站向2号站向1号站转移任务完成")
|
||||
# r321 = self.wait_for_transfer_task()
|
||||
#1号站启动
|
||||
# ws.transfer_1_to_2()
|
||||
#s.wait_for_transfer_task(filter_text="物料转移任务")
|
||||
#ogger.info("1号站向2号站转移任务完成")
|
||||
#ogger.info("全流程结束")
|
||||
|
||||
# 3.31 下料:同理
|
||||
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
|
||||
# print(r2["payload"]["data"])
|
||||
@@ -0,0 +1,772 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import re
|
||||
import threading
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
from unilabos.utils.log import logger
|
||||
from pylabrobot.resources.deck import Deck
|
||||
|
||||
|
||||
def _iso_utc_now_ms() -> str:
|
||||
# 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z
|
||||
dt = datetime.now(timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
|
||||
|
||||
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
"""
|
||||
集成 Bioyond LIMS 的工作站示例,
|
||||
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
|
||||
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
|
||||
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
station_resource: Optional[Dict[str, Any]] = None,
|
||||
debug_mode: bool = False, # 增加调试模式开关
|
||||
*args, **kwargs,
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {
|
||||
#"base_url": "http://192.168.1.200:44386",
|
||||
#"base_url": "http://172.16.11.219:44388",
|
||||
"base_url": "http://61.169.57.196:44422",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"report_token": "CHANGE_ME_TOKEN"
|
||||
}
|
||||
|
||||
|
||||
self.debug_mode = debug_mode
|
||||
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
|
||||
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
|
||||
|
||||
# 实例化并在后台线程启动 HTTP 报送服务
|
||||
self.order_status = {}
|
||||
try:
|
||||
t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
|
||||
t.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"unilab-server后台启动报送服务失败: {e}")
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
try:
|
||||
return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
|
||||
except Exception:
|
||||
return "bioyond_workstation"
|
||||
|
||||
def _start_http_service_bg(self, host: str = "192.168.1.104", port: int = 7000) -> None:
|
||||
try:
|
||||
self.service = WorkstationHTTPService(self, host=host, port=port)
|
||||
self.service.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动HTTP服务失败: {e}")
|
||||
|
||||
# -------------------- 基础HTTP封装 --------------------
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""LIMS API:大多数接口用 {apiKey/requestTime,data} 包装"""
|
||||
payload = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": _iso_utc_now_ms()
|
||||
}
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
|
||||
if self.debug_mode:
|
||||
# 模拟返回,不发真实请求
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self._url(path),
|
||||
json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 修正:_post_report / _post_report_raw 同样走 debug_mode ---
|
||||
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"token": self.bioyond_config.get("report_token", ""),
|
||||
"request_time": _iso_utc_now_ms(),
|
||||
"data": data
|
||||
}
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with body={body}")
|
||||
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=body,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# -------------------- 单点接口封装 --------------------
|
||||
# 2.17 入库物料(单个)
|
||||
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/storage/inbound", {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id
|
||||
})
|
||||
|
||||
# 2.18 批量入库(多个)
|
||||
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
items = [{"materialId": "...", "locationId": "..."}, ...]
|
||||
"""
|
||||
return self._post_lims("/api/lims/storage/batch-inbound", items)
|
||||
|
||||
# 3.30 自动化上料(Excel -> JSON -> POST /api/lims/order/auto-feeding4to3)
|
||||
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据固定模板解析 Excel:
|
||||
- 四号手套箱加样头面 (2-13行, 3-7列)
|
||||
- 四号手套箱原液瓶面 (15-23行, 3-9列)
|
||||
- 三号手套箱人工堆栈 (26-40行, 3-7列)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
# 四号手套箱 - 加样头面(2-13行, 3-7列)
|
||||
for _, row in df.iloc[1:13, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 四号手套箱 - 原液瓶面(15-23行, 3-9列)
|
||||
for _, row in df.iloc[14:23, 2:9].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
|
||||
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 三号手套箱人工堆栈(26-40行, 3-7列)
|
||||
for _, row in df.iloc[25:40, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "三号手套箱人工堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
|
||||
"quantity": 1 # 默认数量1
|
||||
}
|
||||
if item["materialId"] or item["materialType"]:
|
||||
items.append(item)
|
||||
print("items", items)
|
||||
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
|
||||
|
||||
|
||||
|
||||
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
def pick(names: List[str]) -> Optional[str]:
|
||||
for n in names:
|
||||
if n in df.columns:
|
||||
return n
|
||||
return None
|
||||
|
||||
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
|
||||
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
|
||||
c_qty = pick(["数量", "quantity"])
|
||||
c_x = pick(["x", "X", "posX", "坐标X"])
|
||||
c_y = pick(["y", "Y", "posY", "坐标Y"])
|
||||
c_z = pick(["z", "Z", "posZ", "坐标Z"])
|
||||
|
||||
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
|
||||
if any(c is None for c in required):
|
||||
raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。")
|
||||
|
||||
def as_int(v, d=0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_float(v, d=0.0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return float(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_str(v, d=""):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)): return d
|
||||
s = str(v).strip()
|
||||
return s if s else d
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
"locationId": as_str(row[c_loc]),
|
||||
"warehouseId": as_str(row[c_wh]),
|
||||
"quantity": as_float(row[c_qty]),
|
||||
"x": as_int(row[c_x]),
|
||||
"y": as_int(row[c_y]),
|
||||
"z": as_int(row[c_z]),
|
||||
})
|
||||
|
||||
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
|
||||
|
||||
# 2.14 新建实验
|
||||
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Excel 解析并创建实验(2.14)
|
||||
约定:
|
||||
- batchId = Excel 文件名(不含扩展名)
|
||||
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
|
||||
- totalMass 自动计算为所有物料质量之和
|
||||
- createTime 缺失或为空时自动填充为当前日期(YYYY/M/D)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
# 列名容错:返回可选列名,找不到则返回 None
|
||||
def _pick(col_names: List[str]) -> Optional[str]:
|
||||
for c in col_names:
|
||||
if c in df.columns:
|
||||
return c
|
||||
return None
|
||||
|
||||
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
|
||||
col_create_time = _pick(["创建日期", "createTime"])
|
||||
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
|
||||
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
|
||||
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
|
||||
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
|
||||
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
|
||||
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
|
||||
|
||||
# 物料列:所有以 (g) 结尾
|
||||
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
|
||||
if not material_cols:
|
||||
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
|
||||
|
||||
batch_id = path.stem
|
||||
|
||||
def _to_ymd_slash(v) -> str:
|
||||
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
|
||||
ts = datetime.now()
|
||||
else:
|
||||
try:
|
||||
ts = pd.to_datetime(v)
|
||||
except Exception:
|
||||
ts = datetime.now()
|
||||
return f"{ts.year}/{ts.month}/{ts.day}"
|
||||
|
||||
def _as_int(val, default=0) -> int:
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(val)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(val, default="") -> str:
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
s = str(val).strip()
|
||||
return s if s else default
|
||||
|
||||
orders: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
mats: List[Dict[str, Any]] = []
|
||||
total_mass = 0.0
|
||||
|
||||
for mcol in material_cols:
|
||||
val = row.get(mcol, None)
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
continue
|
||||
try:
|
||||
mass = float(val)
|
||||
except Exception:
|
||||
continue
|
||||
if mass > 0:
|
||||
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
|
||||
total_mass += mass
|
||||
|
||||
order_data = {
|
||||
"batchId": batch_id,
|
||||
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
|
||||
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
|
||||
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
|
||||
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
|
||||
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
|
||||
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
|
||||
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
|
||||
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
|
||||
"materialInfos": mats,
|
||||
"totalMass": round(total_mass, 4) # 自动汇总
|
||||
}
|
||||
orders.append(order_data)
|
||||
|
||||
# print(orders)
|
||||
|
||||
response = self._post_lims("/api/lims/order/orders", orders)
|
||||
print(response["data"])
|
||||
|
||||
self.order_status[response["data"][0]["orderCode"]] = "running"
|
||||
|
||||
while True:
|
||||
time.sleep(5)
|
||||
if self.order_status.get(response["data"][0]["orderCode"], None) == "finished":
|
||||
break
|
||||
return response
|
||||
|
||||
|
||||
# 2.7 启动调度
|
||||
def scheduler_start(self) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/scheduler/start")
|
||||
# 3.10 停止调度
|
||||
def scheduler_stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止调度 (3.10)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/stop")
|
||||
# 2.9 继续调度
|
||||
def scheduler_continue(self) -> Dict[str, Any]:
|
||||
"""
|
||||
继续调度 (2.9)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/continue")
|
||||
|
||||
|
||||
|
||||
# 2.24 物料变更推送
|
||||
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
|
||||
"""
|
||||
return self._post_report_raw("/report/material_change", material_obj)
|
||||
|
||||
# 2.21 步骤完成推送(BS → LIMS)
|
||||
def report_step_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
step_name: str,
|
||||
step_id: str,
|
||||
sample_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
execution_status: str = "completed") -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"stepName": step_name,
|
||||
"stepId": step_id,
|
||||
"sampleId": sample_id,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"executionStatus": execution_status
|
||||
}
|
||||
return self._post_report("/report/step_finish", data)
|
||||
|
||||
# 2.23 订单完成推送(BS → LIMS)
|
||||
def report_order_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
|
||||
workflow_status: str = "Finished",
|
||||
completion_time: Optional[str] = None,
|
||||
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"status": status,
|
||||
"workflowStatus": workflow_status,
|
||||
"completionTime": completion_time or end_time,
|
||||
"usedMaterials": used_materials or []
|
||||
}
|
||||
return self._post_report("/report/order_finish", data)
|
||||
|
||||
# 2.5 批量查询实验报告(用于轮询是否完成)
|
||||
def order_list(self,
|
||||
status: Optional[str] = None,
|
||||
begin_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
filter_text: Optional[str] = None,
|
||||
skip: int = 0, page: int = 10) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
|
||||
if status is not None: # 80 成功 / 90 失败 / 100 执行中
|
||||
data["status"] = status
|
||||
if begin_time:
|
||||
data["timeType"] = "CreationTime"
|
||||
data["beginTime"] = begin_time
|
||||
if end_time:
|
||||
data["endTime"] = end_time
|
||||
if filter_text:
|
||||
data["filter"] = filter_text
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
# 2.6 实验报告查询(根据任务ID拿详情)
|
||||
def order_report(self, order_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/order/order-report", order_id)
|
||||
|
||||
# 2.32 3-2-1 物料转运
|
||||
def transfer_3_to_2_to_1(self,
|
||||
# source_wh_id: Optional[str] = None,
|
||||
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
|
||||
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
|
||||
}
|
||||
if source_wh_id:
|
||||
payload["sourceWHID"] = source_wh_id
|
||||
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
|
||||
|
||||
# 2.28 样品/废料取出
|
||||
def take_out(self,
|
||||
order_id: str,
|
||||
preintake_ids: Optional[List[str]] = None,
|
||||
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": preintake_ids or [],
|
||||
"materialIds": material_ids or []
|
||||
}
|
||||
return self._post_lims("/api/lims/order/take-out", data)
|
||||
|
||||
# --------(可选)占位方法:文档未定义的“1号站内部流程 / 1-2转运”--------
|
||||
def start_station1_internal_flow(self, **kwargs) -> None:
|
||||
logger.info("启动1号站内部流程(占位,按现场系统填充具体指令)")
|
||||
|
||||
|
||||
# 3.x 1→2 物料转运
|
||||
def transfer_1_to_2(self) -> Dict[str, Any]:
|
||||
"""
|
||||
1→2 物料转运
|
||||
URL: /api/lims/order/transfer-task1To2
|
||||
只需要 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/order/transfer-task1To2")
|
||||
|
||||
|
||||
# -------------------- 整体编排 --------------------
|
||||
def run_full_workflow(self,
|
||||
inbound_items: List[Dict[str, str]],
|
||||
orders: List[Dict[str, Any]],
|
||||
poll_filter_code: Optional[str] = None,
|
||||
poll_timeout_s: int = 600,
|
||||
poll_interval_s: int = 5,
|
||||
transfer_source: Optional[Dict[str, Any]] = None,
|
||||
takeout_order_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
一键串联:
|
||||
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
|
||||
运行中(如需):4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
|
||||
完成后:查询实验(2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
|
||||
→ 9) 1-2 转运 → 10) 样品/废料取出
|
||||
"""
|
||||
# 1. 入库(多于1个就用批量接口 2.18)
|
||||
if len(inbound_items) == 1:
|
||||
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
|
||||
logger.info(f"单个入库结果: {r}")
|
||||
else:
|
||||
r = self.storage_batch_inbound(inbound_items)
|
||||
logger.info(f"批量入库结果: {r}")
|
||||
|
||||
# 2. 新建实验(2.14)
|
||||
r = self.create_orders(orders)
|
||||
logger.info(f"新建实验结果: {r}")
|
||||
|
||||
# 3. 启动调度(2.7)
|
||||
r = self.scheduler_start()
|
||||
logger.info(f"启动调度结果: {r}")
|
||||
|
||||
# —— 运行中各类推送(2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
|
||||
# self.report_material_change({...})
|
||||
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
|
||||
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
|
||||
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
|
||||
|
||||
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80(成功)
|
||||
if poll_filter_code:
|
||||
import time
|
||||
deadline = time.time() + poll_timeout_s
|
||||
while time.time() < deadline:
|
||||
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
|
||||
if isinstance(res, dict) and res.get("data", {}).get("items"):
|
||||
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
|
||||
break
|
||||
time.sleep(poll_interval_s)
|
||||
else:
|
||||
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80)")
|
||||
|
||||
# 7. 启动 3-2-1 转运(2.32)
|
||||
if transfer_source:
|
||||
r = self.transfer_3_to_2_to_1(
|
||||
source_wh_id=transfer_source.get("sourceWHID"),
|
||||
source_x=transfer_source.get("sourcePosX", 1),
|
||||
source_y=transfer_source.get("sourcePosY", 1),
|
||||
source_z=transfer_source.get("sourcePosZ", 1),
|
||||
)
|
||||
logger.info(f"3-2-1 转运结果: {r}")
|
||||
|
||||
# 8. 1号站内部流程(占位)
|
||||
self.start_station1_internal_flow()
|
||||
|
||||
# 9. 1→2 转运(占位)
|
||||
self.transfer_1_to_2()
|
||||
|
||||
# 10. 样品/废料取出(2.28)
|
||||
if takeout_order_id:
|
||||
r = self.take_out(order_id=takeout_order_id)
|
||||
logger.info(f"样品/废料取出结果: {r}")
|
||||
|
||||
# 2.5 批量查询实验报告
|
||||
def order_list_v2(self,
|
||||
timeType: str = "string",
|
||||
beginTime: str = "",
|
||||
endTime: str = "",
|
||||
status: str = "",
|
||||
filter: str = "物料转移任务",
|
||||
skipCount: int = 0,
|
||||
pageCount: int = 1,
|
||||
sorting: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
批量查询实验报告的详细信息 (2.5)
|
||||
URL: /api/lims/order/order-list
|
||||
参数默认值和接口文档保持一致
|
||||
"""
|
||||
data: Dict[str, Any] = {
|
||||
"timeType": timeType,
|
||||
"beginTime": beginTime,
|
||||
"endTime": endTime,
|
||||
"status": status,
|
||||
"filter": filter,
|
||||
"skipCount": skipCount,
|
||||
"pageCount": pageCount,
|
||||
"sorting": sorting
|
||||
}
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
|
||||
def wait_for_transfer_task(self, timeout: int = 600, interval: int = 3) -> bool:
|
||||
"""
|
||||
轮询查询物料转移任务是否成功完成 (status=80)
|
||||
- timeout: 最大等待秒数 (默认600秒)
|
||||
- interval: 轮询间隔秒数 (默认3秒)
|
||||
返回 True 表示找到并成功完成,False 表示超时未找到
|
||||
"""
|
||||
now = datetime.now()
|
||||
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(beginTime, endTime)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
result = self.order_list_v2(
|
||||
timeType="string",
|
||||
beginTime=beginTime,
|
||||
endTime=endTime,
|
||||
status="",
|
||||
filter="物料转移任务",
|
||||
skipCount=0,
|
||||
pageCount=1,
|
||||
sorting=""
|
||||
)
|
||||
print(result)
|
||||
|
||||
items = result.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
status = item.get("status")
|
||||
if name.startswith("物料转移任务") and status == 80:
|
||||
logger.info(f"硬件转移动作完成: {name}")
|
||||
return True
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
logger.warning("超时未找到成功的物料转移任务")
|
||||
return False
|
||||
|
||||
def wait_for_recent_task(self, timeout: int = 600, interval: int = 3) -> bool:
|
||||
"""
|
||||
轮询查询最近的任务是否成功完成 (status=80)
|
||||
- timeout: 最大等待秒数 (默认600秒)
|
||||
- interval: 轮询间隔秒数 (默认3秒)
|
||||
返回 True 表示找到并成功完成,False 表示超时未找到
|
||||
"""
|
||||
now = datetime.now()
|
||||
beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
print(beginTime, endTime)
|
||||
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
result = self.order_list_v2(
|
||||
timeType="string",
|
||||
beginTime=beginTime,
|
||||
endTime=endTime,
|
||||
status="",
|
||||
filter="",
|
||||
skipCount=0,
|
||||
pageCount=1,
|
||||
sorting=""
|
||||
)
|
||||
print(result)
|
||||
|
||||
items = result.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
status = item.get("status")
|
||||
if name.startswith("物料转移任务") and status == 80:
|
||||
logger.info(f"硬件转移动作完成: {name}")
|
||||
return True
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
logger.warning("超时未找到成功的任务")
|
||||
return False
|
||||
|
||||
# --------------------------------
|
||||
if __name__ == "__main__":
|
||||
ws = BioyondWorkstation()
|
||||
ws.scheduler_stop()
|
||||
ws.scheduler_start()
|
||||
#物料入库
|
||||
r1 = ws.auto_feeding4to3_from_xlsx(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板.xlsx")
|
||||
# print(r1)
|
||||
# print("物料入库任务已提交0")
|
||||
# #等待任务完成
|
||||
# ws.wait_for_transfer_task()
|
||||
#
|
||||
# print("物料入库任务已完成1")
|
||||
#
|
||||
# #新建实验
|
||||
# res = ws.create_orders(r"D:\Uni-lab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\2025092501.xlsx")
|
||||
# print(res)
|
||||
# #等待任务完成
|
||||
# ws.wait_for_recent_task()
|
||||
# print("配液任务已完成")
|
||||
#
|
||||
# #新建3-2-1转运任务
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
# print(r321)
|
||||
# #等待任务完成
|
||||
# ws.wait_for_recent_task()
|
||||
#
|
||||
# ws.transfer_1_to_2()
|
||||
# #等待任务完成
|
||||
# ws.wait_for_recent_task()
|
||||
|
||||
|
||||
#ws._start_http_service_bg()
|
||||
# ws.scheduler_stop()
|
||||
#ws.scheduler_start()
|
||||
# ws.scheduler_continue()
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
# r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
|
||||
#ws.wait_for_transfer_task()
|
||||
#print("转运物料转移任务已完成")
|
||||
|
||||
# ws.scheduler_start()
|
||||
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
|
||||
|
||||
# 新建实验
|
||||
# res = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092501.xlsx")
|
||||
# ws.scheduler_start()
|
||||
# print(res)
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
|
||||
|
||||
# 3.31 下料:同理
|
||||
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
|
||||
# print(r2["payload"]["data"])
|
||||
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
# ws.transfer_1_to_2()
|
||||
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import time
|
||||
import threading
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
from unilabos.utils.log import logger
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from .benyao_test import test_benyao_api
|
||||
|
||||
def _iso_utc_now_ms() -> str:
|
||||
# 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z
|
||||
dt = datetime.now(timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z"
|
||||
|
||||
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
"""
|
||||
集成 Bioyond LIMS 的工作站示例,
|
||||
覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) →
|
||||
运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) →
|
||||
查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
station_resource: Optional[Dict[str, Any]] = None,
|
||||
debug_mode: bool = False, # 增加调试模式开关
|
||||
*args, **kwargs,
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {
|
||||
"base_url": "http://192.168.1.200:44386",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"report_token": "CHANGE_ME_TOKEN"
|
||||
}
|
||||
|
||||
|
||||
self.debug_mode = debug_mode
|
||||
super().__init__(deck=Deck, station_resource=station_resource, *args, **kwargs)
|
||||
logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})")
|
||||
|
||||
# 实例化并在后台线程启动 HTTP 报送服务
|
||||
self.order_status = {}
|
||||
try:
|
||||
t = threading.Thread(target=self._start_http_service_bg, daemon=True, name="unilab_http")
|
||||
t.start()
|
||||
except Exception as e:
|
||||
logger.error(f"unilab-server后台启动报送服务失败: {e}")
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
try:
|
||||
return getattr(self, "_ros_node").device_id # 兼容 ROS 场景
|
||||
except Exception:
|
||||
return "bioyond_workstation"
|
||||
|
||||
def _start_http_service_bg(self, host: str = "127.0.0.1", port: int = 8080) -> None:
|
||||
try:
|
||||
self.service = WorkstationHTTPService(self, host=host, port=port)
|
||||
self.service.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动HTTP服务失败: {e}")
|
||||
|
||||
# -------------------- 基础HTTP封装 --------------------
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.bioyond_config['base_url'].rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""LIMS API:大多数接口用 {apiKey/requestTime,data} 包装"""
|
||||
payload = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": _iso_utc_now_ms()
|
||||
}
|
||||
if data is not None:
|
||||
payload["data"] = data
|
||||
|
||||
if self.debug_mode:
|
||||
# 模拟返回,不发真实请求
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self._url(path),
|
||||
json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# --- 修正:_post_report / _post_report_raw 同样走 debug_mode ---
|
||||
def _post_report(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"token": self.bioyond_config.get("report_token", ""),
|
||||
"request_time": _iso_utc_now_ms(),
|
||||
"data": data
|
||||
}
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with payload={payload}")
|
||||
return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=payload,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _post_report_raw(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.debug_mode:
|
||||
logger.info(f"[DEBUG] POST {path} with body={body}")
|
||||
return {"debug": True, "url": self._url(path), "payload": body, "status": "ok"}
|
||||
try:
|
||||
r = requests.post(self._url(path), json=body,
|
||||
timeout=self.bioyond_config.get("timeout", 30),
|
||||
headers={"Content-Type": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"POST {path} 失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# -------------------- 单点接口封装 --------------------
|
||||
# 2.17 入库物料(单个)
|
||||
def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/storage/inbound", {
|
||||
"materialId": material_id,
|
||||
"locationId": location_id
|
||||
})
|
||||
|
||||
# 2.18 批量入库(多个)
|
||||
def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
items = [{"materialId": "...", "locationId": "..."}, ...]
|
||||
"""
|
||||
return self._post_lims("/api/lims/storage/batch-inbound", items)
|
||||
|
||||
# 3.30 自动化上料(Excel -> JSON -> POST /api/lims/order/auto-feeding4to3)
|
||||
def auto_feeding4to3_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
根据固定模板解析 Excel:
|
||||
- 四号手套箱加样头面 (2-13行, 3-7列)
|
||||
- 四号手套箱原液瓶面 (15-23行, 3-9列)
|
||||
- 三号手套箱人工堆栈 (26-40行, 3-7列)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
# 四号手套箱 - 加样头面(2-13行, 3-7列)
|
||||
for _, row in df.iloc[1:13, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 四号手套箱 - 原液瓶面(15-23行, 3-9列)
|
||||
for _, row in df.iloc[14:23, 2:9].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "四号手套箱堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialName": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"quantity": float(row[6]) if pd.notna(row[6]) else 0.0,
|
||||
"materialType": str(row[7]).strip() if pd.notna(row[7]) else "",
|
||||
"targetWH": str(row[8]).strip() if pd.notna(row[8]) else "",
|
||||
}
|
||||
if item["materialName"]:
|
||||
items.append(item)
|
||||
|
||||
# 三号手套箱人工堆栈(26-40行, 3-7列)
|
||||
for _, row in df.iloc[25:40, 2:7].iterrows():
|
||||
item = {
|
||||
"sourceWHName": "三号手套箱人工堆栈",
|
||||
"posX": int(row[2]),
|
||||
"posY": int(row[3]),
|
||||
"posZ": int(row[4]),
|
||||
"materialType": str(row[5]).strip() if pd.notna(row[5]) else "",
|
||||
"materialId": str(row[6]).strip() if pd.notna(row[6]) else "",
|
||||
"quantity": 1 # 默认数量1
|
||||
}
|
||||
if item["materialId"] or item["materialType"]:
|
||||
items.append(item)
|
||||
|
||||
return self._post_lims("/api/lims/order/auto-feeding4to3", items)
|
||||
|
||||
|
||||
|
||||
def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
def pick(names: List[str]) -> Optional[str]:
|
||||
for n in names:
|
||||
if n in df.columns:
|
||||
return n
|
||||
return None
|
||||
|
||||
c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"])
|
||||
c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"])
|
||||
c_qty = pick(["数量", "quantity"])
|
||||
c_x = pick(["x", "X", "posX", "坐标X"])
|
||||
c_y = pick(["y", "Y", "posY", "坐标Y"])
|
||||
c_z = pick(["z", "Z", "posZ", "坐标Z"])
|
||||
|
||||
required = [c_loc, c_wh, c_qty, c_x, c_y, c_z]
|
||||
if any(c is None for c in required):
|
||||
raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。")
|
||||
|
||||
def as_int(v, d=0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_float(v, d=0.0):
|
||||
try:
|
||||
if pd.isna(v): return d
|
||||
return float(v)
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def as_str(v, d=""):
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)): return d
|
||||
s = str(v).strip()
|
||||
return s if s else d
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
items.append({
|
||||
"locationId": as_str(row[c_loc]),
|
||||
"warehouseId": as_str(row[c_wh]),
|
||||
"quantity": as_float(row[c_qty]),
|
||||
"x": as_int(row[c_x]),
|
||||
"y": as_int(row[c_y]),
|
||||
"z": as_int(row[c_z]),
|
||||
})
|
||||
|
||||
return self._post_lims("/api/lims/storage/auto-batch-out-bound", items)
|
||||
|
||||
# 2.14 新建实验
|
||||
def create_orders(self, xlsx_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Excel 解析并创建实验(2.14)
|
||||
约定:
|
||||
- batchId = Excel 文件名(不含扩展名)
|
||||
- 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列)
|
||||
- totalMass 自动计算为所有物料质量之和
|
||||
- createTime 缺失或为空时自动填充为当前日期(YYYY/M/D)
|
||||
"""
|
||||
path = Path(xlsx_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"未找到 Excel 文件:{path}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取 Excel 失败:{e}")
|
||||
|
||||
# 列名容错:返回可选列名,找不到则返回 None
|
||||
def _pick(col_names: List[str]) -> Optional[str]:
|
||||
for c in col_names:
|
||||
if c in df.columns:
|
||||
return c
|
||||
return None
|
||||
|
||||
col_order_name = _pick(["配方ID", "orderName", "订单编号"])
|
||||
col_create_time = _pick(["创建日期", "createTime"])
|
||||
col_bottle_type = _pick(["配液瓶类型", "bottleType"])
|
||||
col_mix_time = _pick(["混匀时间(s)", "mixTime"])
|
||||
col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"])
|
||||
col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"])
|
||||
col_cond = _pick(["电导测试分液体积", "conductivityInfo"])
|
||||
col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"])
|
||||
|
||||
# 物料列:所有以 (g) 结尾
|
||||
material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")]
|
||||
if not material_cols:
|
||||
raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。")
|
||||
|
||||
batch_id = path.stem
|
||||
|
||||
def _to_ymd_slash(v) -> str:
|
||||
# 统一为 "YYYY/M/D";为空或解析失败则用当前日期
|
||||
if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "":
|
||||
ts = datetime.now()
|
||||
else:
|
||||
try:
|
||||
ts = pd.to_datetime(v)
|
||||
except Exception:
|
||||
ts = datetime.now()
|
||||
return f"{ts.year}/{ts.month}/{ts.day}"
|
||||
|
||||
def _as_int(val, default=0) -> int:
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(val)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _as_str(val, default="") -> str:
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
s = str(val).strip()
|
||||
return s if s else default
|
||||
|
||||
orders: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
mats: List[Dict[str, Any]] = []
|
||||
total_mass = 0.0
|
||||
|
||||
for mcol in material_cols:
|
||||
val = row.get(mcol, None)
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
continue
|
||||
try:
|
||||
mass = float(val)
|
||||
except Exception:
|
||||
continue
|
||||
if mass > 0:
|
||||
mats.append({"name": mcol.replace("(g)", ""), "mass": mass})
|
||||
total_mass += mass
|
||||
|
||||
order_data = {
|
||||
"batchId": batch_id,
|
||||
"orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}",
|
||||
"createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None),
|
||||
"bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶",
|
||||
"mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0,
|
||||
"loadSheddingInfo": _as_int(row[col_load]) if col_load else 0,
|
||||
"pouchCellInfo": _as_int(row[col_pouch]) if col_pouch else 0,
|
||||
"conductivityInfo": _as_int(row[col_cond]) if col_cond else 0,
|
||||
"conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0,
|
||||
"materialInfos": mats,
|
||||
"totalMass": round(total_mass, 4) # 自动汇总
|
||||
}
|
||||
orders.append(order_data)
|
||||
|
||||
# print(orders)
|
||||
|
||||
response = self._post_lims("/api/lims/order/orders", orders)
|
||||
self.order_status[response["data"]["orderCode"]] = "running"
|
||||
|
||||
while True:
|
||||
time.sleep(5)
|
||||
if self.order_status.get(response["data"]["orderCode"], None) == "finished":
|
||||
break
|
||||
return response
|
||||
|
||||
|
||||
# 2.7 启动调度
|
||||
def scheduler_start(self) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/scheduler/start")
|
||||
# 3.10 停止调度
|
||||
def scheduler_stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止调度 (3.10)
|
||||
请求体只包含 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/scheduler/stop")
|
||||
|
||||
|
||||
# 2.24 物料变更推送
|
||||
def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等)
|
||||
"""
|
||||
return self._post_report_raw("/report/material_change", material_obj)
|
||||
|
||||
# 2.21 步骤完成推送(BS → LIMS)
|
||||
def report_step_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
step_name: str,
|
||||
step_id: str,
|
||||
sample_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
execution_status: str = "completed") -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"stepName": step_name,
|
||||
"stepId": step_id,
|
||||
"sampleId": sample_id,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"executionStatus": execution_status
|
||||
}
|
||||
return self._post_report("/report/step_finish", data)
|
||||
|
||||
# 2.23 订单完成推送(BS → LIMS)
|
||||
def report_order_finish(self,
|
||||
order_code: str,
|
||||
order_name: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str = "30", # 30 完成 / -11 异常停止 / -12 人工停止
|
||||
workflow_status: str = "Finished",
|
||||
completion_time: Optional[str] = None,
|
||||
used_materials: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderCode": order_code,
|
||||
"orderName": order_name,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
"status": status,
|
||||
"workflowStatus": workflow_status,
|
||||
"completionTime": completion_time or end_time,
|
||||
"usedMaterials": used_materials or []
|
||||
}
|
||||
return self._post_report("/report/order_finish", data)
|
||||
|
||||
# 2.5 批量查询实验报告(用于轮询是否完成)
|
||||
def order_list(self,
|
||||
status: Optional[str] = None,
|
||||
begin_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
filter_text: Optional[str] = None,
|
||||
skip: int = 0, page: int = 10) -> Dict[str, Any]:
|
||||
data: Dict[str, Any] = {"skipCount": skip, "pageCount": page}
|
||||
if status is not None: # 80 成功 / 90 失败 / 100 执行中
|
||||
data["status"] = status
|
||||
if begin_time:
|
||||
data["timeType"] = "CreationTime"
|
||||
data["beginTime"] = begin_time
|
||||
if end_time:
|
||||
data["endTime"] = end_time
|
||||
if filter_text:
|
||||
data["filter"] = filter_text
|
||||
return self._post_lims("/api/lims/order/order-list", data)
|
||||
|
||||
# 2.6 实验报告查询(根据任务ID拿详情)
|
||||
def order_report(self, order_id: str) -> Dict[str, Any]:
|
||||
return self._post_lims("/api/lims/order/order-report", order_id)
|
||||
|
||||
# 2.32 3-2-1 物料转运
|
||||
def transfer_3_to_2_to_1(self,
|
||||
# source_wh_id: Optional[str] = None,
|
||||
source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b',
|
||||
source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z
|
||||
}
|
||||
if source_wh_id:
|
||||
payload["sourceWHID"] = source_wh_id
|
||||
return self._post_lims("/api/lims/order/transfer-task3To2To1", payload)
|
||||
|
||||
# 2.28 样品/废料取出
|
||||
def take_out(self,
|
||||
order_id: str,
|
||||
preintake_ids: Optional[List[str]] = None,
|
||||
material_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
data = {
|
||||
"orderId": order_id,
|
||||
"preintakeIds": preintake_ids or [],
|
||||
"materialIds": material_ids or []
|
||||
}
|
||||
return self._post_lims("/api/lims/order/take-out", data)
|
||||
|
||||
# --------(可选)占位方法:文档未定义的“1号站内部流程 / 1-2转运”--------
|
||||
def start_station1_internal_flow(self, **kwargs) -> None:
|
||||
logger.info("启动1号站内部流程(占位,按现场系统填充具体指令)")
|
||||
|
||||
|
||||
# 3.x 1→2 物料转运
|
||||
def transfer_1_to_2(self) -> Dict[str, Any]:
|
||||
"""
|
||||
1→2 物料转运
|
||||
URL: /api/lims/order/transfer-task1To2
|
||||
只需要 apiKey 和 requestTime
|
||||
"""
|
||||
return self._post_lims("/api/lims/order/transfer-task1To2")
|
||||
|
||||
|
||||
# -------------------- 整体编排 --------------------
|
||||
def run_full_workflow(self,
|
||||
inbound_items: List[Dict[str, str]],
|
||||
orders: List[Dict[str, Any]],
|
||||
poll_filter_code: Optional[str] = None,
|
||||
poll_timeout_s: int = 600,
|
||||
poll_interval_s: int = 5,
|
||||
transfer_source: Optional[Dict[str, Any]] = None,
|
||||
takeout_order_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
一键串联:
|
||||
1) 入库 3-4 个物料 → 2) 新建实验 → 3) 启动调度
|
||||
运行中(如需):4) 物料变更推送 5) 步骤完成推送 6) 订单完成推送
|
||||
完成后:查询实验(2.5/2.6)→ 7) 3-2-1 转运 → 8) 1号站内部流程
|
||||
→ 9) 1-2 转运 → 10) 样品/废料取出
|
||||
"""
|
||||
# 1. 入库(多于1个就用批量接口 2.18)
|
||||
if len(inbound_items) == 1:
|
||||
r = self.storage_inbound(inbound_items[0]["materialId"], inbound_items[0]["locationId"])
|
||||
logger.info(f"单个入库结果: {r}")
|
||||
else:
|
||||
r = self.storage_batch_inbound(inbound_items)
|
||||
logger.info(f"批量入库结果: {r}")
|
||||
|
||||
# 2. 新建实验(2.14)
|
||||
r = self.create_orders(orders)
|
||||
logger.info(f"新建实验结果: {r}")
|
||||
|
||||
# 3. 启动调度(2.7)
|
||||
r = self.scheduler_start()
|
||||
logger.info(f"启动调度结果: {r}")
|
||||
|
||||
# —— 运行中各类推送(2.24 / 2.21 / 2.23),通常由实际任务驱动,这里提供调用方式 —— #
|
||||
# self.report_material_change({...})
|
||||
# self.report_step_finish(order_code="BSO...", order_name="配液分液", step_name="xxx", step_id="...", sample_id="...",
|
||||
# start_time=_iso_utc_now_ms(), end_time=_iso_utc_now_ms(), execution_status="completed")
|
||||
# self.report_order_finish(order_code="BSO...", order_name="配液分液", start_time="...", end_time=_iso_utc_now_ms())
|
||||
|
||||
# 完成后才能转运:用 2.5 批量查询配合 filter=任务编码 轮询到 status=80(成功)
|
||||
if poll_filter_code:
|
||||
import time
|
||||
deadline = time.time() + poll_timeout_s
|
||||
while time.time() < deadline:
|
||||
res = self.order_list(status="80", filter_text=poll_filter_code, page=5)
|
||||
if isinstance(res, dict) and res.get("data", {}).get("items"):
|
||||
logger.info(f"实验 {poll_filter_code} 已完成:{res['data']['items'][0]}")
|
||||
break
|
||||
time.sleep(poll_interval_s)
|
||||
else:
|
||||
logger.warning(f"等待实验 {poll_filter_code} 完成超时(未到 status=80)")
|
||||
|
||||
# 7. 启动 3-2-1 转运(2.32)
|
||||
if transfer_source:
|
||||
r = self.transfer_3_to_2_to_1(
|
||||
source_wh_id=transfer_source.get("sourceWHID"),
|
||||
source_x=transfer_source.get("sourcePosX", 1),
|
||||
source_y=transfer_source.get("sourcePosY", 1),
|
||||
source_z=transfer_source.get("sourcePosZ", 1),
|
||||
)
|
||||
logger.info(f"3-2-1 转运结果: {r}")
|
||||
|
||||
# 8. 1号站内部流程(占位)
|
||||
self.start_station1_internal_flow()
|
||||
|
||||
# 9. 1→2 转运(占位)
|
||||
self.transfer_1_to_2()
|
||||
|
||||
# 10. 样品/废料取出(2.28)
|
||||
if takeout_order_id:
|
||||
r = self.take_out(order_id=takeout_order_id)
|
||||
logger.info(f"样品/废料取出结果: {r}")
|
||||
|
||||
|
||||
# 套接字服务端收到“步骤完成”时调用
|
||||
def process_step_finish_report(self, report_request):
|
||||
order_code = report_request.data.get("orderCode")
|
||||
if order_code:
|
||||
self.order_status[order_code] = "step_finished"
|
||||
logger.info(f"[REPORT] 订单 {order_code} 步骤完成")
|
||||
return {"ack": True}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials=None):
|
||||
order_code = report_request.data.get("orderCode")
|
||||
if order_code:
|
||||
self.order_status[order_code] = "finished"
|
||||
logger.info(f"[REPORT] 订单 {order_code} 已完成,状态改为 finished")
|
||||
return {"ack": True, "usedMaterials": used_materials or []}
|
||||
|
||||
# 收到“通量完成”时调用
|
||||
def process_sample_finish_report(self, report_request):
|
||||
order_code = report_request.data.get("orderCode")
|
||||
if order_code:
|
||||
self.order_status[order_code] = "sample_finished"
|
||||
logger.info(f"[REPORT] 订单 {order_code} 通量完成")
|
||||
return {"ack": True}
|
||||
|
||||
def test_benyao_workstation(self, num1, num2):
|
||||
num1 = int(num1)
|
||||
num2 = int(num2)
|
||||
for i in range(num1):
|
||||
print(f"num1 = {num1}")
|
||||
for j in range(num2):
|
||||
print(f"num1 = {num2}")
|
||||
test_benyao_api()
|
||||
|
||||
|
||||
# --------------------------------
|
||||
if __name__ == "__main__":
|
||||
ws = BioyondWorkstation()
|
||||
# ws.scheduler_stop()
|
||||
# 3.30 上料:读取模板 Excel 自动解析并 POST
|
||||
# r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板 (8).xlsx")
|
||||
# ws.scheduler_start()
|
||||
# print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items
|
||||
|
||||
# 新建实验
|
||||
# res = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092501.xlsx")
|
||||
# print(res)
|
||||
|
||||
# 3.31 下料:同理
|
||||
# r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx")
|
||||
# print(r2["payload"]["data"])
|
||||
|
||||
# r321 = ws.transfer_3_to_2_to_1()
|
||||
# ws.transfer_1_to_2()
|
||||
|
||||
|
||||
706
unilabos/devices/workstation/bioyond_cell/cellconfig3c.json
Normal file
706
unilabos/devices/workstation/bioyond_cell/cellconfig3c.json
Normal file
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_workstation",
|
||||
"name": "配液分液工站",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bioyondworkstation_device",
|
||||
"config": {
|
||||
"protocol_type": [],
|
||||
"station_resource": {}
|
||||
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "BatteryStation",
|
||||
"name": "扣电工作站",
|
||||
"children": [
|
||||
"coin_cell_deck"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bettery_station_registry",
|
||||
"position": {
|
||||
"x": 600,
|
||||
"y": 400,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"debug_mode": false,
|
||||
"_comment": "protocol_type接外部工站固定写法字段,一般为空,station_resource写法也固定",
|
||||
"protocol_type": [],
|
||||
"station_resource": {
|
||||
"data": {
|
||||
"_resource_child_name": "coin_cell_deck",
|
||||
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.button_battery_station:CoincellDeck"
|
||||
}
|
||||
},
|
||||
|
||||
"address": "192.168.1.20",
|
||||
"port": 502
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "coin_cell_deck",
|
||||
"name": "coin_cell_deck",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
"\u7535\u6c60\u6599\u76d8"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "CoincellDeck",
|
||||
"size_x": 1000,
|
||||
"size_y": 1000,
|
||||
"size_z": 900,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "coin_cell_deck",
|
||||
"barcode": null
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8",
|
||||
"name": "\u7535\u6c60\u6599\u76d8",
|
||||
"sample_id": null,
|
||||
"children": [
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"\u7535\u6c60\u6599\u76d8_materialhole_3_3"
|
||||
],
|
||||
"parent": "coin_cell_deck",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialPlate",
|
||||
"size_x": 120.8,
|
||||
"size_y": 160.5,
|
||||
"size_z": 10.0,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {
|
||||
"A1": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"B1": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"C1": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"D1": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"A2": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"B2": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"C2": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"D2": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"A3": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"B3": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"C3": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"D3": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"A4": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"B4": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"C4": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"D4": "\u7535\u6c60\u6599\u76d8_materialhole_3_3"
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_0_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 12.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_1_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 36.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_2_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 60.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_0",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 104.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_1",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 80.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_2",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 56.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "\u7535\u6c60\u6599\u76d8_materialhole_3_3",
|
||||
"name": "\u7535\u6c60\u6599\u76d8_materialhole_3_3",
|
||||
"sample_id": null,
|
||||
"children": [],
|
||||
"parent": "\u7535\u6c60\u6599\u76d8",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 84.4,
|
||||
"y": 32.25,
|
||||
"z": 10.0
|
||||
},
|
||||
"config": {
|
||||
"type": "MaterialHole",
|
||||
"size_x": 16,
|
||||
"size_y": 16,
|
||||
"size_z": 16,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "material_hole",
|
||||
"model": null,
|
||||
"barcode": null
|
||||
},
|
||||
"data": {
|
||||
"diameter": 20,
|
||||
"depth": 10,
|
||||
"max_sheets": 1,
|
||||
"info": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
20
unilabos/devices/workstation/bioyond_cell/cellconfig3ca.json
Normal file
20
unilabos/devices/workstation/bioyond_cell/cellconfig3ca.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bioyond_workstation",
|
||||
"name": "配液分液工站",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "bioyondworkstation_device",
|
||||
"config": {
|
||||
"protocol_type": [],
|
||||
"station_resource": {}
|
||||
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
BIN
unilabos/devices/workstation/bioyond_cell/样品导入模板.xlsx
Normal file
BIN
unilabos/devices/workstation/bioyond_cell/样品导入模板.xlsx
Normal file
Binary file not shown.
374
unilabos/devices/workstation/bioyond_material_management.py
Normal file
374
unilabos/devices/workstation/bioyond_material_management.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
Bioyond物料管理实现
|
||||
Bioyond Material Management Implementation
|
||||
|
||||
基于Bioyond系统的物料管理,支持从Bioyond系统同步物料到UniLab工作站
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pylabrobot.resources import (
|
||||
Resource as PLRResource,
|
||||
Container,
|
||||
Deck,
|
||||
Coordinate as PLRCoordinate,
|
||||
)
|
||||
|
||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.resources.graphio import (
|
||||
resource_plr_to_ulab,
|
||||
resource_ulab_to_plr,
|
||||
resource_bioyond_to_ulab,
|
||||
resource_bioyond_container_to_ulab,
|
||||
resource_ulab_to_bioyond
|
||||
)
|
||||
from .workstation_material_management import MaterialManagementBase
|
||||
|
||||
|
||||
class BioyondMaterialManagement(MaterialManagementBase):
|
||||
"""Bioyond物料管理类
|
||||
|
||||
实现从Bioyond系统同步物料到UniLab工作站的功能:
|
||||
1. 从Bioyond系统获取物料数据
|
||||
2. 转换为UniLab格式
|
||||
3. 同步到PyLabRobot Deck
|
||||
4. 支持双向同步
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
deck_config: Dict[str, Any],
|
||||
resource_tracker: DeviceNodeResourceTracker,
|
||||
children_config: Dict[str, Dict[str, Any]] = None,
|
||||
bioyond_config: Dict[str, Any] = None
|
||||
):
|
||||
self.bioyond_config = bioyond_config or {}
|
||||
self.bioyond_api_client = None
|
||||
self.sync_interval = self.bioyond_config.get("sync_interval", 30) # 同步间隔(秒)
|
||||
|
||||
# 初始化父类
|
||||
super().__init__(device_id, deck_config, resource_tracker, children_config)
|
||||
|
||||
# 初始化Bioyond API客户端
|
||||
self._initialize_bioyond_client()
|
||||
|
||||
# 启动同步任务
|
||||
self._start_sync_task()
|
||||
|
||||
def _initialize_bioyond_client(self):
|
||||
"""初始化Bioyond API客户端"""
|
||||
try:
|
||||
# 这里应该根据实际的Bioyond API实现
|
||||
# 暂时使用模拟客户端
|
||||
self.bioyond_api_client = BioyondAPIClient(self.bioyond_config)
|
||||
logger.info(f"Bioyond API客户端初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API客户端初始化失败: {e}")
|
||||
self.bioyond_api_client = None
|
||||
|
||||
def _start_sync_task(self):
|
||||
"""启动同步任务"""
|
||||
if self.bioyond_api_client:
|
||||
# 创建异步同步任务
|
||||
asyncio.create_task(self._periodic_sync())
|
||||
logger.info(f"Bioyond同步任务已启动,间隔: {self.sync_interval}秒")
|
||||
|
||||
async def _periodic_sync(self):
|
||||
"""定期同步任务"""
|
||||
while True:
|
||||
try:
|
||||
await self.sync_from_bioyond()
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond同步任务出错: {e}")
|
||||
await asyncio.sleep(self.sync_interval)
|
||||
|
||||
async def sync_from_bioyond(self) -> bool:
|
||||
"""从Bioyond系统同步物料"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 从Bioyond获取物料数据
|
||||
bioyond_data = await self.bioyond_api_client.get_materials()
|
||||
if not bioyond_data:
|
||||
logger.warning("从Bioyond获取物料数据为空")
|
||||
return False
|
||||
|
||||
# 2. 转换为UniLab格式
|
||||
if isinstance(bioyond_data, dict) and "data" in bioyond_data:
|
||||
# 容器格式数据
|
||||
unilab_resources = resource_bioyond_container_to_ulab(bioyond_data)
|
||||
else:
|
||||
# 物料列表格式数据
|
||||
unilab_resources = resource_bioyond_to_ulab(bioyond_data)
|
||||
|
||||
# 3. 转换为PLR格式并分配到Deck
|
||||
await self._assign_resources_to_deck(unilab_resources)
|
||||
|
||||
logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从Bioyond同步物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def sync_to_bioyond(self, plr_resource: PLRResource) -> bool:
|
||||
"""将本地物料变更同步到Bioyond系统"""
|
||||
try:
|
||||
if not self.bioyond_api_client:
|
||||
logger.warning("Bioyond API客户端未初始化")
|
||||
return False
|
||||
|
||||
# 1. 转换为UniLab格式
|
||||
unilab_resource = resource_plr_to_ulab(plr_resource)
|
||||
|
||||
# 2. 转换为Bioyond格式
|
||||
bioyond_materials = resource_ulab_to_bioyond([unilab_resource])
|
||||
|
||||
# 3. 发送到Bioyond系统
|
||||
success = await self.bioyond_api_client.update_materials(bioyond_materials)
|
||||
|
||||
if success:
|
||||
logger.info(f"成功同步物料 {plr_resource.name} 到Bioyond")
|
||||
else:
|
||||
logger.warning(f"同步物料 {plr_resource.name} 到Bioyond失败")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"同步物料到Bioyond失败: {e}")
|
||||
return False
|
||||
|
||||
async def _assign_resources_to_deck(self, unilab_resources: List[Dict[str, Any]]):
|
||||
"""将UniLab资源分配到Deck"""
|
||||
try:
|
||||
# 转换为PLR格式
|
||||
from unilabos.resources.graphio import list_to_nested_dict
|
||||
nested_resources = list_to_nested_dict(unilab_resources)
|
||||
plr_resources = resource_ulab_to_plr(nested_resources)
|
||||
|
||||
# 分配资源到Deck
|
||||
if hasattr(plr_resources, 'children'):
|
||||
resources_to_assign = plr_resources.children
|
||||
elif isinstance(plr_resources, list):
|
||||
resources_to_assign = plr_resources
|
||||
else:
|
||||
resources_to_assign = [plr_resources]
|
||||
|
||||
for resource in resources_to_assign:
|
||||
try:
|
||||
# 获取资源位置
|
||||
if hasattr(resource, 'location') and resource.location:
|
||||
location = PLRCoordinate(resource.location.x, resource.location.y, resource.location.z)
|
||||
else:
|
||||
location = PLRCoordinate(0, 0, 0)
|
||||
|
||||
# 分配资源到Deck
|
||||
self.plr_deck.assign_child_resource(resource, location)
|
||||
|
||||
# 注册到resource tracker
|
||||
self.resource_tracker.add_resource(resource)
|
||||
|
||||
# 保存资源引用
|
||||
self.plr_resources[resource.name] = resource
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源 {resource.name} 到Deck失败: {e}")
|
||||
|
||||
logger.info(f"成功分配了 {len(resources_to_assign)} 个资源到Deck")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配资源到Deck失败: {e}")
|
||||
|
||||
def _create_resource_by_type(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""根据类型创建Bioyond相关资源"""
|
||||
try:
|
||||
# 这里可以根据需要实现特定的Bioyond资源类型
|
||||
# 目前使用通用的容器类型
|
||||
if resource_type in ["container", "plate", "well"]:
|
||||
return self._create_generic_container(resource_id, resource_type, config, data, location)
|
||||
else:
|
||||
logger.warning(f"未知的Bioyond资源类型: {resource_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建Bioyond资源失败 {resource_id} ({resource_type}): {e}")
|
||||
return None
|
||||
|
||||
def _create_generic_container(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
config: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
location: PLRCoordinate
|
||||
) -> Optional[PLRResource]:
|
||||
"""创建通用容器资源"""
|
||||
try:
|
||||
from pylabrobot.resources import Plate, Well
|
||||
|
||||
if resource_type == "plate":
|
||||
return Plate(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 127.76),
|
||||
size_y=config.get("size_y", 85.48),
|
||||
size_z=config.get("size_z", 14.35),
|
||||
location=location,
|
||||
category="plate"
|
||||
)
|
||||
elif resource_type == "well":
|
||||
return Well(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 9.0),
|
||||
size_y=config.get("size_y", 9.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="well"
|
||||
)
|
||||
else:
|
||||
return Container(
|
||||
name=resource_id,
|
||||
size_x=config.get("size_x", 50.0),
|
||||
size_y=config.get("size_y", 50.0),
|
||||
size_z=config.get("size_z", 10.0),
|
||||
location=location,
|
||||
category="container"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建通用容器失败 {resource_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_bioyond_materials(self) -> List[Dict[str, Any]]:
|
||||
"""获取当前Bioyond物料列表"""
|
||||
try:
|
||||
# 将当前PLR资源转换为Bioyond格式
|
||||
bioyond_materials = []
|
||||
for resource in self.plr_resources.values():
|
||||
unilab_resource = resource_plr_to_ulab(resource)
|
||||
bioyond_materials.extend(resource_ulab_to_bioyond([unilab_resource]))
|
||||
return bioyond_materials
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料列表失败: {e}")
|
||||
return []
|
||||
|
||||
def update_material_from_bioyond(self, material_id: str, bioyond_data: Dict[str, Any]) -> bool:
|
||||
"""从Bioyond数据更新指定物料"""
|
||||
try:
|
||||
# 查找现有物料
|
||||
material = self.find_material_by_id(material_id)
|
||||
if not material:
|
||||
logger.warning(f"未找到物料: {material_id}")
|
||||
return False
|
||||
|
||||
# 转换Bioyond数据为UniLab格式
|
||||
unilab_resources = resource_bioyond_to_ulab([bioyond_data])
|
||||
if not unilab_resources:
|
||||
logger.warning(f"转换Bioyond数据失败: {material_id}")
|
||||
return False
|
||||
|
||||
# 更新物料属性
|
||||
unilab_resource = unilab_resources[0]
|
||||
material.name = unilab_resource.get("name", material.name)
|
||||
|
||||
# 更新位置
|
||||
position = unilab_resource.get("position", {})
|
||||
if position:
|
||||
material.location = PLRCoordinate(
|
||||
position.get("x", 0),
|
||||
position.get("y", 0),
|
||||
position.get("z", 0)
|
||||
)
|
||||
|
||||
logger.info(f"成功更新物料: {material_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新物料失败 {material_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class BioyondAPIClient:
|
||||
"""Bioyond API客户端(模拟实现)
|
||||
|
||||
实际使用时需要根据Bioyond系统的API接口实现
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.base_url = config.get("base_url", "http://localhost:8080")
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.timeout = config.get("timeout", 30)
|
||||
|
||||
async def get_materials(self) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
|
||||
"""从Bioyond系统获取物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
# 暂时返回模拟数据
|
||||
logger.info("从Bioyond API获取物料数据")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据(实际应该从API获取)
|
||||
return {
|
||||
"data": [],
|
||||
"code": 1,
|
||||
"message": "success",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond API调用失败: {e}")
|
||||
return None
|
||||
|
||||
async def update_materials(self, materials: List[Dict[str, Any]]) -> bool:
|
||||
"""更新Bioyond系统中的物料数据"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"更新Bioyond系统中的 {len(materials)} 个物料")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 模拟成功响应
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新Bioyond物料失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_material_by_id(self, material_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""根据ID获取单个物料"""
|
||||
try:
|
||||
# 这里应该实现实际的API调用
|
||||
logger.info(f"从Bioyond API获取物料: {material_id}")
|
||||
|
||||
# 模拟API调用延迟
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 返回模拟数据
|
||||
return {
|
||||
"id": material_id,
|
||||
"name": f"material_{material_id}",
|
||||
"type": "container",
|
||||
"quantity": 1.0,
|
||||
"unit": "个"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Bioyond物料失败 {material_id}: {e}")
|
||||
return None
|
||||
1951
unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py
Normal file
1951
unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py
Normal file
File diff suppressed because it is too large
Load Diff
398
unilabos/devices/workstation/bioyond_studio/experiment.py
Normal file
398
unilabos/devices/workstation/bioyond_studio/experiment.py
Normal file
@@ -0,0 +1,398 @@
|
||||
# experiment_workflow.py
|
||||
"""
|
||||
实验流程主程序
|
||||
"""
|
||||
|
||||
import json
|
||||
from bioyond_rpc import BioyondV1RPC
|
||||
from config import API_CONFIG, WORKFLOW_MAPPINGS
|
||||
|
||||
|
||||
def run_experiment():
|
||||
"""运行实验流程"""
|
||||
|
||||
# 初始化Bioyond客户端
|
||||
config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS
|
||||
}
|
||||
|
||||
Bioyond = BioyondV1RPC(config)
|
||||
|
||||
print("\n============= 多工作流参数测试(简化接口+材料缓存)=============")
|
||||
|
||||
# 显示可用的材料名称(前20个)
|
||||
available_materials = Bioyond.get_available_materials()
|
||||
print(f"可用材料名称(前20个): {available_materials[:20]}")
|
||||
print(f"总共有 {len(available_materials)} 个材料可用\n")
|
||||
|
||||
# 1. 反应器放入
|
||||
print("1. 添加反应器放入工作流,带参数...")
|
||||
Bioyond.reactor_taken_in(
|
||||
assign_material_name="BTDA-DD",
|
||||
cutoff="10000",
|
||||
temperature="-10"
|
||||
)
|
||||
|
||||
# 2. 液体投料-烧杯 (第一个)
|
||||
print("2. 添加液体投料-烧杯,带参数...")
|
||||
Bioyond.liquid_feeding_beaker(
|
||||
volume="34768.7",
|
||||
assign_material_name="ODA",
|
||||
time="0",
|
||||
torque_variation="1",
|
||||
titrationType="1",
|
||||
temperature=-10
|
||||
)
|
||||
|
||||
# 3. 液体投料-烧杯 (第二个)
|
||||
print("3. 添加液体投料-烧杯,带参数...")
|
||||
Bioyond.liquid_feeding_beaker(
|
||||
volume="34080.9",
|
||||
assign_material_name="MPDA",
|
||||
time="5",
|
||||
torque_variation="2",
|
||||
titrationType="1",
|
||||
temperature=0
|
||||
)
|
||||
|
||||
# 4. 液体投料-小瓶非滴定
|
||||
print("4. 添加液体投料-小瓶非滴定,带参数...")
|
||||
Bioyond.liquid_feeding_vials_non_titration(
|
||||
volumeFormula="639.5",
|
||||
assign_material_name="SIDA",
|
||||
titration_type="1",
|
||||
time="0",
|
||||
torque_variation="1",
|
||||
temperature=-10
|
||||
)
|
||||
|
||||
# 5. 液体投料溶剂
|
||||
print("5. 添加液体投料溶剂,带参数...")
|
||||
Bioyond.liquid_feeding_solvents(
|
||||
assign_material_name="NMP",
|
||||
volume="19000",
|
||||
titration_type="1",
|
||||
time="5",
|
||||
torque_variation="2",
|
||||
temperature=-10
|
||||
)
|
||||
|
||||
# 6-8. 固体进料小瓶 (三个)
|
||||
print("6. 添加固体进料小瓶,带参数...")
|
||||
Bioyond.solid_feeding_vials(
|
||||
material_id="3",
|
||||
time="180",
|
||||
torque_variation="2",
|
||||
assign_material_name="BTDA-1",
|
||||
temperature=-10.00
|
||||
)
|
||||
|
||||
print("7. 添加固体进料小瓶,带参数...")
|
||||
Bioyond.solid_feeding_vials(
|
||||
material_id="3",
|
||||
time="180",
|
||||
torque_variation="2",
|
||||
assign_material_name="BTDA-2",
|
||||
temperature=25.00
|
||||
)
|
||||
|
||||
print("8. 添加固体进料小瓶,带参数...")
|
||||
Bioyond.solid_feeding_vials(
|
||||
material_id="3",
|
||||
time="480",
|
||||
torque_variation="2",
|
||||
assign_material_name="BTDA-3",
|
||||
temperature=25.00
|
||||
)
|
||||
|
||||
# 液体投料滴定(第一个)
|
||||
print("9. 添加液体投料滴定,带参数...") # ODPA
|
||||
Bioyond.liquid_feeding_titration(
|
||||
volume_formula="1000",
|
||||
assign_material_name="BTDA-DD",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature="25.00"
|
||||
)
|
||||
|
||||
# 液体投料滴定(第二个)
|
||||
print("10. 添加液体投料滴定,带参数...") # ODPA
|
||||
Bioyond.liquid_feeding_titration(
|
||||
volume_formula="500",
|
||||
assign_material_name="BTDA-DD",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature="25.00"
|
||||
)
|
||||
|
||||
# 液体投料滴定(第三个)
|
||||
print("11. 添加液体投料滴定,带参数...") # ODPA
|
||||
Bioyond.liquid_feeding_titration(
|
||||
volume_formula="500",
|
||||
assign_material_name="BTDA-DD",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature="25.00"
|
||||
)
|
||||
|
||||
print("12. 添加液体投料滴定,带参数...") # ODPA
|
||||
Bioyond.liquid_feeding_titration(
|
||||
volume_formula="500",
|
||||
assign_material_name="BTDA-DD",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature="25.00"
|
||||
)
|
||||
|
||||
print("13. 添加液体投料滴定,带参数...") # ODPA
|
||||
Bioyond.liquid_feeding_titration(
|
||||
volume_formula="500",
|
||||
assign_material_name="BTDA-DD",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature="25.00"
|
||||
)
|
||||
|
||||
print("14. 添加液体投料滴定,带参数...") # ODPA
|
||||
Bioyond.liquid_feeding_titration(
|
||||
volume_formula="500",
|
||||
assign_material_name="BTDA-DD",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature="25.00"
|
||||
)
|
||||
|
||||
|
||||
|
||||
print("15. 添加液体投料溶剂,带参数...")
|
||||
Bioyond.liquid_feeding_solvents(
|
||||
assign_material_name="PGME",
|
||||
volume="16894.6",
|
||||
titration_type="1",
|
||||
time="360",
|
||||
torque_variation="2",
|
||||
temperature=25.00
|
||||
)
|
||||
|
||||
# 16. 反应器取出
|
||||
print("16. 添加反应器取出工作流...")
|
||||
Bioyond.reactor_taken_out()
|
||||
|
||||
# 显示当前工作流序列
|
||||
sequence = Bioyond.get_workflow_sequence()
|
||||
print("\n当前工作流执行顺序:")
|
||||
print(sequence)
|
||||
|
||||
# 执行process_and_execute_workflow,合并工作流并创建任务
|
||||
print("\n4. 执行process_and_execute_workflow...")
|
||||
|
||||
result = Bioyond.process_and_execute_workflow(
|
||||
workflow_name="test3_86",
|
||||
task_name="实验3_86"
|
||||
)
|
||||
|
||||
# 显示执行结果
|
||||
print("\n5. 执行结果:")
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result_dict = json.loads(result)
|
||||
if result_dict.get("success"):
|
||||
print("任务创建成功!")
|
||||
print(f"- 工作流: {result_dict.get('workflow', {}).get('name')}")
|
||||
print(f"- 工作流ID: {result_dict.get('workflow', {}).get('id')}")
|
||||
print(f"- 任务结果: {result_dict.get('task')}")
|
||||
else:
|
||||
print(f"任务创建失败: {result_dict.get('error')}")
|
||||
except:
|
||||
print(f"结果解析失败: {result}")
|
||||
else:
|
||||
if result.get("success"):
|
||||
print("任务创建成功!")
|
||||
print(f"- 工作流: {result.get('workflow', {}).get('name')}")
|
||||
print(f"- 工作流ID: {result.get('workflow', {}).get('id')}")
|
||||
print(f"- 任务结果: {result.get('task')}")
|
||||
else:
|
||||
print(f"任务创建失败: {result.get('error')}")
|
||||
|
||||
# 可选:启动调度器
|
||||
# Bioyond.scheduler_start()
|
||||
|
||||
return Bioyond
|
||||
|
||||
|
||||
def prepare_materials(bioyond):
|
||||
"""准备实验材料(可选)"""
|
||||
|
||||
# 样品板材料数据定义
|
||||
material_data_yp_1 = {
|
||||
"typeId": "3a142339-80de-8f25-6093-1b1b1b6c322e",
|
||||
"name": "样品板-1",
|
||||
"unit": "个",
|
||||
"quantity": 1,
|
||||
"details": [
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BPDA-DD-1",
|
||||
"quantity": 1,
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "PEPA",
|
||||
"quantity": 1,
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BPDA-DD-2",
|
||||
"quantity": 1,
|
||||
"x": 1,
|
||||
"y": 3,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BPDA-1",
|
||||
"quantity": 1,
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "PMDA",
|
||||
"quantity": 1,
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BPDA-2",
|
||||
"quantity": 1,
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
}
|
||||
],
|
||||
"Parameters": "{}"
|
||||
}
|
||||
|
||||
material_data_yp_2 = {
|
||||
"typeId": "3a142339-80de-8f25-6093-1b1b1b6c322e",
|
||||
"name": "样品板-2",
|
||||
"unit": "个",
|
||||
"quantity": 1,
|
||||
"details": [
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BPDA-DD",
|
||||
"quantity": 1,
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "SIDA",
|
||||
"quantity": 1,
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BTDA-1",
|
||||
"quantity": 1,
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BTDA-2",
|
||||
"quantity": 1,
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
|
||||
"name": "BTDA-3",
|
||||
"quantity": 1,
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
"Parameters": "{\"molecular\": 1}"
|
||||
}
|
||||
],
|
||||
"Parameters": "{}"
|
||||
}
|
||||
|
||||
# 烧杯材料数据定义
|
||||
beaker_materials = [
|
||||
{
|
||||
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
|
||||
"name": "PDA-1",
|
||||
"unit": "微升",
|
||||
"quantity": 1,
|
||||
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
|
||||
"name": "TFDB",
|
||||
"unit": "微升",
|
||||
"quantity": 1,
|
||||
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
|
||||
"name": "ODA",
|
||||
"unit": "微升",
|
||||
"quantity": 1,
|
||||
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
|
||||
"name": "MPDA",
|
||||
"unit": "微升",
|
||||
"quantity": 1,
|
||||
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
|
||||
},
|
||||
{
|
||||
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
|
||||
"name": "PDA-2",
|
||||
"unit": "微升",
|
||||
"quantity": 1,
|
||||
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
|
||||
}
|
||||
]
|
||||
|
||||
# 如果需要,可以在这里调用add_material方法添加材料
|
||||
# 例如:
|
||||
# result = bioyond.add_material(json.dumps(material_data_yp_1))
|
||||
# print(f"添加材料结果: {result}")
|
||||
|
||||
return {
|
||||
"sample_plates": [material_data_yp_1, material_data_yp_2],
|
||||
"beakers": beaker_materials
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行主实验流程
|
||||
bioyond_client = run_experiment()
|
||||
|
||||
# 可选:准备材料数据
|
||||
# materials = prepare_materials(bioyond_client)
|
||||
# print(f"\n准备的材料数据: {materials}")
|
||||
2379
unilabos/devices/workstation/bioyond_studio/station.py
Normal file
2379
unilabos/devices/workstation/bioyond_studio/station.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user