Compare commits

...

208 Commits

Author SHA1 Message Date
Andy6M
936834f8c3 Update workstation code for YB4 0107 2026-01-07 11:59:32 +08:00
Calvin Cao
915a6a04c3 Merge pull request #201 from sun7151887/push-sync-20251222
合并dev分支
2025-12-22 11:12:40 +08:00
dijkstra402
48b51c3a4a Merge dptech/workstation_dev_YB4: resolve conflicts by keeping local changes (ours) 2025-12-22 11:09:17 +08:00
dijkstra402
acef0b8ca2 Sync local workspace → push to yb4-fix 2025-12-22 10:57:29 +08:00
ZiWei
97788b4e07 feat: introduce wait_time command and configurable device communication timeout. 2025-12-19 18:02:38 +08:00
ZiWei
39cc280c91 feat: Add SyringePump (SY-03B) driver with unified serial/TCP transport for chinwe device, including registry and test configurations. 2025-12-19 03:05:11 +08:00
Xuwznln
152d3a7563 Update docs 2025-12-14 13:12:19 +08:00
Xuwznln
ef14737839 update "laiyu" missing init file. 2025-12-14 13:08:27 +08:00
Xuwznln
5d5569121c fix "laiyu" missing init file. 2025-12-14 12:55:25 +08:00
Xuwznln
d23e85ade4 fix "🐛 fix" 2025-12-14 01:17:24 +08:00
Haohui
02afafd423 🐛 fix: config file is overwrited by default args even if not be set. 2025-12-12 23:55:38 +08:00
Xianwei Qi
6ac510dcd2 mix
修改了mix,仿真流程报错问题
2025-12-11 23:26:11 +08:00
Xuwznln
ed56c1eba2 reduce logs 2025-12-08 19:23:53 +08:00
Xuwznln
16ee3de086 Add workflow upload func. 2025-12-08 19:12:05 +08:00
Junhan Chang
ced961050d add unilabos/workflow and entrypoint 2025-12-07 17:50:27 +08:00
Xuwznln
11b2c99836 update version to 0.10.12
(cherry picked from commit b1cdef9185)
2025-12-04 18:47:44 +08:00
Xuwznln
04024bc8a3 fix ros2 future 2025-12-04 18:44:50 +08:00
Xuwznln
154048107d print all logs to file
fix resource dict dump error
2025-12-04 16:04:56 +08:00
Xuwznln
0b896870ba signal when host node is ready 2025-12-02 12:00:41 +08:00
Xuwznln
ee609e4aa2 Fix startup with remote resource error 2025-12-02 11:49:59 +08:00
Xuwznln
5551fbf360 Resource dict fully change to "pose" key 2025-12-02 03:45:16 +08:00
Xuwznln
e13b250632 Update oss link 2025-12-01 12:23:07 +08:00
Xuwznln
b8278c5026 Reduce pylabrobot conversion warning & force enable log dump. 2025-11-28 22:41:50 +08:00
ZiWei
53e767a054 更新 logo 图片 2025-11-28 11:35:05 +08:00
Calvin Cao
d2a30fe33b Merge pull request #177 from sun7151887/yb4-fix
Yb4默认仿真机
2025-11-27 18:49:41 +08:00
dijkstra402
096875e910 默认仿真机 2025-11-27 18:22:46 +08:00
Xuwznln
cf7032fa81 Auto dump logs, fix workstation input schema 2025-11-27 14:24:50 +08:00
Xuwznln
97681ba433 Add get_regular_container func 2025-11-27 13:47:47 +08:00
Xuwznln
3fa81ab4f6 Add get_regular_container func
(cherry picked from commit ed8ee29732)
2025-11-27 13:47:46 +08:00
Harry Liu
9f4a69ddf5 Transfer_liquid (#176)
* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Typo in base_device_node.py

* Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity.
2025-11-26 19:30:42 +08:00
Xuwznln
05ae4e72df Add backend api and update doc 2025-11-26 19:03:31 +08:00
Xuwznln
2870c04086 Fix port error
(cherry picked from commit f1ad0c9c96)
2025-11-25 15:22:19 +08:00
Xuwznln
343e87df0d Add result schema and add TypedDict conversion.
(cherry picked from commit 8fa3407649)
2025-11-25 15:22:18 +08:00
Xuwznln
5d0807cba6 add session_id and normal_exit 2025-11-20 22:42:42 +08:00
Xuwznln
4875977d5f Support unilabos_samples key
(cherry picked from commit 554bcade24)
2025-11-19 15:55:21 +08:00
Xuwznln
956b1c905b Add startup_json_path, disable_browser, port config
(cherry picked from commit acf5fdebf8)
2025-11-19 14:23:39 +08:00
Xuwznln
944911c52a bump version to 0.10.11
(cherry picked from commit 7f7b1c13c0)
2025-11-19 14:23:36 +08:00
Xuwznln
a13b790926 Revert "feat(main): enhance argument parsing for addr and port with priority handling"
This reverts commit 7cc2fe036f.
2025-11-19 14:22:58 +08:00
Xuwznln
9feadd68c6 Update oss config
(cherry picked from commit d39662f65f)
2025-11-19 14:22:26 +08:00
ZiWei
c68d5246d0 feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数 2025-11-19 11:28:35 +08:00
ZiWei
49073f2c77 feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能
添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互
实现compute_experiment_design方法用于实验设计计算
新增brief_step_parameters等订单相关接口方法
优化物料转移逻辑,增加异步任务处理
扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能
2025-11-18 18:57:47 +08:00
ZiWei
b2afc29f15 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-11-18 18:57:03 +08:00
Xuwznln
4061280f6b Support internal test examples 2025-11-18 18:43:29 +08:00
Xuwznln
6a681e1d73 Update docs 2025-11-18 18:43:29 +08:00
Xuwznln
653e6e1ac3 liquid_handler default use chatterbox instead of rviz backend 2025-11-18 18:43:28 +08:00
ZiWei
2c774bcd1d feat(反应站): 添加反应器子设备支持
- 在设备注册表中添加反应器子设备配置
- 实现BioyondReactor类用于处理反应器数据
- 更新反应站主设备以支持子设备数据同步
- 在测试配置中添加5个反应器实例
2025-11-18 18:43:28 +08:00
ZiWei
2ba395b681 fix(camera): 修正摄像头配置,更新设备ID和UUID参数 2025-11-18 18:43:28 +08:00
ZiWei
b6b3d59083 feat(反应站): 添加反应器子设备支持
- 在设备注册表中添加反应器子设备配置
- 实现BioyondReactor类用于处理反应器数据
- 更新反应站主设备以支持子设备数据同步
- 在测试配置中添加5个反应器实例
2025-11-17 22:55:51 +08:00
ZiWei
f40e3f521c fix(camera): 修正摄像头配置,更新设备ID和UUID参数 2025-11-17 17:07:07 +08:00
Haohui
7cc2fe036f feat(main): enhance argument parsing for addr and port with priority handling 2025-11-16 22:53:54 +08:00
Calvin Cao
2e17dee121 Merge pull request #167 from lixinyu1011/workstation_dev_YB4
解决奔耀输入配方的,电解液体积为小数的问题
2025-11-16 17:36:50 +08:00
lixinyu1011
c03abb341a 解决奔耀输入配方的,电解液体积为小数的问题 2025-11-16 16:24:59 +08:00
ZiWei
f81d20bb1d fix(warehouse): 修正BioYond 4x4x1仓库的物品尺寸参数 2025-11-16 15:47:10 +08:00
ZiWei
db1b5a869f feat(workstation): 添加温度/粘度报送处理功能
- 在反应站设备配置中添加温度/粘度相关状态类型
- 实现温度/粘度报送处理逻辑并添加ROS消息发布
- 扩展HTTP服务支持温度/粘度报送端点
- 添加HTTP请求日志记录功能
2025-11-16 14:35:53 +08:00
dijkstra402
ee4ed26846 Merge branch 'workstation_dev_YB4' of https://github.com/dptech-corp/Uni-Lab-OS into workstation_dev_YB4 2025-11-11 09:54:22 +08:00
calvincao
b97be6a5d4 feat(battery): 更新电池工作站配置与物料布局
- 修改弹夹尺寸默认值,确保非空时使用实际值
- 调整new_cellconfig3c.json中设备位置和尺寸配置
- 更新CoinCellDeck的尺寸和原点坐标
-重新分配所有物料和弹夹的位置坐标
- 调整电解液缓存位和回收位坐标
- 更新物料板和tip box的布局位置
2025-11-10 21:40:02 +08:00
Calvin Cao
44f830cf00 Merge pull request #163 from sun7151887/yb4-fix
更新YB_Deck堆栈坐标位置,根据图片像素坐标映射到实际尺寸
2025-11-10 19:30:26 +08:00
dijkstra402
04b578a68b 更新YB_Deck堆栈坐标位置,根据图片像素坐标映射到实际尺寸 2025-11-10 18:57:20 +08:00
dijkstra402
19dffcb5db 更新YB_Deck堆栈坐标位置,根据图片像素坐标映射到实际尺寸 2025-11-10 18:57:10 +08:00
dijkstra402
b441362cd2 Merge branch 'workstation_dev_YB4' of https://github.com/dptech-corp/Uni-Lab-OS into workstation_dev_YB4 2025-11-10 18:35:41 +08:00
dijkstra402
ed53ef2f64 Update bioyond_cell and YAML configurations: modified default Excel paths and added new bottle carrier resources. Removed unused fields and updated descriptions for clarity. 2025-11-10 18:35:37 +08:00
dijkstra402
0c9f26e8fc Update Excel files: modified bioyond_cell and material_template with new data 2025-11-10 18:35:21 +08:00
calvincao
39a799cabd feat(device): 更新设备配置文件路径和图标
- 修改 bioyond_cell.yaml 中的 xlsx 文件路径为用户目录路径- 在 bioyond_cell.yaml 中新增 warehouse_name 字段并设置默认值- 为 bioyond_cell.yaml 添加 resource_tree_transfer 参数结构定义
- 更新 bioyond_cell.yaml 中的状态类型和设备 ID 配置
- 将 coin_cell_workstation.yaml 的图标从 coin_cell_assembly_picture.webp 更改为 koudian.webp
- 移除 bioyond_cell.yaml 中冗余的 display_name 配置项
2025-11-10 18:28:38 +08:00
Junhan Chang
0d64563fb6 fix serialize for magazine 2025-11-10 15:40:29 +08:00
Calvin Cao
fbb9e0963d Merge pull request #162 from sun7151887/yb4-fix
Fix import: change electrodesheet to electrode_sheet
2025-11-10 13:38:16 +08:00
dijkstra402
af411ddfe6 Fix import: change electrodesheet to electrode_sheet
修改路径
2025-11-10 13:34:49 +08:00
calvincao
f5dbcb1bfc feat(bioyond_cell): 更新默认模板路径并添加温度字段- 更新了自动送料函数中的默认 Excel 模板路径- 在物料信息中新增 temperature 字段,默认值为0
- 更新了 create_orders 函数中的默认实验文件路径
- 注释掉了部分调试代码,保留关键示例和说明
- 添加了关于位置码、实验文件和物料模板的注释提示
2025-11-10 13:27:54 +08:00
calvincao
1ecf89ea27 修改excel 2025-11-10 13:21:56 +08:00
Calvin Cao
6efdf6e5a6 Merge pull request #161 from sun7151887/yb4-fix
Fix import: change electrodesheet to electrode_sheet
2025-11-09 22:35:10 +08:00
dijkstra402
e32dc55db0 Fix import: change electrodesheet to electrode_sheet 2025-11-09 22:02:17 +08:00
Calvin Cao
acc45b716d Merge pull request #160 from sun7151887/yb4-fix
Update coin cell assembly and YB_YH materials configuration
2025-11-09 21:44:42 +08:00
dijkstra402
017eaefb8d Update coin cell assembly and YB_YH materials configuration 2025-11-09 21:43:32 +08:00
Calvin Cao
9e8c692702 Merge pull request #159 from dptech-corp/workstation_dev_YB3
Update coin cell assembly configuration: change CSV file reference an…
2025-11-09 20:57:19 +08:00
calvincao
beb90f20d2 Update coin cell assembly configuration: change CSV file reference and modify resource names; enhance workstation initialization and packing functions. 2025-11-09 20:56:12 +08:00
Calvin Cao
7a284069d2 Merge pull request #158 from dptech-corp/workstation_dev_YB3
Workstation dev yb3
2025-11-09 17:12:41 +08:00
Calvin Cao
4a2d862333 Merge pull request #157 from sun7151887/fix/yb3-material-names-and-model
Update YB resources: add YB_ prefix to models and update deck configu…
2025-11-09 17:11:24 +08:00
dijkstra402
538891fcbe Update YB resources: add YB_ prefix to models and update deck configurations 2025-11-09 17:04:52 +08:00
Calvin Cao
a0e92b8e9b Merge pull request #156 from dptech-corp/workstation_dev_YB3
Workstation dev yb3
2025-11-09 15:48:35 +08:00
Calvin Cao
1d77225912 Merge branch 'workstation_dev_YB4' into workstation_dev_YB3 2025-11-09 15:48:22 +08:00
Calvin Cao
06e6ab0b7f Merge pull request #155 from sun7151887/fix/yb3-material-names-and-model
Fix warehouse mapping: use actual parent warehouse name instead of ha…
2025-11-09 15:15:55 +08:00
dijkstra402
5399c6c1cf Fix warehouse mapping: use actual parent warehouse name instead of hardcoded '手动堆栈' 2025-11-09 15:13:20 +08:00
Junhan Chang
f872d3ef56 add electrode_sheets definition, and fix magazines 2025-11-09 01:00:05 +08:00
Calvin Cao
85c6f4e688 Merge pull request #154 from lixinyu1011/workstation_dev_YB3
修改pymodbus和websocket的报送信息
2025-11-08 15:59:22 +08:00
lixinyu1011
442b759397 修改pymodbus和websocket的报送信息 2025-11-08 15:56:39 +08:00
Calvin Cao
47ecb154c8 Merge pull request #153 from sun7151887/fix/yb3-material-names-and-model
规范堆栈和瓶子的名称
2025-11-08 15:49:59 +08:00
dijkstra402
be429147c0 Fix infinite recursion in YB_jia_yang_tou_da by renaming carrier function to YB_jia_yang_tou_da_Carrier 2025-11-08 15:42:18 +08:00
Calvin Cao
123c69e97a Merge pull request #152 from lixinyu1011/workstation_dev_YB3
修改减少modbus报警信息,以及websocket报警信息
2025-11-08 15:21:33 +08:00
Calvin Cao
04004c9b6f Merge branch 'workstation_dev_YB3' into workstation_dev_YB3 2025-11-08 15:21:25 +08:00
lixinyu1011
45a778b928 修改减少modbus报警信息,以及websocket报警信息 2025-11-08 15:18:52 +08:00
Calvin Cao
c44ae32070 Merge pull request #151 from sun7151887/fix/yb3-material-names-and-model
Add debug prints to create_orders and add resource_tree_transfer method
2025-11-08 15:01:42 +08:00
dijkstra402
7af32b379b Add YB_ prefix to bottle carrier model names 2025-11-08 14:53:25 +08:00
Xuwznln
48d429ae00 fix resource_get param 2025-11-08 14:40:00 +08:00
Xuwznln
9bba4620b7 fix resource_get param 2025-11-08 14:39:36 +08:00
Xuwznln
d7494ca458 fix json dumps 2025-11-08 13:39:15 +08:00
Xuwznln
85dc46cd38 support name change during materials change 2025-11-08 13:39:13 +08:00
Xuwznln
5a0c2f9850 enable slave mode 2025-11-08 13:39:11 +08:00
Xuwznln
d897d70c3e change uuid logger to trace level 2025-11-08 13:39:09 +08:00
Xuwznln
d9dffc6bf8 correct remove_resource stats 2025-11-08 13:39:07 +08:00
Xuwznln
30b202bea0 disable slave connect websocket 2025-11-08 13:39:05 +08:00
Xuwznln
1b2c0dbcd7 adjust with_children param 2025-11-08 13:39:04 +08:00
Xuwznln
0f341e9b4d modify devices to use correct executor (sleep, create_task) 2025-11-08 13:39:01 +08:00
Xuwznln
4c3972820b support sleep and create_task in node 2025-11-08 13:39:00 +08:00
Xuwznln
a2a8ee9088 fix run async execution error 2025-11-08 13:39:00 +08:00
dijkstra402
200105f647 Add debug prints to create_orders and add resource_tree_transfer method 2025-11-08 13:35:47 +08:00
Xuwznln
8b5653d801 fix json dumps 2025-11-08 12:13:57 +08:00
Xuwznln
5f859917d4 support name change during materials change 2025-11-08 12:13:56 +08:00
Xuwznln
af2fb7f34a enable slave mode 2025-11-08 12:13:54 +08:00
Xuwznln
baa107c230 change uuid logger to trace level 2025-11-08 12:13:52 +08:00
Xuwznln
83854a741d correct remove_resource stats 2025-11-08 12:13:50 +08:00
Xuwznln
86c7880b5c disable slave connect websocket 2025-11-08 12:13:48 +08:00
Xuwznln
6d934e354c adjust with_children param 2025-11-08 12:13:46 +08:00
Xuwznln
bed453034f modify devices to use correct executor (sleep, create_task) 2025-11-08 12:13:44 +08:00
Xuwznln
5331d7bfba support sleep and create_task in node 2025-11-08 12:13:41 +08:00
Xuwznln
38ab7d3e78 fix run async execution error 2025-11-08 12:13:41 +08:00
Junhan Chang
966b51042d rename and fix all Yihua Materials: ClipMagazineHole→Magazine(ResourceStack), and use factory functions 2025-11-06 00:59:46 +08:00
Calvin Cao
d81638e20b Merge pull request #148 from lixinyu1011/workstation_dev_YB4
YB4branc_bylixinyu
2025-11-04 20:27:30 +08:00
lixinyu1011
3c583008aa YB4branc_bylixinyu 2025-11-04 20:19:27 +08:00
Calvin Cao
9a85bfddcd Merge pull request #147 from lixinyu1011/workstation_dev_YB3
1104_byxinyu
2025-11-04 03:57:57 +08:00
lixinyu1011
d4e1286df7 1104_byxinyu 2025-11-04 03:42:00 +08:00
calvincao
765038a136 Revert "Update YB_YH_materials.py"
This reverts commit bfd415279b.
2025-11-04 02:18:44 +08:00
Calvin Cao
1d4e4c8377 Merge pull request #146 from sun7151887/feature/update-yb-deck-coordinates
依华扣电工站物料信息正确
2025-11-04 02:05:36 +08:00
Calvin Cao
54f749bcdb Merge branch 'workstation_dev_YB4' into feature/update-yb-deck-coordinates 2025-11-04 02:05:18 +08:00
dijkstra402
16ad4bbecc 更新奔耀和依华工站的Deck坐标配置
- 更新奔耀YB工站deck坐标(基于图片像素精确计算)
  * 将粉末加样头堆栈拆分为左右两部分
  * 将试剂替换仓库拆分为左右两部分
  * 更新所有堆栈的坐标位置

- 更新依华扣电工站deck坐标(使用精确的像素-毫米转换)
  * 修正所有子弹夹的坐标位置(铝箔、正极片、正极壳等)
  * 更新料盘坐标(负极料盘、隔膜料盘)
  * 更新瓶架坐标(奔耀上料瓶架、电解液缓存位、回收位)
  * 更新枪头盒和废枪头盒坐标
  * 确保所有坐标在deck范围内(3650×1550mm)

- 转换比例说明:
  * 奔耀工站:deck左上角(206,446),使用1.56mm/像素
  * 依华工站:deck左上角(494,444)到右下角(2430,1608)
    X方向:1.885mm/像素,Y方向:1.332mm/像素
2025-11-04 02:01:44 +08:00
calvincao
0ad2eaafea Fix BottleRack references in CoincellDeck setup
- Updated references from bottle_rack_2x6 to bottle_rack_6x2 to align with the new configuration.
- Adjusted the loop for assigning ElectrodeSheets to use the correct BottleRack dimensions.
2025-11-04 01:57:30 +08:00
calvincao
1477384c1a Update CoinCellAssembly and YB_YH_materials configurations
- Adjusted CoincellDeck dimensions and origin coordinates for improved layout.
- Replaced CoincellDeck references with specific ClipMagazine instances in YB_YH_materials.py.
- Updated BottleRack configurations to reflect new item arrangements and dimensions.
2025-11-04 01:19:42 +08:00
Calvin Cao
8149a175d9 Merge pull request #145 from lixinyu1011/workstation_dev_YB3
Update YB_YH_materials.py
2025-11-04 00:41:17 +08:00
lixinyu1011
bfd415279b Update YB_YH_materials.py 2025-11-04 00:39:39 +08:00
Calvin Cao
0238a92e75 Merge pull request #144 from sun7151887/fix/yb3-material-names-and-model
更新YB工站deck坐标配置
2025-11-03 23:51:10 +08:00
dijkstra402
8009956326 更新YB工站deck坐标配置
- 根据实际布局图更新各堆栈的坐标位置
- 将粉末加样头堆栈拆分为左右两部分(10x1x1 -> 2个5x1x1)
- 将试剂替换仓库拆分为左右两部分(10x1x1 -> 2个5x1x1)
- 更新配液站内试剂仓库的坐标
- 所有坐标基于像素位置精确计算(deck原点: 206,446)
2025-11-03 23:49:02 +08:00
Calvin Cao
68fc4dd61e Merge pull request #143 from lixinyu1011/workstation_dev_YB3
1103-3byxinyu
2025-11-03 23:02:17 +08:00
lixinyu1011
cd12932788 1103byxinyu 2025-11-03 22:53:37 +08:00
calvincao
f230028558 feat: Enhance CoincellDeck setup with new ClipMagazine and BottleRack configurations
- Refactored ClipMagazine class to inherit from ItemizedResource and updated hole dimensions.
- Introduced ClipMagazine_four class for a new 2x2 hole layout.
- Expanded CoincellDeck setup to include multiple ClipMagazines and MaterialPlates with ElectrodeSheets.
- Improved BottleRack initialization with dynamic item positioning and resource assignment.
- Added serialization methods for new classes to maintain state consistency.
2025-11-03 21:30:27 +08:00
Calvin Cao
1c1a6b16c8 Merge pull request #142 from lixinyu1011/workstation_dev_YB3
11103-2byxinyu
2025-11-03 19:51:56 +08:00
lixinyu1011
a2d6012080 Merge branch 'workstation_dev_YB3' of https://github.com/lixinyu1011/Uni-Lab-OS into workstation_dev_YB3 2025-11-03 19:50:04 +08:00
lixinyu1011
10adc853a5 1103-2byxinyu 2025-11-03 19:50:01 +08:00
Calvin Cao
69ec034623 Merge pull request #141 from lixinyu1011/workstation_dev_YB3
1103byxinyu
2025-11-03 19:47:13 +08:00
Calvin Cao
62d08aa954 Merge branch 'workstation_dev_YB3' into workstation_dev_YB3 2025-11-03 19:46:52 +08:00
lixinyu1011
4485907df8 1103byxinyu 2025-11-03 18:46:50 +08:00
calvincao
b5b2358967 fix: 更新HTTP服务配置和物料类型映射
- 修改BIOYOND_HTTP_HOST的默认值为新的IP地址172.21.32.91
- 调整物料类型映射中“加样头(大)”的UUID顺序,并注释掉“加样头(大)板”配置
2025-11-03 18:20:50 +08:00
lixinyu1011
11f4f44bf9 Update coin_cell_assembly.py 2025-11-03 16:51:28 +08:00
lixinyu1011
f52fbd650e Update bioyond_cell_workstation.py 2025-11-03 16:50:59 +08:00
calvincao
e561c818b8 feat: 添加多个新仓库配置到config.py
- 新增多个仓库配置,包括大分液瓶堆栈、小分液瓶堆栈、站内Tip头盒堆栈等
- 每个仓库配置包含UUID和站点UUID映射
2025-11-03 14:31:50 +08:00
Calvin Cao
5cbd880e5a Merge pull request #140 from sun7151887/fix/yb3-material-names-and-model
fix: 修正YB warehouse排列方式和物料类型映射
2025-11-01 11:16:00 +08:00
Calvin Cao
41e7251f62 Merge branch 'workstation_dev_YB3' into fix/yb3-material-names-and-model 2025-11-01 11:14:45 +08:00
dijkstra402
727d2c2595 fix: 修正YB warehouse排列方式和物料类型映射
- 修改warehouse_factory为YB_warehouse_factory
- 调整warehouse排列方式:左上角为A01,竖着排ABCD,横着排01、02、03
- 修正config.py中的物料名称拼写错误(YB_fen_ye_20ml_Bottle, YB_pei_ye_xiao_Bottle)
- 添加缺失的warehouse函数(bioyond_warehouse_2x2x1, bioyond_warehouse_3x5x1, bioyond_warehouse_20x1x1)
- 更新decks.py中的warehouse位置映射
- 删除废弃的bottles.py和warehouses.py文件
2025-11-01 10:42:31 +08:00
Calvin Cao
202a2667fd Merge pull request #139 from lixinyu1011/workstation_dev_YB3
byxinyu111
2025-11-01 10:41:13 +08:00
lixinyu1011
03745c5d08 byxinyu111 2025-11-01 10:37:45 +08:00
Calvin Cao
385a495e21 Merge pull request #138 from lixinyu1011/workstation_dev_YB3
新建入库物料系统
2025-10-31 19:04:28 +08:00
lixinyu1011
91513a5f4c Delete button_battery_station.py 2025-10-31 19:02:06 +08:00
lixinyu1011
a62896eda2 1031_byxinyu 2025-10-31 18:57:38 +08:00
lixinyu1011
a82d1b7bdb Merge remote-tracking branch 'upstream/workstation_dev_YB3' into workstation_dev_YB3 2025-10-31 15:30:28 +08:00
lixinyu1011
6d7c39da9e 1031 2025-10-31 15:29:59 +08:00
Calvin Cao
d8e9ad4413 Merge pull request #136 from sun7151887/fix/yb3-material-names-and-model
fix: 更新物料类型配置映射
2025-10-31 15:13:48 +08:00
dijkstra402
eb93b83415 fix: 更新物料类型配置映射 2025-10-31 15:05:47 +08:00
lixinyu1011
6df93a5db7 Merge remote-tracking branch 'upstream/workstation_dev_YB3' into workstation_dev_YB3 2025-10-31 14:02:45 +08:00
lixinyu1011
2eb9986edb 123 2025-10-31 13:54:58 +08:00
calvincao
fe4e49e56d feat(workstation): 更新 Bioyond 和 Coin Cell 组装工作站配置
- 修改 Bioyond Studio 配置文件中的 API 主机地址
- 更新 bioyond_cell_workstation.py 中的默认模板路径
- 新增物料模板文件 material_template.xlsx
- 扩展 func_pack_send_msg_cmd 函数以支持 assembly_pressure 参数
- 更新 coin_cell_workstation.yaml 文件以包含 assembly_pressure 的默认值和类型定义
2025-10-31 13:53:58 +08:00
calvincao
0fba4cf275 feat(unilabos): 更新设备配置和资源定义
- 修改了 bioyond_cell.yaml 中的 xlsx_path 路径分隔符为反斜杠- 在 bioyond_cell.yaml 中新增多个自动命令定义,包括创建物料、处理报告和调度重置等功能- 修改 coin_cell_assembly.py 中 func_pack_send_msg_cmd 函数签名并调整调用参数
- 新增 qiming_coin_cell_code 方法用于设置启明扣电配置参数
- 更新 coin_cell_assembly_a.csv 文件中的寄存器描述和新增压制模式及清洁忽略选项- 修改 bioyond_studio 配置文件中的默认 API 主机地址
- 更新 new_cellconfig3c.json 中的设备类名为 coincellassemblyworkstation_device- 删除 reaction_station_bioyond.yaml 的全部内容,仅保留空对象
-重新组织 YB_bottle.yaml 和 YB_bottle_carriers.yaml 中的资源分类和命名定义
2025-10-30 19:56:34 +08:00
Calvin Cao
ef9359776a Merge pull request #134 from sun7151887/fix/yb3-material-names-and-model
feat: 添加扣电工作站 setup() 方法并修复显示问题
2025-10-30 16:31:50 +08:00
Calvin Cao
954f1ee7b2 Merge branch 'workstation_dev_YB3' into fix/yb3-material-names-and-model 2025-10-30 16:31:27 +08:00
dijkstra402
f58921ef82 feat: 添加扣电工作站 setup() 方法并修复显示问题
主要改动:
-  在 CoincellDeck 实现 setup() 方法(模仿 decks.py 三步配置模式)
-  统一 Deck 默认尺寸为 1000x1000x900mm
-  优化料盘布局:横向排列,留50mm边距
-  简化工作站 deck 创建逻辑(从30行减至1行)
-  新增 create_coin_cell_deck() 便捷函数
-  修复 ClipMagazine 参数错误
-  删除约200行冗余代码
-  修复底座不显示问题

技术细节:
- MaterialPlate 位置: liaopan1(50,50), liaopan2(250,50), 电池料盘(450,50)
- 自动为 liaopan1 添加16个初始极片
- 支持3种 deck 创建方式
- 智能判断是否需要 setup
2025-10-30 16:18:43 +08:00
calvincao
95bdd39bf8 fix(workstation): 更新 coin_cell_assembly_a.csv 中的寄存器和线圈定义为中文描述
- 将寄存器和线圈定义中的英文描述替换为中文,提升可读性
- 确保所有定义格式一致,保持文件的整洁性和维护性
2025-10-30 14:25:33 +08:00
calvincao
b3e28196c6 feat(battery): 更新电池工位资源配置
- 将 coin_cell_deck 类型从 container 更改为 coin_cell_deck
- 将 material_plate 类型从 container 更改为 material_plate
- 将 material_hole 类型从 container 更改为 material_hole- 移除电极片容器结构,直接使用 electrode_sheet 类型
- 更新物料孔配置参数,包括直径、深度和最大片数
-重新组织料盘结构,明确父子节点关系
- 添加新的电极片定义并关联到对应的物料孔
- 调整所有物料孔坐标位置以匹配新布局
- 为 liaopan2 添加完整的子节点结构和排序规则
2025-10-30 11:22:17 +08:00
calvincao
9fe8f4f28f fix(workstation): 修复 coin_cell_assembly_a.csv 文件中的寄存器和线圈定义格式
- 重新排列并清理了 coin_cell_assembly_a.csv 中的寄存器和线圈定义
- 确保所有定义的格式一致,提升可读性和维护性
2025-10-29 21:36:52 +08:00
calvincao
39bc317bfc feat(workstation): 支持多种输入类型的 station_resource 并优化物料系统初始化
- 新增 `_coerce_station_resource_input` 函数以支持 dict、list 和其他类型转换为 Deck
- 添加对 Modbus 客户端方法的兼容性封装,确保 slave/unit 参数正确传递- 在初始化时根据 station_resource 动态创建或赋值 deck
- 自动构建默认物料台面及三个料盘,并分配初始电极片资源
- 移除旧有的硬编码物料系统注释代码
- 更新资源导出逻辑以使用工作站实例中的 deck 属性
2025-10-29 18:39:22 +08:00
calvincao
a130c03ebd feat(workstation): 移除旧版bioyond设备配置并优化扣电组装工作站- 删除bioyond.yaml和bioyond_dispensing_station.yaml旧设备配置文件- 优化扣电组装工作站配置,移除不必要的子资源引用- 更新Modbus通信地址和端口配置- 简化CoinCellAssemblyWorkstation类的初始化参数- 移除冗余的deck资源创建逻辑
- 更新反应站配置文件中drip_back命令的位置
- 添加新的Modbus寄存器和线圈定义
- 移除workstation_base.py基类文件
2025-10-29 10:44:30 +08:00
calvincao
a97781c4eb Merge remote-tracking branch 'origin/dev' into workstation_dev_YB3 2025-10-28 11:47:07 +08:00
calvincao
c35edcece1 重构 coin_cell_assembly 目录结构 2025-10-28 11:42:14 +08:00
Calvin Cao
524e0f3053 Merge pull request #132 from sun7151887/fix/yb3-material-names-and-model
feat: 添加YB瓶子和载架配置
2025-10-27 22:30:40 +08:00
Calvin Cao
66f483929d Merge branch 'workstation_dev_YB3' into fix/yb3-material-names-and-model 2025-10-27 22:30:16 +08:00
dijkstra402
2d58576937 feat: 添加YB瓶子和载架配置
- 在YB_bottles.py中添加8种瓶子类型(100ml液体、高粘液、5ml分液瓶、20ml分液瓶、配液瓶小、配液瓶大、枪头等)
- 在YB_bottle_carriers.py中添加12个载架函数(包括新增的高粘液载架和100ml液体载架)
- 更新config.py的MATERIAL_TYPE_MAPPINGS配置,添加16种物料类型映射
- 创建YB_bottle_carriers.yaml注册文件,包含所有载架和瓶子函数
- 创建YB_bottle.yaml注册文件,包含独立的瓶子函数配置
- 移除不存在的瓶子函数引用(YB_Solid_Vial等4个函数)
2025-10-27 22:23:09 +08:00
calvincao
ff25e814de feat: add new glove box internal stack configuration with site UUIDs 2025-10-27 22:08:02 +08:00
Calvin Cao
0163d16cbb Merge pull request #131 from lixinyu1011/workstation_dev_YB3
by_Xinyu1027
2025-10-27 20:15:43 +08:00
lixinyu1011
3231d60646 1027by_Xinyu 2025-10-27 20:08:19 +08:00
lixinyu1011
d0279f63f0 Merge remote-tracking branch 'upstream/workstation_dev_YB3' into workstation_dev_YB3 2025-10-27 19:33:45 +08:00
lixinyu1011
ceef342860 1027byxinyu 2025-10-27 18:16:26 +08:00
h840473807
42f7010134 提交扣电工站最新代码到YB3分支
提交扣电工站最新代码到YB3分支,更新注册表
2025-10-27 11:57:57 +08:00
calvincao
190b2d2518 清理扣电不必要代码 2025-10-27 11:43:03 +08:00
calvincao
2901d72b4b feat: add button battery assembly station resources and configuration files
- Introduced new Python modules for button battery assembly, including resource classes and configurations.
- Added JSON and CSV files for resource definitions and device configurations.
- Created initial setup for the coin cell assembly workstation, including material handling and resource management.
2025-10-25 13:50:41 +08:00
calvincao
6ad0157b50 feat: add new warehouse configurations and update site UUIDs in bioyond_studio config 2025-10-24 16:37:11 +08:00
calvincao
55b678cd37 fix: update report IP address in configuration and clean up parameters in SOLID_LIQUID_MAPPINGS 2025-10-24 14:22:39 +08:00
Calvin Cao
8101a22a0f Merge pull request #130 from sun7151887/workstation_dev_YB3
refactor: 将 BIOYOND_PolymerStation_ 前缀统一改为 YB_
2025-10-24 13:56:08 +08:00
Calvin Cao
667138baac Merge branch 'workstation_dev_YB3' into workstation_dev_YB3 2025-10-24 13:56:00 +08:00
dijkstra402
01adf7ca92 refactor: 将 BIOYOND_PolymerStation_ 前缀统一改为 YB_
- 重命名 bottles.py 中所有工厂函数:BIOYOND_PolymerStation_* -> YB_*
- 重命名 bottle_carriers.py 中所有载具工厂函数和导入
- 更新 registry YAML 文件中的 module 引用
- 更新 MATERIAL_TYPE_MAPPINGS 配置中的类型字符串
- 更新测试文件和样例 JSON 中的类型引用
- 添加 YB_* 别名条目到 registry 以支持双键访问
2025-10-24 13:49:48 +08:00
Calvin Cao
f606062696 Merge pull request #129 from lixinyu1011/workstation_dev_YB3
xinyu1024修改
2025-10-24 11:44:16 +08:00
Calvin Cao
67d1c4acce Merge branch 'workstation_dev_YB3' into workstation_dev_YB3 2025-10-24 11:44:04 +08:00
lixinyu1011
7206e42bf1 xinyu1024修改 2025-10-24 11:37:36 +08:00
calvincao
e92d933968 refactor(bioyond_cell_workstation): 重构物料创建与入库逻辑- 移除从CSV读取物料名称的功能
- 新增通过参数传递物料名称列表的方式- 抽离仓库位置加载逻辑至独立方法
- 简化物料创建与入库流程- 统一使用资源同步器进行数据同步
- 更新调用示例以适配新接口
2025-10-23 22:36:21 +08:00
Calvin Cao
f0ebcc60bb Merge pull request #126 from sun7151887/fix/yb3-material-names-and-model
添加新物料类型映射:包括100ml液体、液、高粘液、5ml/20ml分液瓶、配液瓶、加样头、适配器块、枪头盒等
2025-10-23 21:59:26 +08:00
dijkstra402
e2097f0b22 添加新物料类型映射:包括100ml液体、液、高粘液、5ml/20ml分液瓶、配液瓶、加样头、适配器块、枪头盒等 2025-10-23 21:56:54 +08:00
calvincao
fd73731130 增强批量入库功能,添加物料数据同步逻辑;优化日志记录以提供更详细的同步状态信息。 2025-10-23 18:02:49 +08:00
Calvin Cao
ab7f2081c9 Merge pull request #124 from sun7151887/fix/yb3-material-names-and-model
更新载架网格布局:5ml/20ml/配液瓶(小)板改为4x2,加样头(大)板改为1x1
2025-10-23 17:45:29 +08:00
dijkstra402
9e850d8a81 更新载架网格布局:5ml/20ml/配液瓶(小)板改为4x2,加样头(大)板改为1x1 2025-10-23 17:42:10 +08:00
calvincao
1af6ffafc6 新增批量创建固体物料和从CSV文件入库的功能;更新配置文件中的 report_ip 默认值;新增 solid_materials.csv 文件以支持物料名称导入。 2025-10-23 17:32:08 +08:00
Calvin Cao
35fc2f5ea6 Merge pull request #123 from sun7151887/fix/yb3-material-names-and-model
fix(yb3): 物料名称与模型对齐;YAML 去掉 BIOYOND_PolymerStation_ 前缀;修复 6StockCarri…
2025-10-23 15:43:40 +08:00
dijkstra402
d3d8ba6500 fix(yb3): 物料名称与模型对齐;YAML 去掉 BIOYOND_PolymerStation_ 前缀;修复 6StockCarrier model 2025-10-23 15:32:36 +08:00
calvincao
5a7845d8ca 更新配置文件中的 report_ip 默认值,优化 bioyond_cell_workstation.py 中的订单状态处理逻辑,新增多个瓶子和载架类型的定义,调整仓库结构以支持更灵活的物料管理。 2025-10-23 08:34:33 +08:00
calvincao
9c4d0256cf 增强配置文件,新增 report_ip 选项以支持本机 IP 地址的灵活配置;在 bioyond_cell_workstation.py 中优化推送地址更新逻辑,支持自动检测和配置优先级处理。 2025-10-22 16:38:32 +08:00
calvincao
de7c80c3c2 重构:完善配置加载机制与初始化逻辑
新增环境变量覆盖机制,增强配置灵活性

优化 bioyond_rpc.py 与 bioyond_cell_workstation.py 的初始化流程与结构

修正 station.py 工作流映射逻辑,确保正确性

提高代码可读性与模块间解耦程度
2025-10-22 16:13:36 +08:00
calvincao
e70c545ec8 修复 **bioyond_yihua_YB.json** 中的 JSON 合并冲突,清理不必要的标记。 2025-10-21 15:19:44 +08:00
calvincao
2c2d1e5569 在 **bioyond_cell_workstation.py** 中实现 update_push_ip 方法并增强错误处理;修复 **bioyond_yihua_YB.json** 中的 JSON 合并冲突。 2025-10-21 14:58:38 +08:00
Calvin Cao
4638611fe7 Merge pull request #119 from lixinyu1011/workstation_dev_YB3
Update station.py
2025-10-21 14:51:54 +08:00
lixinyu1011
37641c4389 xinyu1021推送代码 2025-10-21 14:48:55 +08:00
lixinyu1011
ab697ce973 Update station.py 2025-10-20 16:12:38 +08:00
Calvin Cao
d4724b8664 Merge pull request #117 from lixinyu1011/workstation_dev_YB3
Update bioyond_cell_workstation.py
2025-10-20 15:33:17 +08:00
lixinyu1011
2f25063bf1 Update bioyond_cell_workstation.py 2025-10-20 15:30:41 +08:00
Calvin Cao
00b4b9cd87 Merge pull request #116 from lixinyu1011/workstation_dev_YB3
1020_YB奔耀仿真机同步对齐dev_unilab可控
2025-10-20 12:56:36 +08:00
lixinyu1011
d2352cc514 1020_YB奔耀仿真机同步对齐dev_unilab可控
待修改unilab的http服务
2025-10-20 12:48:19 +08:00
225 changed files with 26300 additions and 19270 deletions

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: 0.10.10
version: 0.10.12
source:
path: ../unilabos

View File

@@ -1,3 +1,4 @@
recursive-include unilabos/test *
recursive-include unilabos/registry *.yaml
recursive-include unilabos/app/web/static *
recursive-include unilabos/app/web/templates *

View File

@@ -39,7 +39,9 @@ Uni-Lab-OS recommends using `mamba` for environment management. Choose the appro
```bash
# Create new environment
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
mamba create -n unilab python=3.11.11
mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
## Install Dev Uni-Lab-OS

View File

@@ -41,7 +41,9 @@ Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适
```bash
# 创建新环境
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
mamba create -n unilab python=3.11.11
mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
2. 安装开发版 Uni-Lab-OS:

View File

@@ -1,31 +1,32 @@
{
"nodes": [
{
"id": "BatteryStation",
"name": "扣电工作站",
"id": "bioyond_cell_workstation",
"name": "配液分液工站",
"children": [
"coin_cell_deck"
],
"parent": null,
"type": "device",
"class": "bettery_station_registry",
"position": {
"x": 600,
"y": 400,
"z": 0
"class": "bioyond_cell",
"config": {
"protocol_type": [],
"station_resource": {}
},
"data": {}
},
{
"id": "BatteryStation",
"name": "扣电组装工作站",
"children": [],
"parent": null,
"type": "device",
"class": "bettery_station_registry",
"config": {
"debug_mode": false,
"_comment": "protocol_type接外部工站固定写法字段一般为空deck写法也固定",
"protocol_type": [],
"deck": {
"data": {
"_resource_child_name": "coin_cell_deck",
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.button_battery_station:CoincellDeck"
}
},
"address": "192.168.1.20",
"deck": "unilabos.devices.workstation.coin_cell_assembly.button_battery_station:CoincellDeck",
"address": "172.21.32.20",
"port": 502
},
"data": {}
@@ -98,7 +99,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine_four",
"type": "MagazineHolder_4",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -139,7 +140,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -234,7 +235,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -329,7 +330,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -424,7 +425,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -522,7 +523,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine_four",
"type": "MagazineHolder_4",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -563,7 +564,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -658,7 +659,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -753,7 +754,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -848,7 +849,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -948,7 +949,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -991,7 +992,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1086,7 +1087,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1181,7 +1182,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1276,7 +1277,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1371,7 +1372,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1466,7 +1467,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1566,7 +1567,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -1609,7 +1610,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1704,7 +1705,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1799,7 +1800,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1894,7 +1895,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -1989,7 +1990,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2084,7 +2085,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2184,7 +2185,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -2227,7 +2228,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2322,7 +2323,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2417,7 +2418,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2512,7 +2513,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2607,7 +2608,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2702,7 +2703,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2802,7 +2803,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -2845,7 +2846,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -2940,7 +2941,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3035,7 +3036,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3130,7 +3131,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3225,7 +3226,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3320,7 +3321,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3420,7 +3421,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -3463,7 +3464,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3558,7 +3559,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3653,7 +3654,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3748,7 +3749,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3843,7 +3844,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -3938,7 +3939,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4038,7 +4039,7 @@
"z": 0
},
"config": {
"type": "ClipMagazine",
"type": "MagazineHolder_6",
"size_x": 80,
"size_y": 80,
"size_z": 10,
@@ -4081,7 +4082,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4176,7 +4177,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4271,7 +4272,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4366,7 +4367,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4461,7 +4462,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,
@@ -4556,7 +4557,7 @@
"z": 10
},
"config": {
"type": "ClipMagazineHole",
"type": "Magazine",
"size_x": 14.0,
"size_y": 14.0,
"size_z": 10.0,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,726 @@
# Uni-Lab 配置指南
本文档详细介绍 Uni-Lab 配置文件的结构、配置项、命令行覆盖和环境变量的使用方法。
## 配置文件概述
Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_data/local_config.py`。配置文件采用类属性的方式定义各种配置项,比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
## 获取实验室密钥
在配置文件或启动命令中您需要提供实验室的访问密钥ak和私钥sk
**获取方式:**
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif)
## 配置文件格式
### 默认配置示例
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`
```python
# unilabos的配置文件
class BasicConfig:
ak = "" # 实验室网页给您提供的ak代码
sk = "" # 实验室网页给您提供的sk代码
# WebSocket配置一般无需调整
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
```
### 完整配置示例
您可以根据需要添加更多配置选项:
```python
#!/usr/bin/env python
# coding=utf-8
"""Uni-Lab 配置文件"""
# 基础配置
class BasicConfig:
ak = "" # 实验室访问密钥
sk = "" # 实验室私钥
working_dir = "" # 工作目录(通常自动设置)
config_path = "" # 配置文件路径(自动设置)
is_host_mode = True # 是否为主站模式
slave_no_host = False # 从站模式下是否跳过等待主机服务
upload_registry = False # 是否上传注册表
machine_name = "undefined" # 机器名称(自动获取)
vis_2d_enable = False # 是否启用2D可视化
enable_resource_load = True # 是否启用资源加载
communication_protocol = "websocket" # 通信协议
log_level = "DEBUG" # 日志级别TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
# WebSocket配置
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置
class ROSConfig:
modules = [
"std_msgs.msg",
"geometry_msgs.msg",
"control_msgs.msg",
"control_msgs.action",
"nav2_msgs.action",
"unilabos_msgs.msg",
"unilabos_msgs.action",
] # 需要加载的ROS模块
```
## 配置优先级
配置项的生效优先级从高到低为:
1. **命令行参数**:最高优先级
2. **环境变量**:中等优先级
3. **配置文件**:基础优先级
这意味着命令行参数会覆盖环境变量和配置文件,环境变量会覆盖配置文件。
## 推荐配置方式
根据参数特性,不同配置项有不同的推荐配置方式:
### 建议通过命令行指定的参数(不需要写入配置文件)
以下参数推荐通过命令行或环境变量指定,**一般不需要在配置文件中配置**
| 参数 | 命令行参数 | 原因 |
| ----------------- | ------------------- | ------------------------------------ |
| `ak` / `sk` | `--ak` / `--sk` | **安全考虑**:避免敏感信息泄露 |
| `working_dir` | `--working_dir` | **灵活性**:不同环境可能使用不同目录 |
| `is_host_mode` | `--is_slave` | **运行模式**:由启动场景决定,不固定 |
| `slave_no_host` | `--slave_no_host` | **运行模式**:从站特殊配置,按需使用 |
| `upload_registry` | `--upload_registry` | **临时操作**:仅首次启动或更新时需要 |
| `vis_2d_enable` | `--2d_vis` | **调试功能**:按需临时启用 |
| `remote_addr` | `--addr` | **环境切换**:测试/生产环境快速切换 |
**推荐用法示例:**
```bash
# 标准启动命令(所有必要参数通过命令行指定)
unilab --ak your_ak --sk your_sk -g graph.json
# 测试环境
unilab --addr test --ak your_ak --sk your_sk -g graph.json
# 从站模式
unilab --is_slave --ak your_ak --sk your_sk
# 首次启动上传注册表
unilab --ak your_ak --sk your_sk -g graph.json --upload_registry
```
### 适合在配置文件中配置的参数
以下参数适合在配置文件中配置,通常不会频繁更改:
| 参数 | 配置类 | 说明 |
| ------------------------ | ----------- | ---------------------- |
| `log_level` | BasicConfig | 日志级别配置 |
| `reconnect_interval` | WSConfig | WebSocket 重连间隔 |
| `max_reconnect_attempts` | WSConfig | WebSocket 最大重连次数 |
| `ping_interval` | WSConfig | WebSocket 心跳间隔 |
| `modules` | ROSConfig | ROS 模块列表 |
**配置文件示例(推荐最小配置):**
```python
# unilabos的配置文件
class BasicConfig:
log_level = "INFO" # 生产环境建议 INFO调试时用 DEBUG
# WebSocket配置一般保持默认即可
class WSConfig:
reconnect_interval = 5
max_reconnect_attempts = 999
ping_interval = 30
```
**注意:** `ak``sk` 不建议写在配置文件中,始终通过命令行参数或环境变量传递。
## 命令行参数覆盖配置
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。
### 支持命令行覆盖的配置项
| 配置类 | 配置字段 | 命令行参数 | 说明 |
| ------------- | ----------------- | ------------------- | -------------------------------- |
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
### 特殊命令行参数
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
| 参数 | 说明 |
| ------------------- | ------------------------------------ |
| `--config` | 指定配置文件路径 |
| `--port` | Web 服务端口(不影响配置文件) |
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
| `--visual` | 可视化工具选择(不影响配置文件) |
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
### 命令行覆盖使用示例
```bash
# 通过命令行覆盖认证信息
unilab --ak "new_access_key" --sk "new_secret_key" -g graph.json
# 覆盖服务器地址
unilab --ak ak --sk sk --addr "https://custom.server.com/api/v1" -g graph.json
# 启用从站模式并跳过等待主机
unilab --is_slave --slave_no_host --ak ak --sk sk
# 启用上传注册表和2D可视化
unilab --upload_registry --2d_vis --ak ak --sk sk -g graph.json
# 组合使用多个覆盖参数
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g graph.json
```
### 预设环境地址
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
## 配置选项详解
### 1. BasicConfig - 基础配置
基础配置包含了系统运行的核心参数:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------------- | ------------------------------------------ |
| `ak` | str | `""` | 实验室访问密钥(必需) |
| `sk` | str | `""` | 实验室私钥(必需) |
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
| `config_path` | str | `""` | 配置文件路径,自动设置 |
| `is_host_mode` | bool | `True` | 是否为主站模式 |
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
| `enable_resource_load` | bool | `True` | 是否启用资源加载 |
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
| `log_level` | str | `"DEBUG"` | 日志级别 |
#### 日志级别选项
- `TRACE` - 追踪级别(最详细)
- `DEBUG` - 调试级别(默认)
- `INFO` - 信息级别
- `WARNING` - 警告级别
- `ERROR` - 错误级别
- `CRITICAL` - 严重错误级别(最简略)
#### 认证配置ak / sk
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
- **配置文件**:在 `BasicConfig` 类中设置(不推荐,安全风险)
3. **安全注意**:请妥善保管您的密钥信息,不要提交到版本控制
**推荐做法**
- **开发环境**:使用命令行参数或环境变量
- **生产环境**:使用环境变量
- **临时测试**:使用命令行参数
### 2. WSConfig - WebSocket 配置
WebSocket 是 Uni-Lab 的主要通信方式:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------ | ------------------ |
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
### 3. HTTPConfig - HTTP 配置
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置
配置 ROS 消息转换器需要加载的模块:
| 配置项 | 类型 | 默认值 | 说明 |
| --------- | ---- | ---------- | ------------ |
| `modules` | list | 见下方示例 | ROS 模块列表 |
**默认模块列表:**
```python
class ROSConfig:
modules = [
"std_msgs.msg", # 标准消息类型
"geometry_msgs.msg", # 几何消息类型
"control_msgs.msg", # 控制消息类型
"control_msgs.action", # 控制动作类型
"nav2_msgs.action", # 导航动作类型
"unilabos_msgs.msg", # UniLab 自定义消息类型
"unilabos_msgs.action", # UniLab 自定义动作类型
]
```
您可以根据实际使用的设备和功能添加其他 ROS 模块。
## 环境变量配置
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。
### 环境变量命名规则
```
UNILABOS_<配置类名>_<配置项名>
```
**注意:**
- 环境变量名不区分大小写
- 配置类名和配置项名都会转换为大写进行匹配
### 设置环境变量
#### Linux / macOS
```bash
# 临时设置(当前终端)
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc
echo 'export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO' >> ~/.bashrc
source ~/.bashrc
```
#### Windows (cmd)
```cmd
# 临时设置
set UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
set UNILABOS_BASICCONFIG_AK=your_access_key
# 永久设置(系统环境变量)
setx UNILABOS_BASICCONFIG_LOG_LEVEL INFO
```
#### Windows (PowerShell)
```powershell
# 临时设置
$env:UNILABOS_BASICCONFIG_LOG_LEVEL="INFO"
$env:UNILABOS_BASICCONFIG_AK="your_access_key"
# 永久设置
[Environment]::SetEnvironmentVariable("UNILABOS_BASICCONFIG_LOG_LEVEL", "INFO", "User")
```
### 环境变量类型转换
系统会根据配置项的原始类型自动转换环境变量值:
| 原始类型 | 转换规则 |
| -------- | --------------------------------------- |
| `bool` | "true", "1", "yes" → True其他 → False |
| `int` | 转换为整数 |
| `float` | 转换为浮点数 |
| `str` | 直接使用字符串值 |
**示例:**
```bash
# 布尔值
export UNILABOS_BASICCONFIG_IS_HOST_MODE=true # 将设置为 True
export UNILABOS_BASICCONFIG_IS_HOST_MODE=false # 将设置为 False
# 整数
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 # 将设置为 10
# 字符串
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO # 将设置为 "INFO"
```
### 环境变量示例
```bash
# 设置基础配置
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
# 设置WebSocket配置
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
```
## 配置文件使用方法
### 1. 使用默认配置文件(推荐)
系统会自动查找并加载配置文件:
```bash
# 直接启动,使用默认的 unilabos_data/local_config.py
unilab --ak your_ak --sk your_sk -g graph.json
```
查找顺序:
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
2. 工作目录下的 `local_config.py`
3. 首次使用时会引导创建配置文件
### 2. 指定配置文件启动
```bash
# 使用指定配置文件启动
unilab --config /path/to/your/config.py --ak ak --sk sk -g graph.json
```
### 3. 配置文件验证
系统启动时会自动验证配置文件:
- **语法检查**:确保 Python 语法正确
- **类型检查**:验证配置项类型是否匹配
- **加载确认**:控制台输出加载成功信息
## 常用配置场景
### 场景 1调整日志级别
**配置文件方式:**
```python
class BasicConfig:
log_level = "INFO" # 生产环境建议使用 INFO 或 WARNING
```
**环境变量方式:**
```bash
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
unilab --ak ak --sk sk -g graph.json
```
**命令行方式**(需要配置文件已包含):
```bash
# 配置文件无直接命令行参数,需通过环境变量
UNILABOS_BASICCONFIG_LOG_LEVEL=INFO unilab --ak ak --sk sk -g graph.json
```
### 场景 2配置 WebSocket 重连
**配置文件方式:**
```python
class WSConfig:
reconnect_interval = 10 # 增加重连间隔到 10 秒
max_reconnect_attempts = 100 # 减少最大重连次数到 100 次
```
**环境变量方式:**
```bash
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```
### 场景 3切换服务器环境
**配置文件方式:**
```python
class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
```
**环境变量方式:**
```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
```
**命令行方式(推荐):**
```bash
unilab --addr test --ak your_ak --sk your_sk -g graph.json
```
### 场景 4从站模式配置
**配置文件方式:**
```python
class BasicConfig:
is_host_mode = False # 从站模式
slave_no_host = True # 不等待主机服务
```
**命令行方式(推荐):**
```bash
unilab --is_slave --slave_no_host --ak your_ak --sk your_sk
```
## 最佳实践
### 1. 安全配置
**不要在配置文件中存储敏感信息**
-**不推荐**:在配置文件中明文存储 ak/sk
-**推荐**:使用环境变量或命令行参数
```bash
# 生产环境 - 使用环境变量(推荐)
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
unilab -g graph.json
# 或使用命令行参数
unilab --ak "your_access_key" --sk "your_secret_key" -g graph.json
```
**其他安全建议:**
- 不要将包含密钥的配置文件提交到版本控制系统
- 限制配置文件权限:`chmod 600 local_config.py`
- 定期更换访问密钥
- 使用 `.gitignore` 排除配置文件
### 2. 多环境配置
为不同环境创建不同的配置文件:
```
configs/
├── base_config.py # 基础配置(非敏感)
├── dev_config.py # 开发环境
├── test_config.py # 测试环境
├── prod_config.py # 生产环境
└── example_config.py # 示例配置
```
**环境切换示例**
```bash
# 本地开发环境
unilab --config configs/dev_config.py --addr local --ak ak --sk sk -g graph.json
# 测试环境
unilab --config configs/test_config.py --addr test --ak ak --sk sk --upload_registry -g graph.json
# 生产环境
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -g graph.json
```
### 3. 配置管理
**配置文件最佳实践:**
- 保持配置文件简洁,只包含需要修改的配置项
- 为配置项添加注释说明其作用
- 定期检查和更新配置文件
- 版本控制仅保存示例配置,不包含实际密钥
**命令行参数优先使用场景:**
- 临时测试不同配置
- CI/CD 流水线中的动态配置
- 不同环境间快速切换
- 敏感信息的安全传递
### 4. 灵活配置策略
**基础配置文件 + 命令行覆盖**的推荐方式:
```python
# base_config.py - 基础配置(非敏感信息)
class BasicConfig:
# 非敏感配置写在文件中
is_host_mode = True
upload_registry = False
vis_2d_enable = False
log_level = "INFO"
class WSConfig:
reconnect_interval = 5
max_reconnect_attempts = 999
ping_interval = 30
```
```bash
# 启动时通过命令行覆盖关键参数
unilab --config base_config.py \
--ak "$AK" \
--sk "$SK" \
--addr "test" \
--upload_registry \
--2d_vis \
-g graph.json
```
## 故障排除
### 1. 配置文件加载失败
**错误信息**`[ENV] 配置文件 xxx 不存在`
**解决方法**
- 确认配置文件路径正确
- 检查文件权限是否可读
- 确保配置文件是 `.py` 格式
- 使用绝对路径或相对于当前目录的路径
### 2. 语法错误
**错误信息**`[ENV] 加载配置文件 xxx 失败`
**解决方法**
- 检查 Python 语法是否正确
- 确认类名和字段名拼写正确
- 验证缩进是否正确(使用空格而非制表符)
- 确保字符串使用引号包裹
### 3. 认证失败
**错误信息**`后续运行必须拥有一个实验室`
**解决方法**
- 确认 `ak``sk` 已正确配置
- 检查密钥是否有效(未过期或撤销)
- 确认网络连接正常
- 验证密钥是否来自正确的实验室
### 4. 环境变量不生效
**解决方法**
- 确认环境变量名格式正确(`UNILABOS_<类名>_<字段名>`
- 检查环境变量是否已正确设置(`echo $VARIABLE_NAME`
- 重启终端或重新加载环境变量
- 确认环境变量值的类型正确
### 5. 命令行参数不生效
**错误现象**:设置了命令行参数但配置没有生效
**解决方法**
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
- 查看启动日志确认参数是否被正确解析
- 检查是否有配置文件或环境变量与之冲突
### 6. 配置优先级混淆
**错误现象**:不确定哪个配置生效
**解决方法**
- 记住优先级:**命令行参数 > 环境变量 > 配置文件**
- 使用 `--ak``--sk` 参数时会看到提示信息:"传入了 ak 参数,优先采用传入参数!"
- 检查启动日志中的配置加载信息
- 临时移除低优先级配置来测试高优先级配置是否生效
- 使用 `printenv | grep UNILABOS` 查看所有相关环境变量
## 配置验证
### 检查配置是否生效
启动 Uni-Lab 时,控制台会输出配置加载信息:
```
[ENV] 配置文件 /path/to/config.py 加载成功
[ENV] 设置 BasicConfig.log_level = INFO
传入了ak参数优先采用传入参数
传入了sk参数优先采用传入参数
```
### 常见配置错误
1. **配置文件格式错误**
```
[ENV] 加载配置文件 /path/to/config.py 失败
```
**解决方案**:检查 Python 语法,确保配置类定义正确
2. **环境变量格式错误**
```
[ENV] 环境变量格式不正确UNILABOS_INVALID_VAR
```
**解决方案**:确保环境变量遵循 `UNILABOS_<类名>_<字段名>` 格式
3. **类或字段不存在**
```
[ENV] 未找到类UNKNOWNCONFIG
[ENV] 类 BasicConfig 中未找到字段UNKNOWN_FIELD
```
**解决方案**:检查配置类名和字段名是否正确
## 相关文档
- [工作目录详解](working_directory.md)
- [启动参数详解](../user_guide/launch.md)
- [快速安装指南](../user_guide/quick_install_guide.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

View File

@@ -0,0 +1,218 @@
# 工作目录详解
本文档详细介绍 Uni-Lab 工作目录(`working_dir`)的判断逻辑和详细用法。
## 什么是工作目录
工作目录是 Uni-Lab 存储配置文件、日志和运行数据的目录。默认情况下,工作目录为 `当前目录/unilabos_data`
## 工作目录判断逻辑
系统按以下决策树自动确定工作目录:
### 第一步:初始判断
```python
# 检查当前目录
if 当前目录以 "unilabos_data" 结尾:
working_dir = 当前目录的绝对路径
else:
working_dir = 当前目录/unilabos_data
```
**解释:**
- 如果您已经在 `unilabos_data` 目录内启动,系统直接使用当前目录
- 否则,系统会在当前目录下创建或使用 `unilabos_data` 子目录
### 第二步:处理 `--working_dir` 参数
如果用户指定了 `--working_dir` 参数:
```python
working_dir = 用户指定的路径
```
此时还会检查配置文件:
- 如果同时指定了 `--config` 但该文件不存在
- 系统会尝试在 `working_dir/local_config.py` 查找
- 如果仍未找到,报错退出
### 第三步:处理 `--config` 参数
如果用户指定了 `--config` 且文件存在:
```python
# 工作目录改为配置文件所在目录
working_dir = config_path 的父目录
```
**重要:** 这意味着配置文件的位置会影响工作目录的判断。
## 使用场景示例
### 场景 1默认场景推荐
```bash
# 当前目录:/home/user/project
unilab --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /home/user/project/unilabos_data
# config_path = /home/user/project/unilabos_data/local_config.py
```
### 场景 2在 unilabos_data 目录内启动
```bash
cd /home/user/project/unilabos_data
unilab --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /home/user/project/unilabos_data
# config_path = /home/user/project/unilabos_data/local_config.py
```
### 场景 3手动指定工作目录
```bash
unilab --working_dir /custom/path --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /custom/path
# config_path = /custom/path/local_config.py (如果存在)
```
### 场景 4通过配置文件路径推断工作目录
```bash
unilab --config /data/lab_a/local_config.py --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /data/lab_a
# config_path = /data/lab_a/local_config.py
```
## 高级用法:管理多个实验室配置
### 方法 1使用不同的工作目录
```bash
# 实验室 A
unilab --working_dir ~/labs/lab_a --ak ak_a --sk sk_a -g graph_a.json
# 实验室 B
unilab --working_dir ~/labs/lab_b --ak ak_b --sk sk_b -g graph_b.json
```
### 方法 2使用不同的配置文件
```bash
# 实验室 A
unilab --config ~/labs/lab_a/config.py --ak ak_a --sk sk_a -g graph_a.json
# 实验室 B
unilab --config ~/labs/lab_b/config.py --ak ak_b --sk sk_b -g graph_b.json
```
### 方法 3使用shell脚本管理
创建 `start_lab_a.sh`
```bash
#!/bin/bash
cd ~/labs/lab_a
unilab --ak your_ak_a --sk your_sk_a -g graph_a.json
```
创建 `start_lab_b.sh`
```bash
#!/bin/bash
cd ~/labs/lab_b
unilab --ak your_ak_b --sk your_sk_b -g graph_b.json
```
## 完整决策流程图
```
开始
判断当前目录是否以 unilabos_data 结尾?
├─ 是 → working_dir = 当前目录
└─ 否 → working_dir = 当前目录/unilabos_data
用户是否指定 --working_dir
└─ 是 → working_dir = 指定路径
用户是否指定 --config 且文件存在?
└─ 是 → working_dir = config 文件所在目录
检查 working_dir/local_config.py 是否存在?
├─ 是 → 加载配置文件 → 继续启动
└─ 否 → 询问是否首次使用
├─ 是 → 创建目录和配置文件 → 继续启动
└─ 否 → 退出程序
```
## 常见问题
### 1. 如何查看当前使用的工作目录?
启动 Uni-Lab 时,系统会在控制台输出:
```
当前工作目录为 /path/to/working_dir
```
### 2. 可以在同一台机器上运行多个实验室吗?
可以。使用不同的工作目录或配置文件即可:
```bash
# 终端 1
unilab --working_dir ~/lab1 --ak ak1 --sk sk1 -g graph1.json
# 终端 2
unilab --working_dir ~/lab2 --ak ak2 --sk sk2 -g graph2.json
```
### 3. 工作目录中存储了什么?
- `local_config.py` - 配置文件
- 日志文件
- 临时运行数据
- 缓存文件
### 4. 可以删除工作目录吗?
可以,但会丢失:
- 配置文件(需要重新创建)
- 历史日志
- 缓存数据
建议定期备份配置文件。
### 5. 如何迁移到新的工作目录?
```bash
# 1. 复制旧的工作目录
cp -r ~/old_path/unilabos_data ~/new_path/unilabos_data
# 2. 在新位置启动
cd ~/new_path
unilab --ak your_ak --sk your_sk -g graph.json
```
## 最佳实践
1. **使用默认工作目录**:对于单一实验室,使用默认的 `./unilabos_data` 即可
2. **组织多实验室**:为每个实验室创建独立的目录结构
3. **版本控制**:将配置文件纳入版本控制,但排除日志和缓存
4. **备份配置**:定期备份 `local_config.py` 文件
5. **使用脚本**:为不同实验室创建启动脚本,简化操作
## 相关文档
- [配置文件指南](configuration.md)
- [启动参数详解](../user_guide/launch.md)

View File

@@ -1,7 +1,7 @@
(instructions)=
# 设备抽象、指令集与通信中间件
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
Uni-Lab-OS的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
## 设备间通信模式

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,18 @@
# 添加设备
# 添加设备:编写驱动
在 Uni-Lab 中设备Device是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
在 Uni-Lab 中设备Device是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
能被 Uni-Lab 添加的驱动程序类型有以下种类:
> **💡 提示:** 本文档介绍如何使用已有的设备驱动SDK。若设备没有现成的驱动程序需要自己开发驱动请参考 {doc}`add_old_device`。
1. Python Class
## 支持的驱动类型
Uni-Lab 支持以下两种驱动程序:
### 1. Python Class推荐
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,无需额外编译。
**示例:**
```python
class MockGripper:
@@ -31,12 +39,11 @@ class MockGripper:
def status(self) -> str:
return self._status
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
@status.setter
def status(self, target):
self._status = target
# 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
def push_to(self, position: float, torque: float, velocity: float = 0.0):
self._status = "Running"
current_pos = self.position
@@ -53,9 +60,11 @@ class MockGripper:
self._status = "Idle"
```
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。
### 2. C# Class
2. C# Class
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用(仅需一次)。
**示例:**
```csharp
using System;
@@ -84,7 +93,7 @@ public class MockGripper
position = currentPos + (Position - currentPos) / 20 * (i + 1);
torque = Torque / (20 - i);
velocity = Velocity;
await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds
await Task.Delay((int)(moveTime * 1000 / 20));
}
torque = Torque;
status = "Idle";
@@ -92,12 +101,16 @@ public class MockGripper
}
```
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
---
## 快速开始:使用注册表编辑器(推荐)
## 快速开始:两种方式添加设备
### 方式 1使用注册表编辑器推荐
推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置:
**步骤:**
1. 启动 Uni-Lab-OS
2. 在浏览器中打开"注册表编辑器"页面
3. 选择您的 Python 设备驱动文件
@@ -106,13 +119,18 @@ C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能
6. 点击"生成注册表",复制生成的内容
7. 保存到 `devices/` 目录下
---
**优点:**
## 手动编写注册表(简化版)
- 自动识别设备属性和方法
- 可视化界面,易于操作
- 自动生成完整配置
- 减少手动配置错误
### 方式 2手动编写注册表简化版
如果需要手动编写,只需要提供两个必需字段,系统会自动补全其余内容:
### 最小配置示例
**最小配置示例**
```yaml
my_device: # 设备唯一标识符
@@ -121,22 +139,22 @@ my_device: # 设备唯一标识符
type: python # 驱动类型
```
### 注册表文件位置
**注册表文件位置**
- 默认路径:`unilabos/registry/devices`
- 自定义路径:启动时使用 `--registry` 参数指定
- 可将多个设备写在同一个 yaml 文件中
- 自定义路径:启动时使用 `--registry_path` 参数指定
- 可将多个设备写在同一个 YAML 文件中
### 系统自动生成的内容
**系统自动生成的内容**
系统会自动分析您的 Python 驱动类并生成:
- `status_types`:从 `get_*` 方法自动识别状态属性
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
- `action_value_mappings`:从类方法自动生成动作映射
- `init_param_schema`:从 `__init__` 方法分析初始化参数
- `schema`:前端显示用的属性类型定义
### 完整结构概览
**完整结构概览**
```yaml
my_device:
@@ -151,4 +169,848 @@ my_device:
schema: {} # 自动生成
```
详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 <add_yaml>`
> 💡 **提示:** 详细的注册表编写指南和高级配置,请参考 {doc}`03_add_device_registry`
---
## Python 类结构要求
Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
```python
from typing import Dict, Any
class MyDevice:
"""设备类文档字符串
说明设备的功能、连接方式等
"""
def __init__(self, config: Dict[str, Any]):
"""初始化设备
Args:
config: 配置字典,来自图文件或注册表
"""
self.port = config.get('port', '/dev/ttyUSB0')
self.baudrate = config.get('baudrate', 9600)
self._status = "idle"
# 初始化硬件连接
@property
def status(self) -> str:
"""设备状态(会自动广播)"""
return self._status
def my_action(self, param: float) -> Dict[str, Any]:
"""执行动作
Args:
param: 参数说明
Returns:
{"success": True, "result": ...}
"""
# 执行设备操作
return {"success": True}
```
## 状态属性 vs 动作方法
### 状态属性(@property
状态属性会被自动识别并定期广播:
```python
@property
def temperature(self) -> float:
"""当前温度"""
return self._read_temperature()
@property
def status(self) -> str:
"""设备状态: idle, running, error"""
return self._status
@property
def is_ready(self) -> bool:
"""设备是否就绪"""
return self._status == "idle"
```
**特点**:
- 使用`@property`装饰器
- 只读,不能有参数
- 自动添加到注册表的`status_types`
- 定期发布到 ROS2 topic
### 动作方法
动作方法是设备可以执行的操作:
```python
def start_heating(self, target_temp: float, rate: float = 1.0) -> Dict[str, Any]:
"""开始加热
Args:
target_temp: 目标温度(°C)
rate: 升温速率(°C/min)
Returns:
{"success": bool, "message": str}
"""
self._status = "heating"
self._target_temp = target_temp
# 发送命令到硬件
return {"success": True, "message": f"Heating to {target_temp}°C"}
async def async_operation(self, duration: float) -> Dict[str, Any]:
"""异步操作(长时间运行)
Args:
duration: 持续时间(秒)
"""
# 使用 self.sleep 而不是 asyncio.sleepROS2 异步机制)
await self.sleep(duration)
return {"success": True}
```
**特点**:
- 普通方法或 async 方法
- 返回 Dict 类型的结果
- 自动注册为 ROS2 Action
- 支持参数和返回值
### 返回值设计指南
> **⚠️ 重要:返回值会自动显示在前端**
>
> 动作方法的返回值(字典)会自动显示在 Web 界面的工作流执行结果中。因此,**强烈建议**设计结构化、可读的返回值字典。
**推荐的返回值结构:**
```python
def my_action(self, param: float) -> Dict[str, Any]:
"""执行操作"""
try:
# 执行操作...
result = self._do_something(param)
return {
"success": True, # 必需:操作是否成功
"message": "操作完成", # 推荐:用户友好的消息
"result": result, # 可选:具体结果数据
"param_used": param, # 可选:记录使用的参数
# 其他有用的信息...
}
except Exception as e:
return {
"success": False,
"error": str(e),
"message": "操作失败"
}
```
**最佳实践示例(参考 `host_node.test_latency`**
```python
def test_latency(self) -> Dict[str, Any]:
"""测试网络延迟
返回值会在前端显示,包含详细的测试结果
"""
# 执行测试...
avg_rtt_ms = 25.5
avg_time_diff_ms = 10.2
test_count = 5
# 返回结构化的测试结果
return {
"status": "success", # 状态标识
"avg_rtt_ms": avg_rtt_ms, # 平均往返时间
"avg_time_diff_ms": avg_time_diff_ms, # 平均时间差
"max_time_error_ms": 5.3, # 最大误差
"task_delay_ms": 15.7, # 任务延迟
"test_count": test_count, # 测试次数
}
```
**前端显示效果:**
当用户在 Web 界面执行工作流时,返回的字典会以 JSON 格式显示在结果面板中:
```json
{
"status": "success",
"avg_rtt_ms": 25.5,
"avg_time_diff_ms": 10.2,
"max_time_error_ms": 5.3,
"task_delay_ms": 15.7,
"test_count": 5
}
```
**返回值设计建议:**
1. **始终包含 `success` 字段**:布尔值,表示操作是否成功
2. **包含 `message` 字段**:字符串,提供用户友好的描述
3. **使用有意义的键名**:使用描述性的键名(如 `avg_rtt_ms` 而不是 `v1`
4. **包含单位**:在键名中包含单位(如 `_ms``_ml``_celsius`
5. **记录重要参数**:返回使用的关键参数值,便于追溯
6. **错误信息详细**:失败时包含 `error` 字段和详细的错误描述
7. **避免返回大数据**:不要返回大型数组或二进制数据,这会影响前端性能
**错误处理示例:**
```python
def risky_operation(self, param: float) -> Dict[str, Any]:
"""可能失败的操作"""
if param < 0:
return {
"success": False,
"error": "参数不能为负数",
"message": f"无效参数: {param}",
"param": param
}
try:
result = self._execute(param)
return {
"success": True,
"message": "操作成功",
"result": result,
"param": param
}
except IOError as e:
return {
"success": False,
"error": "通信错误",
"message": str(e),
"device_status": self._status
}
```
## 特殊参数类型ResourceSlot 和 DeviceSlot
Uni-Lab 提供特殊的参数类型,用于在方法中声明需要选择资源或设备。
### 导入类型
```python
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from typing import List
```
### ResourceSlot - 资源选择
用于需要选择物料资源的场景:
```python
def pipette_liquid(
self,
source: ResourceSlot, # 单个源容器
target: ResourceSlot, # 单个目标容器
volume: float
) -> Dict[str, Any]:
"""从源容器吸取液体到目标容器
Args:
source: 源容器(前端会显示资源选择下拉框)
target: 目标容器(前端会显示资源选择下拉框)
volume: 体积(μL)
"""
print(f"Pipetting {volume}μL from {source.id} to {target.id}")
return {"success": True}
```
**多选示例**:
```python
def mix_multiple(
self,
containers: List[ResourceSlot], # 多个容器选择
speed: float
) -> Dict[str, Any]:
"""混合多个容器
Args:
containers: 容器列表(前端会显示多选下拉框)
speed: 混合速度
"""
for container in containers:
print(f"Mixing {container.name}")
return {"success": True}
```
### DeviceSlot - 设备选择
用于需要选择其他设备的场景:
```python
def coordinate_with_device(
self,
other_device: DeviceSlot, # 单个设备选择
command: str
) -> Dict[str, Any]:
"""与另一个设备协同工作
Args:
other_device: 协同设备(前端会显示设备选择下拉框)
command: 命令
"""
print(f"Coordinating with {other_device.name}")
return {"success": True}
```
**多设备示例**:
```python
def sync_devices(
self,
devices: List[DeviceSlot], # 多个设备选择
sync_signal: str
) -> Dict[str, Any]:
"""同步多个设备
Args:
devices: 设备列表(前端会显示多选下拉框)
sync_signal: 同步信号
"""
for dev in devices:
print(f"Syncing {dev.name}")
return {"success": True}
```
### 完整示例:液体处理工作站
```python
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from typing import List, Dict, Any
class LiquidHandler:
"""液体处理工作站"""
def __init__(self, config: Dict[str, Any]):
self.simulation = config.get('simulation', False)
self._status = "idle"
@property
def status(self) -> str:
return self._status
def transfer_liquid(
self,
source: ResourceSlot, # 源容器选择
target: ResourceSlot, # 目标容器选择
volume: float,
tip: ResourceSlot = None # 可选的枪头选择
) -> Dict[str, Any]:
"""转移液体
前端效果:
- source: 下拉框,列出所有可用容器
- target: 下拉框,列出所有可用容器
- volume: 数字输入框
- tip: 下拉框(可选),列出所有枪头
"""
self._status = "transferring"
# source和target会被解析为实际的资源对象
print(f"Transferring {volume}μL")
print(f" From: {source.id} ({source.name})")
print(f" To: {target.id} ({target.name})")
if tip:
print(f" Using tip: {tip.id}")
# 执行实际的液体转移
# ...
self._status = "idle"
return {
"success": True,
"volume_transferred": volume,
"source_id": source.id,
"target_id": target.id
}
def multi_dispense(
self,
source: ResourceSlot, # 单个源
targets: List[ResourceSlot], # 多个目标
volumes: List[float]
) -> Dict[str, Any]:
"""从一个源分配到多个目标
前端效果:
- source: 单选下拉框
- targets: 多选下拉框(可选择多个容器)
- volumes: 数组输入(每个目标对应一个体积)
"""
results = []
for target, vol in zip(targets, volumes):
print(f"Dispensing {vol}μL to {target.name}")
results.append({
"target": target.id,
"volume": vol
})
return {
"success": True,
"dispense_results": results
}
def test_with_balance(
self,
target: ResourceSlot, # 容器
balance: DeviceSlot # 天平设备
) -> Dict[str, Any]:
"""使用天平测量容器
前端效果:
- target: 容器选择下拉框
- balance: 设备选择下拉框(仅显示天平类型)
"""
print(f"Weighing {target.name} on {balance.name}")
# 可以调用balance的方法
# weight = balance.get_weight()
return {
"success": True,
"container": target.id,
"balance_used": balance.id
}
```
### 工作原理
#### 1. 类型识别
注册表扫描方法签名时:
```python
def my_method(self, resource: ResourceSlot, device: DeviceSlot):
pass
```
系统识别到`ResourceSlot``DeviceSlot`类型。
#### 2. 自动添加 placeholder_keys
在注册表中自动生成:
```yaml
my_device:
class:
action_value_mappings:
my_method:
goal:
resource: resource
device: device
placeholder_keys:
resource: unilabos_resources # 自动添加!
device: unilabos_devices # 自动添加!
```
#### 3. 前端 UI 生成
- `unilabos_resources`: 渲染为资源选择下拉框
- `unilabos_devices`: 渲染为设备选择下拉框
#### 4. 运行时解析
用户选择资源/设备后,实际调用时会传入完整的资源/设备对象:
```python
# 用户在前端选择了 plate_1
# 运行时source参数会收到完整的Resource对象
source.id # "plate_1"
source.name # "96孔板"
source.type # "resource"
source.class_ # "corning_96_wellplate_360ul_flat"
```
## 支持的通信方式
### 1. 串口Serial
```python
import serial
class SerialDevice:
def __init__(self, config: Dict[str, Any]):
self.port = config['port']
self.baudrate = config.get('baudrate', 9600)
self.ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1
)
def send_command(self, cmd: str) -> str:
"""发送命令并读取响应"""
self.ser.write(f"{cmd}\r\n".encode())
response = self.ser.readline().decode().strip()
return response
def __del__(self):
if hasattr(self, 'ser') and self.ser.is_open:
self.ser.close()
```
### 2. TCP/IP Socket
```python
import socket
class TCPDevice:
def __init__(self, config: Dict[str, Any]):
self.host = config['host']
self.port = config['port']
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
def send_command(self, cmd: str) -> str:
self.sock.sendall(cmd.encode())
response = self.sock.recv(1024).decode()
return response
```
### 3. Modbus
```python
from pymodbus.client import ModbusTcpClient
class ModbusDevice:
def __init__(self, config: Dict[str, Any]):
self.host = config['host']
self.port = config.get('port', 502)
self.client = ModbusTcpClient(self.host, port=self.port)
self.client.connect()
def read_register(self, address: int) -> int:
result = self.client.read_holding_registers(address, 1)
return result.registers[0]
def write_register(self, address: int, value: int):
self.client.write_register(address, value)
```
### 4. OPC UA
```python
from opcua import Client
class OPCUADevice:
def __init__(self, config: Dict[str, Any]):
self.url = config['url']
self.client = Client(self.url)
self.client.connect()
def read_node(self, node_id: str):
node = self.client.get_node(node_id)
return node.get_value()
def write_node(self, node_id: str, value):
node = self.client.get_node(node_id)
node.set_value(value)
```
### 5. HTTP/RPC
```python
import requests
class HTTPDevice:
def __init__(self, config: Dict[str, Any]):
self.base_url = config['url']
self.auth_token = config.get('token')
def send_command(self, endpoint: str, data: Dict) -> Dict:
url = f"{self.base_url}/{endpoint}"
headers = {'Authorization': f'Bearer {self.auth_token}'}
response = requests.post(url, json=data, headers=headers)
return response.json()
```
## 异步 vs 同步方法
### 同步方法(适合快速操作)
```python
def quick_operation(self, param: float) -> Dict[str, Any]:
"""快速操作,立即返回"""
result = self._do_something(param)
return {"success": True, "result": result}
```
### 异步方法(适合耗时操作)
```python
async def long_operation(self, duration: float) -> Dict[str, Any]:
"""长时间运行的操作"""
self._status = "running"
# 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep
await self.sleep(duration)
# 可以在过程中发送feedback
# 需要配合ROS2 Action的feedback机制
self._status = "idle"
return {"success": True, "duration": duration}
```
> **⚠️ 重要提示ROS2 异步机制 vs Python asyncio**
>
> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。
>
> **不能使用的 asyncio 功能:**
>
> - ❌ `asyncio.sleep()` - 会导致 ROS2 事件循环阻塞
> - ❌ `asyncio.create_task()` - 任务不会被 ROS2 正确调度
> - ❌ `asyncio.gather()` - 无法与 ROS2 集成
> - ❌ 其他 asyncio 标准库函数
>
> **应该使用的方法(继承自 BaseROS2DeviceNode**
>
> - ✅ `await self.sleep(seconds)` - ROS2 兼容的睡眠
> - ✅ `await self.create_task(func, **kwargs)` - ROS2 兼容的任务创建
> - ✅ ROS2 的 Action/Service 回调机制
>
> **示例:**
>
> ```python
> async def complex_operation(self, duration: float) -> Dict[str, Any]:
> """正确使用 ROS2 异步方法"""
> self._status = "processing"
>
> # ✅ 正确:使用 self.sleep
> await self.sleep(duration)
>
> # ✅ 正确:创建并发任务
> task = await self.create_task(self._background_work)
>
> # ❌ 错误:不要使用 asyncio
> # await asyncio.sleep(duration) # 这会导致问题!
> # task = asyncio.create_task(...) # 这也不行!
>
> self._status = "idle"
> return {"success": True}
>
> async def _background_work(self):
> """后台任务"""
> await self.sleep(1.0)
> self.lab_logger().info("Background work completed")
> ```
>
> **为什么不能混用?**
>
> ROS2 使用 `rclpy` 的事件循环来管理所有异步操作。如果使用 `asyncio` 的函数,这些操作会在不同的事件循环中运行,导致:
>
> - ROS2 回调无法正确执行
> - 任务可能永远不会完成
> - 程序可能死锁或崩溃
>
> **参考实现:**
>
> `BaseROS2DeviceNode` 提供的方法定义(`base_device_node.py:563-572`
>
> ```python
> async def sleep(self, rel_time: float, callback_group=None):
> """ROS2 兼容的异步睡眠"""
> if callback_group is None:
> callback_group = self.callback_group
> await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group)
>
> @classmethod
> async def create_task(cls, func, trace_error=True, **kwargs) -> Task:
> """ROS2 兼容的任务创建"""
> return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs)
> ```
## 错误处理
### 基本错误处理
```python
def operation_with_error_handling(self, param: float) -> Dict[str, Any]:
"""带错误处理的操作"""
try:
result = self._risky_operation(param)
return {
"success": True,
"result": result
}
except ValueError as e:
return {
"success": False,
"error": "Invalid parameter",
"message": str(e)
}
except IOError as e:
self._status = "error"
return {
"success": False,
"error": "Communication error",
"message": str(e)
}
```
### 自定义异常
```python
class DeviceError(Exception):
"""设备错误基类"""
pass
class DeviceNotReadyError(DeviceError):
"""设备未就绪"""
pass
class DeviceTimeoutError(DeviceError):
"""设备超时"""
pass
class MyDevice:
def operation(self) -> Dict[str, Any]:
if self._status != "idle":
raise DeviceNotReadyError(f"Device is {self._status}")
# 执行操作
return {"success": True}
```
## 最佳实践
### 1. 类型注解
```python
from typing import Dict, Any, Optional, List
def method(
self,
param1: float,
param2: str,
optional_param: Optional[int] = None
) -> Dict[str, Any]:
"""完整的类型注解有助于自动生成注册表"""
pass
```
### 2. 文档字符串
```python
def method(self, param: float) -> Dict[str, Any]:
"""方法简短描述
更详细的说明...
Args:
param: 参数说明,包括单位和范围
Returns:
Dict包含:
- success (bool): 是否成功
- result (Any): 结果数据
Raises:
DeviceError: 错误情况说明
"""
pass
```
### 3. 配置验证
```python
def __init__(self, config: Dict[str, Any]):
# 验证必需参数
required = ['port', 'baudrate']
for key in required:
if key not in config:
raise ValueError(f"Missing required config: {key}")
self.port = config['port']
self.baudrate = config['baudrate']
```
### 4. 资源清理
```python
def __del__(self):
"""析构函数,清理资源"""
if hasattr(self, 'connection') and self.connection:
self.connection.close()
```
### 5. 设计前端友好的返回值
**记住:返回值会直接显示在 Web 界面**
```python
import time
def measure_temperature(self) -> Dict[str, Any]:
"""测量温度
✅ 好的返回值设计:
- 包含 success 状态
- 使用描述性键名
- 在键名中包含单位
- 记录测量时间
"""
temp = self._read_temperature()
return {
"success": True,
"temperature_celsius": temp, # 键名包含单位
"timestamp": time.time(), # 记录时间
"sensor_status": "normal", # 额外状态信息
"message": f"温度测量完成: {temp}°C" # 用户友好的消息
}
def bad_example(self) -> Dict[str, Any]:
"""❌ 不好的返回值设计"""
return {
"s": True, # ❌ 键名不明确
"v": 25.5, # ❌ 没有说明单位
"t": 1234567890, # ❌ 不清楚是什么时间戳
}
```
**参考 `host_node.test_latency` 方法**(第 1216-1340 行),它返回详细的测试结果,在前端清晰显示:
```python
return {
"status": "success",
"avg_rtt_ms": 25.5, # 有意义的键名 + 单位
"avg_time_diff_ms": 10.2,
"max_time_error_ms": 5.3,
"task_delay_ms": 15.7,
"test_count": 5, # 记录重要信息
}
```
## 下一步
看完本文档后,建议继续阅读:
- {doc}`add_action` - 了解如何添加新的动作指令
- {doc}`add_yaml` - 学习如何编写和完善 YAML 注册表
进阶主题:
- {doc}`03_add_device_registry` - 了解如何配置注册表
- {doc}`04_add_device_testing` - 学习如何测试设备
- {doc}`add_old_device` - 没有 SDK 时如何开发设备驱动
## 参考
- [Python 类型注解](https://docs.python.org/3/library/typing.html)
- [ROS2 rclpy 异步编程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html) - Uni-Lab 使用 ROS2 的异步机制
- [串口通信](https://pyserial.readthedocs.io/)
> **注意:** 虽然设备驱动使用 `async def` 语法,但请**不要参考** Python 标准的 [asyncio 文档](https://docs.python.org/3/library/asyncio.html)。Uni-Lab 使用的是 ROS2 的异步机制,两者不兼容。请使用 `self.sleep()` 和 `self.create_task()` 等 BaseROS2DeviceNode 提供的方法。

View File

@@ -1,8 +1,10 @@
# 设备 Driver 开发
# 设备 Driver 开发(无 SDK 设备)
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章开发。
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。**仅当没有 SDK (Driver) 时,请参考本章进行驱动开发。**
> **💡 提示:** 本文档介绍如何为没有现成驱动的老设备开发驱动程序。如果您的设备已经有 SDK 或驱动,请直接参考 {doc}`add_device`
## 有串口字符串指令集文档的设备Python 串口通信(常见 RS485, RS232, USB
@@ -12,13 +14,13 @@
Modbus 与 RS485、RS232 不一样的地方在于会有更多直接寄存器的读写以及涉及字节序转换Big Endian, Little Endian
Uni-Lab 开发团队在仓库中提供了3个样例:
Uni-Lab 开发团队在仓库中提供了 3 个样例:
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
* 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
****
---
## 其他工业通信协议CANopen, Ethernet, OPCUA...
@@ -26,32 +28,32 @@ Uni-Lab 开发团队在仓库中提供了3个样例
## 没有接口的老设备老软件:使用 PyWinAuto
**pywinauto**是一个 Python 库用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作
**pywinauto**是一个 Python 库,用于自动化 Windows GUI 操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等广泛应用于自动化测试、GUI 自动化等场景。它支持通过两个后端进行操作:
* **win32**后端适用于大多数Windows应用程序使用native Win32 API。pywinauto_recorder默认使用win32后端
* **uia**后端基于Microsoft UI Automation适用于较新的应用程序特别是基于WPFUWP的应用程序。在win10上会有更全的目录有的窗口win32会识别不到
- **win32**后端:适用于大多数 Windows 应用程序,使用 native Win32 API。pywinauto_recorder 默认使用 win32 后端)
- **uia**后端:基于 Microsoft UI Automation适用于较新的应用程序特别是基于 WPFUWP 的应用程序。(在 win10 上,会有更全的目录,有的窗口 win32 会识别不到)
### windows平台安装pywinautopywinauto_recorder
### windows 平台安装 pywinautopywinauto_recorder
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
cd到对应目录执行安装
cd 到对应目录,执行安装
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
` pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple `
![pywinauto_install](image/device_driver/pywinauto_install.png)
windows平台测试 python pywinauto_recorder.py退出使用两次ctrl+alt+r取消选中关闭命令提示符。
windows 平台测试 python pywinauto_recorder.py退出使用两次 ctrl+alt+r 取消选中,关闭命令提示符。
### 计算器例子
你可以先打开windows的计算器然后在ilab的环境中运行下面的代码片段可观察到得到结果通过这一案例你需要掌握的pywinauto用法
你可以先打开 windows 的计算器,然后在 ilab 的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的 pywinauto 用法:
* 连接到指定进程
* 利用dump_tree查找需要的窗口
* 获取某个位置的信息
* 模拟点击
* 模拟输入
- 连接到指定进程
- 利用 dump_tree 查找需要的窗口
- 获取某个位置的信息
- 模拟点击
- 模拟输入
#### 代码学习
@@ -74,39 +76,39 @@ window.dump_tree(depth=3)
Dialog - '计算器' (L-419, T773, R-73, B1287)
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
child_window(title="计算器", control_type="Window")
|
|
| Dialog - '计算器' (L-269, T774, R-81, B806)
| ['计算器Dialog2', 'Dialog2', '计算器2']
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
| |
| |
| | Menu - '系统' (L0, T0, R0, B0)
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
| |
| |
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
| |
| |
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
| |
| |
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
|
| Dialog - '计算器' (L-411, T774, R-81, B1279)
| ['计算器Dialog3', 'Dialog3', '计算器3']
| child_window(title="计算器", control_type="Window")
| |
| |
| | Static - '计算器' (L-363, T782, R-327, B798)
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
| |
| |
| | Custom - '' (L-411, T806, R-81, B1279)
| | ['Custom', '计算器Custom']
| | child_window(auto_id="NavView", control_type="Custom")
|
|
| Pane - '' (L-411, T806, R-81, B1279)
| ['Pane', '计算器Pane']
"""
@@ -122,58 +124,58 @@ target_window.dump_tree(depth=3)
Custom - '' (L-411, T806, R-81, B1279)
['标准Custom', 'Custom']
child_window(auto_id="NavView", control_type="Custom")
|
|
| Button - '打开导航' (L-407, T812, R-367, B848)
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
| |
| |
| | Static - '' (L0, T0, R0, B0)
| | ['Static', 'Static0', 'Static1']
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
|
| GroupBox - '' (L-411, T814, R-81, B1275)
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
| |
| |
| | Static - '表达式为 ' (L0, T0, R0, B0)
| | ['表达式为 ', 'Static2', '表达式为 Static']
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
| |
| |
| | Static - '显示为 0' (L-411, T875, R-81, B947)
| | ['显示为 0Static', '显示为 0', 'Static3']
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
| |
| |
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
| |
| |
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
| |
| |
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
| |
| |
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
| |
| |
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
| |
| |
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
| |
| |
| | Button - '正负' (L-407, T1228, R-328, B1275)
| | ['Button32', '正负Button', '正负']
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
|
| Static - '标准' (L-363, T815, R-322, B842)
| ['标准', '标准Static', 'Static4']
| child_window(title="标准", auto_id="Header", control_type="Text")
|
|
| Button - '始终置顶' (L-312, T814, R-280, B846)
| ['始终置顶Button', '始终置顶', 'Button33']
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
@@ -187,47 +189,47 @@ numpad.dump_tree(depth=2)
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
['GroupBox', '数字键盘', '数字键盘GroupBox']
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
|
| Button - '零' (L-253, T1500, R-174, B1547)
| ['零Button', 'Button', '零', 'Button0', 'Button1']
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
|
| Button - '一' (L-334, T1450, R-255, B1498)
| ['一Button', 'Button2', '一']
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
|
| Button - '二' (L-253, T1450, R-174, B1498)
| ['Button3', '二', '二Button']
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
|
| Button - '三' (L-172, T1450, R-93, B1498)
| ['Button4', '三', '三Button']
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
|
| Button - '四' (L-334, T1400, R-255, B1448)
| ['四', 'Button5', '四Button']
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
|
| Button - '五' (L-253, T1400, R-174, B1448)
| ['Button6', '五Button', '五']
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
|
| Button - '六' (L-172, T1400, R-93, B1448)
| ['六Button', 'Button7', '六']
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
|
| Button - '七' (L-334, T1350, R-255, B1398)
| ['Button8', '七Button', '七']
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
|
| Button - '八' (L-253, T1350, R-174, B1398)
| ['八', 'Button9', '八Button']
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
|
| Button - '九' (L-172, T1350, R-93, B1398)
| ['Button10', '九', '九Button']
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
|
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
@@ -262,13 +264,13 @@ r, g, b = pyautogui.pixel(point_x, point_y)
### pywinauto_recorder
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时可以省去仅用pywinauto的一些查找UI步骤。
pywinauto_recorder 是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用 DLL 的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用 pywinauto 的一些查找 UI 步骤。
#### 运行尝试
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
请参照 上手尝试-环境创建-3 开启 pywinauto_recorder
例如我们这里先启动一个windows自带的计算器软件
例如我们这里先启动一个 windows 自带的计算器软件
![calculator_01](image/device_driver/calculator_01.png)
@@ -286,7 +288,7 @@ with UIPath(u"计算器||Window"):
click(u"九||Button")
```
执行该python脚本可以观察到新开启的计算器被点击了数字9
执行该 python 脚本,可以观察到新开启的计算器被点击了数字 9
![calculator_03](image/device_driver/calculator_03.png)
@@ -308,23 +310,38 @@ window.dump_tree(depth=[int类型数字], filename=None)
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
['GroupBox', '数字键盘', '数字键盘GroupBox']
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
|
| Button - '零' (L-253, T1500, R-174, B1547)
| ['零Button', 'Button', '零', 'Button0', 'Button1']
| child_window(title="零", auto_id="num0Button", control_type="Button")
"""
```
这里以上面计算器的例子对dump_tree进行解读
这里以上面计算器的例子对 dump_tree 进行解读
2~4行为当前对象的窗口
2~4 行为当前对象的窗口
*2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
*3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
*4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
- 2 行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
- 3 行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
- 4 行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
6~8行为当前对象窗口所包含的子窗口信息信息类型对应2~4行
6~8 行为当前对象窗口所包含的子窗口信息,信息类型对应 2~4
### 窗口获取注意事项
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
---
## 下一步
完成设备驱动开发后,建议继续阅读:
- {doc}`add_device` - 了解如何将驱动添加到 Uni-Lab 中
- {doc}`add_action` - 学习如何添加新的动作指令
- {doc}`add_yaml` - 编写和完善 YAML 注册表
进阶主题:
- {doc}`03_add_device_registry` - 详细的注册表配置
- {doc}`04_add_device_testing` - 设备测试指南

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,17 @@
# 电池装配工站接入PLC
# 实例:电池装配工站接入PLC 控制
本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
> **文档类型**:实际应用案例
> **适用场景**:使用 PLC 控制的电池装配工站接入
> **前置知识**{doc}`../add_device` | {doc}`../add_registry`
本指南以电池装配工站为实际案例,引导你完成 PLC 控制设备的完整接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
## 案例概述
**设备类型**:电池装配工站
**通信方式**Modbus TCP (PLC)
**工站基类**`WorkstationBase`
**主要功能**:电池组装、寄存器读写、数据采集
## 1. 新建工站文件
@@ -39,8 +50,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
self.client = tcp.register_node_list(self.nodes)
```
## 2. 编写驱动与寄存器读写
### 2.1 寄存器示例
@@ -84,49 +93,49 @@ def start_and_read_metrics(self):
完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。
### 3.1 新增工站设备(或资源)首次生成注册表
首先通过以下命令启动unilab。进入unilab系统状态检查页面
首先通过以下命令启动 unilab。进入 unilab 系统状态检查页面
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK>
```
点击注册表编辑,进入注册表编辑页面
![Layers](image_add_batteryPLC/unilab_sys_status.png)
![系统状态页面](image_battery_plc/unilab_sys_status.png)
按照图示步骤填写自动生成注册表信息:
![Layers](image_add_batteryPLC/unilab_registry_process.png)
![注册表生成流程](image_battery_plc/unilab_registry_process.png)
步骤说明:
1. 选择新增的工站`coin_cell_assembly.py`文件
2. 点击分析按钮,分析`coin_cell_assembly.py`文件
3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`
4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`
4. 填写新增的工站.py 文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`
5. 此处填写新定义工站的类的名字(名称可以自拟)
6. 填写新的工站注册表备注信息
7. 生成注册表
以上操作步骤完成,则会生成的新的注册表ymal文件,如下图:
![Layers](image_add_batteryPLC/unilab_new_yaml.png)
以上操作步骤完成,则会生成的新的注册表 YAML 文件,如下图:
![生成的YAML文件](image_battery_plc/unilab_new_yaml.png)
### 3.2 添加新生成注册表
`unilabos\registry\devices`目录下新建一个yaml文件此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
`unilabos\registry\devices`目录下新建一个 yaml 文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
在终端输入以下命令进行注册表补全操作。
```bash
python unilabos\app\register.py --complete_registry
```
### 3.3 启动并上传注册表
新增设备之后启动unilab需要增加`--upload_registry`参数,来上传注册表信息。
新增设备之后,启动 unilab 需要增加`--upload_registry`参数,来上传注册表信息。
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> --upload_registry
@@ -134,14 +143,60 @@ python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> -
## 4. 注意事项
- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件:
### 4.1 验证模块路径
```
在新生成的 YAML 中,确认 `module` 指向新工站类。本例中需检查 `coincellassemblyworkstation_device.yaml` 文件中是否正确指向了 `CoinCellAssemblyWorkstation` 类:
```yaml
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
```
- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。
### 4.2 首次接入流程
- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后上传注册表即可。
首次新增设备(或资源)需要完整流程:
1. ✅ 在网页端生成注册表信息
2. ✅ 使用 `--complete_registry` 补全注册表
3. ✅ 使用 `--upload_registry` 上传注册表信息
### 4.3 驱动更新流程
如果不是新增设备,仅修改了工站驱动的 `.py` 文件:
1. ✅ 运行 `--complete_registry` 补全注册表
2. ✅ 运行 `--upload_registry` 上传注册表
3. ❌ 不需要在网页端重新生成注册表
### 4.4 PLC 通信注意事项
- **握手机制**:若需参数下发,建议在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争
- **字节序**FLOAT32 等多字节数据类型需要正确指定字节序(如 `WorderOrder.LITTLE`
- **寄存器映射**:确保 CSV 文件中的寄存器地址与 PLC 实际配置一致
- **连接稳定性**:在初始化时检查 PLC 连接状态,建议添加重连机制
## 5. 扩展阅读
### 相关文档
- {doc}`../add_device` - 设备驱动编写通用指南
- {doc}`../add_registry` - 注册表配置完整指南
- {doc}`../workstation_architecture` - 工站架构详解
### 技术要点
- **Modbus TCP 通信**PLC 通信协议和寄存器读写
- **WorkstationBase**:工站基类的继承和使用
- **寄存器映射**CSV 格式的寄存器配置
- **注册表生成**:自动化工具使用
## 6. 总结
通过本案例,你应该掌握:
1. ✅ 如何创建 PLC 控制的工站驱动
2. ✅ Modbus TCP 通信和寄存器读写
3. ✅ 使用可视化编辑器生成注册表
4. ✅ 注册表的补全和上传流程
5. ✅ 新增设备与更新驱动的区别
这个案例展示了完整的 PLC 设备接入流程,可以作为其他类似设备接入的参考模板。

View File

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 428 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,4 +1,8 @@
# 物料构建指南
# 实例:物料构建指南
> **文档类型**:物料系统实战指南
> **适用场景**工作站物料系统构建、Deck/Warehouse/Carrier/Bottle 配置
> **前置知识**PyLabRobot 基础 | 资源管理概念
## 概述

View File

@@ -1,6 +1,10 @@
# 物料教程Resource
# 实例:物料教程Resource
本教程面向 Uni-Lab-OS 的开发者讲解“物料”的核心概念、3种物料格式UniLab、PyLabRobot、奔耀Bioyond及其相互转换方法并说明4种 children 结构表现形式及使用场景。
> **文档类型**:物料系统完整教程
> **适用场景**:物料格式转换、多系统物料对接、资源结构理解
> **前置知识**Python 基础 | JSON 数据结构
本教程面向 Uni-Lab-OS 的开发者,讲解"物料"的核心概念、3种物料格式UniLab、PyLabRobot、奔耀Bioyond及其相互转换方法并说明4种 children 结构表现形式及使用场景。
---

View File

@@ -1,4 +1,8 @@
# 工作站模板架构设计与对接指南
# 实例:工作站模板架构设计与对接指南
> **文档类型**:架构设计指南与实战案例
> **适用场景**:大型工作站接入、子设备管理、物料系统集成
> **前置知识**{doc}`../add_device` | {doc}`../add_registry`
## 0. 问题简介
@@ -6,19 +10,19 @@
### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发
![workstation_organic_yed](image/workstation_architecture/workstation_organic_yed.png)
![workstation_organic_yed](../image/workstation_architecture/workstation_organic_yed.png)
![workstation_organic](image/workstation_architecture/workstation_organic.png)
![workstation_organic](../image/workstation_architecture/workstation_organic.png)
这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合;
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如2个泵共用1个串口、所有设备共同接入PLC等。
2. 任务系统是统一实现的 protocolsprotocols 中会将高层指令处理成各子设备配合的工作流 json并管理执行、同时更改物料信息
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如 2 个泵共用 1 个串口、所有设备共同接入 PLC 等。
2. 任务系统是统一实现的 protocolsprotocols 中会将高层指令处理成各子设备配合的工作流 json 并管理执行、同时更改物料信息
3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息
### 0.2 移液工作站:物料系统和工作流模板管理
![workstation_liquid_handler](image/workstation_architecture/workstation_liquid_handler.png)
![workstation_liquid_handler](../image/workstation_architecture/workstation_liquid_handler.png)
1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供
2. 所有任务系统均由工作站本身实现并下发指令有统一的抽象函数可实现pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。
@@ -26,12 +30,12 @@
### 0.3 厂家开发的定制大型工站
![workstation_by_supplier](image/workstation_architecture/workstation_by_supplier.png)
![workstation_by_supplier](../image/workstation_architecture/workstation_by_supplier.png)
由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信
1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC连续读写来配置工作站可用的工作流
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC 连续读写来配置工作站可用的工作流;
3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接
## 1. 整体架构图
@@ -45,7 +49,7 @@ graph TB
RPN[ROS2WorkstationNode<br/>Protocol执行引擎]
WB -.post_init关联.-> RPN
end
subgraph "物料管理系统"
DECK[Deck<br/>PLR本地物料系统]
RS[ResourceSynchronizer<br/>外部物料同步器]
@@ -53,7 +57,7 @@ graph TB
WB --> RS
RS --> DECK
end
subgraph "通信与子设备管理"
HW[hardware_interface<br/>硬件通信接口]
SUBDEV[子设备集合<br/>pumps/grippers/sensors]
@@ -61,7 +65,7 @@ graph TB
RPN --> SUBDEV
HW -.代理模式.-> RPN
end
subgraph "工作流任务系统"
PROTO[Protocol定义<br/>LiquidHandling/PlateHandling]
WORKFLOW[Workflow执行器<br/>步骤管理与编排]
@@ -81,32 +85,32 @@ graph LR
HW2[通信接口<br/>hardware_interface]
HTTP[HTTP服务<br/>WorkstationHTTPService]
end
subgraph "外部物料系统"
BIOYOND[Bioyond物料管理]
LIMS[LIMS系统]
WAREHOUSE[第三方仓储]
end
subgraph "外部硬件系统"
PLC[PLC设备]
SERIAL[串口设备]
ROBOT[机械臂/机器人]
end
subgraph "云端系统"
CLOUD[UniLab云端<br/>资源管理]
MONITOR[监控与调度]
end
BIOYOND <-->|RPC双向同步| DECK2
LIMS -->|HTTP报送| HTTP
WAREHOUSE <-->|API对接| DECK2
PLC <-->|Modbus TCP| HW2
SERIAL <-->|串口通信| HW2
ROBOT <-->|SDK/API| HW2
WS -->|ROS消息| CLOUD
CLOUD -->|任务下发| WS
MONITOR -->|状态查询| WS
@@ -119,40 +123,40 @@ graph TB
subgraph "工作站基类"
BASE[WorkstationBase<br/>抽象基类]
end
subgraph "Bioyond集成工作站"
BW[BioyondWorkstation]
BW_DECK[Deck + Warehouses]
BW_SYNC[BioyondResourceSynchronizer]
BW_HW[BioyondV1RPC]
BW_HTTP[HTTP报送服务]
BW --> BW_DECK
BW --> BW_SYNC
BW --> BW_HW
BW --> BW_HTTP
end
subgraph "纯协议节点"
PN[ProtocolNode]
PN_SUB[子设备集合]
PN_PROTO[Protocol工作流]
PN --> PN_SUB
PN --> PN_PROTO
end
subgraph "PLC控制工作站"
PW[PLCWorkstation]
PW_DECK[Deck物料系统]
PW_PLC[Modbus PLC客户端]
PW_WF[工作流定义]
PW --> PW_DECK
PW --> PW_PLC
PW --> PW_WF
end
BASE -.继承.-> BW
BASE -.继承.-> PN
BASE -.继承.-> PW
@@ -171,25 +175,25 @@ classDiagram
+hardware_interface: Union[Any, str]
+current_workflow_status: WorkflowStatus
+supported_workflows: Dict[str, WorkflowInfo]
+post_init(ros_node)*
+set_hardware_interface(interface)
+call_device_method(method, *args, **kwargs)
+get_device_status()
+is_device_available()
+get_deck()
+get_all_resources()
+find_resource_by_name(name)
+find_resources_by_type(type)
+sync_with_external_system()
+execute_workflow(name, params)
+stop_workflow(emergency)
+workflow_status
+is_busy
}
class ROS2WorkstationNode {
+device_id: str
+children: Dict[str, Any]
@@ -198,7 +202,7 @@ classDiagram
+_action_clients: Dict
+_action_servers: Dict
+resource_tracker: DeviceNodeResourceTracker
+initialize_device(device_id, config)
+create_ros_action_server(action_name, mapping)
+execute_single_action(device_id, action, kwargs)
@@ -206,14 +210,14 @@ classDiagram
+transfer_resource_to_another(resources, target, sites)
+_setup_hardware_proxy(device, comm_device, read, write)
}
%% 物料管理相关类
class Deck {
+name: str
+children: List
+assign_child_resource()
}
class ResourceSynchronizer {
<<abstract>>
+workstation: WorkstationBase
@@ -221,23 +225,23 @@ classDiagram
+sync_to_external(plr_resource)*
+handle_external_change(change_info)*
}
class BioyondResourceSynchronizer {
+bioyond_api_client: BioyondV1RPC
+sync_interval: int
+last_sync_time: float
+initialize()
+sync_from_external()
+sync_to_external(resource)
+handle_external_change(change_info)
}
%% 硬件接口相关类
class HardwareInterface {
<<interface>>
}
class BioyondV1RPC {
+base_url: str
+api_key: str
@@ -245,7 +249,7 @@ classDiagram
+add_material()
+material_inbound()
}
%% 服务类
class WorkstationHTTPService {
+workstation: WorkstationBase
@@ -253,7 +257,7 @@ classDiagram
+port: int
+server: HTTPServer
+running: bool
+start()
+stop()
+_handle_step_finish_report()
@@ -262,13 +266,13 @@ classDiagram
+_handle_material_change_report()
+_handle_error_handling_report()
}
%% 具体实现类
class BioyondWorkstation {
+bioyond_config: Dict
+workflow_mappings: Dict
+workflow_sequence: List
+post_init(ros_node)
+transfer_resource_to_another()
+resource_tree_add(resources)
@@ -276,25 +280,25 @@ classDiagram
+get_all_workflows()
+get_bioyond_status()
}
class ProtocolNode {
+post_init(ros_node)
}
%% 核心关系
WorkstationBase o-- ROS2WorkstationNode : post_init关联
WorkstationBase o-- WorkstationHTTPService : 可选服务
%% 物料管理侧
WorkstationBase *-- Deck : deck
WorkstationBase *-- ResourceSynchronizer : 可选组合
ResourceSynchronizer <|-- BioyondResourceSynchronizer
%% 硬件接口侧
WorkstationBase o-- HardwareInterface : hardware_interface
HardwareInterface <|.. BioyondV1RPC : 实现
BioyondResourceSynchronizer --> BioyondV1RPC : 使用
%% 继承关系
BioyondWorkstation --|> WorkstationBase
ProtocolNode --|> WorkstationBase
@@ -312,49 +316,49 @@ sequenceDiagram
participant HW as HardwareInterface
participant ROS as ROS2WorkstationNode
participant HTTP as HTTPService
APP->>WS: 创建工作站实例(__init__)
WS->>DECK: 初始化PLR Deck
DECK->>DECK: 创建Warehouse等子资源
DECK-->>WS: Deck创建完成
WS->>HW: 创建硬件接口(如BioyondV1RPC)
HW->>HW: 建立连接(PLC/RPC/串口等)
HW-->>WS: 硬件接口就绪
WS->>SYNC: 创建ResourceSynchronizer(可选)
SYNC->>HW: 使用hardware_interface
SYNC->>SYNC: 初始化同步配置
SYNC-->>WS: 同步器创建完成
WS->>SYNC: sync_from_external()
SYNC->>HW: 查询外部物料系统
HW-->>SYNC: 返回物料数据
SYNC->>DECK: 转换并添加到Deck
SYNC-->>WS: 同步完成
Note over WS: __init__完成,等待ROS节点
APP->>ROS: 初始化ROS2WorkstationNode
ROS->>ROS: 初始化子设备(children)
ROS->>ROS: 创建Action客户端
ROS->>ROS: 设置硬件接口代理
ROS-->>APP: ROS节点就绪
APP->>WS: post_init(ros_node)
WS->>WS: self._ros_node = ros_node
WS->>ROS: update_resource([deck])
ROS->>ROS: 上传物料到云端
ROS-->>WS: 上传完成
WS->>HTTP: 创建WorkstationHTTPService(可选)
HTTP->>HTTP: 启动HTTP服务器线程
HTTP-->>WS: HTTP服务启动
WS-->>APP: 工作站完全就绪
```
## 4. 工作流执行时序图Protocol模式
## 4. 工作流执行时序图Protocol 模式)
```{mermaid}
sequenceDiagram
@@ -365,15 +369,15 @@ sequenceDiagram
participant DECK as PLR Deck
participant CLOUD as 云端资源管理
participant DEV as 子设备
CLIENT->>ROS: 发送Protocol Action请求
ROS->>ROS: execute_protocol回调
ROS->>ROS: 从Goal提取参数
ROS->>ROS: 调用protocol_steps_generator
ROS->>ROS: 生成action步骤列表
ROS->>WS: 更新workflow_status = RUNNING
loop 执行每个步骤
alt 调用子设备
ROS->>ROS: execute_single_action(device_id, action, params)
@@ -394,19 +398,19 @@ sequenceDiagram
end
WS-->>ROS: 返回结果
end
ROS->>DECK: 更新本地物料状态
DECK->>DECK: 修改PLR资源属性
end
ROS->>CLOUD: 同步物料到云端(可选)
CLOUD-->>ROS: 同步完成
ROS->>WS: 更新workflow_status = COMPLETED
ROS-->>CLIENT: 返回Protocol Result
```
## 5. HTTP报送处理时序图
## 5. HTTP 报送处理时序图
```{mermaid}
sequenceDiagram
@@ -416,25 +420,25 @@ sequenceDiagram
participant DECK as PLR Deck
participant SYNC as ResourceSynchronizer
participant CLOUD as 云端
EXT->>HTTP: POST /report/step_finish
HTTP->>HTTP: 解析请求数据
HTTP->>HTTP: 验证LIMS协议字段
HTTP->>WS: process_step_finish_report(request)
WS->>WS: 增加接收计数(_reports_received_count++)
WS->>WS: 记录步骤完成事件
WS->>DECK: 更新相关物料状态(可选)
DECK->>DECK: 修改PLR资源状态
WS->>WS: 保存报送记录到内存
WS-->>HTTP: 返回处理结果
HTTP->>HTTP: 构造HTTP响应
HTTP-->>EXT: 200 OK + acknowledgment_id
Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送
alt 物料变更报送
EXT->>HTTP: POST /report/material_change
HTTP->>WS: process_material_change_report(data)
@@ -459,7 +463,7 @@ sequenceDiagram
participant HW as HardwareInterface
participant HTTP as HTTPService
participant LOG as 日志系统
alt 设备错误(ROS Action失败)
DEV->>ROS: Action返回失败结果
ROS->>ROS: 记录错误信息
@@ -471,7 +475,7 @@ sequenceDiagram
WS->>WS: 记录错误历史
WS->>LOG: 记录错误日志
end
alt 关键错误需要停止
WS->>ROS: stop_workflow(emergency=True)
ROS->>ROS: 取消所有进行中的Action
@@ -483,44 +487,44 @@ sequenceDiagram
WS->>ROS: 触发重试逻辑(可选)
ROS->>DEV: 重新发送Action
end
WS-->>HTTP: 返回错误处理结果
HTTP-->>DEV: 200 OK + 处理状态
```
## 7. 典型工作站实现示例
### 7.1 Bioyond集成工作站实现
### 7.1 Bioyond 集成工作站实现
```python
class BioyondWorkstation(WorkstationBase):
def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs):
# 初始化deck
super().__init__(deck=deck, *args, **kwargs)
# 设置硬件接口为Bioyond RPC客户端
self.hardware_interface = BioyondV1RPC(bioyond_config)
# 创建资源同步器
self.resource_synchronizer = BioyondResourceSynchronizer(self)
# 从Bioyond同步物料到本地deck
self.resource_synchronizer.sync_from_external()
# 配置工作流
self.workflow_mappings = bioyond_config.get("workflow_mappings", {})
def post_init(self, ros_node: ROS2WorkstationNode):
"""ROS节点就绪后的初始化"""
self._ros_node = ros_node
# 上传deck(包括所有物料)到云端
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource,
True,
self._ros_node.update_resource,
True,
resources=[self.deck]
)
def resource_tree_add(self, resources: List[ResourcePLR]):
"""添加物料并同步到Bioyond"""
for resource in resources:
@@ -533,24 +537,24 @@ class BioyondWorkstation(WorkstationBase):
```python
class ProtocolNode(WorkstationBase):
"""纯协议节点,不需要物料管理和外部通信"""
def __init__(self, deck: Optional[Deck] = None, *args, **kwargs):
super().__init__(deck=deck, *args, **kwargs)
# 不设置hardware_interface和resource_synchronizer
# 所有功能通过子设备协同完成
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
# 不需要上传物料或其他初始化
```
### 7.3 PLC直接控制工作站
### 7.3 PLC 直接控制工作站
```python
class PLCWorkstation(WorkstationBase):
def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs):
super().__init__(deck=deck, *args, **kwargs)
# 设置硬件接口为Modbus客户端
from pymodbus.client import ModbusTcpClient
self.hardware_interface = ModbusTcpClient(
@@ -558,7 +562,7 @@ class PLCWorkstation(WorkstationBase):
port=plc_config["port"]
)
self.hardware_interface.connect()
# 定义支持的工作流
self.supported_workflows = {
"battery_assembly": WorkflowInfo(
@@ -570,49 +574,49 @@ class PLCWorkstation(WorkstationBase):
parameters_schema={"quantity": int, "model": str}
)
}
def execute_workflow(self, workflow_name: str, parameters: Dict):
"""通过PLC执行工作流"""
workflow_id = self._get_workflow_id(workflow_name)
# 写入PLC寄存器启动工作流
self.hardware_interface.write_register(100, workflow_id)
self.hardware_interface.write_register(101, parameters["quantity"])
self.current_workflow_status = WorkflowStatus.RUNNING
return True
```
## 8. 核心接口说明
### 8.1 WorkstationBase核心属性
### 8.1 WorkstationBase 核心属性
| 属性 | 类型 | 说明 |
| --------------------------- | ----------------------- | ----------------------------- |
| `_ros_node` | ROS2WorkstationNode | ROS节点引用由post_init设置 |
| `deck` | Deck | PyLabRobot Deck本地物料系统 |
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
| 属性 | 类型 | 说明 |
| ------------------------- | ----------------------- | ------------------------------- |
| `_ros_node` | ROS2WorkstationNode | ROS 节点引用,由 post_init 设置 |
| `deck` | Deck | PyLabRobot Deck本地物料系统 |
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
### 8.2 必须实现的方法
- `post_init(ros_node)`: ROS节点就绪后的初始化必须实现
- `post_init(ros_node)`: ROS 节点就绪后的初始化,必须实现
### 8.3 硬件接口相关方法
- `set_hardware_interface(interface)`: 设置硬件接口
- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用
- 支持直接模式: 直接调用hardware_interface的方法
- 支持代理模式: hardware_interface="proxy:device_id"通过ROS转发
- 支持直接模式: 直接调用 hardware_interface 的方法
- 支持代理模式: hardware_interface="proxy:device_id"通过 ROS 转发
- `get_device_status()`: 获取设备状态
- `is_device_available()`: 检查设备可用性
### 8.4 物料管理方法
- `get_deck()`: 获取PLR Deck
- `get_deck()`: 获取 PLR Deck
- `get_all_resources()`: 获取所有物料
- `find_resource_by_name(name)`: 按名称查找物料
- `find_resources_by_type(type)`: 按类型查找物料
@@ -626,7 +630,7 @@ class PLCWorkstation(WorkstationBase):
- `is_busy`: 检查是否忙碌(属性)
- `workflow_runtime`: 获取运行时间(属性)
### 8.6 可选的HTTP报送处理方法
### 8.6 可选的 HTTP 报送处理方法
- `process_step_finish_report()`: 步骤完成处理
- `process_sample_finish_report()`: 样本完成处理
@@ -634,10 +638,10 @@ class PLCWorkstation(WorkstationBase):
- `process_material_change_report()`: 物料变更处理
- `handle_external_error()`: 错误处理
### 8.7 ROS2WorkstationNode核心方法
### 8.7 ROS2WorkstationNode 核心方法
- `initialize_device(device_id, config)`: 初始化子设备
- `create_ros_action_server(action_name, mapping)`: 创建Action服务器
- `create_ros_action_server(action_name, mapping)`: 创建 Action 服务器
- `execute_single_action(device_id, action, kwargs)`: 执行单个动作
- `update_resource(resources)`: 同步物料到云端
- `transfer_resource_to_another(...)`: 跨设备物料转移
@@ -694,7 +698,7 @@ workstation = BioyondWorkstation(
"config": {...}
},
"gripper_1": {
"type": "device",
"type": "device",
"driver": "RobotiqGripperDriver",
"communication": "io_modbus_1",
"config": {...}
@@ -716,7 +720,7 @@ workstation = BioyondWorkstation(
}
```
### 9.3 HTTP服务配置
### 9.3 HTTP 服务配置
```python
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
@@ -737,31 +741,31 @@ http_service.start()
### 10.1 清晰的职责分离
- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理
- **ROS2WorkstationNode**: 负责子设备管理、Protocol执行、云端物料同步
- **ResourceSynchronizer**: 可选的外部物料系统同步(如Bioyond)
- **WorkstationHTTPService**: 可选的HTTP报送接收服务
- **ROS2WorkstationNode**: 负责子设备管理、Protocol 执行、云端物料同步
- **ResourceSynchronizer**: 可选的外部物料系统同步(如 Bioyond)
- **WorkstationHTTPService**: 可选的 HTTP 报送接收服务
### 10.2 灵活的硬件接口模式
1. **直接模式**: hardware_interface是具体对象(如BioyondV1RPC、ModbusClient)
2. **代理模式**: hardware_interface="proxy:device_id"通过ROS节点转发到子设备
1. **直接模式**: hardware_interface 是具体对象(如 BioyondV1RPC、ModbusClient)
2. **代理模式**: hardware_interface="proxy:device_id",通过 ROS 节点转发到子设备
3. **混合模式**: 工作站有自己的接口,同时管理多个子设备
### 10.3 统一的物料系统
- 基于PyLabRobot Deck的标准化物料表示
- 通过ResourceSynchronizer实现与外部系统(如Bioyond、LIMS)的双向同步
- 通过ROS2WorkstationNode实现与云端的物料状态同步
- 基于 PyLabRobot Deck 的标准化物料表示
- 通过 ResourceSynchronizer 实现与外部系统(如 Bioyond、LIMS)的双向同步
- 通过 ROS2WorkstationNode 实现与云端的物料状态同步
### 10.4 Protocol驱动的工作流
### 10.4 Protocol 驱动的工作流
- ROS2WorkstationNode负责Protocol的执行和步骤管理
- 支持子设备协同(通过Action Client调用)
- 支持工作站直接控制(通过hardware_interface)
- ROS2WorkstationNode 负责 Protocol 的执行和步骤管理
- 支持子设备协同(通过 Action Client 调用)
- 支持工作站直接控制(通过 hardware_interface)
### 10.5 可选的HTTP报送服务
### 10.5 可选的 HTTP 报送服务
- 基于LIMS协议规范的统一报送接口
- 基于 LIMS 协议规范的统一报送接口
- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型
- 与工作站解耦,可独立启停

View File

@@ -0,0 +1,334 @@
# HTTP API 指南
本文档介绍如何通过 HTTP API 与 Uni-Lab-OS 进行交互,包括查询设备、提交任务和获取结果。
## 概述
Uni-Lab-OS 提供 RESTful HTTP API允许外部系统通过标准 HTTP 请求控制实验室设备。API 基于 FastAPI 构建,默认运行在 `http://localhost:8002`
### 基础信息
- **Base URL**: `http://localhost:8002/api/v1`
- **Content-Type**: `application/json`
- **响应格式**: JSON
### 通用响应结构
```json
{
"code": 0,
"data": { ... },
"message": "success"
}
```
| 字段 | 类型 | 说明 |
| --------- | ------ | ------------------ |
| `code` | int | 状态码0 表示成功 |
| `data` | object | 响应数据 |
| `message` | string | 响应消息 |
## 快速开始
以下是一个完整的工作流示例:查询设备 → 获取动作 → 提交任务 → 获取结果。
### 步骤 1: 获取在线设备
```bash
curl -X GET "http://localhost:8002/api/v1/online-devices"
```
**响应示例**:
```json
{
"code": 0,
"data": {
"online_devices": {
"host_node": {
"device_key": "/host_node",
"namespace": "",
"machine_name": "本地",
"uuid": "xxx-xxx-xxx",
"node_name": "host_node"
}
},
"total_count": 1,
"timestamp": 1732612345.123
},
"message": "success"
}
```
### 步骤 2: 获取设备可用动作
```bash
curl -X GET "http://localhost:8002/api/v1/devices/host_node/actions"
```
**响应示例**:
```json
{
"code": 0,
"data": {
"device_id": "host_node",
"actions": {
"test_latency": {
"type_name": "unilabos_msgs.action._empty_in.EmptyIn",
"type_name_convert": "unilabos_msgs/action/_empty_in/EmptyIn",
"action_path": "/devices/host_node/test_latency",
"goal_info": "{}",
"is_busy": false,
"current_job_id": null
},
"create_resource": {
"type_name": "unilabos_msgs.action._resource_create_from_outer_easy.ResourceCreateFromOuterEasy",
"action_path": "/devices/host_node/create_resource",
"goal_info": "{res_id: '', device_id: '', class_name: '', ...}",
"is_busy": false,
"current_job_id": null
}
},
"action_count": 5
},
"message": "success"
}
```
**动作状态字段说明**:
| 字段 | 说明 |
| ---------------- | ----------------------------- |
| `type_name` | 动作类型的完整名称 |
| `action_path` | ROS2 动作路径 |
| `goal_info` | 动作参数模板 |
| `is_busy` | 动作是否正在执行 |
| `current_job_id` | 当前执行的任务 ID如果繁忙 |
### 步骤 3: 提交任务
```bash
curl -X POST "http://localhost:8002/api/v1/job/add" \
-H "Content-Type: application/json" \
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
```
**请求体**:
```json
{
"device_id": "host_node",
"action": "test_latency",
"action_args": {}
}
```
**请求参数说明**:
| 字段 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | ---------------------------------- |
| `device_id` | string | ✓ | 目标设备 ID |
| `action` | string | ✓ | 动作名称 |
| `action_args` | object | ✓ | 动作参数(根据动作类型不同而变化) |
**响应示例**:
```json
{
"code": 0,
"data": {
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
"status": 1,
"result": {}
},
"message": "success"
}
```
**任务状态码**:
| 状态码 | 含义 | 说明 |
| ------ | --------- | ------------------------------ |
| 0 | UNKNOWN | 未知状态 |
| 1 | ACCEPTED | 任务已接受,等待执行 |
| 2 | EXECUTING | 任务执行中 |
| 3 | CANCELING | 任务取消中 |
| 4 | SUCCEEDED | 任务成功完成 |
| 5 | CANCELED | 任务已取消 |
| 6 | ABORTED | 任务中止(设备繁忙或执行失败) |
### 步骤 4: 查询任务状态和结果
```bash
curl -X GET "http://localhost:8002/api/v1/job/b6acb586-733a-42ab-9f73-55c9a52aa8bd/status"
```
**响应示例(执行中)**:
```json
{
"code": 0,
"data": {
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
"status": 2,
"result": {}
},
"message": "success"
}
```
**响应示例(执行完成)**:
```json
{
"code": 0,
"data": {
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
"status": 4,
"result": {
"error": "",
"suc": true,
"return_value": {
"avg_rtt_ms": 103.99,
"avg_time_diff_ms": 7181.55,
"max_time_error_ms": 7210.57,
"task_delay_ms": -1,
"raw_delay_ms": 33.19,
"test_count": 5,
"status": "success"
}
}
},
"message": "success"
}
```
> **注意**: 任务结果在首次查询后会被自动删除,请确保保存返回的结果数据。
## API 端点列表
### 设备相关
| 端点 | 方法 | 说明 |
| ---------------------------------------------------------- | ---- | ---------------------- |
| `/api/v1/online-devices` | GET | 获取在线设备列表 |
| `/api/v1/devices` | GET | 获取设备配置 |
| `/api/v1/devices/{device_id}/actions` | GET | 获取指定设备的可用动作 |
| `/api/v1/devices/{device_id}/actions/{action_name}/schema` | GET | 获取动作参数 Schema |
| `/api/v1/actions` | GET | 获取所有设备的可用动作 |
### 任务相关
| 端点 | 方法 | 说明 |
| ----------------------------- | ---- | ------------------ |
| `/api/v1/job/add` | POST | 提交新任务 |
| `/api/v1/job/{job_id}/status` | GET | 查询任务状态和结果 |
### 资源相关
| 端点 | 方法 | 说明 |
| ------------------- | ---- | ------------ |
| `/api/v1/resources` | GET | 获取资源列表 |
## 常见动作示例
### test_latency - 延迟测试
测试系统延迟,无需参数。
```bash
curl -X POST "http://localhost:8002/api/v1/job/add" \
-H "Content-Type: application/json" \
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
```
### create_resource - 创建资源
在设备上创建新资源。
```bash
curl -X POST "http://localhost:8002/api/v1/job/add" \
-H "Content-Type: application/json" \
-d '{
"device_id": "host_node",
"action": "create_resource",
"action_args": {
"res_id": "my_plate",
"device_id": "host_node",
"class_name": "Plate",
"parent": "deck",
"bind_locations": {"x": 0, "y": 0, "z": 0}
}
}'
```
## 错误处理
### 设备繁忙
当设备正在执行其他任务时,提交新任务会返回 `status: 6`ABORTED
```json
{
"code": 0,
"data": {
"jobId": "xxx",
"status": 6,
"result": {}
},
"message": "success"
}
```
此时应等待当前任务完成后重试,或使用 `/devices/{device_id}/actions` 检查动作的 `is_busy` 状态。
### 参数错误
```json
{
"code": 2002,
"data": { ... },
"message": "device_id is required"
}
```
## 轮询策略
推荐的任务状态轮询策略:
```python
import requests
import time
def wait_for_job(job_id, timeout=60, interval=0.5):
"""等待任务完成并返回结果"""
start_time = time.time()
while time.time() - start_time < timeout:
response = requests.get(f"http://localhost:8002/api/v1/job/{job_id}/status")
data = response.json()["data"]
status = data["status"]
if status in (4, 5, 6): # SUCCEEDED, CANCELED, ABORTED
return data
time.sleep(interval)
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
# 使用示例
response = requests.post(
"http://localhost:8002/api/v1/job/add",
json={"device_id": "host_node", "action": "test_latency", "action_args": {}}
)
job_id = response.json()["data"]["jobId"]
result = wait_for_job(job_id)
print(result)
```
## 相关文档
- [设备注册指南](add_device.md)
- [动作定义指南](add_action.md)
- [网络架构概述](networking_overview.md)

View File

@@ -0,0 +1,594 @@
# 组网部署与主从模式配置
本文档介绍 Uni-Lab-OS 的组网架构、部署方式和主从模式的详细配置。
## 目录
- [架构概览](#架构概览)
- [节点类型](#节点类型)
- [通信机制](#通信机制)
- [典型拓扑](#典型拓扑)
- [主从模式配置](#主从模式配置)
- [网络配置](#网络配置)
- [示例:多房间部署](#示例多房间部署)
- [故障处理](#故障处理)
- [监控和维护](#监控和维护)
---
## 架构概览
Uni-Lab-OS 支持多种部署模式:
```
┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │
│ uni-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │
│ Monitoring) │
└────────────────────┬─────────────────────────┘
│ WebSocket / HTTP
┌──────────┴──────────┐
│ │
┌────▼─────┐ ┌────▼─────┐
│ Master │◄──ROS2──►│ Slave │
│ Node │ │ Node │
│ (Host) │ │ (Slave) │
└────┬─────┘ └────┬─────┘
│ │
┌────┴────┐ ┌────┴────┐
│ Device A│ │ Device B│
│ Device C│ │ Device D│
└─────────┘ └─────────┘
```
---
## 节点类型
### 主节点Host Node
**功能**:
- 创建和管理全局资源
- 提供 host_node 服务
- 连接云端平台
- 协调多个从节点
- 提供 Web 管理界面
**启动命令**:
```bash
unilab --ak your_ak --sk your_sk -g host_devices.json
```
### 从节点Slave Node
**功能**:
- 管理本地设备
- 不连接云端(可选)
- 向主节点注册
- 执行分配的任务
**启动命令**:
```bash
unilab --ak your_ak --sk your_sk -g slave_devices.json --is_slave
```
---
## 通信机制
### ROS2 通信
**用途**: 节点间实时通信
**通信方式**:
- **Topic**: 状态广播(设备状态、传感器数据)
- **Service**: 同步请求(资源查询、配置获取)
- **Action**: 异步任务(设备操作、长时间运行)
**示例**:
```bash
# 查看ROS2节点
ros2 node list
# 查看topic
ros2 topic list
# 查看action
ros2 action list
```
### WebSocket 通信
**用途**: 主节点与云端通信
**特点**:
- 实时双向通信
- 自动重连
- 心跳保持
**配置**:
```python
# local_config.py
BasicConfig.ak = "your_ak"
BasicConfig.sk = "your_sk"
```
---
## 典型拓扑
### 单节点模式
**适用场景**: 小型实验室、开发测试
```
┌──────────────────┐
│ Uni-Lab Node │
│ ┌────────────┐ │
│ │ Device A │ │
│ │ Device B │ │
│ │ Device C │ │
│ └────────────┘ │
└──────────────────┘
```
**优点**:
- 配置简单
- 无网络延迟
- 适合快速原型
**启动**:
```bash
unilab --ak your_ak --sk your_sk -g all_devices.json
```
### 主从模式
**适用场景**: 多房间、分布式设备
```
┌─────────────┐ ┌──────────────┐
│ Master Node │◄────►│ Slave Node 1 │
│ Coordinator │ │ Liquid │
│ Web UI │ │ Handling │
└──────┬──────┘ └──────────────┘
│ ┌──────────────┐
└────────────►│ Slave Node 2 │
│ Analytical │
│ (NMR/GC) │
└──────────────┘
```
**优点**:
- 物理分隔
- 独立故障域
- 易于扩展
**适用场景**:
- 设备物理位置分散
- 不同房间的设备
- 需要独立故障域
- 分阶段扩展系统
**主节点**:
```bash
unilab --ak your_ak --sk your_sk -g host.json
```
**从节点**:
```bash
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
unilab --ak your_ak --sk your_sk -g slave2.json --is_slave --port 8003
```
### 云端集成模式
**适用场景**: 远程监控、多实验室协作
```
Cloud Platform
┌───────┴────────┐
│ │
Laboratory A Laboratory B
(Master Node) (Master Node)
```
**优点**:
- 远程访问
- 数据同步
- 任务调度
**启动**:
```bash
# 实验室A
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
# 实验室B
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
```
---
## 主从模式配置
### 主节点配置
#### 1. 创建主节点设备图
`host.json`:
```json
{
"nodes": [],
"links": []
}
```
#### 2. 启动主节点
```bash
# 基本启动
unilab --ak your_ak --sk your_sk -g host.json
# 带云端集成
unilab --ak your_ak --sk your_sk -g host.json --upload_registry
# 指定端口
unilab --ak your_ak --sk your_sk -g host.json --port 8002
```
#### 3. 验证主节点
```bash
# 检查ROS2节点
ros2 node list
# 应该看到 /host_node
# 检查服务
ros2 service list | grep host_node
# Web界面
# 访问 http://localhost:8002
```
### 从节点配置
#### 1. 创建从节点设备图
`slave1.json`:
```json
{
"nodes": [
{
"id": "liquid_handler_1",
"name": "液体处理工作站",
"type": "device",
"class": "liquid_handler",
"config": {
"simulation": false
}
}
],
"links": []
}
```
#### 2. 启动从节点
```bash
# 基本从节点启动
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
# 指定不同端口(如果多个从节点在同一台机器)
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --port 8003
# 跳过等待主节点(独立测试)
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --slave_no_host
```
#### 3. 验证从节点
```bash
# 检查节点连接
ros2 node list
# 检查设备状态
ros2 topic echo /liquid_handler_1/status
```
### 跨节点通信
#### 资源访问
主节点可以访问从节点的资源:
```bash
# 在主节点或其他节点调用从节点设备
ros2 action send_goal /liquid_handler_1/transfer_liquid \
unilabos_msgs/action/TransferLiquid \
"{source: {...}, target: {...}, volume: 100.0}"
```
#### 状态监控
主节点监控所有从节点状态:
```bash
# 订阅从节点状态
ros2 topic echo /liquid_handler_1/status
# 查看所有设备状态
ros2 topic list | grep status
```
---
## 网络配置
### ROS2 DDS 配置
确保主从节点在同一网络:
```bash
# 检查网络可达性
ping <slave_node_ip>
# 设置ROS_DOMAIN_ID可选用于隔离
export ROS_DOMAIN_ID=42
```
### 防火墙配置
**建议做法**
为了确保 ROS2 DDS 通信正常建议直接关闭防火墙而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。
**Linux**:
```bash
# 关闭防火墙
sudo ufw disable
# 或者临时停止防火墙
sudo systemctl stop ufw
```
**Windows**:
```powershell
# 在Windows安全中心关闭防火墙
# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭Windows Defender防火墙
```
### 验证网络连通性
在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常:
**在主节点机器上**(激活 unilab 环境后):
```bash
# 启动talker
ros2 run demo_nodes_cpp talker
# 同时在另一个终端启动listener
ros2 run demo_nodes_cpp listener
```
**在从节点机器上**(激活 unilab 环境后):
```bash
# 启动talker
ros2 run demo_nodes_cpp talker
# 同时在另一个终端启动listener
ros2 run demo_nodes_cpp listener
```
**注意**:必须在两台机器上**互相启动** talker 和 listener否则可能出现只能收不能发的单向通信问题。
**预期结果**
- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息
- 如果只能看到本地消息,说明网络配置有问题
- 如果两台机器都能互相收发消息,则组网配置正确
### 本地网络要求
**ROS2 通信**:
- 同一局域网或 VPN
- 端口:默认 DDS 端口7400-7500
- 组播支持(或配置 unicast
**检查连通性**:
```bash
# Ping测试
ping <target_ip>
# ROS2节点发现
ros2 node list
ros2 daemon stop && ros2 daemon start
```
### 云端连接
**要求**:
- HTTPS (443)
- WebSocket 支持
- 稳定的互联网连接
**测试连接**:
```bash
# 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health
# 测试WebSocket
# 启动Uni-Lab后查看日志
```
---
## 示例:多房间部署
### 场景描述
- **房间 A**: 主控室,有 Web 界面
- **房间 B**: 液体处理室
- **房间 C**: 分析仪器室
### 房间 A - 主节点
```bash
# host.json
unilab --ak your_ak --sk your_sk -g host.json --port 8002
```
### 房间 B - 从节点 1
```bash
# liquid_handler.json
unilab --ak your_ak --sk your_sk -g liquid_handler.json --is_slave --port 8003
```
### 房间 C - 从节点 2
```bash
# analytical.json
unilab --ak your_ak --sk your_sk -g analytical.json --is_slave --port 8004
```
---
## 故障处理
### 节点离线
**检测**:
```bash
ros2 node list # 查看在线节点
```
**处理**:
1. 检查网络连接
2. 重启节点
3. 检查日志
### 从节点无法连接主节点
1. 检查网络:
```bash
ping <host_ip>
```
2. 检查 ROS_DOMAIN_ID
```bash
echo $ROS_DOMAIN_ID
```
3. 使用`--slave_no_host`测试:
```bash
unilab --ak your_ak --sk your_sk -g slave.json --is_slave --slave_no_host
```
### 通信延迟
**排查**:
```bash
# 网络延迟
ping <node_ip>
# ROS2话题延迟
ros2 topic hz /device_status
ros2 topic bw /device_status
```
**优化**:
- 减少发布频率
- 使用 QoS 配置
- 优化网络带宽
### 数据同步失败
**检查**:
```bash
# 查看日志
tail -f unilabos_data/logs/unilab.log | grep sync
```
**解决**:
- 检查云端连接
- 验证 AK/SK
- 手动触发同步
### 资源不可见
检查资源注册:
```bash
ros2 service call /host_node/resource_list \
unilabos_msgs/srv/ResourceList
```
---
## 监控和维护
### 节点状态监控
```bash
# 查看所有节点
ros2 node list
# 查看话题
ros2 topic list
```
---
## 相关文档
- [最佳实践指南](../user_guide/best_practice.md) - 完整的实验室搭建流程
- [安装指南](../user_guide/installation.md) - 环境安装步骤
- [启动参数详解](../user_guide/launch.md) - 启动参数说明
- [添加设备驱动](add_device.md) - 自定义设备开发
- [工作站架构](workstation_architecture.md) - 复杂工作站搭建
---
## 参考资料
- [ROS2 网络配置](https://docs.ros.org/en/humble/Tutorials/Advanced/Networking.html)
- [DDS 配置](https://fast-dds.docs.eprosima.com/)
- Uni-Lab 云平台文档

View File

@@ -1,9 +1,23 @@
# Uni-Lab 项目文档
# Uni-Lab-OS 项目文档
欢迎来到项目文档的首页!
Uni-Lab-OS 是一个开源的实验室自动化操作系统,提供统一的设备接口、工作流管理和分布式部署能力。
```{toctree}
:maxdepth: 3
intro.md
```
## 开发者指南
```{toctree}
:maxdepth: 2
developer_guide/http_api.md
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_action.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/action_includes.md
```

View File

@@ -10,35 +10,51 @@ concepts/01-communication-instruction.md
concepts/02-topology-and-chemputer-compile.md
```
## **用户指南**
## 用户指南
本指南将带你了解如何使用项目的功能
快速上手、系统配置与使用说明
```{toctree}
:maxdepth: 2
user_guide/best_practice.md
user_guide/installation.md
user_guide/configuration.md
user_guide/launch.md
user_guide/graph_files.md
boot_examples/index.md
```
## 进阶配置
高级配置和系统管理。
```{toctree}
:maxdepth: 2
advanced_usage/configuration.md
advanced_usage/working_directory.md
```
## 开发者指南
```{toctree}
设备开发、系统扩展与架构说明。
```{toctree}
:maxdepth: 2
developer_guide/device_driver
developer_guide/add_device
developer_guide/add_action
developer_guide/actions
developer_guide/workstation_architecture
developer_guide/add_protocol
developer_guide/add_batteryPLC
developer_guide/materials_tutorial
developer_guide/materials_construction_guide
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_old_device.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/add_action.md
developer_guide/actions.md
developer_guide/action_includes.md
developer_guide/add_protocol.md
developer_guide/examples/workstation_architecture.md
developer_guide/examples/materials_construction_guide.md
developer_guide/examples/materials_tutorial.md
developer_guide/examples/battery_plc_workstation.md
```
## 接口文档

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 262 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,442 +0,0 @@
# Uni-Lab 配置指南
Uni-Lab 支持通过 Python 配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的 Uni-Lab 环境。
## 配置文件格式
Uni-Lab 支持 Python 格式的配置文件,它比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
### 默认配置示例
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`
```python
# unilabos的配置文件
class BasicConfig:
ak = "" # 实验室网页给您提供的ak代码您可以在配置文件中指定也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
sk = "" # 实验室网页给您提供的sk代码您可以在配置文件中指定也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
# WebSocket配置一般无需调整
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
```
您可以进入实验室点击左下角的头像在实验室详情中获取所在实验室的ak sk
![copy_aksk.gif](image/copy_aksk.gif)
### 完整配置示例
您可以根据需要添加更多配置选项:
```python
#!/usr/bin/env python
# coding=utf-8
"""Uni-Lab 配置文件"""
# 基础配置
class BasicConfig:
ak = "your_access_key" # 实验室访问密钥
sk = "your_secret_key" # 实验室私钥
working_dir = "" # 工作目录(通常自动设置)
config_path = "" # 配置文件路径(自动设置)
is_host_mode = True # 是否为主站模式
slave_no_host = False # 从站模式下是否跳过等待主机服务
upload_registry = False # 是否上传注册表
machine_name = "undefined" # 机器名称(自动获取)
vis_2d_enable = False # 是否启用2D可视化
enable_resource_load = True # 是否启用资源加载
communication_protocol = "websocket" # 通信协议
# WebSocket配置
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
# OSS上传配置
class OSSUploadConfig:
api_host = "" # API主机地址
authorization = "" # 授权信息
init_endpoint = "" # 初始化端点
complete_endpoint = "" # 完成端点
max_retries = 3 # 最大重试次数
# HTTP配置
class HTTPConfig:
remote_addr = "http://127.0.0.1:48197/api/v1" # 远程地址
# ROS配置
class ROSConfig:
modules = [
"std_msgs.msg",
"geometry_msgs.msg",
"control_msgs.msg",
"control_msgs.action",
"nav2_msgs.action",
"unilabos_msgs.msg",
"unilabos_msgs.action",
] # 需要加载的ROS模块
```
## 命令行参数覆盖配置
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。命令行参数的优先级高于配置文件。
### 支持命令行覆盖的配置项
以下配置项可以通过命令行参数进行覆盖:
| 配置类 | 配置字段 | 命令行参数 | 说明 |
| ------------- | ----------------- | ------------------- | -------------------------------- |
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
### 特殊命令行参数
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
| 参数 | 说明 |
| ------------------- | ------------------------------------ |
| `--config` | 指定配置文件路径 |
| `--port` | Web 服务端口(不影响配置文件) |
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
| `--visual` | 可视化工具选择(不影响配置文件) |
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
### 配置优先级
配置项的生效优先级从高到低为:
1. **命令行参数**:最高优先级
2. **环境变量**:中等优先级
3. **配置文件**:基础优先级
### 使用示例
```bash
# 通过命令行覆盖认证信息
unilab --ak "new_access_key" --sk "new_secret_key"
# 覆盖服务器地址
unilab --addr "https://custom.server.com/api/v1"
# 启用从站模式并跳过等待主机
unilab --is_slave --slave_no_host
# 启用上传注册表和2D可视化
unilab --upload_registry --2d_vis
# 组合使用多个覆盖参数
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis
```
### 预设环境地址
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
## 配置选项详解
### 基础配置 (BasicConfig)
基础配置包含了系统运行的核心参数:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------------- | ------------------------------------------ |
| `ak` | str | `""` | 实验室访问密钥(必需) |
| `sk` | str | `""` | 实验室私钥(必需) |
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
| `is_host_mode` | bool | `True` | 是否为主站模式 |
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
#### 认证配置
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级)
- **配置文件**:在 `BasicConfig` 类中设置
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
3. **优先级顺序**:命令行参数 > 环境变量 > 配置文件
4. **安全注意**:请妥善保管您的密钥信息
**推荐做法**
- 开发环境:使用配置文件
- 生产环境:使用环境变量或命令行参数
- 临时测试:使用命令行参数
### WebSocket 配置 (WSConfig)
WebSocket 是 Uni-Lab 的主要通信方式:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------ | ------------------ |
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
### HTTP 配置 (HTTPConfig)
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | --------------------------------- | ------------ |
| `remote_addr` | str | `"http://127.0.0.1:48197/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### ROS 配置 (ROSConfig)
配置 ROS 消息转换器需要加载的模块:
```python
class ROSConfig:
modules = [
"std_msgs.msg", # 标准消息类型
"geometry_msgs.msg", # 几何消息类型
"control_msgs.msg", # 控制消息类型
"control_msgs.action", # 控制动作类型
"nav2_msgs.action", # 导航动作类型
"unilabos_msgs.msg", # UniLab 自定义消息类型
"unilabos_msgs.action", # UniLab 自定义动作类型
]
```
您可以根据实际使用的设备和功能添加其他 ROS 模块。
### OSS 上传配置 (OSSUploadConfig)
对象存储服务配置,用于文件上传功能:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------- | ---- | ------ | -------------------- |
| `api_host` | str | `""` | OSS API 主机地址 |
| `authorization` | str | `""` | 授权认证信息 |
| `init_endpoint` | str | `""` | 上传初始化端点 |
| `complete_endpoint` | str | `""` | 上传完成端点 |
| `max_retries` | int | `3` | 上传失败最大重试次数 |
## 环境变量支持
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。环境变量格式为:
```
UNILABOS_{配置类名}_{字段名}
```
### 环境变量示例
```bash
# 设置基础配置
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
# 设置WebSocket配置
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.bohrium.com/api/v1"
```
### 环境变量类型转换
- **布尔值**`"true"`, `"1"`, `"yes"``True`;其他 → `False`
- **整数**:自动转换为 `int` 类型
- **浮点数**:自动转换为 `float` 类型
- **字符串**:保持原值
## 配置文件使用方法
### 1. 指定配置文件启动
```bash
# 使用指定配置文件启动
unilab --config /path/to/your/config.py
```
### 2. 使用默认配置文件
如果不指定配置文件,系统会按以下顺序查找:
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
2. 工作目录下的 `local_config.py`
3. 首次使用时会引导创建配置文件
### 3. 配置文件验证
系统启动时会自动验证配置文件:
- **语法检查**:确保 Python 语法正确
- **类型检查**:验证配置项类型是否匹配
- **必需项检查**:确保 `ak``sk` 已配置
## 最佳实践
### 1. 安全配置
- 不要将包含密钥的配置文件提交到版本控制系统
- 使用环境变量或命令行参数在生产环境中配置敏感信息
- 定期更换访问密钥
- **推荐配置方式**
```bash
# 生产环境 - 使用环境变量
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
unilab
# 或使用命令行参数
unilab --ak "your_access_key" --sk "your_secret_key"
```
### 2. 多环境配置
为不同环境创建不同的配置文件并结合命令行参数:
```
configs/
├── local_config.py # 本地开发
├── test_config.py # 测试环境
├── prod_config.py # 生产环境
└── example_config.py # 示例配置
```
**环境切换示例**
```bash
# 本地开发环境
unilab --config configs/local_config.py --addr local
# 测试环境
unilab --config configs/test_config.py --addr test --upload_registry
# 生产环境
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK"
```
### 3. 配置管理
- 保持配置文件简洁,只包含需要修改的配置项
- 为配置项添加注释说明其作用
- 定期检查和更新配置文件
- **命令行参数优先使用场景**
- 临时测试不同配置
- CI/CD 流水线中的动态配置
- 不同环境间快速切换
- 敏感信息的安全传递
### 4. 灵活配置策略
**基础配置文件 + 命令行覆盖**的推荐方式:
```python
# base_config.py - 基础配置
class BasicConfig:
# 非敏感配置写在文件中
is_host_mode = True
upload_registry = False
vis_2d_enable = False
class WSConfig:
reconnect_interval = 5
max_reconnect_attempts = 999
ping_interval = 30
```
```bash
# 启动时通过命令行覆盖关键参数
unilab --config base_config.py \
--ak "$AK" \
--sk "$SK" \
--addr "test" \
--upload_registry \
--2d_vis
```
## 故障排除
### 1. 配置文件加载失败
**错误信息**`[ENV] 配置文件 xxx 不存在`
**解决方法**
- 确认配置文件路径正确
- 检查文件权限是否可读
- 确保配置文件是 `.py` 格式
### 2. 语法错误
**错误信息**`[ENV] 加载配置文件 xxx 失败`
**解决方法**
- 检查 Python 语法是否正确
- 确认类名和字段名拼写正确
- 验证缩进是否正确(使用空格而非制表符)
### 3. 认证失败
**错误信息**`后续运行必须拥有一个实验室`
**解决方法**
- 确认 `ak` 和 `sk` 已正确配置
- 检查密钥是否有效
- 确认网络连接正常
### 4. 环境变量不生效
**解决方法**
- 确认环境变量名格式正确(`UNILABOS_CLASS_FIELD`
- 检查环境变量是否已正确设置
- 重启系统或重新加载环境变量
### 5. 命令行参数不生效
**错误现象**:设置了命令行参数但配置没有生效
**解决方法**
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
- 查看启动日志确认参数是否被正确解析
### 6. 配置优先级混淆
**错误现象**:不确定哪个配置生效
**解决方法**
- 记住优先级:命令行参数 > 环境变量 > 配置文件
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息
- 检查启动日志中的配置加载信息
- 临时移除低优先级配置来测试高优先级配置是否生效

View File

@@ -0,0 +1,860 @@
# 设备图文件说明
设备图文件定义了实验室中所有设备、资源及其连接关系。本文档说明如何创建和使用设备图文件。
## 概述
设备图文件采用 JSON 格式,节点定义基于 **`ResourceDict`** 标准模型(定义在 `unilabos.ros.nodes.resource_tracker`)。系统会自动处理旧格式并转换为标准格式,确保向后兼容性。
**核心概念**:
- **Nodes节点**: 代表设备或资源,通过 `parent` 字段建立层级关系
- **Links连接**: 可选的连接关系定义,用于展示设备间的物理或通信连接
- **UUID**: 全局唯一标识符,用于跨系统的资源追踪
- **自动转换**: 旧格式会通过 `ResourceDictInstance.get_resource_instance_from_dict()` 自动转换
## 文件格式
Uni-Lab 支持两种格式的设备图文件:
### JSON 格式(推荐)
**优点**:
- 易于编辑和阅读
- 支持注释(使用预处理)
- 与 Web 界面完全兼容
- 便于版本控制
**示例**: `workshop1.json`
### GraphML 格式
**优点**:
- 可用图形化工具编辑(如 yEd
- 适合复杂拓扑可视化
**示例**: `setup.graphml`
## JSON 文件结构
一个完整的 JSON 设备图文件包含两个主要部分:
```json
{
"nodes": [
/* 设备和资源节点 */
],
"links": [
/* 连接关系(可选)*/
]
}
```
### Nodes节点
每个节点代表一个设备或资源。节点的定义遵循 `ResourceDict` 标准模型:
```json
{
"id": "liquid_handler_1",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "液体处理工作站",
"type": "device",
"class": "liquid_handler",
"config": {
"port": "/dev/ttyUSB0",
"baudrate": 9600
},
"data": {},
"position": {
"x": 100,
"y": 200
},
"parent": null
}
```
**字段说明(基于 ResourceDict 标准定义)**:
| 字段 | 必需 | 说明 | 示例 | 默认值 |
| ------------- | ---- | ------------------------ | ---------------------------------------------------- | -------- |
| `id` | ✓ | 唯一标识符 | `"pump_1"` | - |
| `uuid` | | 全局唯一标识符 (UUID) | `"550e8400-e29b-41d4-a716-446655440000"` | 自动生成 |
| `name` | ✓ | 显示名称 | `"主反应泵"` | - |
| `type` | ✓ | 节点类型 | `"device"`, `"resource"`, `"container"`, `"deck"` 等 | - |
| `class` | ✓ | 设备/资源类别 | `"liquid_handler"`, `"syringepump.runze"` | `""` |
| `config` | | Python 类的初始化参数 | `{"port": "COM3"}` | `{}` |
| `data` | | 资源的运行状态数据 | `{"status": "Idle", "position": 0.0}` | `{}` |
| `position` | | 在图中的位置 | `{"x": 100, "y": 200}` 或完整的 pose 结构 | - |
| `pose` | | 完整的 3D 位置信息 | 参见下文 | - |
| `parent` | | 父节点 ID | `"deck_1"` | `null` |
| `parent_uuid` | | 父节点 UUID | `"550e8400-..."` | `null` |
| `children` | | 子节点 ID 列表(旧格式) | `["child1", "child2"]` | - |
| `description` | | 资源描述 | `"用于精确控制试剂A的加料速率"` | `""` |
| `schema` | | 资源 schema 定义 | `{}` | `{}` |
| `model` | | 资源 3D 模型信息 | `{}` | `{}` |
| `icon` | | 资源图标 | `"pump.webp"` | `""` |
| `extra` | | 额外的自定义数据 | `{"custom_field": "value"}` | `{}` |
### Position 和 Pose位置信息
**简单格式(旧格式,兼容)**:
```json
"position": {
"x": 100,
"y": 200,
"z": 0
}
```
**完整格式(推荐)**:
```json
"pose": {
"size": {
"width": 127.76,
"height": 85.48,
"depth": 10.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"layout": "x-y",
"position": {
"x": 100,
"y": 200,
"z": 0
},
"position3d": {
"x": 100,
"y": 200,
"z": 0
},
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"cross_section_type": "rectangle"
}
```
### Links连接
定义节点之间的连接关系(可选,主要用于物理连接或通信关系的可视化):
```json
{
"source": "pump_1",
"target": "reactor_1",
"sourceHandle": "output",
"targetHandle": "input",
"type": "physical"
}
```
**字段说明**:
| 字段 | 必需 | 说明 | 示例 |
| -------------- | ---- | ---------------- | ---------------------------------------- |
| `source` | ✓ | 源节点 ID | `"pump_1"` |
| `target` | ✓ | 目标节点 ID | `"reactor_1"` |
| `sourceHandle` | | 源节点的连接点 | `"output"` |
| `targetHandle` | | 目标节点的连接点 | `"input"` |
| `type` | | 连接类型 | `"physical"`, `"communication"` |
| `port` | | 端口映射信息 | `{"source": "port1", "target": "port2"}` |
**注意**: Links 主要用于图形化展示和文档说明,父子关系通过 `parent` 字段定义,不依赖 links。
## 完整示例
### 示例 1液体处理工作站PRCXI9300
这是一个真实的液体处理工作站配置,包含设备、工作台和多个板资源。
**文件位置**: `test/experiments/prcxi_9300.json`
```json
{
"nodes": [
{
"id": "PRCXI9300",
"name": "PRCXI9300",
"parent": null,
"type": "device",
"class": "liquid_handler.prcxi",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"deck": {
"_resource_child_name": "PRCXI_Deck_9300",
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
},
"host": "10.181.214.132",
"port": 9999,
"timeout": 10.0,
"axis": "Left",
"channel_num": 8,
"setup": false,
"debug": true,
"simulator": true,
"matrix_id": "71593"
},
"data": {},
"children": ["PRCXI_Deck_9300"]
},
{
"id": "PRCXI_Deck_9300",
"name": "PRCXI_Deck_9300",
"parent": "PRCXI9300",
"type": "deck",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "PRCXI9300Deck",
"size_x": 100,
"size_y": 100,
"size_z": 100,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "deck"
},
"data": {},
"children": [
"RackT1",
"PlateT2",
"trash",
"PlateT4",
"PlateT5",
"PlateT6"
]
},
{
"id": "RackT1",
"name": "RackT1",
"parent": "PRCXI_Deck_9300",
"type": "tip_rack",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "TipRack",
"size_x": 127.76,
"size_y": 85.48,
"size_z": 100
},
"data": {},
"children": []
}
]
}
```
**关键点**:
- 使用 `parent` 字段建立层级关系PRCXI9300 → Deck → Rack/Plate
- 使用 `children` 字段(旧格式)列出子节点
- `config` 中包含设备特定的连接参数
- `data` 存储运行时状态
- `position` 使用简单的 x/y/z 坐标
### 示例 2有机合成工作站带 Links
这是一个格林纳德反应的流动化学工作站配置,展示了完整的设备连接和通信关系。
**文件位置**: `test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
```json
{
"nodes": [
{
"id": "YugongStation",
"name": "愚公常量合成工作站",
"parent": null,
"type": "device",
"class": "workstation",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"protocol_type": [
"PumpTransferProtocol",
"CleanProtocol",
"SeparateProtocol",
"EvaporateProtocol"
]
},
"data": {},
"children": [
"serial_pump",
"pump_reagents",
"flask_CH2Cl2",
"reactor",
"pump_workup",
"separator_controller",
"flask_separator",
"rotavap",
"column"
]
},
{
"id": "serial_pump",
"name": "serial_pump",
"parent": "YugongStation",
"type": "device",
"class": "serial",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "COM7",
"baudrate": 9600
},
"data": {},
"children": []
},
{
"id": "pump_reagents",
"name": "pump_reagents",
"parent": "YugongStation",
"type": "device",
"class": "syringepump.runze",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "1",
"max_volume": 25.0
},
"data": {
"max_velocity": 1.0,
"position": 0.0,
"status": "Idle",
"valve_position": "0"
},
"children": []
},
{
"id": "reactor",
"name": "reactor",
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 430.4087301587302,
"y": 428,
"z": 0
},
"config": {},
"data": {},
"children": []
}
],
"links": [
{
"source": "pump_reagents",
"target": "serial_pump",
"type": "communication",
"port": {
"pump_reagents": "port",
"serial_pump": "port"
}
},
{
"source": "pump_workup",
"target": "serial_pump",
"type": "communication",
"port": {
"pump_workup": "port",
"serial_pump": "port"
}
}
]
}
```
**关键点**:
- 多级设备层次:工作站包含多个子设备和容器
- `links` 定义通信关系(泵通过串口连接)
- `data` 字段存储设备状态(如泵的位置、速度等)
- `class` 可以使用点号分层(如 `"syringepump.runze"`
- 容器的 `class` 可以为 `null`
## 格式兼容性和转换
### 旧格式自动转换
Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法自动处理旧格式的节点数据,确保向后兼容性。
**自动转换规则**:
1. **自动生成缺失字段**:
```python
# 如果缺少 id使用 name 作为 id
if "id" not in content:
content["id"] = content["name"]
# 如果缺少 uuid自动生成
if "uuid" not in content:
content["uuid"] = str(uuid.uuid4())
```
2. **Position 格式转换**:
```python
# 旧格式:简单的 x/y 坐标
"position": {"x": 100, "y": 200}
# 自动转换为新格式
"position": {
"position": {"x": 100, "y": 200}
}
```
3. **默认值填充**:
```python
# 自动填充空字段
if not content.get("class"):
content["class"] = ""
if not content.get("config"):
content["config"] = {}
if not content.get("data"):
content["data"] = {}
if not content.get("extra"):
content["extra"] = {}
```
4. **Pose 字段同步**:
```python
# 如果没有 pose使用 position
if "pose" not in content:
content["pose"] = content.get("position", {})
```
### 使用示例
```python
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
# 旧格式节点
old_format_node = {
"name": "pump_1",
"type": "device",
"class": "syringepump",
"position": {"x": 100, "y": 200}
}
# 自动转换为标准格式
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
# 访问标准化后的数据
print(instance.res_content.id) # "pump_1"
print(instance.res_content.uuid) # 自动生成的 UUID
print(instance.res_content.config) # {}
print(instance.res_content.data) # {}
```
### 格式迁移建议
虽然系统会自动处理旧格式,但建议在新文件中使用完整的标准格式:
| 字段 | 旧格式(兼容) | 新格式(推荐) |
| ------ | ---------------------------------- | ------------------------------------------------ |
| 标识符 | 仅 `id` 或仅 `name` | `id` + `uuid` |
| 位置 | `"position": {"x": 100, "y": 200}` | 完整的 `pose` 结构 |
| 父节点 | `"parent": "parent_id"` | `"parent": "parent_id"` + `"parent_uuid": "..."` |
| 配置 | 可省略 | 显式设置为 `{}` |
| 数据 | 可省略 | 显式设置为 `{}` |
## 节点类型详解
### Device 节点
设备节点代表实际的硬件设备:
```json
{
"id": "device_id",
"name": "设备名称",
"type": "device",
"class": "设备类别",
"parent": null,
"config": {
"port": "COM3"
},
"data": {},
"children": []
}
```
**常见设备类别**:
- `liquid_handler`: 液体处理工作站
- `liquid_handler.prcxi`: PRCXI 液体处理工作站
- `syringepump`: 注射泵
- `syringepump.runze`: 润泽注射泵
- `heaterstirrer`: 加热搅拌器
- `balance`: 天平
- `reactor_vessel`: 反应釜
- `serial`: 串口通信设备
- `workstation`: 自动化工作站
### Resource 节点
资源节点代表物料容器、载具等:
```json
{
"id": "resource_id",
"name": "资源名称",
"type": "resource",
"class": "资源类别",
"parent": "父节点ID",
"config": {
"size_x": 127.76,
"size_y": 85.48,
"size_z": 100
},
"data": {},
"children": []
}
```
**常见资源类型**:
- `deck`: 工作台/甲板
- `plate`: 板96 孔板等)
- `tip_rack`: 枪头架
- `tube`: 试管
- `container`: 容器
- `well`: 孔位
- `bottle_carrier`: 瓶架
## Handle连接点
每个设备和资源可以有多个连接点handles用于定义可以连接的接口。
### 查看可用 handles
设备和资源的可用 handles 定义在注册表中:
```yaml
# 设备注册表示例
liquid_handler:
handles:
- handler_key: pipette
io_type: source
- handler_key: deck
io_type: target
```
### 常见 handles
| 设备类型 | Source Handles | Target Handles |
| ---------- | -------------- | -------------- |
| 泵 | output | input |
| 反应釜 | output, vessel | input |
| 液体处理器 | pipette | deck |
| 板 | wells | access |
## 使用 Web 界面创建图文件
Uni-Lab 提供 Web 界面来可视化创建和编辑设备图:
### 1. 启动 Uni-Lab
```bash
unilab
```
### 2. 访问 Web 界面
打开浏览器访问 `http://localhost:8002`
### 3. 图形化编辑
- 拖拽添加设备和资源
- 连线建立连接关系
- 编辑节点属性
- 保存为 JSON 文件
### 4. 导出图文件
点击"导出"按钮,下载 JSON 文件到本地。
## 从云端获取图文件
如果不指定`-g`参数Uni-Lab 会自动从云端获取:
```bash
# 使用云端配置
unilab
# 日志会显示:
# [INFO] 未指定设备加载文件路径尝试从HTTP获取...
# [INFO] 联网获取设备加载文件成功
```
**云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com
2. 进入"设备配置"
3. 创建或编辑配置
4. 保存到云端
本地启动时会自动同步最新配置。
## 调试图文件
### 验证 JSON 格式
```bash
# 使用Python验证
python -c "import json; json.load(open('workshop1.json'))"
# 使用在线工具
# https://jsonlint.com/
```
### 检查节点引用
确保:
- 所有`links`中的`source`和`target`都存在于`nodes`中
- `parent`字段指向的节点存在
- `class`字段对应的设备/资源在注册表中存在
### 启动时验证
```bash
# Uni-Lab启动时会验证图文件
unilab -g workshop1.json
# 查看日志中的错误或警告
# [ERROR] 节点 xxx 的source端点 yyy 不存在
# [WARNING] 节点 zzz missing 'name', defaulting to ...
```
## 最佳实践
### 1. 命名规范
```json
{
"id": "pump_reagent_1", // 小写+下划线,描述性
"name": "试剂进料泵A", // 中文显示名称
"class": "syringepump" // 使用注册表中的精确名称
}
```
### 2. 层级组织
```
host_node (主节点)
└── liquid_handler_1 (设备)
└── deck_1 (资源)
├── tiprack_1 (资源)
├── plate_1 (资源)
└── reservoir_1 (资源)
```
### 3. 配置分离
将设备特定配置放在`config`中:
```json
{
"id": "pump_1",
"class": "syringepump",
"config": {
"port": "COM3", // 设备特定
"max_flow_rate": 10, // 设备特定
"volume": 50 // 设备特定
}
}
```
### 4. 版本控制
```bash
# 使用Git管理图文件
git add workshop1.json
git commit -m "Add new liquid handler configuration"
# 使用有意义的文件名
workshop_v1.json
workshop_production.json
workshop_test.json
```
### 5. 注释(通过描述字段)
虽然 JSON 不支持注释,但可以使用`description`字段:
```json
{
"id": "pump_1",
"name": "进料泵",
"description": "用于精确控制试剂A的加料速率最大流速10mL/min",
"class": "syringepump"
}
```
## 示例文件位置
Uni-Lab 在安装时已预置了 **40+ 个真实的设备图文件示例**,位于 `unilabos/test/experiments/` 目录。这些都是真实项目中使用的配置文件,可以直接使用或作为参考。
### 📁 主要示例文件
```
test/experiments/
├── workshop.json # 综合工作台(推荐新手)
├── empty_devices.json # 空设备配置(最小化)
├── prcxi_9300.json # PRCXI液体处理工作站本文示例1
├── prcxi_9320.json # PRCXI 9320工作站
├── biomek.json # Biomek液体处理工作站
├── Grignard_flow_batchreact_single_pumpvalve.json # 格林纳德反应工作站本文示例2
├── dispensing_station_bioyond.json # Bioyond配液站
├── reaction_station_bioyond.json # Bioyond反应站
├── HPLC.json # HPLC分析系统
├── plr_test.json # PyLabRobot测试配置
├── lidocaine-graph.json # 利多卡因合成工作站
├── opcua_example.json # OPC UA设备集成示例
├── mock_devices/ # 虚拟设备(用于离线测试)
│ ├── mock_all.json # 完整虚拟设备集
│ ├── mock_pump.json # 虚拟泵
│ ├── mock_stirrer.json # 虚拟搅拌器
│ ├── mock_heater.json # 虚拟加热器
│ └── ... # 更多虚拟设备
├── Protocol_Test_Station/ # 协议测试工作站
│ ├── pumptransfer_test_station.json # 泵转移协议测试
│ ├── heatchill_protocol_test_station.json # 加热冷却协议测试
│ ├── filter_protocol_test_station.json # 过滤协议测试
│ └── ... # 更多协议测试
└── comprehensive_protocol/ # 综合协议示例
├── comprehensive_station.json # 综合工作站
└── comprehensive_slim.json # 精简版综合工作站
```
### 🚀 快速使用
无需下载或创建,直接使用 `-g` 参数指定路径:
```bash
# 使用简单工作台(推荐新手)
unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json
# 使用虚拟设备(无需真实硬件)
unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
# 使用 PRCXI 液体处理工作站
unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json
# 使用格林纳德反应工作站
unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json
```
### 📚 文件分类
| 类别 | 说明 | 文件数量 |
| ------------ | ------------------------ | -------- |
| **主工作站** | 完整的实验工作站配置 | 15+ |
| **虚拟设备** | 用于开发测试的 mock 设备 | 10+ |
| **协议测试** | 各种实验协议的测试配置 | 12+ |
| **综合示例** | 包含多种协议的综合工作站 | 3+ |
这些文件展示了不同场景下的设备图配置,涵盖液体处理、有机合成、分析检测等多个领域,是学习和创建自己配置的绝佳参考。
## 快速参考ResourceDict 完整字段列表
基于 `unilabos.ros.nodes.resource_tracker.ResourceDict` 的完整字段定义:
```python
class ResourceDict(BaseModel):
# === 基础标识 ===
id: str # 资源ID必需
uuid: str # 全局唯一标识符(自动生成)
name: str # 显示名称(必需)
# === 类型和分类 ===
type: Union[Literal["device"], str] # 节点类型(必需)
klass: str # 资源类别alias="class",必需)
# === 层级关系 ===
parent: Optional[ResourceDict] # 父资源对象(不序列化)
parent_uuid: Optional[str] # 父资源UUID
# === 位置和姿态 ===
position: ResourceDictPosition # 位置信息
pose: ResourceDictPosition # 姿态信息(推荐使用)
# === 配置和数据 ===
config: Dict[str, Any] # 设备配置参数
data: Dict[str, Any] # 运行时状态数据
extra: Dict[str, Any] # 额外自定义数据
# === 元数据 ===
description: str # 资源描述
resource_schema: Dict[str, Any] # schema定义alias="schema"
model: Dict[str, Any] # 3D模型信息
icon: str # 图标路径
```
**Position/Pose 结构**:
```python
class ResourceDictPosition(BaseModel):
size: ResourceDictPositionSize # width, height, depth
scale: ResourceDictPositionScale # x, y, z
layout: Literal["2d", "x-y", "z-y", "x-z"]
position: ResourceDictPositionObject # x, y, z
position3d: ResourceDictPositionObject # x, y, z
rotation: ResourceDictPositionObject # x, y, z
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
```
## 下一步
- {doc}`../boot_examples/index` - 查看完整启动示例
- {doc}`../developer_guide/add_device` - 了解如何添加新设备
- {doc}`06_troubleshooting` - 图文件相关问题排查
- 源码参考: `unilabos/ros/nodes/resource_tracker.py` - ResourceDict 标准定义
## 获取帮助
- 在 Web 界面中使用模板创建
- 参考示例文件:`test/experiments/` 目录
- 查看 ResourceDict 源码了解完整定义
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,43 +1,516 @@
# **Uni-Lab 安装**
# Uni-Lab-OS 安装指南
## 快速开始
本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。
1. **配置 Conda 环境**
## 系统要求
Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境:
- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+)
- **内存**: 最小 4GB推荐 8GB 以上
- **磁盘空间**: 至少 10GB 可用空间
- **网络**: 稳定的互联网连接(用于下载软件包)
- **其他**:
- 已安装 Conda/Miniconda/Miniforge/Mamba
- 开发者需要 Git 和基本的 Python 开发知识
- 自定义 msgs 需要 GitHub 账号
```shell
## 安装方式选择
根据您的使用场景,选择合适的安装方式:
| 安装方式 | 适用人群 | 特点 | 安装时间 |
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
---
## 方式一:一键安装(推荐新用户)
使用预打包的 conda 环境,最快速的安装方法。
### 前置条件
确保已安装 Conda/Miniconda/Miniforge/Mamba。
### 安装步骤
#### 第一步:下载预打包环境
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
2. 选择最新的成功构建记录(绿色勾号 ✓)
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
- Windows: `unilab-pack-win-64-{branch}.zip`
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
#### 第二步:解压并运行安装脚本
**Windows**:
```batch
REM 使用 Windows 资源管理器解压下载的 zip 文件
REM 或使用命令行:
tar -xzf unilab-pack-win-64-dev.zip
REM 进入解压后的目录
cd unilab-pack-win-64-dev
REM 双击运行 install_unilab.bat
REM 或在命令行中执行:
install_unilab.bat
```
**macOS**:
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-osx-arm64-dev
# 运行安装脚本
bash install_unilab.sh
```
**Linux**:
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-linux-64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-linux-64-dev
# 添加执行权限(如果需要)
chmod +x install_unilab.sh
# 运行安装脚本
./install_unilab.sh
```
#### 第三步:激活环境
```bash
conda activate unilab
```
激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。
---
## 方式二:手动安装(标准用户)
适合生产环境和需要灵活配置的用户。
### 第一步:安装 Mamba 环境管理器
Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。
#### Windows
下载并安装 Miniforge包含 Mamba:
```powershell
# 访问 https://github.com/conda-forge/miniforge/releases
# 下载 Miniforge3-Windows-x86_64.exe
# 运行安装程序
# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/
# 下载 Miniforge3-Windows-x86_64.exe
# 运行安装程序
```
#### Linux/macOS
```bash
# 下载 Miniforge 安装脚本
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
# 运行安装
bash Miniforge3-$(uname)-$(uname -m).sh
# 按照提示完成安装,建议选择 yes 来初始化
```
安装完成后,重新打开终端使 Mamba 生效。
### 第二步:创建 Uni-Lab 环境
使用以下命令创建 Uni-Lab 专用环境:
```bash
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
**参数说明**:
- `-n unilab`: 创建名为 "unilab" 的环境
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
**如果遇到网络问题**,可以使用清华镜像源加速下载:
```bash
# 配置清华镜像源
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
# 然后重新执行安装命令
mamba create -n unilab uni-lab::unilabos -c robostack-staging
```
### 第三步:激活环境
```bash
conda activate unilab
```
---
## 方式三:开发者安装
适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。
### 前置条件
- 已安装 Git
- 已安装 Mamba/Conda
- 有 GitHub 账号(如需自定义 msgs
- 基本的 Python 开发知识
### 第一步:克隆仓库
```bash
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
```
如果您需要贡献代码,建议先 Fork 仓库:
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
2. 点击右上角的 "Fork" 按钮
3. Clone 您的 Fork 版本:
```bash
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
cd Uni-Lab-OS
```
### 第二步:安装基础环境
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装这将包含所有必需的依赖项ROS2、msgs 等)。
#### 选项 A通过一键安装推荐
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
```bash
conda activate unilab
```
#### 选项 B通过手动安装
参考上文"方式二:手动安装",创建并安装环境:
```bash
mamba create -n unilab python=3.11.11
conda activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
### 第三步:切换到开发版本
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
```bash
# 确保环境已激活
conda activate unilab
# 卸载 pip 安装的 unilabos保留所有 conda 依赖)
pip uninstall unilabos -y
# 克隆 dev 分支(如果还未克隆)
cd /path/to/your/workspace
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
# 或者如果已经克隆,切换到 dev 分支
cd Uni-Lab-OS
git checkout dev
git pull
# 以可编辑模式安装开发版 unilabos
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
```
**参数说明**
- `-e`: editable mode可编辑模式代码修改立即生效无需重新安装
- `-i`: 使用清华镜像源加速下载
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
### 第四步:安装或自定义 ros-humble-unilabos-msgs可选
Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装msgs 包已经自动安装。
#### 使用已安装的 msgs大多数用户
如果你不需要修改 msgs可以跳过此步骤直接使用已安装的 msgs 包。验证安装:
```bash
# 列出所有 unilabos_msgs 接口
ros2 interface list | grep unilabos_msgs
# 查看特定 action 定义
ros2 interface show unilabos_msgs/action/DeviceCmd
```
#### 自定义 msgs高级用户
如果你需要:
- 添加新的 ROS2 action 定义
- 修改现有 msg/srv/action 接口
- 为特定设备定制通信协议
请参考 **[添加新动作指令Action指南](../developer_guide/add_action.md)**,该指南详细介绍了如何:
- 编写新的 Action 定义
- 在线构建 Action通过 GitHub Actions
- 下载并安装自定义的 msgs 包
- 测试和验证新的 Action
```bash
# 安装自定义构建的 msgs 包
mamba remove --force ros-humble-unilabos-msgs
mamba config set safety_checks disabled # 关闭 md5 检查
mamba install /path/to/ros-humble-unilabos-msgs-*.conda --offline
```
### 第五步:验证开发环境
完成上述步骤后,验证开发环境是否正确配置:
```bash
# 确保环境已激活
conda activate unilab
# 检查 ROS2 环境
ros2 --version
# 检查 msgs 包
ros2 interface list | grep unilabos_msgs
# 检查 Python 可以导入 unilabos
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
# 检查 unilab 命令
unilab --help
```
如果所有命令都正常输出,说明开发环境配置成功!
---
## 验证安装
无论使用哪种安装方式,都应该验证安装是否成功。
### 基本验证
```bash
# 确保已激活环境
conda activate unilab # 或 unilab-dev
# 检查 unilab 命令
unilab --help
```
您应该看到类似以下的输出:
```
usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH]
[--working_dir WORKING_DIR] [--backend {ros,simple,automancer}]
...
```
### 检查版本
```bash
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
```
### 使用验证脚本(方式一)
如果使用一键安装,可以运行预打包的验证脚本:
```bash
# 确保已激活环境
conda activate unilab
# 运行验证脚本
python verify_installation.py
```
如果看到 "✓ All checks passed!",说明安装成功!
---
## 常见问题
### 问题 1: 找不到 unilab 命令
**原因**: 环境未正确激活或 PATH 未设置
**解决方案**:
```bash
# 确保激活了正确的环境
conda activate unilab
# 检查 unilab 是否在 PATH 中
which unilab # Linux/macOS
where unilab # Windows
```
### 问题 2: 包冲突或依赖错误
**解决方案**:
```bash
# 删除旧环境重新创建
conda deactivate
conda env remove -n unilab
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
2. **安装开发版 Uni-Lab-OS**
### 问题 3: 下载速度慢
```shell
# 配置好conda环境后克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
cd Uni-Lab-OS
**解决方案**: 使用国内镜像源(清华、中科大等)
# 安装 Uni-Lab-OS
pip install -e .
```bash
# 查看当前 channel 配置
conda config --show channels
# 添加清华镜像
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
```
3. **安装开发版 ros-humble-unilabos-msgs**
### 问题 4: 权限错误
**卸载老版本:**
```shell
**Windows 解决方案**: 以管理员身份运行命令提示符
**Linux/macOS 解决方案**:
```bash
# 不要使用 sudo 安装 conda 包
# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录
```
### 问题 5: 安装脚本找不到 conda方式一
**解决方案**: 确保你已经安装了 conda/miniconda/miniforge并且安装在标准位置
- **Windows**:
- `%USERPROFILE%\miniforge3`
- `%USERPROFILE%\miniconda3`
- `%USERPROFILE%\anaconda3`
- `C:\ProgramData\miniforge3`
- **macOS/Linux**:
- `~/miniforge3`
- `~/miniconda3`
- `~/anaconda3`
- `/opt/conda`
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
### 问题 6: 安装后激活环境提示找不到?
**解决方案**: 尝试以下方法:
```bash
# 方法 1: 使用 conda activate
conda activate unilab
conda remove --force ros-humble-unilabos-msgs
```
有时相同的安装包版本会由于dev构建得到的md5不一样触发安全检查可输入 `config set safety_checks disabled` 来关闭安全检查。
**安装新版本:**
# 方法 2: 使用完整路径激活Windows
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件使用如下指令
```shell
conda activate base
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
# 方法 2: 使用完整路径激活Unix
source ~/miniforge3/envs/unilab/bin/activate
```
4. **启动 Uni-Lab 系统**
### 问题 7: conda-unpack 失败怎么办?(方式一)
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。
**解决方案**: 尝试手动运行:
```bash
# Windows
cd %CONDA_PREFIX%\envs\unilab
.\Scripts\conda-unpack.exe
# macOS/Linux
cd $CONDA_PREFIX/envs/unilab
./bin/conda-unpack
```
### 问题 8: 环境很大,有办法减小吗?
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB。这是为了确保离线安装和完整功能。如果空间有限考虑使用方式二手动安装只安装需要的组件。
### 问题 9: 如何更新到最新版本?
**解决方案**:
**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
**方式二/三用户**: 在现有环境中更新:
```bash
conda activate unilab
# 更新 unilabos
cd /path/to/Uni-Lab-OS
git pull
pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
# 更新 ros-humble-unilabos-msgs
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
```
---
## 下一步
安装完成后,请继续:
- **快速启动**: 学习如何首次启动 Uni-Lab
- **配置指南**: 配置您的实验室环境和设备
- **运行示例**: 查看启动示例和最佳实践
- **开发指南**:
- 添加新设备驱动
- 添加新物料资源
- 了解工作站架构
## 需要帮助?
- **故障排查**: 查看更详细的故障排查信息
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
- **开发者文档**: 查看开发者指南获取更多技术细节
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
---
**提示**:
- 生产环境推荐使用方式二(手动安装)的稳定版本
- 开发和测试推荐使用方式三(开发者安装)
- 快速体验和演示推荐使用方式一(一键安装)

View File

@@ -132,15 +132,14 @@ unilab --config path/to/your/config.py
使用 `-c` 传入控制逻辑配置。
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`,只输入<your-registry-path>即可,支持多次--registry_path指定多个目录
## 通信中间件 `--backend`
目前 Uni-Lab 支持以下通信中间件:
- **ros** (默认):基于 ROS2 的通信
- **simple**:简化通信模式
- **automancer**Automancer 兼容模式
- **automancer**Automancer 兼容模式 (实验性)
## 端云桥接 `--app_bridges`
@@ -169,7 +168,7 @@ unilab --config path/to/your/config.py
通过 `--visual` 参数选择:
- **rviz**:使用 RViz 进行 3D 可视化
- **web**:使用 Web 界面进行可视化
- **web**:使用 Web 界面进行可视化 (基于Pylabrobot)
- **disable** (默认):禁用可视化
## 实验室管理
@@ -245,78 +244,3 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
- 检查图谱文件格式是否正确
- 验证设备连接和端点配置
- 确保注册表路径正确
## 页面操作
### 1. 启动成功
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
![material.png](image/material.png)
### 2. 根据需求创建设备和物料
我们可以做一个简单的案例
* 在容器1中加入水
* 通过传输泵将容器1中的水转移到容器2中
#### 2.1 添加所需的设备和物料
仪器设备work_station中的workstation 数量x1
仪器设备virtual_device中的virtual_transfer_pump 数量x1
物料耗材container中的container 数量x2
#### 2.2 将设备和物料根据父子关系进行关联
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来就像真实的设备操作一样
![links.png](image/links.png)
### 3. 创建工作流
进入工作流模块 → 点击"我创建的" → 新建工作流
![new.png](image/new.png)
#### 3.1 新增工作流节点
我们可以进入指定工作流,在空白处右键
* 选择Laboratory→host_node中的creat_resource
* 选择Laboratory→workstation中的PumpTransferProtocol
![creatworkfollow.gif](image/creatworkfollow.gif)
#### 3.2 配置节点参数
根据案例,工作流包含两个步骤:
1. 使用creat_resource在容器中创建水
2. 通过泵传输协议将水传输到另一个容器
我们点击creat_resource卡片上的编辑按钮来配置参数⭐
class_name container
device_id workstation
liquid_input_slot 0或-1均可
liquid_type : water
liquid_volume 根据需求填写即可默认单位ml这里举例50
parent workstation
res_id containe
关联设备名称(原unilabos_device_id) 这里就填写host_node
**配置完成后点击底部保存按钮**
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐
event transfer_liquid
from_vessel water
to_vessel container1
volume 根据需求填写即可默认单位ml这里举例50
关联设备名称(原unilabos_device_id) 这里就填写workstation
**配置完成后点击底部保存按钮**
#### 3.3 运行工作流
1. 连接两个节点卡片
2. 点击底部保存按钮
3. 点击运行按钮执行工作流
![linksandrun.png](image/linksandrun.png)
### 运行监控
* 运行状态和消息实时显示在底部控制台
* 如有报错,可点击查看详细信息
### 结果验证
工作流完成后,返回仪器耗材模块:
* 点击 container1卡片查看详情
* 确认其中包含参数指定的水和容量

View File

@@ -1,197 +0,0 @@
# Uni-Lab-OS 一键安装快速指南
## 概述
本指南提供最快速的 Uni-Lab-OS 安装方法,使用预打包的 conda 环境,无需手动配置依赖。
## 前置要求
- 已安装 Conda/Miniconda/Miniforge/Mamba
- 至少 10GB 可用磁盘空间
- Windows 10+, macOS 10.14+, 或 Linux (Ubuntu 20.04+)
## 安装步骤
### 第一步:下载预打包环境
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
2. 选择最新的成功构建记录(绿色勾号 ✓)
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
- Windows: `unilab-pack-win-64-{branch}.zip`
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
### 第二步:解压并运行安装脚本
#### Windows
```batch
REM 使用 Windows 资源管理器解压下载的 zip 文件
REM 或使用命令行:
tar -xzf unilab-pack-win-64-dev.zip
REM 进入解压后的目录
cd unilab-pack-win-64-dev
REM 双击运行 install_unilab.bat
REM 或在命令行中执行:
install_unilab.bat
```
#### macOS
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-osx-arm64-dev
# 运行安装脚本
bash install_unilab.sh
```
#### Linux
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-linux-64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-linux-64-dev
# 添加执行权限(如果需要)
chmod +x install_unilab.sh
# 运行安装脚本
./install_unilab.sh
```
### 第三步:激活环境
```bash
conda activate unilab
```
### 第四步:验证安装(推荐)
```bash
# 确保已激活环境
conda activate unilab
# 运行验证脚本
python verify_installation.py
```
如果看到 "✓ All checks passed!",说明安装成功!
## 常见问题
### Q: 安装脚本找不到 conda
**A:** 确保你已经安装了 conda/miniconda/miniforge并且安装在标准位置
- **Windows**:
- `%USERPROFILE%\miniforge3`
- `%USERPROFILE%\miniconda3`
- `%USERPROFILE%\anaconda3`
- `C:\ProgramData\miniforge3`
- **macOS/Linux**:
- `~/miniforge3`
- `~/miniconda3`
- `~/anaconda3`
- `/opt/conda`
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
### Q: 安装后激活环境提示找不到?
**A:** 尝试以下方法:
```bash
# 方法 1: 使用 conda activate
conda activate unilab
# 方法 2: 使用完整路径激活Windows
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
# 方法 2: 使用完整路径激活Unix
source ~/miniforge3/envs/unilab/bin/activate
```
### Q: conda-unpack 失败怎么办?
**A:** 尝试手动运行:
```bash
# Windows
cd %CONDA_PREFIX%\envs\unilab
.\Scripts\conda-unpack.exe
# macOS/Linux
cd $CONDA_PREFIX/envs/unilab
./bin/conda-unpack
```
### Q: 验证脚本报错?
**A:** 首先确认环境已激活:
```bash
# 检查当前环境
conda env list
# 应该看到 unilab 前面有 * 标记
```
如果仍有问题,查看具体报错信息,可能需要:
- 重新运行安装脚本
- 检查磁盘空间
- 查看详细文档
### Q: 环境很大,有办法减小吗?
**A:** 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB。这是为了确保离线安装和完整功能。如果空间有限考虑使用手动安装方式只安装需要的组件。
### Q: 如何更新到最新版本?
**A:** 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
或者在现有环境中更新:
```bash
conda activate unilab
# 更新 unilabos
cd /path/to/Uni-Lab-OS
git pull
pip install -e . --upgrade
# 更新 ros-humble-unilabos-msgs
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
```
## 下一步
安装完成后,你可以:
1. **查看启动指南**: {doc}`launch`
2. **运行示例**: {doc}`../boot_examples/index`
3. **配置设备**: 编辑 `unilabos_data/startup_config.json`
4. **阅读开发文档**: {doc}`../developer_guide/workstation_architecture`
## 需要帮助?
- **文档**: [docs/user_guide/installation.md](installation.md)
- **问题反馈**: [GitHub Issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
- **开发版安装**: 参考 {doc}`installation` 的方式二
---
**提示**: 这个预打包环境包含了从指定分支(通常是 `dev`)构建的最新代码。如果需要稳定版本,请使用方式二手动安装 release 版本。

32
fix_datatype.py Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import re
filepath = r'd:\UniLab\Uni-Lab-OS\unilabos\device_comms\modbus_plc\modbus.py'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Replace the DataType placeholder with actual enum
find_pattern = r'# DataType will be accessed via client instance.*?DataType = None # Placeholder.*?\n'
replacement = '''# Define DataType enum for pymodbus 2.5.3 compatibility
class DataType(Enum):
INT16 = "int16"
UINT16 = "uint16"
INT32 = "int32"
UINT32 = "uint32"
INT64 = "int64"
UINT64 = "uint64"
FLOAT32 = "float32"
FLOAT64 = "float64"
STRING = "string"
BOOL = "bool"
'''
new_content = re.sub(find_pattern, replacement, content, flags=re.DOTALL)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)
print('File updated successfully!')

54
new_cellconfig.json Normal file
View File

@@ -0,0 +1,54 @@
{
"nodes": [
{
"id": "BatteryStation",
"name": "扣电工作站",
"parent": null,
"children": [
"coin_cell_deck"
],
"type": "device",
"class":"coincellassemblyworkstation_device",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_YH_Deck",
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
}
},
"debug_mode": true,
"protocol_type": []
}
},
{
"id": "YB_YH_Deck",
"name": "YB_YH_Deck",
"children": [],
"parent": "BatteryStation",
"type": "deck",
"class": "CoincellDeck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "CoincellDeck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
],
"links": []
}

98
new_cellconfig3c.json Normal file
View File

@@ -0,0 +1,98 @@
{
"nodes": [
{
"id": "bioyond_cell_workstation",
"name": "配液分液工站",
"parent": null,
"children": [
"YB_Bioyond_Deck"
],
"type": "device",
"class": "bioyond_cell",
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "YB_Bioyond_Deck",
"name": "YB_Bioyond_Deck",
"children": [],
"parent": "bioyond_cell_workstation",
"type": "deck",
"class": "BIOYOND_YB_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_YB_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
},
{
"id": "BatteryStation",
"name": "扣电工作站",
"parent": null,
"children": [
"coin_cell_deck"
],
"type": "device",
"class":"coincellassemblyworkstation_device",
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_YH_Deck",
"_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck"
}
},
"protocol_type": []
},
"position": {
"size": {"height": 1450, "width": 1450, "depth": 2100},
"position": {
"x": -1500,
"y": 0,
"z": 0
}
}
},
{
"id": "YB_YH_Deck",
"name": "YB_YH_Deck",
"children": [],
"parent": "BatteryStation",
"type": "deck",
"class": "CoincellDeck",
"config": {
"type": "CoincellDeck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
],
"links": []
}

View File

@@ -1,6 +1,6 @@
package:
name: ros-humble-unilabos-msgs
version: 0.10.10
version: 0.10.12
source:
path: ../../unilabos_msgs
target_directory: src

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: "0.10.10"
version: "0.10.12"
source:
path: ../..

View File

@@ -2,7 +2,6 @@ import json
import logging
import traceback
import uuid
import xml.etree.ElementTree as ET
from typing import Any, Dict, List
import networkx as nx
@@ -25,7 +24,15 @@ class SimpleGraph:
def add_edge(self, source, target, **attrs):
"""添加边"""
edge = {"source": source, "target": target, **attrs}
# edge = {"source": source, "target": target, **attrs}
edge = {
"source": source, "target": target,
"source_node_uuid": source,
"target_node_uuid": target,
"source_handle_io": "source",
"target_handle_io": "target",
**attrs
}
self.edges.append(edge)
def to_dict(self):
@@ -42,6 +49,7 @@ class SimpleGraph:
"multigraph": False,
"graph": {},
"nodes": nodes_list,
"edges": self.edges,
"links": self.edges,
}
@@ -58,495 +66,8 @@ def extract_json_from_markdown(text: str) -> str:
return text
def convert_to_type(val: str) -> Any:
"""将字符串值转换为适当的数据类型"""
if val == "True":
return True
if val == "False":
return False
if val == "?":
return None
if val.endswith(" g"):
return float(val.split(" ")[0])
if val.endswith("mg"):
return float(val.split("mg")[0])
elif val.endswith("mmol"):
return float(val.split("mmol")[0]) / 1000
elif val.endswith("mol"):
return float(val.split("mol")[0])
elif val.endswith("ml"):
return float(val.split("ml")[0])
elif val.endswith("RPM"):
return float(val.split("RPM")[0])
elif val.endswith(" °C"):
return float(val.split(" ")[0])
elif val.endswith(" %"):
return float(val.split(" ")[0])
return val
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""统一的数据重构函数,根据操作类型自动选择模板"""
refactored_data = []
# 定义操作映射,包含生物实验和有机化学的所有操作
OPERATION_MAPPING = {
# 生物实验操作
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
# 有机化学操作
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
"Transfer": "SynBioFactory-workstation-TransferProtocol",
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
"Filter": "SynBioFactory-workstation-FilterProtocol",
"Dry": "SynBioFactory-workstation-DryProtocol",
"Add": "SynBioFactory-workstation-AddProtocol",
}
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
for step in data:
operation = step.get("action")
if not operation or operation in UNSUPPORTED_OPERATIONS:
continue
# 处理重复操作
if operation == "Repeat":
times = step.get("times", step.get("parameters", {}).get("times", 1))
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
for i in range(int(times)):
sub_data = refactor_data(sub_steps)
refactored_data.extend(sub_data)
continue
# 获取模板名称
template = OPERATION_MAPPING.get(operation)
if not template:
# 自动推断模板类型
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
else:
template = f"SynBioFactory-workstation-{operation}Protocol"
# 创建步骤数据
step_data = {
"template": template,
"description": step.get("description", step.get("purpose", f"{operation} operation")),
"lab_node_type": "Device",
"parameters": step.get("parameters", step.get("action_args", {})),
}
refactored_data.append(step_data)
return refactored_data
def build_protocol_graph(
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
) -> SimpleGraph:
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
G = SimpleGraph()
resource_last_writer = {}
LAB_NAME = "SynBioFactory"
protocol_steps = refactor_data(protocol_steps)
# 检查协议步骤中的模板来判断协议类型
has_biomek_template = any(
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
for step in protocol_steps
)
if has_biomek_template:
# 生物实验协议图构建
for labware_id, labware in labware_info.items():
node_id = str(uuid.uuid4())
labware_attrs = labware.copy()
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
labware_attrs["description"] = labware_id
labware_attrs["lab_node_type"] = (
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
)
labware_attrs["device_id"] = workstation_name
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
resource_last_writer[labware_id] = f"{node_id}:labware"
# 处理协议步骤
prev_node = None
for i, step in enumerate(protocol_steps):
node_id = str(uuid.uuid4())
G.add_node(node_id, **step)
# 添加控制流边
if prev_node is not None:
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
prev_node = node_id
# 处理物料流
params = step.get("parameters", {})
if "sources" in params and params["sources"] in resource_last_writer:
source_node, source_port = resource_last_writer[params["sources"]].split(":")
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
if "targets" in params:
resource_last_writer[params["targets"]] = f"{node_id}:labware"
# 添加协议结束节点
end_id = str(uuid.uuid4())
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
if prev_node is not None:
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
else:
# 有机化学协议图构建
WORKSTATION_ID = workstation_name
# 为所有labware创建资源节点
for item_id, item in labware_info.items():
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
node_id = str(uuid.uuid4())
# 判断节点类型
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
if "reactor" not in str(item_id).lower():
continue
lab_node_type = "Sample"
description = f"Prepare Reactor: {item_id}"
liquid_type = []
liquid_volume = []
else:
lab_node_type = "Reagent"
description = f"Add Reagent to Flask: {item_id}"
liquid_type = [item_id]
liquid_volume = [1e5]
G.add_node(
node_id,
template=f"{LAB_NAME}-host_node-create_resource",
description=description,
lab_node_type=lab_node_type,
res_id=item_id,
device_id=WORKSTATION_ID,
class_name="container",
parent=WORKSTATION_ID,
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
liquid_input_slot=[-1],
liquid_type=liquid_type,
liquid_volume=liquid_volume,
slot_on_deck="",
role=item.get("role", ""),
)
resource_last_writer[item_id] = f"{node_id}:labware"
last_control_node_id = None
# 处理协议步骤
for step in protocol_steps:
node_id = str(uuid.uuid4())
G.add_node(node_id, **step)
# 控制流
if last_control_node_id is not None:
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
last_control_node_id = node_id
# 物料流
params = step.get("parameters", {})
input_resources = {
"Vessel": params.get("vessel"),
"ToVessel": params.get("to_vessel"),
"FromVessel": params.get("from_vessel"),
"reagent": params.get("reagent"),
"solvent": params.get("solvent"),
"compound": params.get("compound"),
"sources": params.get("sources"),
"targets": params.get("targets"),
}
for target_port, resource_name in input_resources.items():
if resource_name and resource_name in resource_last_writer:
source_node, source_port = resource_last_writer[resource_name].split(":")
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
output_resources = {
"VesselOut": params.get("vessel"),
"FromVesselOut": params.get("from_vessel"),
"ToVesselOut": params.get("to_vessel"),
"FiltrateOut": params.get("filtrate_vessel"),
"reagent": params.get("reagent"),
"solvent": params.get("solvent"),
"compound": params.get("compound"),
"sources_out": params.get("sources"),
"targets_out": params.get("targets"),
}
for source_port, resource_name in output_resources.items():
if resource_name:
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
return G
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
"""
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
"""
if not protocol_graph:
print("Cannot draw graph: Graph object is empty.")
return
G = nx.DiGraph()
for node_id, attrs in protocol_graph.nodes.items():
label = attrs.get("description", attrs.get("template", node_id[:8]))
G.add_node(node_id, label=label, **attrs)
for edge in protocol_graph.edges:
G.add_edge(edge["source"], edge["target"])
plt.figure(figsize=(20, 15))
try:
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
except Exception:
pos = nx.shell_layout(G) # Fallback layout
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
nx.draw(
G,
pos,
with_labels=False,
node_size=2500,
node_color="skyblue",
node_shape="o",
edge_color="gray",
width=1.5,
arrowsize=15,
)
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
plt.title("Chemical Protocol Workflow Graph", size=15)
plt.savefig(output_path, dpi=300, bbox_inches="tight")
plt.close()
print(f" - Visualization saved to '{output_path}'")
from networkx.drawing.nx_agraph import to_agraph
import re
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
def _is_compass(port: str) -> bool:
return isinstance(port, str) and port.lower() in COMPASS
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
"""
使用 Graphviz 端口语法绘制协议工作流图。
- 若边上的 source_port/target_port 是 compassn/e/s/w/...),直接用 compass。
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
最终由 PyGraphviz 渲染并输出到 output_path后缀决定格式如 .png/.svg/.pdf
"""
if not protocol_graph:
print("Cannot draw graph: Graph object is empty.")
return
# 1) 先用 networkx 搭建有向图,保留端口属性
G = nx.DiGraph()
for node_id, attrs in protocol_graph.nodes.items():
label = attrs.get("description", attrs.get("template", node_id[:8]))
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
edges_data = []
in_ports_by_node = {} # 收集命名输入端口
out_ports_by_node = {} # 收集命名输出端口
for edge in protocol_graph.edges:
u = edge["source"]
v = edge["target"]
sp = edge.get("source_port")
tp = edge.get("target_port")
# 记录到图里(保留原始端口信息)
G.add_edge(u, v, source_port=sp, target_port=tp)
edges_data.append((u, v, sp, tp))
# 如果不是 compass就按“命名端口”先归类等会儿给节点造 record
if sp and not _is_compass(sp):
out_ports_by_node.setdefault(u, set()).add(str(sp))
if tp and not _is_compass(tp):
in_ports_by_node.setdefault(v, set()).add(str(tp))
# 2) 转为 AGraph使用 Graphviz 渲染
A = to_agraph(G)
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
A.edge_attr.update(arrowsize="0.8", color="#666666")
# 3) 为需要命名端口的节点设置 record 形状与 label
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
for n in A.nodes():
node = A.get_node(n)
core = G.nodes[n].get("_core_label", n)
in_ports = sorted(in_ports_by_node.get(n, []))
out_ports = sorted(out_ports_by_node.get(n, []))
# 如果该节点涉及命名端口,则用 record否则保留原 box
if in_ports or out_ports:
def port_fields(ports):
if not ports:
return " " # 必须留一个空槽占位
# 每个端口一个小格子,<p> name
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
left = port_fields(in_ports)
right = port_fields(out_ports)
# 三栏:左(入) | 中(节点名) | 右(出)
record_label = f"{{ {left} | {core} | {right} }}"
node.attr.update(shape="record", label=record_label)
else:
# 没有命名端口:普通盒子,显示核心标签
node.attr.update(label=str(core))
# 4) 给边设置 headport / tailport
# - 若端口为 compass直接用 compasse.g., headport="e"
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
for (u, v, sp, tp) in edges_data:
e = A.get_edge(u, v)
# Graphviz 属性tail 是源head 是目标
if sp:
if _is_compass(sp):
e.attr["tailport"] = sp.lower()
else:
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
if tp:
if _is_compass(tp):
e.attr["headport"] = tp.lower()
else:
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
# e.attr["arrowhead"] = "vee"
# 5) 输出
A.draw(output_path, prog="dot")
print(f" - Port-aware workflow rendered to '{output_path}'")
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
"""展平嵌套的XDL程序结构"""
flattened_operations = []
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
def extract_operations(element: ET.Element):
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
flattened_operations.append(element)
for child in element:
extract_operations(child)
for child in procedure_elem:
extract_operations(child)
return flattened_operations
def parse_xdl_content(xdl_content: str) -> tuple:
"""解析XDL内容"""
try:
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
root = ET.fromstring(xdl_content_cleaned)
synthesis_elem = root.find("Synthesis")
if synthesis_elem is None:
return None, None, None
# 解析硬件组件
hardware_elem = synthesis_elem.find("Hardware")
hardware = []
if hardware_elem is not None:
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
# 解析试剂
reagents_elem = synthesis_elem.find("Reagents")
reagents = []
if reagents_elem is not None:
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
# 解析程序
procedure_elem = synthesis_elem.find("Procedure")
if procedure_elem is None:
return None, None, None
flattened_operations = flatten_xdl_procedure(procedure_elem)
return hardware, reagents, flattened_operations
except ET.ParseError as e:
raise ValueError(f"Invalid XDL format: {e}")
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
"""
将XDL XML格式转换为标准的字典格式
Args:
xdl_content: XDL XML内容
Returns:
转换结果,包含步骤和器材信息
"""
try:
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
if hardware is None:
return {"error": "Failed to parse XDL content", "success": False}
# 将XDL元素转换为字典格式
steps_data = []
for elem in flattened_operations:
# 转换参数类型
parameters = {}
for key, val in elem.attrib.items():
converted_val = convert_to_type(val)
if converted_val is not None:
parameters[key] = converted_val
step_dict = {
"operation": elem.tag,
"parameters": parameters,
"description": elem.get("purpose", f"Operation: {elem.tag}"),
}
steps_data.append(step_dict)
# 合并硬件和试剂为统一的labware_info格式
labware_data = []
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
return {
"success": True,
"steps": steps_data,
"labware": labware_data,
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
}
except Exception as e:
error_msg = f"XDL conversion failed: {str(e)}"
logger.error(error_msg)
return {"error": error_msg, "success": False}
def create_workflow(

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup(
name=package_name,
version='0.10.10',
version='0.10.12',
packages=find_packages(),
include_package_data=True,
install_requires=['setuptools'],

View File

@@ -24,34 +24,13 @@
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"BIOYOND_PolymerStation_Reactor": [
"反应器",
"3a14233b-902d-0d7b-4533-3f60f1c41c1b"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14233b-56e3-6c53-a8ab-fcaac163a9ba"
],
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14233b-f0a9-ba84-eaa9-0d4718b361b6"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"样品板",
"3a142339-80de-8f25-6093-1b1b1b6c322e"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14233a-26e1-28f8-af6a-60ca06ba0165"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14233a-84a3-088d-6676-7cb4acd57c64"
],
"BIOYOND_PolymerStation_TipBox": [
"枪头盒",
"3a143890-9d51-60ac-6d6f-6edb43c12041"
]
"烧杯": ["YB_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"],
"试剂瓶": ["YB_1BottleCarrier", ""],
"样品板": ["YB_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"],
"分装板": ["YB_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"],
"样品瓶": ["YB_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"],
"90%分装小瓶": ["YB_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"],
"10%分装小瓶": ["YB_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"]
}
},
"deck": {
@@ -67,7 +46,8 @@
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"children": [],
"children": [
],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",

View File

@@ -0,0 +1,52 @@
[
{
"id": "3a1d377b-299d-d0f2-ced9-48257f60dfad",
"typeName": "加样头(大)",
"code": "0005-00145",
"barCode": "",
"name": "LiDFOB",
"quantity": 9999.0,
"lockQuantity": 0.0,
"unit": "个",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a19da56-1379-ff7c-1745-07e200b44ce2",
"whid": "3a19da56-1378-613b-29f2-871e1a287aa5",
"whName": "粉末加样头堆栈",
"code": "0005-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1d377b-6a81-6a7e-147c-f89f6463656d",
"typeName": "液",
"code": "0006-00141",
"barCode": "",
"name": "EMC",
"quantity": 99999.0,
"lockQuantity": 0.0,
"unit": "g",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a1baa20-a7b1-c665-8b9c-d8099d07d2f6",
"whid": "3a1baa20-a7b0-5c19-8844-5de8924d4e78",
"whName": "4号手套箱内部堆栈",
"code": "0015-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
}
]

View File

View File

@@ -0,0 +1,99 @@
{
"typeId": "3a190c8b-3284-af78-d29f-9a69463ad047",
"code": "",
"barCode": "",
"name": "test",
"unit": "",
"parameters": "{}",
"quantity": "",
"details": [
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)11",
"quantity": "1",
"x": 1,
"y": 1,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)21",
"quantity": "1",
"x": 2,
"y": 1,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)12",
"quantity": "1",
"x": 1,
"y": 2,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)22",
"quantity": "1",
"x": 2,
"y": 2,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)13",
"quantity": "1",
"x": 1,
"y": 3,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)23",
"quantity": "1",
"x": 2,
"y": 3,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)14",
"quantity": "1",
"x": 1,
"y": 4,
"z": 1,
"unit": "",
"parameters": "{}"
},
{
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb",
"code": "",
"name": "配液瓶(小)24",
"quantity": "1",
"x": 2,
"y": 4,
"z": 1,
"unit": "",
"parameters": "{}"
}
]
}

148
test/resources/test.json Normal file
View File

@@ -0,0 +1,148 @@
[
{
"id": "3a1d4c14-a9fb-d7dc-9e96-7a3ad6e50219",
"typeName": "配液瓶(小)板",
"code": "0001-00093",
"barCode": "",
"name": "test",
"quantity": 2.0,
"lockQuantity": 0.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
"whid": "3a19deae-2c79-05a3-9c76-8e6760424841",
"whName": "手动堆栈",
"code": "1",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1d4c14-a9fc-1daa-71fa-146cb1ccb930",
"detailMaterialId": "3a1d4c14-a9fc-4f38-4c48-68486c391c42",
"code": "0001-00093 - 05",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 3,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-3659-ea61-cd587da9e131",
"detailMaterialId": "3a1d4c14-a9fc-018f-93e5-c49343d37758",
"code": "0001-00093 - 08",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 4,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-3f94-de83-979d2646e313",
"detailMaterialId": "3a1d4c14-a9fc-9987-c0ef-4b7cbad49e6b",
"code": "0001-00093 - 01",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 1,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-8c35-6b25-913b11dbaf4e",
"detailMaterialId": "3a1d4c14-a9fc-9a83-865b-0c26ea5e8cc4",
"code": "0001-00093 - 03",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 2,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-b41f-e968-64953bfddccd",
"detailMaterialId": "3a1d4c14-a9fc-daf7-9d64-e5ec8d3ae0e2",
"code": "0001-00093 - 07",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 1,
"y": 4,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-c20f-c26e-b1bb2cdc3bca",
"detailMaterialId": "3a1d4c14-a9fc-673b-ac83-aaaf71287f1f",
"code": "0001-00093 - 06",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 3,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-cf21-059c-fde361d82b6f",
"detailMaterialId": "3a1d4c14-a9fc-25b1-e736-6b0d8dac0fae",
"code": "0001-00093 - 02",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 1,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
},
{
"id": "3a1d4c14-a9fc-d732-2b93-9b2bd2bf581b",
"detailMaterialId": "3a1d4c14-a9fc-7f5d-b6b6-8bcb2e15f320",
"code": "0001-00093 - 04",
"name": "配液瓶(小)",
"quantity": "1",
"lockQuantity": "0",
"unit": "个",
"x": 2,
"y": 2,
"z": 1,
"associateId": null,
"typeName": "配液瓶(小)",
"typeId": "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"
}
]
}
]

View File

@@ -1,7 +1,7 @@
import pytest
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
from unilabos.resources.bioyond.bottles import YB_Solid_Vial, YB_Solution_Beaker, YB_Reagent_Bottle
def test_bottle_carrier() -> "BottleCarrier":
@@ -16,9 +16,9 @@ def test_bottle_carrier() -> "BottleCarrier":
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
# 创建瓶子和烧杯
powder_bottle = BIOYOND_PolymerStation_Solid_Vial("powder_bottle_01")
solution_beaker = BIOYOND_PolymerStation_Solution_Beaker("solution_beaker_01")
reagent_bottle = BIOYOND_PolymerStation_Reagent_Bottle("reagent_bottle_01")
powder_bottle = YB_Solid_Vial("powder_bottle_01")
solution_beaker = YB_Solution_Beaker("solution_beaker_01")
reagent_bottle = YB_Reagent_Bottle("reagent_bottle_01")
print(f"\n创建的物料:")
print(f"粉末瓶: {powder_bottle.name} - {powder_bottle.diameter}mm x {powder_bottle.height}mm, {powder_bottle.max_volume}μL")

View File

@@ -12,13 +12,13 @@ lab_registry.setup()
type_mapping = {
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
"烧杯": ("YB_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
"试剂瓶": ("YB_1BottleCarrier", ""),
"样品板": ("YB_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
"分装板": ("YB_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
"样品瓶": ("YB_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
"90%分装小瓶": ("YB_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
"10%分装小瓶": ("YB_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
}

View File

@@ -1,3 +1,4 @@
from ast import If
import pytest
import json
import os
@@ -8,18 +9,16 @@ from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.registry.registry import lab_registry
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
from unilabos.resources.bioyond.decks import YB_Deck
lab_registry.setup()
type_mapping = {
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
"样品": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
"加样头(大)": ("YB_jia_yang_tou_da", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
"": ("YB_1BottleCarrier", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
"配液瓶(小)": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"),
"配液瓶(小)": ("YB_pei_ye_xiao_Bottler", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"),
}
@@ -57,12 +56,20 @@ def bioyond_materials_liquidhandling_2() -> list[dict]:
"bioyond_materials_reaction",
"bioyond_materials_liquidhandling_1",
])
def test_resourcetreeset_from_plr(materials_fixture, request) -> list[dict]:
materials = request.getfixturevalue(materials_fixture)
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
def test_resourcetreeset_from_plr() -> list[dict]:
# 直接加载 bioyond_materials_reaction.json 文件
current_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(current_dir, "test.json")
with open(json_path, "r", encoding="utf-8") as f:
materials = json.load(f)
deck = YB_Deck("test_deck")
output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
print(deck.summary())
print(output)
# print(deck.summary())
r = ResourceTreeSet.from_plr_resources([deck])
print(r.dump())
# json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)
if __name__ == "__main__":
test_resourcetreeset_from_plr()

0
test/ros/__init__.py Normal file
View File

View File

View File

@@ -1,4 +1,3 @@
import json
import sys
from datetime import datetime
from pathlib import Path
@@ -9,86 +8,28 @@ if str(ROOT_DIR) not in sys.path:
import pytest
from scripts.workflow import build_protocol_graph, draw_protocol_graph, draw_protocol_graph_with_ports
from unilabos.workflow.convert_from_json import (
convert_from_json,
normalize_steps as _normalize_steps,
normalize_labware as _normalize_labware,
)
from unilabos.workflow.common import draw_protocol_graph_with_ports
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _normalize_steps(data):
normalized = []
for step in data:
action = step.get("action") or step.get("operation")
if not action:
continue
raw_params = step.get("parameters") or step.get("action_args") or {}
params = dict(raw_params)
if "source" in raw_params and "sources" not in raw_params:
params["sources"] = raw_params["source"]
if "target" in raw_params and "targets" not in raw_params:
params["targets"] = raw_params["target"]
description = step.get("description") or step.get("purpose")
step_dict = {"action": action, "parameters": params}
if description:
step_dict["description"] = description
normalized.append(step_dict)
return normalized
def _normalize_labware(data):
labware = {}
for item in data:
reagent_name = item.get("reagent_name")
key = reagent_name or item.get("material_name") or item.get("name")
if not key:
continue
key = str(key)
idx = 1
original_key = key
while key in labware:
idx += 1
key = f"{original_key}_{idx}"
labware[key] = {
"slot": item.get("positions") or item.get("slot"),
"labware": item.get("material_name") or item.get("labware"),
"well": item.get("well", []),
"type": item.get("type", "reagent"),
"role": item.get("role", ""),
"name": key,
}
return labware
@pytest.mark.parametrize("protocol_name", [
"example_bio",
# "bioyond_materials_liquidhandling_1",
"example_prcxi",
])
@pytest.mark.parametrize(
"protocol_name",
[
"example_bio",
# "bioyond_materials_liquidhandling_1",
"example_prcxi",
],
)
def test_build_protocol_graph(protocol_name):
data_path = Path(__file__).with_name(f"{protocol_name}.json")
with data_path.open("r", encoding="utf-8") as fp:
d = json.load(fp)
if "workflow" in d and "reagent" in d:
protocol_steps = d["workflow"]
labware_info = d["reagent"]
elif "steps_info" in d and "labware_info" in d:
protocol_steps = _normalize_steps(d["steps_info"])
labware_info = _normalize_labware(d["labware_info"])
else:
raise ValueError("Unsupported protocol format")
graph = convert_from_json(data_path, workstation_name="PRCXi")
graph = build_protocol_graph(
labware_info=labware_info,
protocol_steps=protocol_steps,
workstation_name="PRCXi",
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
draw_protocol_graph_with_ports(graph, str(output_path))
print(graph)
print(graph)

View File

@@ -1 +1 @@
__version__ = "0.10.10"
__version__ = "0.10.12"

View File

@@ -141,7 +141,7 @@ class CommunicationClientFactory:
"""
if cls._client_cache is None:
cls._client_cache = cls.create_client(protocol)
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
return cls._client_cache

View File

@@ -20,6 +20,7 @@ if unilabos_dir not in sys.path:
from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
def load_config_from_file(config_path):
if config_path is None:
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
@@ -41,7 +42,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
for i, arg in enumerate(sys.argv):
for option_string in option_strings:
if arg.startswith(option_string):
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
sys.argv[i] = new_arg
break
@@ -49,6 +50,8 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
parser.add_argument(
@@ -105,7 +108,7 @@ def parse_args():
parser.add_argument(
"--port",
type=int,
default=8002,
default=None,
help="Port for web service information page",
)
parser.add_argument(
@@ -153,21 +156,54 @@ def parse_args():
default=False,
help="Complete registry information",
)
# workflow upload subcommand
workflow_parser = subparsers.add_parser(
"workflow_upload",
aliases=["wf"],
help="Upload workflow from xdl/json/python files",
)
workflow_parser.add_argument(
"-f",
"--workflow_file",
type=str,
required=True,
help="Path to the workflow file (JSON format)",
)
workflow_parser.add_argument(
"-n",
"--workflow_name",
type=str,
default=None,
help="Workflow name, if not provided will use the name from file or filename",
)
workflow_parser.add_argument(
"--tags",
type=str,
nargs="*",
default=[],
help="Tags for the workflow (space-separated)",
)
workflow_parser.add_argument(
"--published",
action="store_true",
default=False,
help="Whether to publish the workflow (default: False)",
)
return parser
def main():
"""主函数"""
# 解析命令行参数
args = parse_args()
convert_argv_dashes_to_underscores(args)
args_dict = vars(args.parse_args())
parser = parse_args()
convert_argv_dashes_to_underscores(parser)
args = parser.parse_args()
args_dict = vars(args)
# 环境检查 - 检查并自动安装必需的包 (可选)
if not args_dict.get("skip_env_check", False):
from unilabos.utils.environment_check import check_environment
print_status("正在进行环境依赖检查...", "info")
if not check_environment(auto_install=True):
print_status("环境检查失败,程序退出", "error")
os._exit(1)
@@ -218,19 +254,20 @@ def main():
if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
configure_logger(loglevel=BasicConfig.log_level)
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
if args_dict["addr"] == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
elif args_dict["addr"] == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
elif args_dict["addr"] == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
else:
HTTPConfig.remote_addr = args_dict.get("addr", "")
if args.addr != parser.get_default("addr"):
if args.addr == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
elif args.addr == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
elif args.addr == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
else:
HTTPConfig.remote_addr = args.addr
# 设置BasicConfig参数
if args_dict.get("ak", ""):
@@ -239,9 +276,12 @@ def main():
if args_dict.get("sk", ""):
BasicConfig.sk = args_dict.get("sk", "")
print_status("传入了sk参数优先采用传入参数", "info")
BasicConfig.working_dir = working_dir
workflow_upload = args_dict.get("command") in ("workflow_upload", "wf")
# 使用远程资源启动
if args_dict["use_remote_resource"]:
if not workflow_upload and args_dict["use_remote_resource"]:
print_status("使用远程资源启动", "info")
from unilabos.app.web import http_client
@@ -252,7 +292,8 @@ def main():
else:
print_status("远程资源不存在,本地将进行首次上报!", "info")
BasicConfig.working_dir = working_dir
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
@@ -281,9 +322,31 @@ def main():
# 注册表
lab_registry = build_registry(
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
)
if BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
if BasicConfig.ak and BasicConfig.sk:
print_status("开始注册设备到服务端...", "info")
try:
register_devices_and_resources(lab_registry)
print_status("设备注册完成", "info")
except Exception as e:
print_status(f"设备注册失败: {e}", "error")
else:
print_status("未提供 ak 和 sk跳过设备注册", "info")
else:
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
# 处理 workflow_upload 子命令
if workflow_upload:
from unilabos.workflow.wf_utils import handle_workflow_upload_command
handle_workflow_upload_command(args_dict)
print_status("工作流上传完成,程序退出", "info")
os._exit(0)
if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
@@ -291,7 +354,9 @@ def main():
resource_tree_set: ResourceTreeSet
resource_links: List[Dict[str, Any]]
request_startup_json = http_client.request_startup_json()
if args_dict["graph"] is None:
file_path = args_dict.get("graph", BasicConfig.startup_json_path)
if file_path is None:
if not request_startup_json:
print_status(
"未指定设备加载文件路径尝试从HTTP获取失败请检查网络或者使用-g参数指定设备加载文件路径", "error"
@@ -301,7 +366,11 @@ def main():
print_status("联网获取设备加载文件成功", "info")
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
else:
file_path = args_dict["graph"]
if not os.path.isfile(file_path):
temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path)))
if os.path.isfile(temp_file_path):
print_status(f"使用相对路径{temp_file_path}", "info")
file_path = temp_file_path
if file_path.endswith(".json"):
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
else:
@@ -354,20 +423,6 @@ def main():
args_dict["devices_config"] = resource_tree_set
args_dict["graph"] = graph_res.physical_setup_graph
if BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
if BasicConfig.ak and BasicConfig.sk:
print_status("开始注册设备到服务端...", "info")
try:
register_devices_and_resources(lab_registry)
print_status("设备注册完成", "info")
except Exception as e:
print_status(f"设备注册失败: {e}", "error")
else:
print_status("未提供 ak 和 sk跳过设备注册", "info")
else:
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
if args_dict["controllers"] is not None:
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
else:
@@ -382,6 +437,7 @@ def main():
comm_client = get_communication_client()
if "websocket" in args_dict["app_bridges"]:
args_dict["bridges"].append(comm_client)
def _exit(signum, frame):
comm_client.stop()
sys.exit(0)
@@ -413,8 +469,8 @@ def main():
server_thread = threading.Thread(
target=start_server,
kwargs=dict(
open_browser=not args_dict["disable_browser"],
port=args_dict["port"],
open_browser=not BasicConfig.disable_browser,
port=BasicConfig.port,
),
)
server_thread.start()
@@ -423,16 +479,13 @@ def main():
resource_visualization.start()
except OSError as e:
if "AMENT_PREFIX_PATH" in str(e):
print_status(
f"ROS 2环境未正确设置跳过3D可视化启动。错误详情: {e}",
"warning"
)
print_status(f"ROS 2环境未正确设置跳过3D可视化启动。错误详情: {e}", "warning")
print_status(
"建议解决方案:\n"
"1. 激活Conda环境: conda activate unilab\n"
"2. 或使用 --backend simple 参数\n"
"3. 或使用 --visual disable 参数禁用可视化",
"info"
"info",
)
else:
raise
@@ -442,13 +495,13 @@ def main():
start_backend(**args_dict)
start_server(
open_browser=not args_dict["disable_browser"],
port=args_dict["port"],
port=BasicConfig.port,
)
else:
start_backend(**args_dict)
start_server(
open_browser=not args_dict["disable_browser"],
port=args_dict["port"],
port=BasicConfig.port,
)

View File

@@ -51,21 +51,25 @@ class Resp(BaseModel):
class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id")
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
task_id: str = Field(examples=["task_id"], description="task uuid")
job_id: str = Field(examples=["job_id"], description="goal uuid")
node_id: str = Field(examples=["node_id"], description="node uuid")
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
action_type: str = Field(
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
)
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
node_id: str = Field(examples=["node_id"], description="node uuid", default="")
server_info: dict = Field(
examples=[{"send_timestamp": 1717000000.0}],
description="server info (auto-generated if empty)",
default_factory=dict,
)
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict)
class JobStepFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
data: dict = Field(
examples=[
{
@@ -83,9 +87,7 @@ class JobStepFinishReq(BaseModel):
class JobPreintakeFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
data: dict = Field(
examples=[
{
@@ -102,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel):
class JobFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
data: dict = Field(
examples=[
{
@@ -133,6 +133,10 @@ class JobData(BaseModel):
default=0,
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
)
result: dict = Field(
default_factory=dict,
description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)",
)
class JobStatusResp(Resp):

View File

@@ -1,161 +1,158 @@
import argparse
import os
import time
from typing import Dict, Optional, Tuple
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple, Union
import requests
from unilabos.config.config import OSSUploadConfig
from unilabos.app.web.client import http_client, HTTPClient
from unilabos.utils import logger
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
process_key: str = "file-upload", device_id: str = "default",
expires_hours: int = 1) -> Tuple[bool, Dict]:
def _get_oss_token(
filename: str,
driver_name: str = "default",
exp_type: str = "default",
client: Optional[HTTPClient] = None,
) -> Tuple[bool, Dict]:
"""
初始化上传过程
获取OSS上传Token
Args:
file_path: 本地文件路径
oss_path: OSS目标路径
filename: 文件名如果为None则使用file_path的文件名
process_key: 处理键
device_id: 设备ID
expires_hours: 链接过期小时数
filename: 文件名
driver_name: 驱动名称
exp_type: 实验类型
client: HTTPClient实例如果不提供则使用默认的http_client
Returns:
(成功标志, 响应数据)
(成功标志, Token数据字典包含token/path/host/expires)
"""
if filename is None:
filename = os.path.basename(file_path)
# 使用提供的client或默认的http_client
if client is None:
client = http_client
# 构造初始化请求
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
headers = {
"Authorization": OSSUploadConfig.authorization,
"Content-Type": "application/json"
}
# 构造scene参数: driver_name-exp_type
sub_path = f"{driver_name}-{exp_type}"
payload = {
"device_id": device_id,
"process_key": process_key,
"filename": filename,
"path": oss_path,
"expires_hours": expires_hours
}
# 构造请求URL使用client的remote_addr已包含/api/v1/
url = f"{client.remote_addr}/applications/token"
params = {"sub_path": sub_path, "filename": filename, "scene": "job"}
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 201:
result = response.json()
if result.get("code") == "10000":
return True, result.get("data", {})
logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}")
response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10)
print(f"初始化上传失败: {response.status_code}, {response.text}")
if response.status_code == 200:
result = response.json()
if result.get("code") == 0:
data = result.get("data", {})
# 转换expires时间戳为可读格式
expires_timestamp = data.get("expires", 0)
expires_datetime = datetime.fromtimestamp(expires_timestamp)
expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S")
logger.info(f"[OSS] 获取预签名URL成功")
logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}")
logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})")
return True, data
logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}")
return False, {}
except Exception as e:
print(f"初始化上传异常: {str(e)}")
logger.error(f"[OSS] 获取预签名URL异常: {str(e)}")
return False, {}
def _put_upload(file_path: str, upload_url: str) -> bool:
"""
执行PUT上传
使用预签名URL上传文件到OSS
Args:
file_path: 本地文件路径
upload_url: 上传URL
upload_url: 完整的预签名上传URL
Returns:
是否成功
"""
try:
logger.info(f"[OSS] 开始上传文件: {file_path}")
with open(file_path, "rb") as f:
response = requests.put(upload_url, data=f)
# 使用预签名URL上传不需要额外的认证header
response = requests.put(upload_url, data=f, timeout=300)
if response.status_code == 200:
logger.info(f"[OSS] 文件上传成功")
return True
print(f"PUT上传失败: {response.status_code}, {response.text}")
logger.error(f"[OSS] 上传失败: {response.status_code}")
logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}")
return False
except Exception as e:
print(f"PUT上传异常: {str(e)}")
logger.error(f"[OSS] 上传异常: {str(e)}")
return False
def _complete_upload(uuid: str) -> bool:
"""
完成上传过程
Args:
uuid: 上传的UUID
Returns:
是否成功
"""
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
headers = {
"Authorization": OSSUploadConfig.authorization,
"Content-Type": "application/json"
}
payload = {
"uuid": uuid
}
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("code") == "10000":
return True
print(f"完成上传失败: {response.status_code}, {response.text}")
return False
except Exception as e:
print(f"完成上传异常: {str(e)}")
return False
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
process_key: str = "file-upload", device_id: str = "default") -> bool:
def oss_upload(
file_path: Union[str, Path],
filename: Optional[str] = None,
driver_name: str = "default",
exp_type: str = "default",
max_retries: int = 3,
client: Optional[HTTPClient] = None,
) -> Dict:
"""
文件上传主函数,包含重试机制
Args:
file_path: 本地文件路径
oss_path: OSS目标路径
filename: 文件名如果为None则使用file_path的文件名
process_key: 处理键
device_id: 设备ID
driver_name: 驱动名称用于构造scene
exp_type: 实验类型用于构造scene
max_retries: 最大重试次数
client: HTTPClient实例如果不提供则使用默认的http_client
Returns:
是否成功上传
Dict: {
"success": bool, # 是否上传成功
"original_path": str, # 原始文件路径
"oss_path": str # OSS路径成功时或空字符串失败时
}
"""
max_retries = OSSUploadConfig.max_retries
file_path = Path(file_path)
if filename is None:
filename = os.path.basename(file_path)
if not os.path.exists(file_path):
logger.error(f"[OSS] 文件不存在: {file_path}")
return {"success": False, "original_path": file_path, "oss_path": ""}
retry_count = 0
oss_path = ""
while retry_count < max_retries:
try:
# 步骤1初始化上传
init_success, init_data = _init_upload(
file_path=file_path,
oss_path=oss_path,
filename=filename,
process_key=process_key,
device_id=device_id
# 步骤1获取预签名URL
token_success, token_data = _get_oss_token(
filename=filename, driver_name=driver_name, exp_type=exp_type, client=client
)
if not init_success:
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
if not token_success:
logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1) # 等待1秒后重试
time.sleep(1)
continue
# 获取UUID和上传URL
uuid = init_data.get("uuid")
upload_url = init_data.get("upload_url")
# 获取预签名URL和OSS路径
upload_url = token_data.get("url")
oss_path = token_data.get("path", "")
if not uuid or not upload_url:
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
if not upload_url:
logger.warning(f"[OSS] 无法获取上传URLAPI未返回url字段")
retry_count += 1
time.sleep(1)
continue
@@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
# 步骤2PUT上传文件
put_success = _put_upload(file_path, upload_url)
if not put_success:
print(f"PUT上传失败重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
continue
# 步骤3完成上传
complete_success = _complete_upload(uuid)
if not complete_success:
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
logger.warning(f"[OSS] PUT上传失败重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
continue
# 所有步骤都成功
print(f"文件 {file_path} 上传成功")
return True
logger.info(f"[OSS] 文件 {file_path} 上传成功")
return {"success": True, "original_path": file_path, "oss_path": oss_path}
except Exception as e:
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
return False
logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
return {"success": False, "original_path": file_path, "oss_path": oss_path}
if __name__ == "__main__":
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \
# --ak xxx --sk yyy --remote-addr http://xxx/api/v1
# 命令行参数解析
parser = argparse.ArgumentParser(description='文件上传测试工具')
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
parser = argparse.ArgumentParser(description="文件上传测试工具")
parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径")
parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称")
parser.add_argument("--type", "-t", type=str, default="default", help="实验类型")
parser.add_argument("--ak", type=str, help="Access Key如果提供则覆盖配置")
parser.add_argument("--sk", type=str, help="Secret Key如果提供则覆盖配置")
parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1如果提供则覆盖配置")
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.file):
print(f"错误:文件 {args.file} 不存在")
logger.error(f"错误:文件 {args.file} 不存在")
exit(1)
print("=" * 50)
print(f"开始上传文件: {args.file}")
print(f"目标路径: {args.path}")
print(f"设备ID: {args.device}")
print(f"处理键: {args.process}")
print("=" * 50)
# 如果提供了ak/sk/remote_addr创建临时HTTPClient
temp_client = None
if args.ak and args.sk:
import base64
auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8")
remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr
temp_client = HTTPClient(remote_addr=remote_addr, auth=auth)
logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}")
elif args.remote_addr:
temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth)
logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}")
else:
logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}")
logger.info("=" * 50)
logger.info(f"开始上传文件: {args.file}")
logger.info(f"驱动名称: {args.driver}")
logger.info(f"实验类型: {args.type}")
logger.info(f"Scene: {args.driver}-{args.type}")
logger.info("=" * 50)
# 执行上传
success = oss_upload(
result = oss_upload(
file_path=args.file,
oss_path=args.path,
filename=None, # 使用默认文件名
process_key=args.process,
device_id=args.device
driver_name=args.driver,
exp_type=args.type,
client=temp_client,
)
# 输出结果
if success:
print("\n√ 文件上传成功!")
if result["success"]:
logger.info(f"\n√ 文件上传成功!")
logger.info(f"原始路径: {result['original_path']}")
logger.info(f"OSS路径: {result['oss_path']}")
exit(0)
else:
print("\n× 文件上传失败!")
logger.error(f"\n× 文件上传失败!")
logger.error(f"原始路径: {result['original_path']}")
exit(1)

View File

@@ -9,13 +9,22 @@ import asyncio
import yaml
from unilabos.app.web.controler import devices, job_add, job_info
from unilabos.app.web.controller import (
devices,
job_add,
job_info,
get_online_devices,
get_device_actions,
get_action_schema,
get_all_available_actions,
)
from unilabos.app.model import (
Resp,
RespCode,
JobStatusResp,
JobAddResp,
JobAddReq,
JobData,
)
from unilabos.app.web.utils.host_utils import get_host_node_info
from unilabos.registry.registry import lab_registry
@@ -1234,6 +1243,65 @@ def get_devices():
return Resp(data=dict(data))
@api.get("/online-devices", summary="Online devices list", response_model=Resp)
def api_get_online_devices():
"""获取在线设备列表
返回当前在线的设备列表包含设备ID、命名空间、机器名等信息
"""
isok, data = get_online_devices()
if not isok:
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/devices/{device_id}/actions", summary="Device actions list", response_model=Resp)
def api_get_device_actions(device_id: str):
"""获取设备可用的动作列表
Args:
device_id: 设备ID
返回指定设备的所有可用动作,包含动作名称、类型、是否繁忙等信息
"""
isok, data = get_device_actions(device_id)
if not isok:
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/devices/{device_id}/actions/{action_name}/schema", summary="Action schema", response_model=Resp)
def api_get_action_schema(device_id: str, action_name: str):
"""获取动作的Schema详情
Args:
device_id: 设备ID
action_name: 动作名称
返回动作的参数Schema、默认值、类型等详细信息
"""
isok, data = get_action_schema(device_id, action_name)
if not isok:
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/actions", summary="All available actions", response_model=Resp)
def api_get_all_actions():
"""获取所有设备的可用动作
返回所有已注册设备的动作列表,包含设备信息和各动作的状态
"""
isok, data = get_all_available_actions()
if not isok:
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp)
def job_status(id: str):
"""获取任务状态"""
@@ -1244,11 +1312,22 @@ def job_status(id: str):
@api.post("/job/add", summary="Create job", response_model=JobAddResp)
def post_job_add(req: JobAddReq):
"""创建任务"""
device_id = req.device_id
if not req.data:
return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data")
# 检查必要参数device_id 和 action
if not req.device_id:
return JobAddResp(
data=JobData(jobId="", status=6),
code=RespCode.ErrorInvalidReq,
message="device_id is required",
)
action_name = req.data.get("action", req.action) if req.data else req.action
if not action_name:
return JobAddResp(
data=JobData(jobId="", status=6),
code=RespCode.ErrorInvalidReq,
message="action is required",
)
req.device_id = device_id
data = job_add(req)
return JobAddResp(data=data)

View File

@@ -76,7 +76,8 @@ class HTTPClient:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
if not self.initialized or first_add:
@@ -331,6 +332,67 @@ class HTTPClient:
logger.error(f"响应内容: {response.text}")
return None
def workflow_import(
self,
name: str,
workflow_uuid: str,
workflow_name: str,
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
tags: Optional[List[str]] = None,
published: bool = False,
) -> Dict[str, Any]:
"""
导入工作流到服务器
Args:
name: 工作流名称(顶层)
workflow_uuid: 工作流UUID
workflow_name: 工作流名称data内部
nodes: 工作流节点列表
edges: 工作流边列表
tags: 工作流标签列表,默认为空列表
published: 是否发布工作流默认为False
Returns:
Dict: API响应数据包含 code 和 data (uuid, name)
"""
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
payload = {
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
"name": name,
"data": {
"workflow_uuid": workflow_uuid,
"workflow_name": workflow_name,
"nodes": nodes,
"edges": edges,
"tags": tags if tags is not None else [],
"published": published,
},
}
# 保存请求到文件
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = requests.post(
f"{self.remote_addr}/lab/workflow/owner/import",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
# 保存响应到文件
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(f"{response.status_code}" + "\n" + response.text)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"导入工作流失败: {response.text}")
return res
else:
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
# 创建默认客户端实例
http_client = HTTPClient()

View File

@@ -1,45 +0,0 @@
import json
import traceback
import uuid
from unilabos.app.model import JobAddReq, JobData
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils.type_check import serialize_result_info
def get_resources() -> tuple:
if HostNode.get_instance() is None:
return False, "Host node not initialized"
return True, HostNode.get_instance().resources_config
def devices() -> tuple:
if HostNode.get_instance() is None:
return False, "Host node not initialized"
return True, HostNode.get_instance().devices_config
def job_info(id: str):
get_goal_status = HostNode.get_instance().get_goal_status(id)
return JobData(jobId=id, status=get_goal_status)
def job_add(req: JobAddReq) -> JobData:
if req.job_id is None:
req.job_id = str(uuid.uuid4())
action_name = req.data["action"]
action_type = req.data.get("action_type", "LocalUnknown")
action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除
if action_args is None:
action_args = req.data.get("action_args")
else:
if "command" in action_args:
action_args = action_args["command"]
# print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
try:
HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info)
except Exception as e:
for bridge in HostNode.get_instance().bridges:
traceback.print_exc()
if hasattr(bridge, "publish_job_status"):
bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {}))
return JobData(jobId=req.job_id)

View File

@@ -0,0 +1,587 @@
"""
Web API Controller
提供Web API的控制器函数处理设备、任务和动作相关的业务逻辑
"""
import threading
import time
import traceback
import uuid
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, Tuple
from unilabos.app.model import JobAddReq, JobData
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils import logger
@dataclass
class JobResult:
"""任务结果数据"""
job_id: str
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
result: Dict[str, Any] = field(default_factory=dict)
feedback: Dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.time)
class JobResultStore:
"""任务结果存储(单例)"""
_instance: Optional["JobResultStore"] = None
_lock = threading.Lock()
def __init__(self):
if not hasattr(self, "_initialized"):
self._results: Dict[str, JobResult] = {}
self._results_lock = threading.RLock()
self._initialized = True
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def store_result(
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
):
"""存储任务结果"""
with self._results_lock:
self._results[job_id] = JobResult(
job_id=job_id,
status=status,
result=result or {},
feedback=feedback or {},
timestamp=time.time(),
)
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
"""获取并删除任务结果"""
with self._results_lock:
result = self._results.pop(job_id, None)
if result:
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
return result
def get_result(self, job_id: str) -> Optional[JobResult]:
"""仅获取任务结果(不删除)"""
with self._results_lock:
return self._results.get(job_id)
def cleanup_old_results(self, max_age_seconds: float = 3600):
"""清理过期的结果"""
current_time = time.time()
with self._results_lock:
expired_jobs = [
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
]
for job_id in expired_jobs:
del self._results[job_id]
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
# 全局结果存储实例
job_result_store = JobResultStore()
def store_job_result(
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
):
"""存储任务结果(供外部调用)
Args:
job_id: 任务ID
status: 状态字符串 ("success", "failed", "cancelled")
result: 结果数据
feedback: 反馈数据
"""
# 转换状态字符串为整数
status_map = {
"success": 4, # SUCCEEDED
"failed": 6, # ABORTED
"cancelled": 5, # CANCELED
"running": 2, # EXECUTING
}
status_int = status_map.get(status, 0)
# 只存储最终状态
if status_int in (4, 5, 6):
job_result_store.store_result(job_id, status_int, result, feedback)
def get_resources() -> Tuple[bool, Any]:
"""获取资源配置
Returns:
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, "Host node not initialized"
return True, host_node.resources_config
def devices() -> Tuple[bool, Any]:
"""获取设备配置
Returns:
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, "Host node not initialized"
return True, host_node.devices_config
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
"""获取任务信息
Args:
job_id: 任务ID
remove_after_read: 是否在读取后删除结果默认True
Returns:
JobData: 任务数据
"""
# 首先检查结果存储中是否有已完成的结果
if remove_after_read:
stored_result = job_result_store.get_and_remove(job_id)
else:
stored_result = job_result_store.get_result(job_id)
if stored_result:
# 有存储的结果,直接返回
return JobData(
jobId=job_id,
status=stored_result.status,
result=stored_result.result,
)
# 没有存储的结果,从 HostNode 获取当前状态
host_node = HostNode.get_instance(0)
if host_node is None:
return JobData(jobId=job_id, status=0)
get_goal_status = host_node.get_goal_status(job_id)
return JobData(jobId=job_id, status=get_goal_status)
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
"""检查设备动作是否正在执行(被占用)
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, None
device_action_key = f"/devices/{device_id}/{action_name}"
# 检查 _device_action_status 中是否有正在执行的任务
if device_action_key in host_node._device_action_status:
status = host_node._device_action_status[device_action_key]
if status.job_ids:
# 返回第一个正在执行的job_id
current_job_id = next(iter(status.job_ids.keys()), None)
return True, current_job_id
return False, None
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
"""从注册表自动获取动作类型
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
动作类型字符串未找到返回None
"""
try:
from unilabos.ros.nodes.base_device_node import registered_devices
# 方法1: 从运行时注册设备获取
if device_id in registered_devices:
device_info = registered_devices[device_id]
base_node = device_info.get("base_node_instance")
if base_node and hasattr(base_node, "_action_value_mappings"):
action_mappings = base_node._action_value_mappings
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
action_type = action_mappings[key].get("type")
if action_type:
# 转换为字符串格式
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
return f"{action_type.__module__}.{action_type.__name__}"
return str(action_type)
# 方法2: 从lab_registry获取
from unilabos.registry.registry import lab_registry
host_node = HostNode.get_instance(0)
if host_node and lab_registry:
devices_config = host_node.devices_config
device_class = None
for tree in devices_config.trees:
node = tree.root_node
if node.res_content.id == device_id:
device_class = node.res_content.klass
break
if device_class and device_class in lab_registry.device_type_registry:
device_type_info = lab_registry.device_type_registry[device_class]
class_info = device_type_info.get("class", {})
action_mappings = class_info.get("action_value_mappings", {})
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
action_type = action_mappings[key].get("type")
if action_type:
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
return f"{action_type.__module__}.{action_type.__name__}"
return str(action_type)
except Exception as e:
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
return None
def job_add(req: JobAddReq) -> JobData:
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
Args:
req: 任务添加请求
Returns:
JobData: 任务数据(包含状态)
"""
# 服务端自动生成 job_id 和 task_id
job_id = str(uuid.uuid4())
task_id = str(uuid.uuid4())
# 服务端自动生成 server_info
server_info = {"send_timestamp": time.time()}
host_node = HostNode.get_instance(0)
if host_node is None:
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
return JobData(jobId=job_id, status=6) # 6 = ABORTED
# 解析动作信息
action_name = req.data.get("action", req.action) if req.data else req.action
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
if action_args is None:
action_args = req.action_args or {}
elif isinstance(action_args, dict) and "command" in action_args:
action_args = action_args["command"]
# 自动获取 action_type
action_type = _get_action_type(req.device_id, action_name)
if action_type is None:
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
return JobData(jobId=job_id, status=6) # ABORTED
# 检查设备动作是否繁忙
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
if is_busy:
logger.warning(
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
)
# 返回失败状态status=6 表示 ABORTED
return JobData(jobId=job_id, status=6)
# 设备空闲,提交任务执行
try:
from unilabos.app.ws_client import QueueItem
device_action_key = f"/devices/{req.device_id}/{action_name}"
queue_item = QueueItem(
task_type="job_call_back_status",
device_id=req.device_id,
action_name=action_name,
task_id=task_id,
job_id=job_id,
device_action_key=device_action_key,
)
host_node.send_goal(
queue_item,
action_type=action_type,
action_kwargs=action_args,
server_info=server_info,
)
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
# 返回已接受状态status=1 表示 ACCEPTED
return JobData(jobId=job_id, status=1)
except ValueError as e:
# ActionClient not found 等错误
logger.error(f"[Controller] Action not available: {str(e)}")
return JobData(jobId=job_id, status=6) # ABORTED
except Exception as e:
logger.error(f"[Controller] Error submitting job: {str(e)}")
traceback.print_exc()
return JobData(jobId=job_id, status=6) # ABORTED
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
"""获取在线设备列表
Returns:
Tuple[bool, Dict]: (是否成功, 在线设备信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
online_devices = {}
for device_key in host_node._online_devices:
# device_key 格式: "namespace/device_id"
parts = device_key.split("/")
if len(parts) >= 2:
device_id = parts[-1]
else:
device_id = device_key
# 获取设备详细信息
device_info = registered_devices.get(device_id, {})
machine_name = host_node.device_machine_names.get(device_id, "未知")
online_devices[device_id] = {
"device_key": device_key,
"namespace": host_node.devices_names.get(device_id, ""),
"machine_name": machine_name,
"uuid": device_info.get("uuid", "") if device_info else "",
"node_name": device_info.get("node_name", "") if device_info else "",
}
return True, {
"online_devices": online_devices,
"total_count": len(online_devices),
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"[Controller] Error getting online devices: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
"""获取设备可用的动作列表
Args:
device_id: 设备ID
Returns:
Tuple[bool, Dict]: (是否成功, 动作列表信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
from unilabos.app.web.utils.action_utils import get_action_info
# 检查设备是否已注册
if device_id not in registered_devices:
return False, {"error": f"Device not found: {device_id}"}
device_info = registered_devices[device_id]
actions = device_info.get("actions", {})
actions_list = {}
for action_name, action_server in actions.items():
try:
action_info = get_action_info(action_server, action_name)
# 检查动作是否繁忙
is_busy, current_job = check_device_action_busy(device_id, action_name)
actions_list[action_name] = {
**action_info,
"is_busy": is_busy,
"current_job_id": current_job[:8] if current_job else None,
}
except Exception as e:
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
actions_list[action_name] = {
"type_name": "unknown",
"action_path": f"/devices/{device_id}/{action_name}",
"is_busy": False,
"error": str(e),
}
return True, {
"device_id": device_id,
"actions": actions_list,
"action_count": len(actions_list),
}
except Exception as e:
logger.error(f"[Controller] Error getting device actions: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
"""获取动作的Schema详情
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
Tuple[bool, Dict]: (是否成功, Schema信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.registry.registry import lab_registry
from unilabos.ros.nodes.base_device_node import registered_devices
result = {
"device_id": device_id,
"action_name": action_name,
"schema": None,
"goal_default": None,
"action_type": None,
"is_busy": False,
}
# 检查动作是否繁忙
is_busy, current_job = check_device_action_busy(device_id, action_name)
result["is_busy"] = is_busy
result["current_job_id"] = current_job[:8] if current_job else None
# 方法1: 从 registered_devices 获取运行时信息
if device_id in registered_devices:
device_info = registered_devices[device_id]
base_node = device_info.get("base_node_instance")
if base_node and hasattr(base_node, "_action_value_mappings"):
action_mappings = base_node._action_value_mappings
if action_name in action_mappings:
mapping = action_mappings[action_name]
result["schema"] = mapping.get("schema")
result["goal_default"] = mapping.get("goal_default")
result["action_type"] = str(mapping.get("type", ""))
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
if result["schema"] is None and lab_registry:
# 尝试查找设备类型
devices_config = host_node.devices_config
device_class = None
# 从配置中获取设备类型
for tree in devices_config.trees:
node = tree.root_node
if node.res_content.id == device_id:
device_class = node.res_content.klass
break
if device_class and device_class in lab_registry.device_type_registry:
device_type_info = lab_registry.device_type_registry[device_class]
class_info = device_type_info.get("class", {})
action_mappings = class_info.get("action_value_mappings", {})
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
mapping = action_mappings[key]
result["schema"] = mapping.get("schema")
result["goal_default"] = mapping.get("goal_default")
result["action_type"] = str(mapping.get("type", ""))
result["handles"] = mapping.get("handles", {})
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
break
if result["schema"] is None:
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
return True, result
except Exception as e:
logger.error(f"[Controller] Error getting action schema: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
"""获取所有设备的可用动作
Returns:
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
from unilabos.app.web.utils.action_utils import get_action_info
all_actions = {}
total_action_count = 0
for device_id, device_info in registered_devices.items():
actions = device_info.get("actions", {})
device_actions = {}
for action_name, action_server in actions.items():
try:
action_info = get_action_info(action_server, action_name)
is_busy, current_job = check_device_action_busy(device_id, action_name)
device_actions[action_name] = {
"type_name": action_info.get("type_name", ""),
"action_path": action_info.get("action_path", ""),
"is_busy": is_busy,
"current_job_id": current_job[:8] if current_job else None,
}
total_action_count += 1
except Exception as e:
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
if device_actions:
all_actions[device_id] = {
"actions": device_actions,
"action_count": len(device_actions),
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
}
return True, {
"devices": all_actions,
"device_count": len(all_actions),
"total_action_count": total_action_count,
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}

View File

@@ -359,6 +359,7 @@ class MessageProcessor:
self.device_manager = device_manager
self.queue_processor = None # 延迟设置
self.websocket_client = None # 延迟设置
self.session_id = ""
# WebSocket连接
self.websocket = None
@@ -388,7 +389,7 @@ class MessageProcessor:
self.is_running = True
self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor")
self.thread.start()
logger.info("[MessageProcessor] Started")
logger.trace("[MessageProcessor] Started")
def stop(self) -> None:
"""停止消息处理线程"""
@@ -420,21 +421,24 @@ class MessageProcessor:
ssl_context = ssl_module.create_default_context()
ws_logger = logging.getLogger("websockets.client")
ws_logger.setLevel(logging.INFO)
# 日志级别已在 unilabos.utils.log 中统一配置为 WARNING
async with websockets.connect(
self.websocket_url,
ssl=ssl_context,
ping_interval=WSConfig.ping_interval,
ping_timeout=10,
additional_headers={"Authorization": f"Lab {BasicConfig.auth_secret()}"},
additional_headers={
"Authorization": f"Lab {BasicConfig.auth_secret()}",
"EdgeSession": f"{self.session_id}",
},
logger=ws_logger,
) as websocket:
self.websocket = websocket
self.connected = True
self.reconnect_count = 0
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
# 启动发送协程
send_task = asyncio.create_task(self._send_handler())
@@ -499,7 +503,7 @@ class MessageProcessor:
async def _send_handler(self):
"""处理发送队列中的消息"""
logger.debug("[MessageProcessor] Send handler started")
logger.trace("[MessageProcessor] Send handler started")
try:
while self.connected and self.websocket:
@@ -572,6 +576,9 @@ class MessageProcessor:
await self._handle_resource_tree_update(message_data, "update")
elif message_type == "remove_material":
await self._handle_resource_tree_update(message_data, "remove")
elif message_type == "session_id":
self.session_id = message_data.get("session_id")
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -932,7 +939,7 @@ class QueueProcessor:
# 事件通知机制
self.queue_update_event = threading.Event()
logger.info("[QueueProcessor] Initialized")
logger.trace("[QueueProcessor] Initialized")
def set_websocket_client(self, websocket_client: "WebSocketClient"):
"""设置WebSocket客户端引用"""
@@ -947,7 +954,7 @@ class QueueProcessor:
self.is_running = True
self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor")
self.thread.start()
logger.info("[QueueProcessor] Started")
logger.trace("[QueueProcessor] Started")
def stop(self) -> None:
"""停止队列处理线程"""
@@ -958,7 +965,7 @@ class QueueProcessor:
def _run(self):
"""运行队列处理主循环"""
logger.debug("[QueueProcessor] Queue processor started")
logger.trace("[QueueProcessor] Queue processor started")
while self.is_running:
try:
@@ -1168,7 +1175,6 @@ class WebSocketClient(BaseCommunicationClient):
else:
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
logger.debug(f"[WebSocketClient] URL: {url}")
return url
def start(self) -> None:
@@ -1181,13 +1187,11 @@ class WebSocketClient(BaseCommunicationClient):
logger.error("[WebSocketClient] WebSocket URL not configured")
return
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
# 启动两个核心线程
self.message_processor.start()
self.queue_processor.start()
logger.info("[WebSocketClient] All threads started")
logger.trace("[WebSocketClient] All threads started")
def stop(self) -> None:
"""停止WebSocket客户端"""
@@ -1196,6 +1200,18 @@ class WebSocketClient(BaseCommunicationClient):
logger.info("[WebSocketClient] Stopping connection")
# 发送 normal_exit 消息
if self.is_connected():
try:
session_id = self.message_processor.session_id
message = {"action": "normal_exit", "data": {"session_id": session_id}}
self.message_processor.send_message(message)
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
# 给一点时间让消息发送出去
time.sleep(1)
except Exception as e:
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
# 停止两个核心线程
self.message_processor.stop()
self.queue_processor.stop()
@@ -1224,7 +1240,7 @@ class WebSocketClient(BaseCommunicationClient):
},
}
self.message_processor.send_message(message)
logger.debug(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
@@ -1295,3 +1311,19 @@ class WebSocketClient(BaseCommunicationClient):
logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully")
else:
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
def publish_host_ready(self) -> None:
"""发布host_node ready信号"""
if self.is_disabled or not self.is_connected():
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
return
message = {
"action": "host_node_ready",
"data": {
"status": "ready",
"timestamp": time.time(),
},
}
self.message_processor.send_message(message)
logger.info("[WebSocketClient] Host node ready signal published")

View File

@@ -18,7 +18,11 @@ class BasicConfig:
vis_2d_enable = False
enable_resource_load = True
communication_protocol = "websocket"
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
startup_json_path = None # 填写绝对路径
disable_browser = False # 禁止浏览器自动打开
port = 8002 # 本地HTTP服务
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
@classmethod
def auth_secret(cls):
@@ -36,18 +40,9 @@ class WSConfig:
ping_interval = 30 # ping间隔
# OSS上传配置
class OSSUploadConfig:
api_host = ""
authorization = ""
init_endpoint = ""
complete_endpoint = ""
max_retries = 3
# HTTP配置
class HTTPConfig:
remote_addr = "http://127.0.0.1:48197/api/v1"
remote_addr = "https://uni-lab.bohrium.com/api/v1"
# ROS配置
@@ -71,13 +66,14 @@ def _update_config_from_module(module):
if not attr.startswith("_"):
setattr(obj, attr, getattr(getattr(module, name), attr))
def _update_config_from_env():
prefix = "UNILABOS_"
for env_key, env_value in os.environ.items():
if not env_key.startswith(prefix):
continue
try:
key_path = env_key[len(prefix):] # Remove UNILAB_ prefix
key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix
class_field = key_path.upper().split("_", 1)
if len(class_field) != 2:
logger.warning(f"[ENV] 环境变量格式不正确:{env_key}")

View File

@@ -4,8 +4,7 @@ import traceback
from typing import Any, Union, List, Dict, Callable, Optional, Tuple
from pydantic import BaseModel
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
from pymodbus.framer import FramerType
from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient
from typing import TypedDict
from unilabos.device_comms.modbus_plc.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder
@@ -403,7 +402,7 @@ class TCPClient(BaseClient):
class RTUClient(BaseClient):
def __init__(self, port: str, baudrate: int, timeout: int):
super().__init__()
self._set_client(ModbusSerialClient(framer=FramerType.RTU, port=port, baudrate=baudrate, timeout=timeout))
self._set_client(ModbusSerialClient(method='rtu', port=port, baudrate=baudrate, timeout=timeout))
self._connect()
if __name__ == '__main__':

View File

@@ -1,12 +1,26 @@
# coding=utf-8
from enum import Enum
from abc import ABC, abstractmethod
from typing import Tuple, Union, Optional, TYPE_CHECKING
from pymodbus.payload import BinaryPayloadDecoder, BinaryPayloadBuilder
from pymodbus.constants import Endian
from pymodbus.client import ModbusBaseSyncClient
from pymodbus.client.mixin import ModbusClientMixin
from typing import Tuple, Union, Optional
if TYPE_CHECKING:
from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient
# Define DataType enum for pymodbus 2.5.3 compatibility
class DataType(Enum):
INT16 = "int16"
UINT16 = "uint16"
INT32 = "int32"
UINT32 = "uint32"
INT64 = "int64"
UINT64 = "uint64"
FLOAT32 = "float32"
FLOAT64 = "float64"
STRING = "string"
BOOL = "bool"
DataType = ModbusClientMixin.DATATYPE
class WorderOrder(Enum):
BIG = "big"
@@ -19,8 +33,96 @@ class DeviceType(Enum):
INPUT_REGISTER = 'input_register'
def _convert_from_registers(registers, data_type: DataType, word_order: str = 'big'):
"""Convert registers to a value using BinaryPayloadDecoder.
Args:
registers: List of register values
data_type: DataType enum specifying the target data type
word_order: 'big' or 'little' endian
Returns:
Converted value
"""
# Determine byte and word order based on word_order parameter
if word_order == 'little':
byte_order = Endian.Little
word_order_enum = Endian.Little
else:
byte_order = Endian.Big
word_order_enum = Endian.Big
decoder = BinaryPayloadDecoder.fromRegisters(registers, byteorder=byte_order, wordorder=word_order_enum)
if data_type == DataType.INT16:
return decoder.decode_16bit_int()
elif data_type == DataType.UINT16:
return decoder.decode_16bit_uint()
elif data_type == DataType.INT32:
return decoder.decode_32bit_int()
elif data_type == DataType.UINT32:
return decoder.decode_32bit_uint()
elif data_type == DataType.INT64:
return decoder.decode_64bit_int()
elif data_type == DataType.UINT64:
return decoder.decode_64bit_uint()
elif data_type == DataType.FLOAT32:
return decoder.decode_32bit_float()
elif data_type == DataType.FLOAT64:
return decoder.decode_64bit_float()
elif data_type == DataType.STRING:
return decoder.decode_string(len(registers) * 2)
else:
raise ValueError(f"Unsupported data type: {data_type}")
def _convert_to_registers(value, data_type: DataType, word_order: str = 'little'):
"""Convert a value to registers using BinaryPayloadBuilder.
Args:
value: Value to convert
data_type: DataType enum specifying the source data type
word_order: 'big' or 'little' endian
Returns:
List of register values
"""
# Determine byte and word order based on word_order parameter
if word_order == 'little':
byte_order = Endian.Little
word_order_enum = Endian.Little
else:
byte_order = Endian.Big
word_order_enum = Endian.Big
builder = BinaryPayloadBuilder(byteorder=byte_order, wordorder=word_order_enum)
if data_type == DataType.INT16:
builder.add_16bit_int(value)
elif data_type == DataType.UINT16:
builder.add_16bit_uint(value)
elif data_type == DataType.INT32:
builder.add_32bit_int(value)
elif data_type == DataType.UINT32:
builder.add_32bit_uint(value)
elif data_type == DataType.INT64:
builder.add_64bit_int(value)
elif data_type == DataType.UINT64:
builder.add_64bit_uint(value)
elif data_type == DataType.FLOAT32:
builder.add_32bit_float(value)
elif data_type == DataType.FLOAT64:
builder.add_64bit_float(value)
elif data_type == DataType.STRING:
builder.add_string(value)
else:
raise ValueError(f"Unsupported data type: {data_type}")
return builder.to_registers()
class Base(ABC):
def __init__(self, client: ModbusBaseSyncClient, name: str, address: int, typ: DeviceType, data_type: DataType):
def __init__(self, client, name: str, address: int, typ: DeviceType, data_type):
self._address: int = address
self._client = client
self._name = name
@@ -58,7 +160,11 @@ class Coil(Base):
count = value,
slave = slave)
return resp.bits, resp.isError()
# 检查是否读取出错
if resp.isError():
return [], True
return resp.bits, False
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
if isinstance(value, list):
@@ -91,8 +197,18 @@ class DiscreteInputs(Base):
count = value,
slave = slave)
# 检查是否读取出错
if resp.isError():
# 根据数据类型返回默认值
if data_type in [DataType.FLOAT32, DataType.FLOAT64]:
return 0.0, True
elif data_type == DataType.STRING:
return "", True
else:
return 0, True
# noinspection PyTypeChecker
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
return _convert_from_registers(resp.registers, data_type, word_order=word_order.value), False
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
raise ValueError('discrete inputs only support read')
@@ -112,8 +228,19 @@ class HoldRegister(Base):
address = self.address,
count = value,
slave = slave)
# 检查是否读取出错
if resp.isError():
# 根据数据类型返回默认值
if data_type in [DataType.FLOAT32, DataType.FLOAT64]:
return 0.0, True
elif data_type == DataType.STRING:
return "", True
else:
return 0, True
# noinspection PyTypeChecker
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
return _convert_from_registers(resp.registers, data_type, word_order=word_order.value), False
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
@@ -132,7 +259,7 @@ class HoldRegister(Base):
return self._client.write_register(self.address, value, slave= slave).isError()
else:
# noinspection PyTypeChecker
encoder_resp = self._client.convert_to_registers(value, data_type=data_type, word_order=word_order.value)
encoder_resp = _convert_to_registers(value, data_type=data_type, word_order=word_order.value)
return self._client.write_registers(self.address, encoder_resp, slave=slave).isError()
@@ -153,8 +280,19 @@ class InputRegister(Base):
address = self.address,
count = value,
slave = slave)
# 检查是否读取出错
if resp.isError():
# 根据数据类型返回默认值
if data_type in [DataType.FLOAT32, DataType.FLOAT64]:
return 0.0, True
elif data_type == DataType.STRING:
return "", True
else:
return 0, True
# noinspection PyTypeChecker
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
return _convert_from_registers(resp.registers, data_type, word_order=word_order.value), False
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
raise ValueError('input register only support read')

View File

@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
import serial
import time
import csv
import threading
import os
from collections import deque
from typing import Dict, Any, Optional
from pylabrobot.resources import Deck
from unilabos.devices.workstation.workstation_base import WorkstationBase
class ElectrolysisWaterPlatform(WorkstationBase):
"""
电解水平台工作站
基于 WorkstationBase 的电解水实验平台,支持串口通信和数据采集
"""
def __init__(
self,
deck: Deck,
port: str = "COM10",
baudrate: int = 115200,
csv_path: Optional[str] = None,
timeout: float = 0.2,
**kwargs
):
super().__init__(deck, **kwargs)
# ========== 配置 ==========
self.port = port
self.baudrate = baudrate
# 如果没有指定路径,默认保存在代码文件所在目录
if csv_path is None:
current_dir = os.path.dirname(os.path.abspath(__file__))
self.csv_path = os.path.join(current_dir, "stm32_data.csv")
else:
self.csv_path = csv_path
self.ser_timeout = timeout
self.chunk_read = 128
# 串口对象
self.ser: Optional[serial.Serial] = None
self.stop_flag = False
# 线程对象
self.rx_thread: Optional[threading.Thread] = None
self.tx_thread: Optional[threading.Thread] = None
# ==== 接收(下位机->上位机):固定 1+13+1 = 15 字节 ====
self.RX_HEAD = 0x3E
self.RX_TAIL = 0x3E
self.RX_FRAME_LEN = 1 + 13 + 1 # 15
# ==== 发送(上位机->下位机):固定 1+9+1 = 11 字节 ====
self.TX_HEAD = 0x3E
self.TX_TAIL = 0xE3 # 协议图中标注 E3 作为帧尾
self.TX_FRAME_LEN = 1 + 9 + 1 # 11
def open_serial(self, port: Optional[str] = None, baudrate: Optional[int] = None, timeout: Optional[float] = None) -> Optional[serial.Serial]:
"""打开串口"""
port = port or self.port
baudrate = baudrate or self.baudrate
timeout = timeout or self.ser_timeout
try:
ser = serial.Serial(port, baudrate, timeout=timeout)
print(f"[OK] 串口 {port} 已打开,波特率 {baudrate}")
ser.reset_input_buffer()
ser.reset_output_buffer()
self.ser = ser
return ser
except serial.SerialException as e:
print(f"[ERR] 无法打开串口 {port}: {e}")
return None
def close_serial(self):
"""关闭串口"""
if self.ser and self.ser.is_open:
self.ser.close()
print("[INFO] 串口已关闭")
@staticmethod
def u16_be(h: int, l: int) -> int:
"""将两个字节组合成16位无符号整数大端序"""
return ((h & 0xFF) << 8) | (l & 0xFF)
@staticmethod
def split_u16_be(val: int) -> tuple:
"""返回 (高字节, 低字节),输入会夹到 0..65535"""
v = int(max(0, min(65535, int(val))))
return (v >> 8) & 0xFF, v & 0xFF
# ================== 接收固定15字节 ==================
def parse_rx_payload(self, dat13: bytes) -> Optional[Dict[str, Any]]:
"""解析 13 字节数据区(下位机发送到上位机)"""
if len(dat13) != 13:
return None
current_mA = self.u16_be(dat13[0], dat13[1])
voltage_mV = self.u16_be(dat13[2], dat13[3])
temperature_raw = self.u16_be(dat13[4], dat13[5])
tds_ppm = self.u16_be(dat13[6], dat13[7])
gas_sccm = self.u16_be(dat13[8], dat13[9])
liquid_mL = self.u16_be(dat13[10], dat13[11])
ph_raw = dat13[12] & 0xFF
return {
"Current_mA": current_mA,
"Voltage_mV": voltage_mV,
"Temperature_C": round(temperature_raw / 100.0, 2),
"TDS_ppm": tds_ppm,
"GasFlow_sccm": gas_sccm,
"LiquidFlow_mL": liquid_mL,
"pH": round(ph_raw / 10.0, 2)
}
def try_parse_rx_frame(self, frame15: bytes) -> Optional[Dict[str, Any]]:
"""尝试解析接收帧"""
if len(frame15) != self.RX_FRAME_LEN:
return None
if frame15[0] != self.RX_HEAD or frame15[-1] != self.RX_TAIL:
return None
return self.parse_rx_payload(frame15[1:-1])
def rx_thread_fn(self):
"""接收线程函数"""
headers = ["Timestamp", "Current_mA", "Voltage_mV",
"Temperature_C", "TDS_ppm", "GasFlow_sccm", "LiquidFlow_mL", "pH"]
new_file = not os.path.exists(self.csv_path)
f = open(self.csv_path, mode='a', newline='', encoding='utf-8')
writer = csv.writer(f)
if new_file:
writer.writerow(headers)
f.flush()
buf = deque(maxlen=8192)
print(f"[RX] 开始接收(帧长 {self.RX_FRAME_LEN} 字节);写入:{self.csv_path}")
try:
while not self.stop_flag and self.ser and self.ser.is_open:
chunk = self.ser.read(self.chunk_read)
if chunk:
buf.extend(chunk)
while True:
# 找帧头
try:
start = next(i for i, b in enumerate(buf) if b == self.RX_HEAD)
except StopIteration:
buf.clear()
break
if start > 0:
for _ in range(start):
buf.popleft()
if len(buf) < self.RX_FRAME_LEN:
break
candidate = bytes([buf[i] for i in range(self.RX_FRAME_LEN)])
if candidate[-1] == self.RX_TAIL:
parsed = self.try_parse_rx_frame(candidate)
for _ in range(self.RX_FRAME_LEN):
buf.popleft()
if parsed:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
row = [ts,
parsed["Current_mA"], parsed["Voltage_mV"],
parsed["Temperature_C"], parsed["TDS_ppm"],
parsed["GasFlow_sccm"], parsed["LiquidFlow_mL"],
parsed["pH"]]
writer.writerow(row)
f.flush()
# 若不想打印可注释下一行
# print(f"[{ts}] I={parsed['Current_mA']} mA, V={parsed['Voltage_mV']} mV, "
# f"T={parsed['Temperature_C']} °C, TDS={parsed['TDS_ppm']}, "
# f"Gas={parsed['GasFlow_sccm']} sccm, Liq={parsed['LiquidFlow_mL']} mL, pH={parsed['pH']}")
else:
# 头不变尾不对丢1字节继续对齐
buf.popleft()
else:
time.sleep(0.01)
finally:
f.close()
print("[RX] 接收线程退出CSV 已关闭")
# ================== 发送固定11字节 ==================
def build_tx_frame(self, mode: int, current_ma: int, voltage_mv: int, temp_c: float, ki: float, pump_percent: float) -> bytes:
"""
发送帧HEAD + [mode, I_hi, I_lo, V_hi, V_lo, T_hi, T_lo, Ki_byte, Pump_byte] + TAIL
- mode: 0=恒压, 1=恒流
- current_ma: mA (0..65535)
- voltage_mv: mV (0..65535)
- temp_c: ℃,将 *100 后拆分为高/低字节
- ki: 0.0..20.0 -> byte = round(ki * 10) 夹到 0..200
- pump_percent: 0..100 -> byte = round(pump * 2) 夹到 0..200
"""
mode_b = 1 if int(mode) == 1 else 0
i_hi, i_lo = self.split_u16_be(current_ma)
v_hi, v_lo = self.split_u16_be(voltage_mv)
t100 = int(round(float(temp_c) * 100.0))
t_hi, t_lo = self.split_u16_be(t100)
ki_b = int(max(0, min(200, round(float(ki) * 10))))
pump_b = int(max(0, min(200, round(float(pump_percent) * 2))))
return bytes((
self.TX_HEAD,
mode_b,
i_hi, i_lo,
v_hi, v_lo,
t_hi, t_lo,
ki_b,
pump_b,
self.TX_TAIL
))
def tx_thread_fn(self):
"""
发送线程函数
用户输入 6 个用逗号分隔的数值:
mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent
例如: 0,1000,500,0,0,50
"""
print("\n输入 6 个值(用英文逗号分隔),顺序为:")
print("mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent")
print("示例恒压0,500,1000,25,0,100 stop 结束)\n")
print("示例恒流1,1000,500,25,0,100 stop 结束)\n")
print("示例恒流1,2000,500,25,0,100 stop 结束)\n")
# 1,2000,500,25,0,100
while not self.stop_flag and self.ser and self.ser.is_open:
try:
line = input(">>> ").strip()
except EOFError:
self.stop_flag = True
break
if not line:
continue
if line.lower() == "stop":
self.stop_flag = True
print("[SYS] 停止程序")
break
try:
parts = [p.strip() for p in line.split(",")]
if len(parts) != 6:
raise ValueError("需要 6 个逗号分隔的数值")
mode = int(parts[0])
i_ma = int(float(parts[1]))
v_mv = int(float(parts[2]))
t_c = float(parts[3])
ki = float(parts[4])
pump = float(parts[5])
frame = self.build_tx_frame(mode, i_ma, v_mv, t_c, ki, pump)
self.ser.write(frame)
print("[TX]", " ".join(f"{b:02X}" for b in frame))
except Exception as e:
print("[TX] 输入/打包失败:", e)
print("格式mode,current_mA,voltage_mV,set_temp_C,Ki,pump_percent")
continue
def start(self):
"""启动电解水平台"""
self.ser = self.open_serial()
if self.ser:
try:
self.rx_thread = threading.Thread(target=self.rx_thread_fn, daemon=True)
self.tx_thread = threading.Thread(target=self.tx_thread_fn, daemon=True)
self.rx_thread.start()
self.tx_thread.start()
print("[INFO] 电解水平台已启动")
self.tx_thread.join() # 等待用户输入线程结束(输入 stop
finally:
self.close_serial()
def stop(self):
"""停止电解水平台"""
self.stop_flag = True
if self.rx_thread and self.rx_thread.is_alive():
self.rx_thread.join(timeout=2.0)
if self.tx_thread and self.tx_thread.is_alive():
self.tx_thread.join(timeout=2.0)
self.close_serial()
print("[INFO] 电解水平台已停止")
# ================== 主入口 ==================
if __name__ == "__main__":
# 创建一个简单的 Deck 用于测试
from pylabrobot.resources import Deck
deck = Deck()
platform = ElectrolysisWaterPlatform(deck)
platform.start()

View File

@@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker):
for i in range(self.driver._finished, temp):
sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数
pdf, txt = self.driver.get_data_file(i + 1)
device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default"
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id)
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id)
# 使用新的OSS上传接口传入driver_name和exp_type
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
if pdf_result["success"]:
print(f"PDF上传成功: {pdf_result['oss_path']}")
else:
print(f"PDF上传失败: {pdf_result['original_path']}")
if txt_result["success"]:
print(f"TXT上传成功: {txt_result['oss_path']}")
else:
print(f"TXT上传失败: {txt_result['original_path']}")
# self.driver.extract_data_from_txt()
except Exception as ex:
self.driver._finished = 0
@@ -456,8 +466,12 @@ if __name__ == "__main__":
}
sample_id = obj._get_resource_sample_id("test", 0)
pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6))
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example")
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result")
# 使用新的OSS上传接口传入driver_name和exp_type
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
print(f"PDF上传结果: {pdf_result}")
print(f"TXT上传结果: {txt_result}")
# driver = HPLCDriver()
# for i in range(10000):
# print({k: v for k, v in driver._device_status.items() if isinstance(v, str)})

View File

@@ -31,15 +31,17 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class LiquidHandlerMiddleware(LiquidHandler):
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, total_height: float = 310, **kwargs):
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs):
self._simulator = simulator
self.channel_num = channel_num
joint_config = kwargs.get("joint_config", None)
if simulator:
self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num,total_height, joint_config=joint_config, lh_device_id = deck.name)
if joint_config:
self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num, kwargs["total_height"],
joint_config=joint_config, lh_device_id=deck.name)
else:
self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num)
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
if hasattr(backend, "total_height"):
backend.total_height = total_height
super().__init__(backend, deck)
async def setup(self, **backend_kwargs):
@@ -145,6 +147,9 @@ class LiquidHandlerMiddleware(LiquidHandler):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
# 如果 use_channels 为 None使用默认值所有通道
if use_channels is None:
use_channels = list(range(self.channel_num))
if not offsets or (isinstance(offsets, list) and len(offsets) != len(use_channels)):
offsets = [Coordinate.zero()] * len(use_channels)
if self._simulator:
@@ -544,51 +549,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
support_touch_tip = True
_ros_node: BaseROS2DeviceNode
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8,total_height: float = 310,**backend_kwargs):
def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool=False, channel_num:int = 8):
"""Initialize a LiquidHandler.
Args:
backend: Backend to use.
deck: Deck to use.
"""
backend_type = None
if isinstance(backend, dict) and "type" in backend:
backend_dict = backend.copy()
type_str = backend_dict.pop("type")
try:
# Try to get class from string using globals (current module), or fallback to pylabrobot or unilabos namespaces
backend_cls = None
if type_str in globals():
backend_cls = globals()[type_str]
else:
# Try resolving dotted notation, e.g. "xxx.yyy.ClassName"
components = type_str.split(".")
mod = None
if len(components) > 1:
module_name = ".".join(components[:-1])
try:
import importlib
mod = importlib.import_module(module_name)
except ImportError:
mod = None
if mod is not None:
backend_cls = getattr(mod, components[-1], None)
if backend_cls is None:
# Try pylabrobot style import (if available)
try:
import pylabrobot
backend_cls = getattr(pylabrobot, type_str, None)
except Exception:
backend_cls = None
if backend_cls is not None and isinstance(backend_cls, type):
backend_type = backend_cls(**backend_dict) # pass the rest of dict as kwargs
except Exception as exc:
raise RuntimeError(f"Failed to convert backend type '{type_str}' to class: {exc}")
else:
backend_type = backend
self._simulator = simulator
self.group_info = dict()
super().__init__(backend_type, deck, simulator, channel_num,total_height,**backend_kwargs)
super().__init__(backend, deck, simulator, channel_num)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
@@ -792,7 +762,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
blow_out_air_volume=current_dis_blow_out_air_volume,
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(current_targets)
await self.discard_tips()
@@ -866,17 +836,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.mix(
targets=[targets[_]],
mix_time=mix_time,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None:
# 只有在 mix_time 有效时才调用 mix
if mix_time is not None and mix_time > 0:
await self.mix(
targets=[targets[_]],
mix_time=mix_time,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_])
await self.discard_tips()
@@ -926,18 +898,20 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
blow_out_air_volume=current_dis_blow_out_air_volume,
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.mix(
targets=current_targets,
mix_time=mix_time,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None:
# 只有在 mix_time 有效时才调用 mix
if mix_time is not None and mix_time > 0:
await self.mix(
targets=current_targets,
mix_time=mix_time,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(current_targets)
await self.discard_tips()
@@ -975,60 +949,158 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
):
"""Transfer liquid from each *source* well/plate to the corresponding *target*.
"""Transfer liquid with automatic mode detection.
Supports three transfer modes:
1. One-to-many (1 source -> N targets): Distribute from one source to multiple targets
2. One-to-one (N sources -> N targets): Standard transfer, each source to corresponding target
3. Many-to-one (N sources -> 1 target): Combine multiple sources into one target
Parameters
----------
asp_vols, dis_vols
Single volume (µL) or list matching the number of transfers.
Single volume (µL) or list. Automatically expanded based on transfer mode.
sources, targets
Samelength sequences of containers (wells or plates). In 96well mode
each must contain exactly one plate.
Containers (wells or plates). Length determines transfer mode:
- len(sources) == 1, len(targets) > 1: One-to-many mode
- len(sources) == len(targets): One-to-one mode
- len(sources) > 1, len(targets) == 1: Many-to-one mode
tip_racks
One or more TipRacks providing fresh tips.
is_96_well
Set *True* to use the 96channel head.
"""
# 确保 use_channels 有默认值
if use_channels is None:
use_channels = [0] if self.channel_num >= 1 else list(range(self.channel_num))
if is_96_well:
pass # This mode is not verified.
else:
if len(asp_vols) != len(targets):
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
# 转换体积参数为列表
if isinstance(asp_vols, (int, float)):
asp_vols = [float(asp_vols)]
else:
asp_vols = [float(v) for v in asp_vols]
if isinstance(dis_vols, (int, float)):
dis_vols = [float(dis_vols)]
else:
dis_vols = [float(v) for v in dis_vols]
# 首先应该对任务分组然后每次1个/8个进行操作处理
if len(use_channels) == 1:
for _ in range(len(targets)):
tip = []
for ___ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
# 统一混合次数为标量,防止数组/列表与 int 比较时报错
if mix_times is not None and not isinstance(mix_times, (int, float)):
try:
mix_times = mix_times[0] if len(mix_times) > 0 else None
except Exception:
try:
mix_times = next(iter(mix_times))
except Exception:
pass
if mix_times is not None:
mix_times = int(mix_times)
# 识别传输模式
num_sources = len(sources)
num_targets = len(targets)
if num_sources == 1 and num_targets > 1:
# 模式1: 一对多 (1 source -> N targets)
await self._transfer_one_to_many(
sources[0], targets, tip_racks, use_channels,
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
offsets, touch_tip, liquid_height, blow_out_air_volume,
spread, mix_stage, mix_times, mix_vol, mix_rate,
mix_liquid_height, delays
)
elif num_sources > 1 and num_targets == 1:
# 模式2: 多对一 (N sources -> 1 target)
await self._transfer_many_to_one(
sources, targets[0], tip_racks, use_channels,
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
offsets, touch_tip, liquid_height, blow_out_air_volume,
spread, mix_stage, mix_times, mix_vol, mix_rate,
mix_liquid_height, delays
)
elif num_sources == num_targets:
# 模式3: 一对一 (N sources -> N targets) - 原有逻辑
await self._transfer_one_to_one(
sources, targets, tip_racks, use_channels,
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
offsets, touch_tip, liquid_height, blow_out_air_volume,
spread, mix_stage, mix_times, mix_vol, mix_rate,
mix_liquid_height, delays
)
else:
raise ValueError(
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
"Supported modes: 1->N, N->1, or N->N."
)
await self.aspirate(
resources=[sources[_]],
vols=[asp_vols[_]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[0]] if asp_flow_rates else None,
offsets=[offsets[0]] if offsets else None,
liquid_height=[liquid_height[0]] if liquid_height else None,
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=[targets[_]],
vols=[dis_vols[_]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[1]] if dis_flow_rates else None,
offsets=[offsets[1]] if offsets else None,
blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None,
liquid_height=[liquid_height[1]] if liquid_height else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
async def _transfer_one_to_one(
self,
sources: Sequence[Container],
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
asp_flow_rates: Optional[List[Optional[float]]],
dis_flow_rates: Optional[List[Optional[float]]],
offsets: Optional[List[Coordinate]],
touch_tip: bool,
liquid_height: Optional[List[Optional[float]]],
blow_out_air_volume: Optional[List[Optional[float]]],
spread: Literal["wide", "tight", "custom"],
mix_stage: Optional[Literal["none", "before", "after", "both"]],
mix_times: Optional[int],
mix_vol: Optional[int],
mix_rate: Optional[int],
mix_liquid_height: Optional[float],
delays: Optional[List[int]],
):
"""一对一传输模式N sources -> N targets"""
# 验证参数长度
if len(asp_vols) != len(targets):
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
if len(dis_vols) != len(targets):
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
if len(sources) != len(targets):
raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.")
if len(use_channels) == 1:
for _ in range(len(targets)):
tip = []
for ___ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
resources=[sources[_]],
vols=[asp_vols[_]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[_]] if asp_flow_rates and len(asp_flow_rates) > _ else None,
offsets=[offsets[_]] if offsets and len(offsets) > _ else None,
liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None,
blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=[targets[_]],
vols=[dis_vols[_]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[_]] if dis_flow_rates and len(dis_flow_rates) > _ else None,
offsets=[offsets[_]] if offsets and len(offsets) > _ else None,
blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None,
liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[_]],
mix_time=mix_times,
@@ -1037,63 +1109,60 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_])
await self.discard_tips()
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_])
await self.discard_tips(use_channels=use_channels)
elif len(use_channels) == 8:
# 对于8个的情况需要判断此时任务是不是能被8通道移液站来成功处理
if len(targets) % 8 != 0:
raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.")
elif len(use_channels) == 8:
if len(targets) % 8 != 0:
raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.")
# 8个8个来取任务序列
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i:i + 8]
current_reagent_sources = sources[i:i + 8]
current_asp_vols = asp_vols[i:i + 8]
current_dis_vols = dis_vols[i:i + 8]
current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
for i in range(0, len(targets), 8):
# 取出8个任务
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i:i + 8]
current_reagent_sources = sources[i:i + 8]
current_asp_vols = asp_vols[i:i + 8]
current_dis_vols = dis_vols[i:i + 8]
current_asp_flow_rates = asp_flow_rates[i:i + 8]
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else [None] * 8
await self.aspirate(
resources=current_reagent_sources,
vols=current_asp_vols,
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
blow_out_air_volume=current_asp_blow_out_air_volume,
liquid_height=current_asp_liquid_height,
spread=spread,
)
await self.aspirate(
resources=current_reagent_sources,
vols=current_asp_vols,
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
blow_out_air_volume=current_asp_blow_out_air_volume,
liquid_height=current_asp_liquid_height,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=current_targets,
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=current_targets,
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=current_targets,
mix_time=mix_times,
@@ -1102,10 +1171,363 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if delays is not None:
await self.custom_delay(seconds=delays[1])
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.touch_tip(current_targets)
await self.discard_tips([0,1,2,3,4,5,6,7])
async def _transfer_one_to_many(
self,
source: Container,
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
asp_flow_rates: Optional[List[Optional[float]]],
dis_flow_rates: Optional[List[Optional[float]]],
offsets: Optional[List[Coordinate]],
touch_tip: bool,
liquid_height: Optional[List[Optional[float]]],
blow_out_air_volume: Optional[List[Optional[float]]],
spread: Literal["wide", "tight", "custom"],
mix_stage: Optional[Literal["none", "before", "after", "both"]],
mix_times: Optional[int],
mix_vol: Optional[int],
mix_rate: Optional[int],
mix_liquid_height: Optional[float],
delays: Optional[List[int]],
):
"""一对多传输模式1 source -> N targets"""
# 验证和扩展体积参数
if len(asp_vols) == 1:
# 如果只提供一个吸液体积,计算总吸液体积(所有分液体积之和)
total_asp_vol = sum(dis_vols)
asp_vol = asp_vols[0] if asp_vols[0] >= total_asp_vol else total_asp_vol
else:
raise ValueError("For one-to-many mode, `asp_vols` should be a single value or list with one element.")
if len(dis_vols) != len(targets):
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
if len(use_channels) == 1:
# 单通道模式:一次吸液,多次分液
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
# 从源容器吸液(总体积)
await self.aspirate(
resources=[source],
vols=[asp_vol],
use_channels=use_channels,
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分多次分液到不同的目标容器
for idx, target in enumerate(targets):
await self.dispense(
resources=[target],
vols=[dis_vols[idx]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[idx]] if dis_flow_rates and len(dis_flow_rates) > idx else None,
offsets=[offsets[idx]] if offsets and len(offsets) > idx else None,
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[target],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[idx:idx+1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if touch_tip:
await self.touch_tip([target])
await self.discard_tips(use_channels=use_channels)
elif len(use_channels) == 8:
# 8通道模式需要确保目标数量是8的倍数
if len(targets) % 8 != 0:
raise ValueError(f"For 8-channel mode, number of targets {len(targets)} must be a multiple of 8.")
# 每次处理8个目标
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i:i + 8]
current_dis_vols = dis_vols[i:i + 8]
# 8个通道都从同一个源容器吸液每个通道的吸液体积等于对应的分液体积
current_asp_flow_rates = asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None
current_asp_offset = offsets[0:1] * 8 if offsets and len(offsets) > 0 else [None] * 8
current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8
# 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同
await self.aspirate(
resources=[source] * 8, # 8个通道都从同一个源
vols=current_dis_vols, # 每个通道的吸液体积等于对应的分液体积
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
liquid_height=current_asp_liquid_height,
blow_out_air_volume=current_asp_blow_out_air_volume,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分液到8个目标
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
await self.dispense(
resources=current_targets,
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=current_targets,
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if touch_tip:
await self.touch_tip(current_targets)
await self.discard_tips([0,1,2,3,4,5,6,7])
await self.discard_tips([0,1,2,3,4,5,6,7])
async def _transfer_many_to_one(
self,
sources: Sequence[Container],
target: Container,
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
asp_flow_rates: Optional[List[Optional[float]]],
dis_flow_rates: Optional[List[Optional[float]]],
offsets: Optional[List[Coordinate]],
touch_tip: bool,
liquid_height: Optional[List[Optional[float]]],
blow_out_air_volume: Optional[List[Optional[float]]],
spread: Literal["wide", "tight", "custom"],
mix_stage: Optional[Literal["none", "before", "after", "both"]],
mix_times: Optional[int],
mix_vol: Optional[int],
mix_rate: Optional[int],
mix_liquid_height: Optional[float],
delays: Optional[List[int]],
):
"""多对一传输模式N sources -> 1 target汇总/混合)"""
# 验证和扩展体积参数
if len(asp_vols) != len(sources):
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.")
# 支持两种模式:
# 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积
# 2. dis_vols 长度等于 asp_vols每个源按不同比例分液按比例混合
if len(dis_vols) == 1:
# 模式1使用单个分液体积
total_dis_vol = sum(asp_vols)
dis_vol = dis_vols[0] if dis_vols[0] >= total_dis_vol else total_dis_vol
use_proportional_mixing = False
elif len(dis_vols) == len(asp_vols):
# 模式2按不同比例混合
use_proportional_mixing = True
else:
raise ValueError(
f"For many-to-one mode, `dis_vols` should be a single value or list with length {len(asp_vols)} "
f"(matching `asp_vols`). Got length {len(dis_vols)}."
)
if len(use_channels) == 1:
# 单通道模式:多次吸液,一次分液
# 先混合前(如果需要)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
# 注意:在吸液前混合源容器通常不常见,这里跳过
pass
# 从每个源容器吸液并分液到目标容器
for idx, source in enumerate(sources):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
resources=[source],
vols=[asp_vols[idx]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[idx]] if asp_flow_rates and len(asp_flow_rates) > idx else None,
offsets=[offsets[idx]] if offsets and len(offsets) > idx else None,
liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None,
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分液到目标容器
if use_proportional_mixing:
# 按不同比例混合:使用对应的 dis_vols
dis_vol = dis_vols[idx]
dis_flow_rate = dis_flow_rates[idx] if dis_flow_rates and len(dis_flow_rates) > idx else None
dis_offset = offsets[idx] if offsets and len(offsets) > idx else None
dis_liquid_height = liquid_height[idx] if liquid_height and len(liquid_height) > idx else None
dis_blow_out = blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None
else:
# 标准模式:分液体积等于吸液体积
dis_vol = asp_vols[idx]
dis_flow_rate = dis_flow_rates[0] if dis_flow_rates and len(dis_flow_rates) > 0 else None
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None
dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
await self.dispense(
resources=[target],
vols=[dis_vol],
use_channels=use_channels,
flow_rates=[dis_flow_rate] if dis_flow_rate is not None else None,
offsets=[dis_offset] if dis_offset is not None else None,
blow_out_air_volume=[dis_blow_out] if dis_blow_out is not None else None,
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.discard_tips(use_channels=use_channels)
# 最后在目标容器中混合(如果需要)
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[target],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if touch_tip:
await self.touch_tip([target])
elif len(use_channels) == 8:
# 8通道模式需要确保源数量是8的倍数
if len(sources) % 8 != 0:
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
# 每次处理8个源
for i in range(0, len(sources), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_sources = sources[i:i + 8]
current_asp_vols = asp_vols[i:i + 8]
current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None
current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8
current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
# 从8个源容器吸液
await self.aspirate(
resources=current_sources,
vols=current_asp_vols,
use_channels=use_channels,
flow_rates=current_asp_flow_rates,
offsets=current_asp_offset,
blow_out_air_volume=current_asp_blow_out_air_volume,
liquid_height=current_asp_liquid_height,
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
# 分液到目标容器(每个通道分液到同一个目标)
if use_proportional_mixing:
# 按比例混合:使用对应的 dis_vols
current_dis_vols = dis_vols[i:i + 8]
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8
current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
else:
# 标准模式:每个通道分液体积等于其吸液体积
current_dis_vols = current_asp_vols
current_dis_flow_rates = dis_flow_rates[0:1] * 8 if dis_flow_rates else None
current_dis_offset = offsets[0:1] * 8 if offsets else [None] * 8
current_dis_liquid_height = liquid_height[0:1] * 8 if liquid_height else [None] * 8
current_dis_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8
await self.dispense(
resources=[target] * 8, # 8个通道都分到同一个目标
vols=current_dis_vols,
use_channels=use_channels,
flow_rates=current_dis_flow_rates,
offsets=current_dis_offset,
blow_out_air_volume=current_dis_blow_out_air_volume,
liquid_height=current_dis_liquid_height,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
await self.discard_tips([0,1,2,3,4,5,6,7])
# 最后在目标容器中混合(如果需要)
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[target],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
if touch_tip:
await self.touch_tip([target])
# except Exception as e:
# traceback.print_exc()

View File

@@ -5,6 +5,7 @@ import json
import os
import socket
import time
import uuid
from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal
from pylabrobot.liquid_handling import (
@@ -856,7 +857,30 @@ class PRCXI9300Api:
def _raw_request(self, payload: str) -> str:
if self.debug:
return " "
# 调试/仿真模式下直接返回可解析的模拟 JSON避免后续 json.loads 报错
try:
req = json.loads(payload)
method = req.get("MethodName")
except Exception:
method = None
data: Any = True
if method in {"AddSolution"}:
data = str(uuid.uuid4())
elif method in {"AddWorkTabletMatrix", "AddWorkTabletMatrix2"}:
data = {"Success": True, "Message": "debug mock"}
elif method in {"GetErrorCode"}:
data = ""
elif method in {"RemoveErrorCodet", "Reset", "Start", "LoadSolution", "Pause", "Resume", "Stop"}:
data = True
elif method in {"GetStepStateList", "GetStepStatus", "GetStepState"}:
data = []
elif method in {"GetLocation"}:
data = {"X": 0, "Y": 0, "Z": 0}
elif method in {"GetResetStatus"}:
data = False
return json.dumps({"Success": True, "Msg": "debug mock", "Data": data})
with contextlib.closing(socket.socket()) as sock:
sock.settimeout(self.timeout)
sock.connect((self.host, self.port))

View File

@@ -1,282 +1,649 @@
import sys
import threading
import serial
import serial.tools.list_ports
import re
import time
from typing import Optional, List, Dict, Tuple
# -*- coding: utf-8 -*-
"""
Contains drivers for:
1. SyringePump: Runze Fluid SY-03B (ASCII)
2. EmmMotor: Emm V5.0 Closed-loop Stepper (Modbus-RTU variant)
3. XKCSensor: XKC Non-contact Level Sensor (Modbus-RTU)
"""
class ChinweDevice:
import socket
import serial
import time
import threading
import struct
import re
import traceback
import queue
from typing import Optional, Dict, List, Any
try:
from unilabos.device_comms.universal_driver import UniversalDriver
except ImportError:
import logging
class UniversalDriver:
def __init__(self):
self.logger = logging.getLogger(self.__class__.__name__)
def execute_command_from_outer(self, command: str):
pass
# ==============================================================================
# 1. Transport Layer (通信层)
# ==============================================================================
class TransportManager:
"""
ChinWe设备控制类
提供串口通信、电机控制、传感器数据读取等功能
统一通信管理类。
自动识别 串口 (Serial) 或 网络 (TCP) 连接。
"""
def __init__(self, port: str, baudrate: int = 115200, debug: bool = False):
"""
初始化ChinWe设备
Args:
port: 串口名称如果为None则自动检测
baudrate: 波特率默认115200
"""
self.debug = debug
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
self.port = port
self.baudrate = baudrate
self.serial_port: Optional[serial.Serial] = None
self._voltage: float = 0.0
self._ec_value: float = 0.0
self._ec_adc_value: int = 0
self.timeout = timeout
self.logger = logger
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
self.is_tcp = False
self.serial = None
self.socket = None
# 简单判断: 如果包含 ':' (如 192.168.1.1:8899) 或者看起来像 IP则认为是 TCP
if ':' in self.port or (self.port.count('.') == 3 and not self.port.startswith('/')):
self.is_tcp = True
self._connect_tcp()
else:
self._connect_serial()
def _log(self, msg):
if self.logger:
pass
# self.logger.debug(f"[Transport] {msg}")
def _connect_tcp(self):
try:
if ':' in self.port:
host, p = self.port.split(':')
self.tcp_host = host
self.tcp_port = int(p)
else:
self.tcp_host = self.port
self.tcp_port = 8899 # 默认端口
# if self.logger: self.logger.info(f"Connecting TCP {self.tcp_host}:{self.tcp_port} ...")
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.settimeout(self.timeout)
self.socket.connect((self.tcp_host, self.tcp_port))
except Exception as e:
raise ConnectionError(f"TCP connection failed: {e}")
def _connect_serial(self):
try:
# if self.logger: self.logger.info(f"Opening Serial {self.port} (Baud: {self.baudrate}) ...")
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
except Exception as e:
raise ConnectionError(f"Serial open failed: {e}")
def close(self):
"""关闭连接"""
if self.is_tcp and self.socket:
try: self.socket.close()
except: pass
elif not self.is_tcp and self.serial and self.serial.is_open:
self.serial.close()
def clear_buffer(self):
"""清空缓冲区 (Thread-safe)"""
with self.lock:
if self.is_tcp:
self.socket.setblocking(False)
try:
while True:
if not self.socket.recv(1024): break
except: pass
finally: self.socket.settimeout(self.timeout)
else:
self.serial.reset_input_buffer()
def write(self, data: bytes):
"""发送原始字节"""
with self.lock:
if self.is_tcp:
self.socket.sendall(data)
else:
self.serial.write(data)
def read(self, size: int) -> bytes:
"""读取指定长度字节"""
if self.is_tcp:
data = b''
start = time.time()
while len(data) < size:
if time.time() - start > self.timeout: break
try:
chunk = self.socket.recv(size - len(data))
if not chunk: break
data += chunk
except socket.timeout: break
return data
else:
return self.serial.read(size)
def send_ascii_command(self, command: str) -> str:
"""
发送 ASCII 字符串命令 (如注射泵指令),读取直到 '\r'
"""
with self.lock:
data = command.encode('ascii') if isinstance(command, str) else command
self.clear_buffer()
self.write(data)
# Read until \r
if self.is_tcp:
resp = b''
start = time.time()
while True:
if time.time() - start > self.timeout: break
try:
char = self.socket.recv(1)
if not char: break
resp += char
if char == b'\r': break
except: break
return resp.decode('ascii', errors='ignore').strip()
else:
return self.serial.read_until(b'\r').decode('ascii', errors='ignore').strip()
# ==============================================================================
# 2. Syringe Pump Driver (注射泵)
# ==============================================================================
class SyringePump:
"""SY-03B 注射泵驱动 (ASCII协议)"""
CMD_INITIALIZE = "Z{speed},{drain_port},{output_port}R"
CMD_SWITCH_VALVE = "I{port}R"
CMD_ASPIRATE = "P{vol}R"
CMD_DISPENSE = "D{vol}R"
CMD_DISPENSE_ALL = "A0R"
CMD_STOP = "TR"
CMD_QUERY_STATUS = "Q"
CMD_QUERY_PLUNGER = "?0"
def __init__(self, device_id: int, transport: TransportManager):
if not 1 <= device_id <= 15:
pass # Allow all IDs for now
self.id = str(device_id)
self.transport = transport
def _send(self, template: str, **kwargs) -> str:
cmd = f"/{self.id}" + template.format(**kwargs) + "\r"
return self.transport.send_ascii_command(cmd)
def is_busy(self) -> bool:
"""查询繁忙状态"""
resp = self._send(self.CMD_QUERY_STATUS)
# 响应如 /0` (Ready, 0x60) 或 /0@ (Busy, 0x40)
if len(resp) >= 3:
status_byte = ord(resp[2])
# Bit 5: 1=Ready, 0=Busy
return (status_byte & 0x20) == 0
return False
def wait_until_idle(self, timeout=30):
"""阻塞等待直到空闲"""
start = time.time()
while time.time() - start < timeout:
if not self.is_busy(): return
time.sleep(0.5)
# raise TimeoutError(f"Pump {self.id} wait idle timeout")
pass
def initialize(self, drain_port=0, output_port=0, speed=10):
"""初始化"""
self._send(self.CMD_INITIALIZE, speed=speed, drain_port=drain_port, output_port=output_port)
def switch_valve(self, port: int):
"""切换阀门 (1-8)"""
self._send(self.CMD_SWITCH_VALVE, port=port)
def aspirate(self, steps: int):
"""吸液 (相对步数)"""
self._send(self.CMD_ASPIRATE, vol=steps)
def dispense(self, steps: int):
"""排液 (相对步数)"""
self._send(self.CMD_DISPENSE, vol=steps)
def stop(self):
"""停止"""
self._send(self.CMD_STOP)
def get_position(self) -> int:
"""获取柱塞位置 (步数)"""
resp = self._send(self.CMD_QUERY_PLUNGER)
m = re.search(r'\d+', resp)
return int(m.group()) if m else -1
# ==============================================================================
# 3. Stepper Motor Driver (步进电机)
# ==============================================================================
class EmmMotor:
"""Emm V5.0 闭环步进电机驱动"""
def __init__(self, device_id: int, transport: TransportManager):
self.id = device_id
self.transport = transport
def _send(self, func_code: int, payload: list) -> bytes:
with self.transport.lock:
self.transport.clear_buffer()
# 格式: [ID] [Func] [Data...] [Check=0x6B]
body = [self.id, func_code] + payload
body.append(0x6B) # Checksum
self.transport.write(bytes(body))
# 根据指令不同,读取不同长度响应
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
return self.transport.read(read_len)
def enable(self, on=True):
"""使能 (True=锁轴, False=松轴)"""
state = 1 if on else 0
self._send(0xF3, [0xAB, state, 0])
def run_speed(self, speed_rpm: int, direction=0, acc=10):
"""速度模式运行"""
sp = struct.pack('>H', int(speed_rpm))
self._send(0xF6, [direction, sp[0], sp[1], acc, 0])
def run_position(self, pulses: int, speed_rpm: int, direction=0, acc=10, absolute=False):
"""位置模式运行"""
sp = struct.pack('>H', int(speed_rpm))
pl = struct.pack('>I', int(pulses))
is_abs = 1 if absolute else 0
self._send(0xFD, [direction, sp[0], sp[1], acc, pl[0], pl[1], pl[2], pl[3], is_abs, 0])
def stop(self):
"""停止"""
self._send(0xFE, [0x98, 0])
def set_zero(self):
"""清零位置"""
self._send(0x0A, [])
def get_position(self) -> int:
"""获取当前脉冲位置"""
resp = self._send(0x32, [])
if len(resp) >= 8:
sign = resp[2]
val = struct.unpack('>I', resp[3:7])[0]
return -val if sign == 1 else val
return 0
# ==============================================================================
# 4. Liquid Sensor Driver (液位传感器)
# ==============================================================================
class XKCSensor:
"""XKC RS485 液位传感器 (Modbus RTU)"""
def __init__(self, device_id: int, transport: TransportManager, threshold: int = 300):
self.id = device_id
self.transport = transport
self.threshold = threshold
def _crc(self, data: bytes) -> bytes:
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return struct.pack('<H', crc)
def read_level(self) -> Optional[Dict[str, Any]]:
"""
读取液位。
返回: {'level': bool, 'rssi': int}
"""
with self.transport.lock:
self.transport.clear_buffer()
# Modbus Read Registers: 01 03 00 01 00 02 CRC
payload = struct.pack('>HH', 0x0001, 0x0002)
msg = struct.pack('BB', self.id, 0x03) + payload
msg += self._crc(msg)
self.transport.write(msg)
# Read header
h = self.transport.read(3) # Addr, Func, Len
if len(h) < 3: return None
length = h[2]
# Read body + CRC
body = self.transport.read(length + 2)
if len(body) < length + 2:
# Firmware bug fix specific to some modules
if len(body) == 4 and length == 4:
pass
else:
return None
data = body[:-2]
if len(data) == 2:
rssi = data[1]
elif len(data) >= 4:
rssi = (data[2] << 8) | data[3]
else:
return None
return {
'level': rssi > self.threshold,
'rssi': rssi
}
# ==============================================================================
# 5. Main Device Class (ChinweDevice)
# ==============================================================================
class ChinweDevice(UniversalDriver):
"""
ChinWe 工作站主驱动
继承自 UniversalDriver管理所有子设备泵、电机、传感器
"""
def __init__(self, port: str = "192.168.1.200:8899", baudrate: int = 9600,
pump_ids: List[int] = None, motor_ids: List[int] = None,
sensor_id: int = 6, sensor_threshold: int = 300,
timeout: float = 10.0):
"""
初始化 ChinWe 工作站
:param port: 串口号 或 IP:Port
:param baudrate: 串口波特率
:param pump_ids: 注射泵 ID列表 (默认 [1, 2, 3])
:param motor_ids: 步进电机 ID列表 (默认 [4, 5])
:param sensor_id: 液位传感器 ID (默认 6)
:param sensor_threshold: 传感器液位判定阈值
:param timeout: 通信超时时间 (默认 10秒)
"""
super().__init__()
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.mgr = None
self._is_connected = False
self.connect()
# 默认配置
if pump_ids is None: pump_ids = [1, 2, 3]
if motor_ids is None: motor_ids = [4, 5]
# 配置信息
self.pump_ids = pump_ids
self.motor_ids = motor_ids
self.sensor_id = sensor_id
self.sensor_threshold = sensor_threshold
# 子设备实例容器
self.pumps: Dict[int, SyringePump] = {}
self.motors: Dict[int, EmmMotor] = {}
self.sensor: Optional[XKCSensor] = None
# 轮询线程控制
self._stop_event = threading.Event()
self._poll_thread = None
# 实时状态缓存
self.status_cache = {
"sensor_rssi": 0,
"sensor_level": False,
"connected": False
}
# 自动连接
if self.port:
self.connect()
def connect(self) -> bool:
if self._is_connected: return True
try:
self.logger.info(f"Connecting to {self.port} (timeout={self.timeout})...")
self.mgr = TransportManager(self.port, baudrate=self.baudrate, timeout=self.timeout, logger=self.logger)
# 初始化所有泵
for pid in self.pump_ids:
self.pumps[pid] = SyringePump(pid, self.mgr)
# 初始化所有电机
for mid in self.motor_ids:
self.motors[mid] = EmmMotor(mid, self.mgr)
# 初始化传感器
self.sensor = XKCSensor(self.sensor_id, self.mgr, self.sensor_threshold)
self._is_connected = True
self.status_cache["connected"] = True
# 启动轮询线程
self._start_polling()
return True
except Exception as e:
self.logger.error(f"Connection failed: {e}")
self._is_connected = False
self.status_cache["connected"] = False
return False
def disconnect(self):
self._stop_event.set()
if self._poll_thread:
self._poll_thread.join(timeout=2.0)
if self.mgr:
self.mgr.close()
self._is_connected = False
self.status_cache["connected"] = False
self.logger.info("Disconnected.")
def _start_polling(self):
"""启动传感器轮询线程"""
if self._poll_thread and self._poll_thread.is_alive():
return
self._stop_event.clear()
self._poll_thread = threading.Thread(target=self._polling_loop, daemon=True, name="ChinwePoll")
self._poll_thread.start()
def _polling_loop(self):
"""轮询主循环"""
self.logger.info("Sensor polling started.")
error_count = 0
while not self._stop_event.is_set():
if not self._is_connected or not self.sensor:
time.sleep(1)
continue
try:
# 获取传感器数据
data = self.sensor.read_level()
if data:
self.status_cache["sensor_rssi"] = data['rssi']
self.status_cache["sensor_level"] = data['level']
error_count = 0
else:
error_count += 1
# 降低轮询频率防止总线拥塞
time.sleep(0.2)
except Exception as e:
error_count += 1
if error_count > 10: # 连续错误记录日志
# self.logger.error(f"Polling error: {e}")
error_count = 0
time.sleep(1)
# --- 对外暴露属性 (Properties) ---
@property
def sensor_level(self) -> bool:
return self.status_cache["sensor_level"]
@property
def sensor_rssi(self) -> int:
return self.status_cache["sensor_rssi"]
@property
def is_connected(self) -> bool:
"""获取连接状态"""
return self._is_connected and self.serial_port and self.serial_port.is_open
@property
def voltage(self) -> float:
"""获取电源电压值"""
return self._voltage
@property
def ec_value(self) -> float:
"""获取电导率值 (ms/cm)"""
return self._ec_value
return self._is_connected
@property
def ec_adc_value(self) -> int:
"""获取EC ADC原始值"""
return self._ec_adc_value
# --- 对外功能指令 (Actions) ---
@property
def device_status(self) -> Dict[str, any]:
"""
获取设备状态信息
Returns:
包含设备状态的字典
"""
return {
"connected": self.is_connected,
"port": self.port,
"baudrate": self.baudrate,
"voltage": self.voltage,
"ec_value": self.ec_value,
"ec_adc_value": self.ec_adc_value
}
def connect(self, port: Optional[str] = None, baudrate: Optional[int] = None) -> bool:
"""
连接到串口设备
Args:
port: 串口名称如果为None则使用初始化时的port或自动检测
baudrate: 波特率如果为None则使用初始化时的baudrate
Returns:
连接是否成功
"""
if self.is_connected:
def pump_initialize(self, pump_id: int, drain_port=0, output_port=0, speed=10):
"""指定泵初始化"""
pump_id = int(pump_id)
if pump_id in self.pumps:
self.pumps[pump_id].initialize(drain_port, output_port, speed)
self.pumps[pump_id].wait_until_idle()
return True
target_port = port or self.port
target_baudrate = baudrate or self.baudrate
try:
self.serial_port = serial.Serial(target_port, target_baudrate, timeout=0.5)
self._is_connected = True
self.port = target_port
self.baudrate = target_baudrate
connect_allow_times = 5
while not self.serial_port.is_open and connect_allow_times > 0:
time.sleep(0.5)
connect_allow_times -= 1
print(f"尝试连接到 {target_port} @ {target_baudrate},剩余尝试次数: {connect_allow_times}", self.debug)
raise ValueError("串口未打开,请检查设备连接")
print(f"已连接到 {target_port} @ {target_baudrate}", self.debug)
threading.Thread(target=self._read_data, daemon=True).start()
return False
def pump_aspirate(self, pump_id: int, volume: int, valve_port: int):
"""
泵吸液 (阻塞)
:param valve_port: 阀门端口 (1-8)
"""
pump_id = int(pump_id)
valve_port = int(valve_port)
if pump_id in self.pumps:
pump = self.pumps[pump_id]
# 1. 切换阀门
pump.switch_valve(valve_port)
pump.wait_until_idle()
# 2. 吸液
pump.aspirate(volume)
pump.wait_until_idle()
return True
except Exception as e:
print(f"ChinweDevice连接失败: {e}")
self._is_connected = False
return False
def disconnect(self) -> bool:
return False
def pump_dispense(self, pump_id: int, volume: int, valve_port: int):
"""
断开串口连接
Returns:
断开是否成功
泵排液 (阻塞)
:param valve_port: 阀门端口 (1-8)
"""
if self.serial_port and self.serial_port.is_open:
try:
self.serial_port.close()
self._is_connected = False
print("已断开串口连接")
return True
except Exception as e:
print(f"断开连接失败: {e}")
return False
pump_id = int(pump_id)
valve_port = int(valve_port)
if pump_id in self.pumps:
pump = self.pumps[pump_id]
# 1. 切换阀门
pump.switch_valve(valve_port)
pump.wait_until_idle()
# 2. 排液
pump.dispense(volume)
pump.wait_until_idle()
return True
return False
def pump_valve(self, pump_id: int, port: int):
"""泵切换阀门 (阻塞)"""
pump_id = int(pump_id)
port = int(port)
if pump_id in self.pumps:
pump = self.pumps[pump_id]
pump.switch_valve(port)
pump.wait_until_idle()
return True
return False
def motor_run_continuous(self, motor_id: int, speed: int, direction: str = "顺时针"):
"""
电机一直旋转 (速度模式)
:param direction: "顺时针" or "逆时针"
"""
motor_id = int(motor_id)
if motor_id not in self.motors: return False
dir_val = 0 if direction == "顺时针" else 1
self.motors[motor_id].run_speed(speed, dir_val)
return True
def _send_motor_command(self, command: str) -> bool:
def motor_rotate_quarter(self, motor_id: int, speed: int = 60, direction: str = "顺时针"):
"""
发送电机控制命令
Args:
command: 电机命令字符串,例如 "M 1 CW 1.5"
Returns:
发送是否成功
电机旋转1/4圈 (阻塞)
假设电机设置为 3200 脉冲/圈1/4圈 = 800脉冲
"""
if not self.is_connected:
print("设备未连接")
return False
try:
self.serial_port.write((command + "\n").encode('utf-8'))
print(f"发送命令: {command}")
motor_id = int(motor_id)
if motor_id not in self.motors: return False
pulses = 800
dir_val = 0 if direction == "顺时针" else 1
self.motors[motor_id].run_position(pulses, speed, dir_val, absolute=False)
# 预估时间阻塞 (单位: 分钟 -> 秒)
# Time(s) = revs / (RPM/60). revs = 0.25. time = 15 / RPM.
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
return True
def motor_stop(self, motor_id: int):
"""电机停止"""
motor_id = int(motor_id)
if motor_id in self.motors:
self.motors[motor_id].stop()
return True
except Exception as e:
print(f"发送命令失败: {e}")
return False
def rotate_motor(self, motor_id: int, turns: float, clockwise: bool = True) -> bool:
"""
使电机转动指定圈数
Args:
motor_id: 电机ID1, 2, 3...
turns: 转动圈数,支持小数
clockwise: True为顺时针False为逆时针
Returns:
命令发送是否成功
"""
if clockwise:
command = f"M {motor_id} CW {turns}"
else:
command = f"M {motor_id} CCW {turns}"
return self._send_motor_command(command)
return False
def set_motor_speed(self, motor_id: int, speed: float) -> bool:
def wait_sensor_level(self, target_state: str = "有液", timeout: int = 30) -> bool:
"""
设置电机转速(如果设备支持)
Args:
motor_id: 电机ID1, 2, 3...
speed: 转速值
Returns:
命令发送是否成功
等待传感器达到指定电平
:param target_state: "有液" or "无液"
"""
command = f"M {motor_id} SPEED {speed}"
return self._send_motor_command(command)
target_bool = True if target_state == "有液" else False
def _read_data(self) -> List[str]:
"""
读取串口数据并解析
Returns:
读取到的数据行列表
"""
print("开始读取串口数据...")
if not self.is_connected:
return []
data_lines = []
try:
while self.serial_port.in_waiting:
time.sleep(0.1) # 等待数据稳定
try:
line = self.serial_port.readline().decode('utf-8', errors='ignore').strip()
if line:
data_lines.append(line)
self._parse_sensor_data(line)
except Exception as ex:
print(f"解码数据错误: {ex}")
except Exception as e:
print(f"读取串口数据错误: {e}")
return data_lines
def _parse_sensor_data(self, line: str) -> None:
"""
解析传感器数据
Args:
line: 接收到的数据行
"""
# 解析电源电压
if "电源电压" in line:
try:
val = float(line.split("")[1].replace("V", "").strip())
self._voltage = val
if self.debug:
print(f"电源电压更新: {val}V")
except Exception:
pass
self.logger.info(f"Wait sensor: {target_state} ({target_bool}), timeout: {timeout}")
start = time.time()
while time.time() - start < timeout:
if self.sensor_level == target_bool:
return True
time.sleep(0.1)
self.logger.warning("Wait sensor level timeout")
return False
# 解析电导率和ADC原始值支持两种格式
if "电导率" in line and "ADC原始值" in line:
try:
# 支持格式如电导率2.50ms/cm, ADC原始值2052
ec_match = re.search(r"电导率[:]\s*([\d\.]+)", line)
adc_match = re.search(r"ADC原始值[:]\s*(\d+)", line)
if ec_match:
ec_val = float(ec_match.group(1))
self._ec_value = ec_val
if self.debug:
print(f"电导率更新: {ec_val:.2f} ms/cm")
if adc_match:
adc_val = int(adc_match.group(1))
self._ec_adc_value = adc_val
if self.debug:
print(f"EC ADC原始值更新: {adc_val}")
except Exception:
pass
# 仅电导率无ADC原始值
elif "电导率" in line:
try:
val = float(line.split("")[1].replace("ms/cm", "").strip())
self._ec_value = val
if self.debug:
print(f"电导率更新: {val:.2f} ms/cm")
except Exception:
pass
# 仅ADC原始值如有分开回传场景
elif "ADC原始值" in line:
try:
adc_val = int(line.split("")[1].strip())
self._ec_adc_value = adc_val
if self.debug:
print(f"EC ADC原始值更新: {adc_val}")
except Exception:
pass
def spin_when_ec_ge_0():
pass
def wait_time(self, duration: int) -> bool:
"""
等待指定时间 (秒)
:param duration: 秒
"""
self.logger.info(f"Waiting for {duration} seconds...")
time.sleep(duration)
return True
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
"""支持标准 JSON 指令调用"""
return super().execute_command_from_outer(command_dict)
def main():
"""测试函数"""
print("=== ChinWe设备测试 ===")
# 创建设备实例
device = ChinweDevice("/dev/tty.usbserial-A5069RR4", debug=True)
try:
# 测试5: 发送电机命令
print("\n5. 发送电机命令测试:")
print(" 5.3 使用通用函数控制电机20顺时针转2圈:")
device.rotate_motor(2, 20.0, clockwise=True)
time.sleep(0.5)
finally:
time.sleep(10)
# 测试7: 断开连接
print("\n7. 断开连接:")
device.disconnect()
if __name__ == "__main__":
main()
# Test
logging.basicConfig(level=logging.INFO)
dev = ChinweDevice(port="192.168.31.201:8899")
try:
if dev.is_connected:
print(f"Status: Level={dev.sensor_level}, RSSI={dev.sensor_rssi}")
# Test pump 1
# dev.pump_valve(1, 1)
# dev.pump_move(1, 1000, "aspirate")
# Test motor 4
# dev.motor_run(4, 60, 0, 2)
for _ in range(5):
print(f"Level={dev.sensor_level}, RSSI={dev.sensor_rssi}")
time.sleep(1)
finally:
dev.disconnect()

View File

@@ -7,7 +7,7 @@ class VirtualMultiwayValve:
"""
虚拟九通阀门 - 0号位连接transfer pump1-8号位连接其他设备 🔄
"""
def __init__(self, port: str = "VIRTUAL", positions: int = 8):
def __init__(self, port: str = "VIRTUAL", positions: int = 8, **kwargs):
self.port = port
self.max_positions = positions # 1-8号位
self.total_positions = positions + 1 # 0-8号位共9个位置

View File

@@ -0,0 +1,197 @@
{
"token": "",
"request_time": "2025-12-24T15:32:09.2148671+08:00",
"data": {
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
"orderCode": "BSO2025122400024",
"orderName": "DP20251224001",
"startTime": "2025-12-24T14:51:50.549848",
"endTime": "2025-12-24T15:32:09.000765",
"status": "30",
"workflowStatus": "completed",
"completionTime": "2025-12-24T15:32:09.000765",
"usedMaterials": [
{
"materialId": "3a1e614b-53a6-0ec4-10bd-956b240c0f04",
"locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"typemode": "1",
"usedQuantity": 2,
"realQuantity": 2
},
{
"materialId": "3a1e614b-4da7-cf62-3a40-7e5879255c0c",
"locationId": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb",
"typemode": "1",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-53a7-2850-42c8-a7a2de8ff4bf",
"locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"typemode": "1",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-4da6-ac9d-02be-4b0716796bd2",
"locationId": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb",
"typemode": "1",
"usedQuantity": 2,
"realQuantity": 2
},
{
"materialId": "3a1e614d-9c9a-fafa-4757-c7411b03bd9f",
"locationId": "3a1abd46-18fe-1f56-6ced-a1f7fe08e36c",
"typemode": "0",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-6917-b8f9-7987-7a33a3792829",
"locationId": "3a19da43-57b5-294f-d663-154a1cc32270",
"typemode": "2",
"usedQuantity": 3.51,
"realQuantity": 3.5155000000000000000000000000
},
{
"materialId": "3a1e614b-6914-d92b-e348-f52e13817a5d",
"locationId": "3a19da56-1379-ff7c-1745-07e200b44ce2",
"typemode": "2",
"usedQuantity": 0.33,
"realQuantity": 0.3336000000000000000000000000
}
]
}
}
{
"token": "",
"request_time": "2025-12-24T15:32:09.9999039+08:00",
"data": {
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
"orderCode": "BSO2025122400025",
"orderName": "DP20251224002",
"startTime": "2025-12-24T14:53:03.44259",
"endTime": "2025-12-24T15:32:09.828261",
"status": "30",
"workflowStatus": "completed",
"completionTime": "2025-12-24T15:32:09.828261",
"usedMaterials": [
{
"materialId": "3a1e614b-4da7-6527-9f1c-b39e3de8ff2b",
"locationId": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb",
"typemode": "1",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-53a6-0ec4-10bd-956b240c0f04",
"locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"typemode": "1",
"usedQuantity": 2,
"realQuantity": 2
},
{
"materialId": "3a1e614b-4da6-ac9d-02be-4b0716796bd2",
"locationId": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb",
"typemode": "1",
"usedQuantity": 2,
"realQuantity": 2
},
{
"materialId": "3a1e614b-53a8-8474-cac8-0fd7d349e4b2",
"locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"typemode": "1",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614d-9c9a-fafa-4757-c7411b03bd9f",
"locationId": null,
"typemode": "0",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-6917-b8f9-7987-7a33a3792829",
"locationId": "3a19da43-57b5-294f-d663-154a1cc32270",
"typemode": "2",
"usedQuantity": 0.7,
"realQuantity": 0
},
{
"materialId": "3a1e614b-6914-d92b-e348-f52e13817a5d",
"locationId": "3a19da56-1379-ff7c-1745-07e200b44ce2",
"typemode": "2",
"usedQuantity": 1.15,
"realQuantity": 1.1627000000000000000000000000
}
]
}
}
{
"token": "",
"request_time": "2025-12-24T15:34:00.4139986+08:00",
"data": {
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
"orderCode": "BSO2025122400026",
"orderName": "DP20251224003",
"startTime": "2025-12-24T14:54:24.443344",
"endTime": "2025-12-24T15:34:00.26321",
"status": "30",
"workflowStatus": "completed",
"completionTime": "2025-12-24T15:34:00.26321",
"usedMaterials": [
{
"materialId": "3a1e614b-4da6-ac9d-02be-4b0716796bd2",
"locationId": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
"typemode": "1",
"usedQuantity": 2,
"realQuantity": 2
},
{
"materialId": "3a1e614b-4da8-b678-f204-207076f09c83",
"locationId": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
"typemode": "1",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-53a6-0ec4-10bd-956b240c0f04",
"locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"typemode": "1",
"usedQuantity": 2,
"realQuantity": 2
},
{
"materialId": "3a1e614b-53a8-e3f2-dee0-fa97b600b652",
"locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"typemode": "1",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614d-9c9a-fafa-4757-c7411b03bd9f",
"locationId": null,
"typemode": "0",
"usedQuantity": 1,
"realQuantity": 1
},
{
"materialId": "3a1e614b-6917-b8f9-7987-7a33a3792829",
"locationId": "3a19da43-57b5-294f-d663-154a1cc32270",
"typemode": "2",
"usedQuantity": 2.0,
"realQuantity": 2.0075000000000000000000000000
},
{
"materialId": "3a1e614b-6914-d92b-e348-f52e13817a5d",
"locationId": "3a19da56-1379-ff7c-1745-07e200b44ce2",
"typemode": "2",
"usedQuantity": 1.2,
"realQuantity": 1.2126000000000000000000000000
}
]
}
}

View File

@@ -0,0 +1,7 @@
material_name
LiPF6
LiDFOB
DTD
LiFSI
LiPO2F2
1 material_name
2 LiPF6
3 LiDFOB
4 DTD
5 LiFSI
6 LiPO2F2

View File

@@ -0,0 +1,113 @@
# Bioyond Cell 工作站 - 多订单返回示例
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
## 问题描述
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
## 解决方案
修改后的 `create_orders` 函数现在会:
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
2. **逐个等待完成**:遍历所有 orderCode调用 `wait_for_order_finish` 等待每个订单完成
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
## 返回格式
```json
{
"status": "all_completed",
"total_orders": 3,
"reports": [
{
"token": "",
"request_time": "2025-12-24T15:32:09.2148671+08:00",
"data": {
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
"orderCode": "BSO2025122400024",
"orderName": "DP20251224001",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
},
{
"token": "",
"request_time": "2025-12-24T15:32:09.9999039+08:00",
"data": {
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
"orderCode": "BSO2025122400025",
"orderName": "DP20251224002",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
},
{
"token": "",
"request_time": "2025-12-24T15:34:00.4139986+08:00",
"data": {
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
"orderCode": "BSO2025122400026",
"orderName": "DP20251224003",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
}
],
"original_response": {...}
}
```
## 使用示例
```python
# 调用 create_orders
result = workstation.create_orders("20251224.xlsx")
# 访问返回数据
print(f"总订单数: {result['total_orders']}")
print(f"状态: {result['status']}")
# 遍历所有订单的报文
for i, report in enumerate(result['reports'], 1):
order_data = report.get('data', {})
print(f"\n订单 {i}:")
print(f" orderCode: {order_data.get('orderCode')}")
print(f" orderName: {order_data.get('orderName')}")
print(f" status: {order_data.get('status')}")
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
```
## 控制台输出示例
```
[create_orders] 即将提交订单数量: 3
[create_orders] 接口返回: {...}
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
[create_orders] ✓ 订单 BSO2025122400024 完成
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
[create_orders] ✓ 订单 BSO2025122400025 完成
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
[create_orders] ✓ 订单 BSO2025122400026 完成
[create_orders] 所有订单已完成,共收集 3 个报文
实验记录本========================create_orders========================
返回报文数量: 3
报文 1: orderCode=BSO2025122400024, status=30
报文 2: orderCode=BSO2025122400025, status=30
报文 3: orderCode=BSO2025122400026, status=30
========================
```
## 关键改进
1.**等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
2.**收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
3.**详细日志**:清晰显示正在等待哪个订单,以及完成情况
4.**错误处理**:即使某个订单失败,也会记录其状态信息
5.**统一格式**:返回的 JSON 格式便于后续处理和分析

View File

@@ -47,8 +47,8 @@ class BioyondV1RPC(BaseRequest):
super().__init__()
print("开始初始化 BioyondV1RPC")
self.config = config
self.api_key = config["api_key"]
self.host = config["api_host"]
self.api_key = config.get("api_key", "")
self.host = config.get("api_host", "") or config.get("base_url", "")
self._logger = SimpleLogger()
self.material_cache = {}
self._load_material_cache()
@@ -61,7 +61,7 @@ class BioyondV1RPC(BaseRequest):
:return: 当前时间的 ISO 8601 格式字符串
"""
current_time = datetime.now(timezone.utc).isoformat(
current_time = datetime.now().isoformat(
timespec='milliseconds'
)
# 替换时区部分为 'Z'
@@ -212,14 +212,8 @@ class BioyondV1RPC(BaseRequest):
})
if not response or response['code'] != 1:
if response:
error_msg = response.get('message', '未知错误')
print(f"[ERROR] 物料入库失败: code={response.get('code')}, message={error_msg}")
else:
print(f"[ERROR] 物料入库失败: API 无响应")
return {}
# 入库成功时,即使没有 data 字段,也返回成功标识
return response.get("data") or {"success": True}
return response.get("data", {})
def delete_material(self, material_id: str) -> dict:
"""
@@ -239,7 +233,7 @@ class BioyondV1RPC(BaseRequest):
return response.get("data", {})
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
"""指定库位出库物料(通过库位名称)"""
"""指定库位出库物料"""
location_id = LOCATION_MAPPING.get(location_name, location_name)
params = {
@@ -257,36 +251,7 @@ class BioyondV1RPC(BaseRequest):
})
if not response or response['code'] != 1:
return None
return response
def material_outbound_by_id(self, material_id: str, location_id: str, quantity: int) -> dict:
"""指定库位出库物料直接使用location_id
Args:
material_id: 物料ID
location_id: 库位ID不是库位名称是UUID
quantity: 数量
Returns:
dict: API响应失败返回None
"""
params = {
"materialId": material_id,
"locationId": location_id,
"quantity": quantity
}
response = self.post(
url=f'{self.host}/api/lims/storage/outbound',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
"data": params
})
if not response or response['code'] != 1:
return None
return {}
return response
# ==================== 工作流查询相关接口 ====================
@@ -507,7 +472,7 @@ class BioyondV1RPC(BaseRequest):
return {}
response = self.post(
url=f'{self.host}/api/lims/order/project-order-report',
url=f'{self.host}/api/lims/order/order-report',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -684,7 +649,7 @@ class BioyondV1RPC(BaseRequest):
def scheduler_pause(self) -> int:
"""描述:暂停调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/pause',
url=f'{self.host}/api/lims/scheduler/scheduler-pause',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -695,9 +660,8 @@ class BioyondV1RPC(BaseRequest):
return response.get("code", 0)
def scheduler_continue(self) -> int:
"""描述:继续调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/continue',
url=f'{self.host}/api/lims/scheduler/scheduler-continue',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -710,7 +674,7 @@ class BioyondV1RPC(BaseRequest):
def scheduler_stop(self) -> int:
"""描述:停止调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/stop',
url=f'{self.host}/api/lims/scheduler/scheduler-stop',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -721,9 +685,9 @@ class BioyondV1RPC(BaseRequest):
return response.get("code", 0)
def scheduler_reset(self) -> int:
"""描述:复位调度器"""
"""描述:重置调度器"""
response = self.post(
url=f'{self.host}/api/lims/scheduler/reset',
url=f'{self.host}/api/lims/scheduler/scheduler-reset',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
@@ -739,10 +703,10 @@ class BioyondV1RPC(BaseRequest):
"""预加载材料列表到缓存中"""
try:
print("正在加载材料列表缓存...")
# 加载所有类型的材料:耗材(0)、样品(1)、试剂(2)
material_types = [0, 1, 2]
material_types = [1, 2]
for type_mode in material_types:
print(f"正在加载类型 {type_mode} 的材料...")
stock_query = f'{{"typeMode": {type_mode}, "includeDetail": true}}'
@@ -759,7 +723,7 @@ class BioyondV1RPC(BaseRequest):
material_id = material.get("id")
if material_name and material_id:
self.material_cache[material_name] = material_id
# 处理样品板等容器中的detail材料
detail_materials = material.get("detail", [])
for detail_material in detail_materials:
@@ -795,4 +759,4 @@ class BioyondV1RPC(BaseRequest):
def get_available_materials(self):
"""获取所有可用的材料名称列表"""
return list(self.material_cache.keys())
return list(self.material_cache.keys())

View File

@@ -2,141 +2,332 @@
"""
配置文件 - 包含所有配置信息和映射关系
"""
import os
# API配置
# ==================== API 基础配置 ====================
# BioyondCellWorkstation 默认配置(包含所有必需参数)
API_CONFIG = {
"api_key": "",
"api_host": ""
}
# 工作流映射配置
WORKFLOW_MAPPINGS = {
"reactor_taken_out": "",
"reactor_taken_in": "",
"Solid_feeding_vials": "",
"Liquid_feeding_vials(non-titration)": "",
"Liquid_feeding_solvents": "",
"Liquid_feeding(titration)": "",
"liquid_feeding_beaker": "",
"Drip_back": "",
}
# 工作流名称到DisplaySectionName的映射
WORKFLOW_TO_SECTION_MAP = {
'reactor_taken_in': '反应器放入',
'liquid_feeding_beaker': '液体投料-烧杯',
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
'Liquid_feeding_solvents': '液体投料-溶剂',
'Solid_feeding_vials': '固体投料-小瓶',
'Liquid_feeding(titration)': '液体投料-滴定',
'reactor_taken_out': '反应器取出'
# API 连接配置
# 实机
#"api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.11.118:44389"),
# 仿真机
"api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.11.219:44388"),
"api_key": os.getenv("BIOYOND_API_KEY", "8A819E5C"),
"timeout": int(os.getenv("BIOYOND_TIMEOUT", "30")),
# 报送配置
"report_token": os.getenv("BIOYOND_REPORT_TOKEN", "CHANGE_ME_TOKEN"),
# HTTP 服务配置
"HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.206"), # HTTP服务监听地址监听计算机飞连ip地址
"HTTP_port": int(os.getenv("BIOYOND_HTTP_PORT", "8080")),
"debug_mode": False,# 调试模式
}
# 库位映射配置
WAREHOUSE_MAPPING = {
"粉末堆栈": {
"粉末加样头堆栈": {
"uuid": "",
"site_uuids": {
# 样品板
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
# 分装板
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
"A01": "3a19da56-1379-ff7c-1745-07e200b44ce2",
"B01": "3a19da56-1379-2424-d751-fe6e94cef938",
"C01": "3a19da56-1379-271c-03e3-6bdb590e395e",
"D01": "3a19da56-1379-277f-2b1b-0d11f7cf92c6",
"E01": "3a19da56-1379-2f1c-a15b-e01db90eb39a",
"F01": "3a19da56-1379-3fa1-846b-088158ac0b3d",
"G01": "3a19da56-1379-5aeb-d0cd-d3b4609d66e1",
"H01": "3a19da56-1379-6077-8258-bdc036870b78",
"I01": "3a19da56-1379-863b-a120-f606baf04617",
"J01": "3a19da56-1379-8a74-74e5-35a9b41d4fd5",
"K01": "3a19da56-1379-b270-b7af-f18773918abe",
"L01": "3a19da56-1379-ba54-6d78-fd770a671ffc",
"M01": "3a19da56-1379-c22d-c96f-0ceb5eb54a04",
"N01": "3a19da56-1379-d64e-c6c5-c72ea4829888",
"O01": "3a19da56-1379-d887-1a3c-6f9cce90f90e",
"P01": "3a19da56-1379-e77d-0e65-7463b238a3b9",
"Q01": "3a19da56-1379-edf6-1472-802ddb628774",
"R01": "3a19da56-1379-f281-0273-e0ef78f0fd97",
"S01": "3a19da56-1379-f924-7f68-df1fa51489f4",
"T01": "3a19da56-1379-ff7c-1745-07e200b44ce2"
}
},
"溶液堆栈": {
"配液站内试剂仓库": {
"uuid": "",
"site_uuids": {
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f",
"A02": "3a19da43-57b5-8441-db94-b4d3875a4b6c",
"B02": "3a19da43-57b5-3e41-c181-5119dddaf50c",
"C02": "3a19da43-57b5-269b-282d-fba61fe8ce96",
"A03": "3a19da43-57b5-7c1e-d02e-c40e8c33f8a1",
"B03": "3a19da43-57b5-659f-621f-1dcf3f640363",
"C03": "3a19da43-57b5-855a-6e71-f398e376dee1",
}
},
"试剂堆栈": {
"试剂替换仓库": {
"uuid": "",
"site_uuids": {
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
"A01": "3a19da51-8f4e-30f3-ea08-4f8498e9b097",
"B01": "3a19da51-8f4e-1da7-beb0-80a4a01e67a8",
"C01": "3a19da51-8f4e-337d-2675-bfac46880b06",
"D01": "3a19da51-8f4e-e514-b92c-9c44dc5e489d",
"E01": "3a19da51-8f4e-22d1-dd5b-9774ddc80402",
"F01": "3a19da51-8f4e-273a-4871-dff41c29bfd9",
"G01": "3a19da51-8f4e-b32f-454f-74bc1a665653",
"H01": "3a19da51-8f4e-8c93-68c9-0b4382320f59",
"I01": "3a19da51-8f4e-360c-0149-291b47c6089b",
"J01": "3a19da51-8f4e-4152-9bca-8d64df8c1af0"
}
},
"自动堆栈-左": {
"uuid": "",
"site_uuids": {
"A01": "3a19debc-84b5-4c1c-d3a1-26830cf273ff",
"A02": "3a19debc-84b5-033b-b31f-6b87f7c2bf52",
"B01": "3a19debc-84b5-3924-172f-719ab01b125c",
"B02": "3a19debc-84b5-aad8-70c6-b8c6bb2d8750"
}
},
"自动堆栈-右": {
"uuid": "",
"site_uuids": {
"A01": "3a19debe-5200-7df2-1dd9-7d202f158864",
"A02": "3a19debe-5200-573b-6120-8b51f50e1e50",
"B01": "3a19debe-5200-7cd8-7666-851b0a97e309",
"B02": "3a19debe-5200-e6d3-96a3-baa6e3d5e484"
}
},
"手动堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
"A03": "3a19deae-2c7a-5876-c454-6b7e224ca927",
"B01": "3a19deae-2c7a-2426-6d71-e9de3cb158b1",
"B02": "3a19deae-2c7a-79b0-5e44-efaafd1e4cf3",
"B03": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
"C01": "3a19deae-2c7a-32bc-768e-556647e292f3",
"C02": "3a19deae-2c7a-e97a-8484-f5a4599447c4",
"C03": "3a19deae-2c7a-3056-6504-10dc73fbc276",
"D01": "3a19deae-2c7a-ffad-875e-8c4cda61d440",
"D02": "3a19deae-2c7a-61be-601c-b6fb5610499a",
"D03": "3a19deae-2c7a-c0f7-05a7-e3fe2491e560",
"E01": "3a19deae-2c7a-a6f4-edd1-b436a7576363",
"E02": "3a19deae-2c7a-4367-96dd-1ca2186f4910",
"E03": "3a19deae-2c7a-b163-2219-23df15200311",
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a",
"F02": "3a19deae-2c7a-a194-ea63-8b342b8d8679",
"F03": "3a19deae-2c7a-f7c4-12bd-425799425698",
"G01": "3a19deae-2c7a-0b56-72f1-8ab86e53b955",
"G02": "3a19deae-2c7a-204e-95ed-1f1950f28343",
"G03": "3a19deae-2c7a-392b-62f1-4907c66343f8",
"H01": "3a19deae-2c7a-5602-e876-d27aca4e3201",
"H02": "3a19deae-2c7a-f15c-70e0-25b58a8c9702",
"H03": "3a19deae-2c7a-780b-8965-2e1345f7e834",
"I01": "3a19deae-2c7a-8849-e172-07de14ede928",
"I02": "3a19deae-2c7a-4772-a37f-ff99270bafc0",
"I03": "3a19deae-2c7a-cce7-6e4a-25ea4a2068c4",
"J01": "3a19deae-2c7a-1848-de92-b5d5ed054cc6",
"J02": "3a19deae-2c7a-1d45-b4f8-6f866530e205",
"J03": "3a19deae-2c7a-f237-89d9-8fe19025dee9"
}
},
"4号手套箱内部堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a1baa20-a7b1-c665-8b9c-d8099d07d2f6",
"A02": "3a1baa20-a7b1-93a7-c988-f9c8ad6c58c9",
"A03": "3a1baa20-a7b1-00ee-f751-da9b20b6c464",
"A04": "3a1baa20-a7b1-4712-c37b-0b5b658ef7b9",
"B01": "3a1baa20-a7b1-9847-fc9c-96d604cd1a8e",
"B02": "3a1baa20-a7b1-4ae9-e604-0601db06249c",
"B03": "3a1baa20-a7b1-8329-ea75-81ca559d9ce1",
"B04": "3a1baa20-a7b1-89c5-d96f-36e98a8f7268",
"C01": "3a1baa20-a7b1-32ec-39e6-8044733839d6",
"C02": "3a1baa20-a7b1-b573-e426-4c86040348b2",
"C03": "3a1baa20-a7b1-cca7-781e-0522b729bf5d",
"C04": "3a1baa20-a7b1-7c98-5fd9-5855355ae4b3"
}
},
"大分液瓶堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a19da3d-4f3d-bcac-2932-7542041e10e0",
"A02": "3a19da3d-4f3d-4d75-38ac-fb58ad0687c3",
"A03": "3a19da3d-4f3d-b25e-f2b1-85342a5b7eae",
"B01": "3a19da3d-4f3d-fd3e-058a-2733a0925767",
"B02": "3a19da3d-4f3d-37bd-a944-c391ad56857f",
"B03": "3a19da3d-4f3d-e353-7862-c6d1d4bc667f",
"C01": "3a19da3d-4f3d-9519-5da7-76179c958e70",
"C02": "3a19da3d-4f3d-b586-d7ed-9ec244f6f937",
"C03": "3a19da3d-4f3d-5061-249b-35dfef732811"
}
},
"小分液瓶堆栈": {
"uuid": "",
"site_uuids": {
"C03": "3a19da40-55bf-8943-d20d-a8b3ea0d16c0"
}
},
"站内Tip头盒堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a19deab-d5cc-be1e-5c37-4e9e5a664388",
"A02": "3a19deab-d5cc-b394-8141-27cb3853e8ea",
"B01": "3a19deab-d5cc-4dca-596e-ca7414d5f501",
"B02": "3a19deab-d5cc-9bc0-442b-12d9d59aa62a",
"C01": "3a19deab-d5cc-2eaf-b6a4-f0d54e4f1246",
"C02": "3a19deab-d5cc-d9f4-25df-b8018c372bc7"
}
},
"配液站内配液大板仓库(无需提前上料)": {
"uuid": "",
"site_uuids": {
"A01": "3a1a21dc-06af-3915-9cb9-80a9dc42f386"
}
},
"配液站内配液小板仓库(无需以前入料)": {
"uuid": "",
"site_uuids": {
"A01": "3a1a21de-8e8b-7938-2d06-858b36c10e31"
}
},
"移液站内大瓶板仓库(无需提前如料)": {
"uuid": "",
"site_uuids": {
"A01": "3a1a224c-c727-fa62-1f2b-0037a84b9fca"
}
},
"移液站内小瓶板仓库(无需提前入料)": {
"uuid": "",
"site_uuids": {
"A01": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb"
}
},
"适配器位仓库": {
"uuid": "",
"site_uuids": {
"A01": "3a1abd46-18fe-1f56-6ced-a1f7fe08e36c"
}
},
"1号2号手套箱交接堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a1baa49-7f77-35aa-60b1-e55a45d065fa"
}
},
"2号手套箱内部堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a1baa4b-393e-9f86-3921-7a18b0a8e371",
"A02": "3a1baa4b-393e-9425-928b-ee0f6f679d44",
"A03": "3a1baa4b-393e-0baf-632b-59dfdc931a3a",
"B01": "3a1baa4b-393e-f8aa-c8a9-956f3132f05c",
"B02": "3a1baa4b-393e-ef05-42f6-53f4c6e89d70",
"B03": "3a1baa4b-393e-c07b-a924-a9f0dfda9711",
"C01": "3a1baa4b-393e-4c2b-821a-16a7fe025c48",
"C02": "3a1baa4b-393e-2eaf-61a1-9063c832d5a2",
"C03": "3a1baa4b-393e-034e-8e28-8626d934a85f"
}
}
}
# 物料类型配置
MATERIAL_TYPE_MAPPINGS = {
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
"10%分装小": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
"100ml液体": ("YB_100ml_yeti", "d37166b3-ecaa-481e-bd84-3032b795ba07"),
"": ("YB_ye", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"),
"高粘液": ("YB_gaonianye", "abe8df30-563d-43d2-85e0-cabec59ddc16"),
"加样头(大)": ("YB_jia_yang_tou_da_Carrier", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "加样头(大)板": ("YB_jia_yang_tou_da", "a8e714ae-2a4e-4eb9-9614-e4c140ec3f16"),
"5ml分液瓶板": ("YB_5ml_fenyepingban", "3a192fa4-007d-ec7b-456e-2a8be7a13f23"),
"5ml分液": ("YB_5ml_fenyeping", "3a192c2a-ebb7-58a1-480d-8b3863bf74f4"),
"20ml分液瓶板": ("YB_20ml_fenyepingban", "3a192fa4-47db-3449-162a-eaf8aba57e27"),
"20ml分液瓶": ("YB_20ml_fenyeping", "3a192c2b-19e8-f0a3-035e-041ca8ca1035"),
"配液瓶(小)板": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"),
"配液瓶(小)": ("YB_pei_ye_xiao_Bottle", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"),
"配液瓶(大)板": ("YB_peiyepingdaban", "53e50377-32dc-4781-b3c0-5ce45bc7dc27"),
"配液瓶(大)": ("YB_pei_ye_da_Bottle", "19c52ad1-51c5-494f-8854-576f4ca9c6ca"),
"适配器块": ("YB_shi_pei_qi_kuai", "efc3bb32-d504-4890-91c0-b64ed3ac80cf"),
"枪头盒": ("YB_qiang_tou_he", "3a192c2e-20f3-a44a-0334-c8301839d0b3"),
"枪头": ("YB_qiang_tou", "b6196971-1050-46da-9927-333e8dea062d"),
}
# 步骤参数配置各工作流的步骤UUID
WORKFLOW_STEP_IDS = {
"reactor_taken_in": {
"config": ""
SOLID_LIQUID_MAPPINGS = {
# 固体
"LiDFOB": {
"typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
"code": "",
"barCode": "",
"name": "LiDFOB",
"unit": "g",
"parameters": "",
"quantity": "2",
"warningQuantity": "1",
"details": []
},
"liquid_feeding_beaker": {
"liquid": "",
"observe": ""
},
"liquid_feeding_vials_non_titration": {
"liquid": "",
"observe": ""
},
"liquid_feeding_solvents": {
"liquid": "",
"observe": ""
},
"solid_feeding_vials": {
"feeding": "",
"observe": ""
},
"liquid_feeding_titration": {
"liquid": "",
"observe": ""
},
"drip_back": {
"liquid": "",
"observe": ""
}
# "LiPF6": {
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
# "code": "",
# "barCode": "",
# "name": "LiPF6",
# "unit": "g",
# "parameters": "",
# "quantity": 2,
# "warningQuantity": 1,
# "details": []
# },
# "LiFSI": {
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
# "code": "",
# "barCode": "",
# "name": "LiFSI",
# "unit": "g",
# "parameters": "",
# "quantity": 2,
# "warningQuantity": 1,
# "details": []
# },
# "DTC": {
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
# "code": "",
# "barCode": "",
# "name": "DTC",
# "unit": "g",
# "parameters": "",
# "quantity": 2,
# "warningQuantity": 1,
# "details": []
# },
# "LiPO2F2": {
# "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469",
# "code": "",
# "barCode": "",
# "name": "LiPO2F2",
# "unit": "g",
# "parameters": "",
# "quantity": 2,
# "warningQuantity": 1,
# "details": []
# },
# 液体
# "SA": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "EC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "VC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "AND": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "HTCN": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "DENE": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "TMSP": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "TMSB": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "EP": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "DEC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "EMC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "SN": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "DMC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
# "FEC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"),
}
LOCATION_MAPPING = {}
WORKFLOW_MAPPINGS = {}
ACTION_NAMES = {}
HTTP_SERVICE_CONFIG = {}
LOCATION_MAPPING = {}

View File

@@ -1,7 +1,5 @@
from datetime import datetime
import json
import time
from typing import Optional, Dict, Any
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
@@ -25,9 +23,6 @@ class BioyondDispensingStation(BioyondWorkstation):
# self._logger = SimpleLogger()
# self.is_running = False
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
self.order_completion_status = {}
# 90%10%小瓶投料任务创建方法
def create_90_10_vial_feeding_task(self,
order_name: str = None,
@@ -275,45 +270,7 @@ class BioyondDispensingStation(BioyondWorkstation):
# 7. 调用create_order方法创建任务
result = self.hardware_interface.create_order(json_str)
self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}")
# 8. 解析结果获取order_id
order_id = None
if isinstance(result, str):
# result 格式: "{'3a1d895c-4d39-d504-1398-18f5a40bac1e': [{'id': '...', ...}]}"
# 第一个键就是order_id (UUID)
try:
# 尝试解析字符串为字典
import ast
result_dict = ast.literal_eval(result)
# 获取第一个键作为order_id
if result_dict and isinstance(result_dict, dict):
first_key = list(result_dict.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}")
else:
self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}")
except Exception as e:
self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}, result类型={type(result)}")
elif isinstance(result, dict):
# 如果已经是字典
if result:
first_key = list(result.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}")
if not order_id:
self.hardware_interface._logger.warning(
f"⚠ 未能提取order_idresult={result[:100] if isinstance(result, str) else result}"
)
# 返回成功结果和构建的JSON数据
return json.dumps({
"suc": True,
"order_code": order_code,
"order_id": order_id,
"result": result,
"order_params": order_data
})
return json.dumps({"suc": True})
except BioyondException:
# 重新抛出BioyondException
@@ -441,37 +398,7 @@ class BioyondDispensingStation(BioyondWorkstation):
result = self.hardware_interface.create_order(json_str)
self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}")
# 8. 解析结果获取order_id
order_id = None
if isinstance(result, str):
try:
import ast
result_dict = ast.literal_eval(result)
if result_dict and isinstance(result_dict, dict):
first_key = list(result_dict.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}")
else:
self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}")
except Exception as e:
self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}")
elif isinstance(result, dict):
if result:
first_key = list(result.keys())[0]
order_id = first_key
self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}")
if not order_id:
self.hardware_interface._logger.warning(f"⚠ 未能提取order_id")
# 返回成功结果和构建的JSON数据
return json.dumps({
"suc": True,
"order_code": order_code,
"order_id": order_id,
"result": result,
"order_params": order_data
})
return json.dumps({"suc": True})
except BioyondException:
# 重新抛出BioyondException
@@ -572,24 +499,15 @@ class BioyondDispensingStation(BioyondWorkstation):
hold_m_name=hold_m_name
)
# 解析返回结果以获取order_code和order_id
result_data = json.loads(result) if isinstance(result, str) else result
order_code = result_data.get("order_code")
order_id = result_data.get("order_id")
order_params = result_data.get("order_params", {})
results.append({
"index": idx + 1,
"name": name,
"success": True,
"order_code": order_code,
"order_id": order_id,
"hold_m_name": hold_m_name,
"order_params": order_params
"hold_m_name": hold_m_name
})
success_count += 1
self.hardware_interface._logger.info(
f"成功创建二胺溶液配置任务: {name}, order_code={order_code}, order_id={order_id}"
f"成功创建二胺溶液配置任务: {name}"
)
except BioyondException as e:
@@ -615,17 +533,11 @@ class BioyondDispensingStation(BioyondWorkstation):
f"创建第 {idx + 1} 个任务时发生未知错误: {str(e)}"
)
# 提取所有成功任务的order_code和order_id
order_codes = [r["order_code"] for r in results if r["success"]]
order_ids = [r["order_id"] for r in results if r["success"]]
# 返回汇总结果
summary = {
"total": len(solutions),
"success": success_count,
"failed": failed_count,
"order_codes": order_codes,
"order_ids": order_ids,
"details": results
}
@@ -634,13 +546,8 @@ class BioyondDispensingStation(BioyondWorkstation):
f"成功={success_count}, 失败={failed_count}"
)
# 构建返回结果
summary["return_info"] = {
"order_codes": order_codes,
"order_ids": order_ids,
}
return summary
# 返回JSON字符串格式
return json.dumps(summary, ensure_ascii=False)
except BioyondException:
raise
@@ -706,15 +613,22 @@ class BioyondDispensingStation(BioyondWorkstation):
if not all([name, main_portion is not None, titration_portion is not None, titration_solvent is not None]):
raise BioyondException("titration 数据缺少必要参数")
# 将main_portion平均分成3份作为90%物料3个小瓶
portion_90 = main_portion / 3
# 调用单个任务创建方法
result = self.create_90_10_vial_feeding_task(
order_name=f"90%10%小瓶投料-{name}",
speed=speed,
temperature=temperature,
delay_time=delay_time,
# 90%物料 - 主称固体直接使用main_portion
# 90%物料 - 主称固体平均分成3份
percent_90_1_assign_material_name=name,
percent_90_1_target_weigh=str(round(main_portion, 6)),
percent_90_1_target_weigh=str(round(portion_90, 6)),
percent_90_2_assign_material_name=name,
percent_90_2_target_weigh=str(round(portion_90, 6)),
percent_90_3_assign_material_name=name,
percent_90_3_target_weigh=str(round(portion_90, 6)),
# 10%物料 - 滴定固体 + 滴定溶剂只使用第1个10%小瓶)
percent_10_1_assign_material_name=name,
percent_10_1_target_weigh=str(round(titration_portion, 6)),
@@ -723,54 +637,29 @@ class BioyondDispensingStation(BioyondWorkstation):
hold_m_name=hold_m_name
)
# 解析返回结果以获取order_code和order_id
result_data = json.loads(result) if isinstance(result, str) else result
order_code = result_data.get("order_code")
order_id = result_data.get("order_id")
order_params = result_data.get("order_params", {})
# 构建详细信息(保持原有结构)
detail = {
"index": 1,
"name": name,
summary = {
"success": True,
"order_code": order_code,
"order_id": order_id,
"hold_m_name": hold_m_name,
"material_name": name,
"90_vials": {
"count": 1,
"weight_per_vial": round(main_portion, 6),
"count": 3,
"weight_per_vial": round(portion_90, 6),
"total_weight": round(main_portion, 6)
},
"10_vials": {
"count": 1,
"solid_weight": round(titration_portion, 6),
"liquid_volume": round(titration_solvent, 6)
},
"order_params": order_params
}
# 构建批量结果格式与diamine_solution_tasks保持一致
summary = {
"total": 1,
"success": 1,
"failed": 0,
"order_codes": [order_code],
"order_ids": [order_id],
"details": [detail]
}
}
self.hardware_interface._logger.info(
f"成功创建90%10%小瓶投料任务: {name}, order_code={order_code}, order_id={order_id}"
f"成功创建90%10%小瓶投料任务: {hold_m_name}, "
f"90%物料={portion_90:.6f}g×3, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL"
)
# 构建返回结果
summary["return_info"] = {
"order_codes": [order_code],
"order_ids": [order_id],
}
return summary
# 返回JSON字符串格式
return json.dumps(summary, ensure_ascii=False)
except BioyondException:
raise
@@ -779,541 +668,6 @@ class BioyondDispensingStation(BioyondWorkstation):
self.hardware_interface._logger.error(error_msg)
raise BioyondException(error_msg)
def _extract_actuals_from_report(self, report) -> Dict[str, Any]:
data = report.get('data') if isinstance(report, dict) else None
actual_target_weigh = None
actual_volume = None
if data:
extra = data.get('extraProperties') or {}
if isinstance(extra, dict):
for v in extra.values():
obj = None
try:
obj = json.loads(v) if isinstance(v, str) else v
except Exception:
obj = None
if isinstance(obj, dict):
tw = obj.get('targetWeigh')
vol = obj.get('volume')
if tw is not None:
try:
actual_target_weigh = float(tw)
except Exception:
pass
if vol is not None:
try:
actual_volume = float(vol)
except Exception:
pass
return {
'actualTargetWeigh': actual_target_weigh,
'actualVolume': actual_volume
}
# 等待多个任务完成并获取实验报告
def wait_for_multiple_orders_and_get_reports(self,
batch_create_result: str = None,
timeout: int = 7200,
check_interval: int = 10) -> Dict[str, Any]:
"""
同时等待多个任务完成并获取实验报告
参数说明:
- batch_create_result: 批量创建任务的返回结果JSON字符串包含order_codes和order_ids数组
- timeout: 超时时间默认7200秒2小时
- check_interval: 检查间隔默认10秒
返回: 包含所有任务状态和报告的字典
{
"total": 2,
"completed": 2,
"timeout": 0,
"elapsed_time": 120.5,
"reports": [
{
"order_code": "task_vial_1",
"order_id": "uuid1",
"status": "completed",
"completion_status": 30,
"report": {...}
},
...
]
}
异常:
- BioyondException: 所有任务都超时或发生错误
"""
try:
# 参数类型转换
timeout = int(timeout) if timeout else 7200
check_interval = int(check_interval) if check_interval else 10
# 验证batch_create_result参数
if not batch_create_result or batch_create_result == "":
raise BioyondException("batch_create_result参数为空请确保从batch_create节点正确连接handle")
# 解析batch_create_result JSON对象
try:
# 清理可能存在的截断标记 [...]
if isinstance(batch_create_result, str) and '[...]' in batch_create_result:
batch_create_result = batch_create_result.replace('[...]', '[]')
result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result
# 兼容外层包装格式 {error, suc, return_value}
if isinstance(result_obj, dict) and "return_value" in result_obj:
inner = result_obj.get("return_value")
if isinstance(inner, str):
result_obj = json.loads(inner)
elif isinstance(inner, dict):
result_obj = inner
# 从summary对象中提取order_codes和order_ids
order_codes = result_obj.get("order_codes", [])
order_ids = result_obj.get("order_ids", [])
except json.JSONDecodeError as e:
raise BioyondException(f"解析batch_create_result失败: {e}")
except Exception as e:
raise BioyondException(f"处理batch_create_result时出错: {e}")
# 验证提取的数据
if not order_codes:
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
if not order_ids:
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
# 确保order_codes和order_ids是列表类型
if not isinstance(order_codes, list):
order_codes = [order_codes] if order_codes else []
if not isinstance(order_ids, list):
order_ids = [order_ids] if order_ids else []
codes_list = order_codes
ids_list = order_ids
if len(codes_list) != len(ids_list):
raise BioyondException(
f"order_codes数量({len(codes_list)})与order_ids数量({len(ids_list)})不匹配"
)
if not codes_list or not ids_list:
raise BioyondException("order_codes和order_ids不能为空")
# 初始化跟踪变量
total = len(codes_list)
pending_orders = {code: {"order_id": ids_list[i], "completed": False}
for i, code in enumerate(codes_list)}
reports = []
start_time = time.time()
self.hardware_interface._logger.info(
f"开始等待 {total} 个任务完成: {', '.join(codes_list)}"
)
# 轮询检查任务状态
while pending_orders:
elapsed_time = time.time() - start_time
# 检查超时
if elapsed_time > timeout:
# 收集超时任务
timeout_orders = list(pending_orders.keys())
self.hardware_interface._logger.error(
f"等待任务完成超时,剩余未完成任务: {', '.join(timeout_orders)}"
)
# 为超时任务添加记录
for order_code in timeout_orders:
reports.append({
"order_code": order_code,
"order_id": pending_orders[order_code]["order_id"],
"status": "timeout",
"completion_status": None,
"report": None,
"extracted": None,
"elapsed_time": elapsed_time
})
break
# 检查每个待完成的任务
completed_in_this_round = []
for order_code in list(pending_orders.keys()):
order_id = pending_orders[order_code]["order_id"]
# 检查任务是否完成
if order_code in self.order_completion_status:
completion_info = self.order_completion_status[order_code]
self.hardware_interface._logger.info(
f"检测到任务 {order_code} 已完成,状态: {completion_info.get('status')}"
)
# 获取实验报告
try:
report_query = json.dumps({"order_id": order_id})
report = self.hardware_interface.order_report(report_query)
if not report:
self.hardware_interface._logger.warning(
f"任务 {order_code} 已完成但无法获取报告"
)
report = {"error": "无法获取报告"}
else:
self.hardware_interface._logger.info(
f"成功获取任务 {order_code} 的实验报告"
)
reports.append({
"order_code": order_code,
"order_id": order_id,
"status": "completed",
"completion_status": completion_info.get('status'),
"report": report,
"extracted": self._extract_actuals_from_report(report),
"elapsed_time": elapsed_time
})
# 标记为已完成
completed_in_this_round.append(order_code)
# 清理完成状态记录
del self.order_completion_status[order_code]
except Exception as e:
self.hardware_interface._logger.error(
f"查询任务 {order_code} 报告失败: {str(e)}"
)
reports.append({
"order_code": order_code,
"order_id": order_id,
"status": "error",
"completion_status": completion_info.get('status'),
"report": None,
"extracted": None,
"error": str(e),
"elapsed_time": elapsed_time
})
completed_in_this_round.append(order_code)
# 从待完成列表中移除已完成的任务
for order_code in completed_in_this_round:
del pending_orders[order_code]
# 如果还有待完成的任务,等待后继续
if pending_orders:
time.sleep(check_interval)
# 每分钟记录一次等待状态
new_elapsed_time = time.time() - start_time
if int(new_elapsed_time) % 60 == 0 and new_elapsed_time > 0:
self.hardware_interface._logger.info(
f"批量等待任务中... 已完成 {len(reports)}/{total}, "
f"待完成: {', '.join(pending_orders.keys())}, "
f"已等待 {int(new_elapsed_time/60)} 分钟"
)
# 统计结果
completed_count = sum(1 for r in reports if r['status'] == 'completed')
timeout_count = sum(1 for r in reports if r['status'] == 'timeout')
error_count = sum(1 for r in reports if r['status'] == 'error')
final_elapsed_time = time.time() - start_time
summary = {
"total": total,
"completed": completed_count,
"timeout": timeout_count,
"error": error_count,
"elapsed_time": round(final_elapsed_time, 2),
"reports": reports
}
self.hardware_interface._logger.info(
f"批量等待任务完成: 总数={total}, 成功={completed_count}, "
f"超时={timeout_count}, 错误={error_count}, 耗时={final_elapsed_time:.1f}"
)
# 返回字典格式,在顶层包含统计信息
return {
"return_info": json.dumps(summary, ensure_ascii=False)
}
except BioyondException:
raise
except Exception as e:
error_msg = f"批量等待任务完成时发生未预期的错误: {str(e)}"
self.hardware_interface._logger.error(error_msg)
raise BioyondException(error_msg)
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
"""
重写父类方法,处理任务完成报送并记录到 order_completion_status
Args:
report_request: WorkstationReportRequest 对象,包含任务完成信息
used_materials: 物料使用记录列表
Returns:
Dict[str, Any]: 处理结果
"""
try:
# 调用父类方法
result = super().process_order_finish_report(report_request, used_materials)
# 记录任务完成状态
data = report_request.data
order_code = data.get('orderCode')
if order_code:
self.order_completion_status[order_code] = {
'status': data.get('status'),
'order_name': data.get('orderName'),
'timestamp': datetime.now().isoformat(),
'start_time': data.get('startTime'),
'end_time': data.get('endTime')
}
self.hardware_interface._logger.info(
f"已记录任务完成状态: {order_code}, status={data.get('status')}"
)
return result
except Exception as e:
self.hardware_interface._logger.error(f"处理任务完成报送失败: {e}")
return {"processed": False, "error": str(e)}
def transfer_materials_to_reaction_station(
self,
target_device_id: str,
transfer_groups: list
) -> dict:
"""
将配液站完成的物料转移到指定反应站的堆栈库位
支持多组转移任务,每组包含物料名称、目标堆栈和目标库位
Args:
target_device_id: 目标反应站设备ID(所有转移组使用同一个设备)
transfer_groups: 转移任务组列表,每组包含:
- materials: 物料名称(字符串,将通过RPC查询)
- target_stack: 目标堆栈名称(如"堆栈1左")
- target_sites: 目标库位(如"A01")
Returns:
dict: 转移结果
{
"success": bool,
"total_groups": int,
"successful_groups": int,
"failed_groups": int,
"target_device_id": str,
"details": [...]
}
"""
try:
# 验证参数
if not target_device_id:
raise ValueError("目标设备ID不能为空")
if not transfer_groups:
raise ValueError("转移任务组列表不能为空")
if not isinstance(transfer_groups, list):
raise ValueError("transfer_groups必须是列表类型")
# 标准化设备ID格式: 确保以 /devices/ 开头
if not target_device_id.startswith("/devices/"):
if target_device_id.startswith("/"):
target_device_id = f"/devices{target_device_id}"
else:
target_device_id = f"/devices/{target_device_id}"
self.hardware_interface._logger.info(
f"目标设备ID标准化为: {target_device_id}"
)
self.hardware_interface._logger.info(
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
)
from .config import WAREHOUSE_MAPPING
results = []
successful_count = 0
failed_count = 0
for idx, group in enumerate(transfer_groups, 1):
try:
# 提取参数
material_name = group.get("materials", "")
target_stack = group.get("target_stack", "")
target_sites = group.get("target_sites", "")
# 验证必填参数
if not material_name:
raise ValueError(f"{idx}组: 物料名称不能为空")
if not target_stack:
raise ValueError(f"{idx}组: 目标堆栈不能为空")
if not target_sites:
raise ValueError(f"{idx}组: 目标库位不能为空")
self.hardware_interface._logger.info(
f"处理第{idx}组转移: {material_name} -> "
f"{target_device_id}/{target_stack}/{target_sites}"
)
# 通过物料名称从deck获取ResourcePLR对象
try:
material_resource = self.deck.get_resource(material_name)
if not material_resource:
raise ValueError(f"在deck中未找到物料: {material_name}")
self.hardware_interface._logger.info(
f"从deck获取到物料 {material_name}: {material_resource}"
)
except Exception as e:
raise ValueError(
f"获取物料 {material_name} 失败: {str(e)}请确认物料已正确加载到deck中"
)
# 验证目标堆栈是否存在
if target_stack not in WAREHOUSE_MAPPING:
raise ValueError(
f"未知的堆栈名称: {target_stack}"
f"可选值: {list(WAREHOUSE_MAPPING.keys())}"
)
# 验证库位是否有效
stack_sites = WAREHOUSE_MAPPING[target_stack].get("site_uuids", {})
if target_sites not in stack_sites:
raise ValueError(
f"库位 {target_sites} 不存在于堆栈 {target_stack} 中,"
f"可选库位: {list(stack_sites.keys())}"
)
# 调用父类的 transfer_resource_to_another 方法
# 传入ResourcePLR对象
self.transfer_resource_to_another(
resource=[material_resource],
mount_resource=[],
sites=[target_sites],
mount_device_id=target_device_id
)
# 等待异步任务完成(临时方案)
import time
time.sleep(5)
self.hardware_interface._logger.info(
f"{idx}组转移成功: {material_name} -> "
f"{target_device_id}/{target_stack}/{target_sites}"
)
successful_count += 1
results.append({
"group_index": idx,
"success": True,
"material_name": material_name,
"target_stack": target_stack,
"target_site": target_sites,
"message": "转移成功"
})
except Exception as e:
error_msg = f"{idx}组转移失败: {str(e)}"
self.hardware_interface._logger.error(error_msg)
failed_count += 1
results.append({
"group_index": idx,
"success": False,
"material_name": group.get("materials", ""),
"error": str(e)
})
# 返回汇总结果
return {
"success": failed_count == 0,
"total_groups": len(transfer_groups),
"successful_groups": successful_count,
"failed_groups": failed_count,
"target_device_id": target_device_id,
"details": results,
"message": f"完成 {len(transfer_groups)} 组转移任务到 {target_device_id}: "
f"{successful_count} 成功, {failed_count} 失败"
}
except Exception as e:
error_msg = f"批量转移物料失败: {str(e)}"
self.hardware_interface._logger.error(error_msg)
return {
"success": False,
"total_groups": len(transfer_groups) if transfer_groups else 0,
"successful_groups": 0,
"failed_groups": len(transfer_groups) if transfer_groups else 0,
"target_device_id": target_device_id if target_device_id else "",
"error": error_msg
}
def query_resource_by_name(self, material_name: str):
"""
通过物料名称查询资源对象(适用于Bioyond系统)
Args:
material_name: 物料名称
Returns:
物料ID或None
"""
try:
# Bioyond系统使用material_cache存储物料信息
if not hasattr(self.hardware_interface, 'material_cache'):
self.hardware_interface._logger.error(
"hardware_interface没有material_cache属性"
)
return None
material_cache = self.hardware_interface.material_cache
self.hardware_interface._logger.info(
f"查询物料 '{material_name}', 缓存中共有 {len(material_cache)} 个物料"
)
# 调试: 打印前几个物料信息
if material_cache:
cache_items = list(material_cache.items())[:5]
for name, material_id in cache_items:
self.hardware_interface._logger.debug(
f"缓存物料: name={name}, id={material_id}"
)
# 直接从缓存中查找
if material_name in material_cache:
material_id = material_cache[material_name]
self.hardware_interface._logger.info(
f"找到物料: {material_name} -> ID: {material_id}"
)
return material_id
self.hardware_interface._logger.warning(
f"未找到物料: {material_name} (缓存中无此物料)"
)
# 打印所有可用物料名称供参考
available_materials = list(material_cache.keys())
if available_materials:
self.hardware_interface._logger.info(
f"可用物料列表(前10个): {available_materials[:10]}"
)
return None
except Exception as e:
self.hardware_interface._logger.error(
f"查询物料失败 {material_name}: {str(e)}"
)
return None
if __name__ == "__main__":
bioyond = BioyondDispensingStation(config={
@@ -1735,3 +1089,4 @@ if __name__ == "__main__":
# id = "3a1bce3c-4f31-c8f3-5525-f3b273bc34dc"
# bioyond.sample_waste_removal(id)

View File

@@ -1,5 +1,4 @@
import json
import time
import requests
from typing import List, Dict, Any
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
@@ -233,7 +232,7 @@ class BioyondReactionStation(BioyondWorkstation):
temperature: 温度设定(°C)
"""
# 处理 volume 参数:优先使用直接传入的 volume,否则从 solvents 中提取
if not volume and solvents is not None:
if volume is None and solvents is not None:
# 参数类型转换:如果是字符串则解析为字典
if isinstance(solvents, str):
try:
@@ -292,39 +291,22 @@ class BioyondReactionStation(BioyondWorkstation):
def liquid_feeding_titration(
self,
volume_formula: str,
assign_material_name: str,
volume_formula: str = None,
x_value: str = None,
feeding_order_data: str = None,
extracted_actuals: str = None,
titration_type: str = "2",
titration_type: str = "1",
time: str = "90",
torque_variation: int = 2,
temperature: float = 25.00
):
"""液体进料(滴定)
支持两种模式:
1. 直接提供 volume_formula (传统方式)
2. 自动计算公式: 提供 x_value, feeding_order_data, extracted_actuals (新方式)
Args:
volume_formula: 分液公式(μL)
assign_material_name: 物料名称
volume_formula: 分液公式(μL),如果提供则直接使用,否则自动计算
x_value: 手工输入的x值,格式如 "1-2-3"
feeding_order_data: feeding_order JSON字符串或对象,用于获取m二酐值
extracted_actuals: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume
titration_type: 是否滴定(1=否, 2=是),默认2
titration_type: 是否滴定(1=否, 2=是)
time: 观察时间(分钟)
torque_variation: 是否观察(int类型, 1=否, 2=是)
temperature: 温度(°C)
自动公式模板: 1000*(m二酐-x)*V二酐滴定/m二酐滴定
其中:
- m二酐滴定 = actualTargetWeigh (从extracted_actuals获取)
- V二酐滴定 = actualVolume (从extracted_actuals获取)
- x = x_value (手工输入)
- m二酐 = feeding_order中type为"main_anhydride"的amount值
"""
self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding(titration)"}')
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
@@ -334,84 +316,6 @@ class BioyondReactionStation(BioyondWorkstation):
if isinstance(temperature, str):
temperature = float(temperature)
# 如果没有直接提供volume_formula,则自动计算
if not volume_formula and x_value and feeding_order_data and extracted_actuals:
# 1. 解析 feeding_order_data 获取 m二酐
if isinstance(feeding_order_data, str):
try:
feeding_order_data = json.loads(feeding_order_data)
except json.JSONDecodeError as e:
raise ValueError(f"feeding_order_data JSON解析失败: {str(e)}")
# 支持两种格式:
# 格式1: 直接是数组 [{...}, {...}]
# 格式2: 对象包裹 {"feeding_order": [{...}, {...}]}
if isinstance(feeding_order_data, list):
feeding_order_list = feeding_order_data
elif isinstance(feeding_order_data, dict):
feeding_order_list = feeding_order_data.get("feeding_order", [])
else:
raise ValueError("feeding_order_data 必须是数组或包含feeding_order的字典")
# 从feeding_order中找到main_anhydride的amount
m_anhydride = None
for item in feeding_order_list:
if item.get("type") == "main_anhydride":
m_anhydride = item.get("amount")
break
if m_anhydride is None:
raise ValueError("在feeding_order中未找到type为'main_anhydride'的条目")
# 2. 解析 extracted_actuals 获取 actualTargetWeigh 和 actualVolume
if isinstance(extracted_actuals, str):
try:
extracted_actuals_obj = json.loads(extracted_actuals)
except json.JSONDecodeError as e:
raise ValueError(f"extracted_actuals JSON解析失败: {str(e)}")
else:
extracted_actuals_obj = extracted_actuals
# 获取actuals数组
actuals_list = extracted_actuals_obj.get("actuals", [])
if not actuals_list:
# actuals为空,无法自动生成公式,回退到手动模式
print(f"警告: extracted_actuals中actuals数组为空,无法自动生成公式,请手动提供volume_formula")
volume_formula = None # 清空,触发后续的错误检查
else:
# 根据assign_material_name匹配对应的actual数据
# 假设order_code中包含物料名称
matched_actual = None
for actual in actuals_list:
order_code = actual.get("order_code", "")
# 简单匹配:如果order_code包含物料名称
if assign_material_name in order_code:
matched_actual = actual
break
# 如果没有匹配到,使用第一个
if not matched_actual and actuals_list:
matched_actual = actuals_list[0]
if not matched_actual:
raise ValueError("无法从extracted_actuals中获取实际加料量数据")
m_anhydride_titration = matched_actual.get("actualTargetWeigh") # m二酐滴定
v_anhydride_titration = matched_actual.get("actualVolume") # V二酐滴定
if m_anhydride_titration is None or v_anhydride_titration is None:
raise ValueError(f"实际加料量数据不完整: actualTargetWeigh={m_anhydride_titration}, actualVolume={v_anhydride_titration}")
# 3. 构建公式: 1000*(m二酐-x)*V二酐滴定/m二酐滴定
# x_value 格式如 "{{1-2-3}}",保留完整格式(包括花括号)直接替换到公式中
volume_formula = f"1000*({m_anhydride}-{x_value})*{v_anhydride_titration}/{m_anhydride_titration}"
print(f"自动生成滴定公式: {volume_formula}")
print(f" m二酐={m_anhydride}, x={x_value}, V二酐滴定={v_anhydride_titration}, m二酐滴定={m_anhydride_titration}")
elif not volume_formula:
raise ValueError("必须提供 volume_formula 或 (x_value + feeding_order_data + extracted_actuals)")
liquid_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["liquid"]
observe_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["observe"]
@@ -439,185 +343,9 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True})
def _extract_actuals_from_report(self, report) -> Dict[str, Any]:
data = report.get('data') if isinstance(report, dict) else None
actual_target_weigh = None
actual_volume = None
if data:
extra = data.get('extraProperties') or {}
if isinstance(extra, dict):
for v in extra.values():
obj = None
try:
obj = json.loads(v) if isinstance(v, str) else v
except Exception:
obj = None
if isinstance(obj, dict):
tw = obj.get('targetWeigh')
vol = obj.get('volume')
if tw is not None:
try:
actual_target_weigh = float(tw)
except Exception:
pass
if vol is not None:
try:
actual_volume = float(vol)
except Exception:
pass
return {
'actualTargetWeigh': actual_target_weigh,
'actualVolume': actual_volume
}
def extract_actuals_from_batch_reports(self, batch_reports_result: str) -> dict:
print(f"[DEBUG] extract_actuals 收到原始数据: {batch_reports_result[:500]}...") # 打印前500字符
try:
obj = json.loads(batch_reports_result) if isinstance(batch_reports_result, str) else batch_reports_result
if isinstance(obj, dict) and "return_info" in obj:
inner = obj["return_info"]
obj = json.loads(inner) if isinstance(inner, str) else inner
reports = obj.get("reports", []) if isinstance(obj, dict) else []
print(f"[DEBUG] 解析后的 reports 数组长度: {len(reports)}")
except Exception as e:
print(f"[DEBUG] 解析异常: {e}")
reports = []
actuals = []
for i, r in enumerate(reports):
print(f"[DEBUG] 处理 report[{i}]: order_code={r.get('order_code')}, has_extracted={r.get('extracted') is not None}, has_report={r.get('report') is not None}")
order_code = r.get("order_code")
order_id = r.get("order_id")
ex = r.get("extracted")
if isinstance(ex, dict) and (ex.get("actualTargetWeigh") is not None or ex.get("actualVolume") is not None):
print(f"[DEBUG] 从 extracted 字段提取: actualTargetWeigh={ex.get('actualTargetWeigh')}, actualVolume={ex.get('actualVolume')}")
actuals.append({
"order_code": order_code,
"order_id": order_id,
"actualTargetWeigh": ex.get("actualTargetWeigh"),
"actualVolume": ex.get("actualVolume")
})
continue
report = r.get("report")
vals = self._extract_actuals_from_report(report) if report else {"actualTargetWeigh": None, "actualVolume": None}
print(f"[DEBUG] 从 report 字段提取: {vals}")
actuals.append({
"order_code": order_code,
"order_id": order_id,
**vals
})
print(f"[DEBUG] 最终提取的 actuals 数组长度: {len(actuals)}")
result = {
"return_info": json.dumps({"actuals": actuals}, ensure_ascii=False)
}
print(f"[DEBUG] 返回结果: {result}")
return result
def wait_for_multiple_orders_and_get_reports(self, batch_create_result: str = None, timeout: int = 7200, check_interval: int = 10) -> Dict[str, Any]:
try:
timeout = int(timeout) if timeout else 7200
check_interval = int(check_interval) if check_interval else 10
if not batch_create_result or batch_create_result == "":
raise ValueError("batch_create_result为空")
try:
if isinstance(batch_create_result, str) and '[...]' in batch_create_result:
batch_create_result = batch_create_result.replace('[...]', '[]')
result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result
if isinstance(result_obj, dict) and "return_value" in result_obj:
inner = result_obj.get("return_value")
if isinstance(inner, str):
result_obj = json.loads(inner)
elif isinstance(inner, dict):
result_obj = inner
order_codes = result_obj.get("order_codes", [])
order_ids = result_obj.get("order_ids", [])
except Exception as e:
raise ValueError(f"解析batch_create_result失败: {e}")
if not order_codes or not order_ids:
raise ValueError("缺少order_codes或order_ids")
if not isinstance(order_codes, list):
order_codes = [order_codes]
if not isinstance(order_ids, list):
order_ids = [order_ids]
if len(order_codes) != len(order_ids):
raise ValueError("order_codes与order_ids数量不匹配")
total = len(order_codes)
pending = {c: {"order_id": order_ids[i], "completed": False} for i, c in enumerate(order_codes)}
reports = []
start_time = time.time()
while pending:
elapsed_time = time.time() - start_time
if elapsed_time > timeout:
for oc in list(pending.keys()):
reports.append({
"order_code": oc,
"order_id": pending[oc]["order_id"],
"status": "timeout",
"completion_status": None,
"report": None,
"extracted": None,
"elapsed_time": elapsed_time
})
break
completed_round = []
for oc in list(pending.keys()):
oid = pending[oc]["order_id"]
if oc in self.order_completion_status:
info = self.order_completion_status[oc]
try:
rq = json.dumps({"order_id": oid})
rep = self.hardware_interface.order_report(rq)
if not rep:
rep = {"error": "无法获取报告"}
reports.append({
"order_code": oc,
"order_id": oid,
"status": "completed",
"completion_status": info.get('status'),
"report": rep,
"extracted": self._extract_actuals_from_report(rep),
"elapsed_time": elapsed_time
})
completed_round.append(oc)
del self.order_completion_status[oc]
except Exception as e:
reports.append({
"order_code": oc,
"order_id": oid,
"status": "error",
"completion_status": info.get('status') if 'info' in locals() else None,
"report": None,
"extracted": None,
"error": str(e),
"elapsed_time": elapsed_time
})
completed_round.append(oc)
for oc in completed_round:
del pending[oc]
if pending:
time.sleep(check_interval)
completed_count = sum(1 for r in reports if r['status'] == 'completed')
timeout_count = sum(1 for r in reports if r['status'] == 'timeout')
error_count = sum(1 for r in reports if r['status'] == 'error')
final_elapsed_time = time.time() - start_time
summary = {
"total": total,
"completed": completed_count,
"timeout": timeout_count,
"error": error_count,
"elapsed_time": round(final_elapsed_time, 2),
"reports": reports
}
return {
"return_info": json.dumps(summary, ensure_ascii=False)
}
except Exception as e:
raise
def liquid_feeding_beaker(
self,
volume: str = "350",
volume: str = "35000",
assign_material_name: str = "BAPP",
time: str = "0",
torque_variation: int = 1,
@@ -627,7 +355,7 @@ class BioyondReactionStation(BioyondWorkstation):
"""液体进料烧杯
Args:
volume: 分液量(g)
volume: 分液量(μL)
assign_material_name: 物料名称(试剂瓶位)
time: 观察时间(分钟)
torque_variation: 是否观察(int类型, 1=否, 2=是)
@@ -852,14 +580,7 @@ class BioyondReactionStation(BioyondWorkstation):
# print(f"\n✅ 任务创建成功: {result}")
# print(f"\n✅ 任务创建成功")
print(f"{'='*60}\n")
# 返回结果,包含合并后的工作流数据和订单参数
return json.dumps({
"success": True,
"result": result,
"merged_workflow": merged_workflow,
"order_params": order_params
})
return json.dumps({"success": True, "result": result})
def _build_workflows_with_parameters(self, workflows_result: list) -> list:
"""
@@ -1059,4 +780,4 @@ class BioyondReactionStation(BioyondWorkstation):
except Exception as e:
print(f" ❌ 工作流ID验证失败: {e}")
print(f" 💡 将重新合并工作流")
return False
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
# Modbus CSV 地址映射说明
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
## 1. CSV 文件结构
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
每一行定义了一个 Modbus 节点,包含以下关键列:
| 列名 | 说明 | 示例 |
|------|------|------|
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
| **Comment** | 注释说明 | `使用铝箔垫` |
| **Attribute** | 属性 (通常留空或用于额外标记) | |
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
| **Address** | **Modbus 地址** (十进制) | `8340` |
### 示例行 (铝箔垫片)
```csv
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
```
- **名称**: `COIL_ALUMINUM_FOIL`
- **类型**: `coil` (线圈,读写单个位)
- **地址**: `8340`
---
## 2. 加载与注册流程
`coin_cell_assembly.py` 的初始化代码中:
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
```python
# 代码位置: coin_cell_assembly.py (L174-175)
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
self.client = modbus_client.register_node_list(self.nodes)
```
---
## 3. 代码中的使用方式
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
```python
# 代码位置: qiming_coin_cell_code 函数 (L1048)
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
```
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
---
## 4. 地址转换注意事项 (Modbus vs PLC)
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...``%MW...`)。这两者之间通常需要转换。
### 常见的转换规则 (示例)
- **Coil (线圈) %QX**:
- `Modbus地址 = 字节地址 * 8 + 位偏移`
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
- **Register (寄存器) %MW**:
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
### 验证方法
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。

View File

@@ -0,0 +1,645 @@
"""
纽扣电池组装工作站物料类定义
Button Battery Assembly Station Resource Classes
"""
from __future__ import annotations
from collections import OrderedDict
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.container import Container
from pylabrobot.resources.deck import Deck
from pylabrobot.resources.itemized_resource import ItemizedResource
from pylabrobot.resources.resource import Resource
from pylabrobot.resources.resource_stack import ResourceStack
from pylabrobot.resources.tip_rack import TipRack, TipSpot
from pylabrobot.resources.trash import Trash
from pylabrobot.resources.utils import create_ordered_items_2d
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
# TODO: 这个应该只能放一个极片
class MaterialHoleState(TypedDict):
diameter: int
depth: int
max_sheets: int
info: Optional[str] # 附加信息
class MaterialHole(Resource):
"""料板洞位类"""
children: List[ElectrodeSheet] = []
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "material_hole",
**kwargs
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
)
self._unilabos_state: MaterialHoleState = MaterialHoleState(
diameter=20,
depth=10,
max_sheets=1,
info=None
)
def get_all_sheet_info(self):
info_list = []
for sheet in self.children:
info_list.append(sheet._unilabos_state["info"])
return info_list
#这个函数函数好像没用,一般不会集中赋值质量
def set_all_sheet_mass(self):
for sheet in self.children:
sheet._unilabos_state["mass"] = 0.5 # 示例设置质量为0.5g
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
#移动极片前先取出对象
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
for sheet in self.children:
if sheet.name == name:
return sheet
return None
def has_electrode_sheet(self) -> bool:
"""检查洞位是否有极片"""
return len(self.children) > 0
def assign_child_resource(
self,
resource: ElectrodeSheet,
location: Optional[Coordinate],
reassign: bool = True,
):
"""放置极片"""
# TODO: 这里要改diameter找不到加入._unilabos_state后应该没问题
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
#if len(self.children) >= self._unilabos_state["max_sheets"]:
# raise ValueError(f"洞位已满,无法放置更多极片")
super().assign_child_resource(resource, location, reassign)
# 根据children的编号取物料对象。
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
return self.children[index]
class MaterialPlateState(TypedDict):
hole_spacing_x: float
hole_spacing_y: float
hole_diameter: float
info: Optional[str] # 附加信息
class MaterialPlate(ItemizedResource[MaterialHole]):
"""料板类 - 4x4个洞位每个洞位放1个极片"""
children: List[MaterialHole]
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
ordered_items: Optional[Dict[str, MaterialHole]] = None,
ordering: Optional[OrderedDict[str, str]] = None,
category: str = "material_plate",
model: Optional[str] = None,
fill: bool = False
):
"""初始化料板
Args:
name: 料板名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
hole_spacing_x: X方向洞位间距 (mm)
hole_spacing_y: Y方向洞位间距 (mm)
number: 编号
category: 类别
model: 型号
"""
self._unilabos_state: MaterialPlateState = MaterialPlateState(
hole_spacing_x=24.0,
hole_spacing_y=24.0,
hole_diameter=20.0,
info="",
)
# 创建4x4的洞位
# TODO: 这里要改,对应不同形状
holes = create_ordered_items_2d(
klass=MaterialHole,
num_items_x=4,
num_items_y=4,
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
dz=size_z,
item_dx=self._unilabos_state["hole_spacing_x"],
item_dy=self._unilabos_state["hole_spacing_y"],
size_x = 16,
size_y = 16,
size_z = 16,
)
if fill:
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=holes,
category=category,
model=model,
)
else:
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=ordered_items,
ordering=ordering,
category=category,
model=model,
)
def update_locations(self):
# TODO:调多次相加
holes = create_ordered_items_2d(
klass=MaterialHole,
num_items_x=4,
num_items_y=4,
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
dz=self._size_z,
item_dx=self._unilabos_state["hole_spacing_x"],
item_dy=self._unilabos_state["hole_spacing_y"],
size_x = 1,
size_y = 1,
size_z = 1,
)
for item, original_item in zip(holes.items(), self.children):
original_item.location = item[1].location
class PlateSlot(ResourceStack):
"""板槽位类 - 1个槽上能堆放8个板移板只能操作最上方的板"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
max_plates: int = 8,
category: str = "plate_slot",
model: Optional[str] = None
):
"""初始化板槽位
Args:
name: 槽位名称
max_plates: 最大板数量
category: 类别
"""
super().__init__(
name=name,
direction="z", # Z方向堆叠
resources=[],
)
self.max_plates = max_plates
self.category = category
def can_add_plate(self) -> bool:
"""检查是否可以添加板"""
return len(self.children) < self.max_plates
def add_plate(self, plate: MaterialPlate) -> None:
"""添加料板"""
if not self.can_add_plate():
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
self.assign_child_resource(plate)
def get_top_plate(self) -> MaterialPlate:
"""获取最上方的板"""
if len(self.children) == 0:
raise ValueError(f"槽位 {self.name} 为空")
return cast(MaterialPlate, self.get_top_item())
def take_top_plate(self) -> MaterialPlate:
"""取出最上方的板"""
top_plate = self.get_top_plate()
self.unassign_child_resource(top_plate)
return top_plate
def can_access_for_picking(self) -> bool:
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
return len(self.children) > 0
def serialize(self) -> dict:
return {
**super().serialize(),
"max_plates": self.max_plates,
}
#是一种类型注解不用self
class BatteryState(TypedDict):
"""电池状态字典"""
diameter: float
height: float
assembly_pressure: float
electrolyte_volume: float
electrolyte_name: str
class Battery(Resource):
"""电池类 - 可容纳极片"""
children: List[ElectrodeSheet] = []
def __init__(
self,
name: str,
size_x=1,
size_y=1,
size_z=1,
category: str = "battery",
):
"""初始化电池
Args:
name: 电池名称
diameter: 直径 (mm)
height: 高度 (mm)
max_volume: 最大容量 (μL)
barcode: 二维码编号
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=1,
size_y=1,
size_z=1,
category=category,
)
self._unilabos_state: BatteryState = BatteryState(
diameter = 1.0,
height = 1.0,
assembly_pressure = 1.0,
electrolyte_volume = 1.0,
electrolyte_name = "DP001"
)
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
to_add_name = bottle._unilabos_state["electrolyte_name"]
if bottle.aspirate_electrolyte(10):
if self.add_electrolyte(to_add_name, 10):
pass
else:
bottle._unilabos_state["electrolyte_volume"] += 10
def set_electrolyte(self, name: str, volume: float) -> None:
"""设置电解液信息"""
self._unilabos_state["electrolyte_name"] = name
self._unilabos_state["electrolyte_volume"] = volume
#这个应该没用,不会有加了后再加的事情
def add_electrolyte(self, name: str, volume: float) -> bool:
"""添加电解液信息"""
if name != self._unilabos_state["electrolyte_name"]:
return False
self._unilabos_state["electrolyte_volume"] += volume
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
# 电解液作为属性放进去
class BatteryPressSlotState(TypedDict):
"""电池状态字典"""
diameter: float =20.0
depth: float = 4.0
class BatteryPressSlot(Resource):
"""电池压制槽类 - 设备,可容纳一个电池"""
children: List[Battery] = []
def __init__(
self,
name: str = "BatteryPressSlot",
category: str = "battery_press_slot",
):
"""初始化电池压制槽
Args:
name: 压制槽名称
diameter: 直径 (mm)
depth: 深度 (mm)
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=10,
size_y=12,
size_z=13,
category=category,
)
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
def has_battery(self) -> bool:
"""检查是否有电池"""
return len(self.children) > 0
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
def assign_child_resource(
self,
resource: Battery,
location: Optional[Coordinate],
reassign: bool = True,
):
"""放置极片"""
# TODO: 让高京看下槽位只有一个电池时是否这么写。
if self.has_battery():
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
super().assign_child_resource(resource, location, reassign)
# 根据children的编号取物料对象。
def get_battery_info(self, index: int) -> Battery:
return self.children[0]
def TipBox64(
name: str,
size_x: float = 127.8,
size_y: float = 85.5,
size_z: float = 60.0,
category: str = "tip_rack",
model: Optional[str] = None,
):
"""64孔枪头盒类"""
from pylabrobot.resources.tip import Tip
# 创建12x8=96个枪头位
def make_tip():
return Tip(
has_filter=False,
total_tip_length=20.0,
maximal_volume=1000, # 1mL
fitting_depth=8.0,
)
tip_spots = create_ordered_items_2d(
klass=TipSpot,
num_items_x=12,
num_items_y=8,
dx=8.0,
dy=8.0,
dz=0.0,
item_dx=9.0,
item_dy=9.0,
size_x=10,
size_y=10,
size_z=0.0,
make_tip=make_tip,
)
idx_available = list(range(0, 32)) + list(range(64, 96))
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
tip_rack = TipRack(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
# ordered_items=tip_spots_available,
ordered_items=tip_spots,
category=category,
model=model,
with_tips=False,
)
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头中间32个无枪头
return tip_rack
class WasteTipBoxstate(TypedDict):
""""废枪头盒状态字典"""
max_tips: int = 100
tip_count: int = 0
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
class WasteTipBox(Trash):
"""废枪头盒类 - 100个枪头容量"""
def __init__(
self,
name: str,
size_x: float = 127.8,
size_y: float = 85.5,
size_z: float = 60.0,
material_z_thickness=0,
max_volume=float("inf"),
category="trash",
model=None,
compute_volume_from_height=None,
compute_height_from_volume=None,
):
"""初始化废枪头盒
Args:
name: 废枪头盒名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
max_tips: 最大枪头容量
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
model=model,
)
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
def add_tip(self) -> None:
"""添加废枪头"""
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
raise ValueError(f"废枪头盒 {self.name} 已满")
self._unilabos_state["tip_count"] += 1
def get_tip_count(self) -> int:
"""获取枪头数量"""
return self._unilabos_state["tip_count"]
def empty(self) -> None:
"""清空废枪头盒"""
self._unilabos_state["tip_count"] = 0
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
class CoincellDeck(Deck):
"""纽扣电池组装工作站台面类"""
def __init__(
self,
name: str = "coin_cell_deck",
size_x: float = 1450.0, # 1m
size_y: float = 1450.0, # 1m
size_z: float = 100.0, # 0.9m
origin: Coordinate = Coordinate(-2200, 0, 0),
category: str = "coin_cell_deck",
setup: bool = False, # 是否自动执行 setup
):
"""初始化纽扣电池组装工作站台面
Args:
name: 台面名称
size_x: 长度 (mm) - 1m
size_y: 宽度 (mm) - 1m
size_z: 高度 (mm) - 0.9m
origin: 原点坐标
category: 类别
setup: 是否自动执行 setup 配置标准布局
"""
super().__init__(
name=name,
size_x=1450.0,
size_y=1450.0,
size_z=100.0,
origin=origin,
)
if setup:
self.setup()
def setup(self) -> None:
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
# ====================================== 子弹夹 ============================================
# 正极片4个洞位2x2布局
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
# 正极壳、平垫片6个洞位2x2+2布局
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
# 负极壳、弹垫片6个洞位2x2+2布局
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
# 成品弹夹6个洞位3x2布局
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
# ====================================== 物料板 ============================================
# 创建物料板料盘carrier- 4x4布局
# 负极料盘
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
# for i in range(16):
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
# 隔膜料盘
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
# for i in range(16):
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
# ====================================== 瓶架、移液枪 ============================================
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
# bottle_rack_3x4 = BottleRack(
# name="bottle_rack_3x4",
# size_x=210.0,
# size_y=140.0,
# size_z=100.0,
# num_items_x=2,
# num_items_y=4,
# position_spacing=35.0,
# orientation="vertical",
# )
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
# 电解液缓存位 - 6x2布局
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
# 电解液回收位6x2
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
tip_box = TipBox64(name="tip_box_64")
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
waste_tip_box = WasteTipBox(name="waste_tip_box")
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
def YH_Deck(name=""):
cd = CoincellDeck(name=name)
cd.setup()
return cd
if __name__ == "__main__":
deck = create_coin_cell_deck()
print(deck)

View File

@@ -0,0 +1,64 @@
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
COIL_SYS_START_CMD,BOOL,,,,coil,9010,
COIL_SYS_STOP_CMD,BOOL,,,,coil,9020,
COIL_SYS_RESET_CMD,BOOL,,,,coil,9030,
COIL_SYS_HAND_CMD,BOOL,,,,coil,9040,
COIL_SYS_AUTO_CMD,BOOL,,,,coil,9050,
COIL_SYS_INIT_CMD,BOOL,,,,coil,9060,
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,9700,
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,9710,unilab_rec_msg_succ_cmd
COIL_SYS_START_STATUS,BOOL,,,,coil,9210,
COIL_SYS_STOP_STATUS,BOOL,,,,coil,9220,
COIL_SYS_RESET_STATUS,BOOL,,,,coil,9230,
COIL_SYS_HAND_STATUS,BOOL,,,,coil,9240,
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,9250,
COIL_SYS_INIT_STATUS,BOOL,,,,coil,9260,
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,9500,
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,9510,request_send_msg_status
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,17000,
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,17002,unilab_send_msg_electrolyte_num
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,17004,unilab_send_msg_electrolyte_vol
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,17006,unilab_send_msg_assembly_type
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,17008,unilab_send_msg_assembly_pressure
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,16000,data_assembly_coin_cell_num
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,16002,data_open_circuit_voltage
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,16004,
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,16006,
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,16008,
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,16010,data_pole_weight
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,16012,data_assembly_time
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,16014,data_assembly_pressure
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,16016,data_electrolyte_volume
REG_DATA_COIN_NUM,INT16,,,,hold_register,16018,data_coin_num
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,16020,data_electrolyte_code()
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,16030,data_coin_cell_code()
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,18004,data_stack_vision_code()
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,16050,data_glove_box_pressure
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,16052,data_glove_box_water_content
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,16054,data_glove_box_o2_content
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,9720,
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,9520,
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,17496,
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,16000,
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,9730,
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,9530,
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,16018,ASSEMBLY_TYPE7or8
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,9340,
REG_MSG_NE_PLATE_MATRIX,INT16,,负极片矩阵点位,,hold_register,17440,
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,隔膜矩阵点位,,hold_register,17450,
REG_MSG_TIP_BOX_MATRIX,INT16,,移液枪头矩阵点位,,hold_register,17480,
REG_MSG_NE_PLATE_NUM,INT16,,负极片盘数,,hold_register,17443,
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,隔膜盘数,,hold_register,17453,
REG_MSG_PRESS_MODE,BOOL,,压制模式false:压力检测模式True:距离模式),,coil,9360,电池压制模式
,,,,,,,
,BOOL,,视觉对位false:使用true:忽略),,coil,9300,视觉对位
,BOOL,,复检false:使用true:忽略),,coil,9310,视觉复检
,BOOL,,手套箱_左仓false:使用true:忽略),,coil,9320,手套箱左仓
,BOOL,,手套箱_右仓false:使用true:忽略),,coil,9420,手套箱右仓
,BOOL,,真空检知false:使用true:忽略),,coil,9350,真空检知
,BOOL,,电解液添加模式false:单次滴液true:二次滴液),,coil,9370,滴液模式
,BOOL,,正极片称重false:使用true:忽略),,coil,9380,正极片称重
,BOOL,,正负极片组装方式false:正装true:倒装),,coil,9390,正负极反装
,BOOL,,压制清洁false:使用true:忽略),,coil,9400,压制清洁
,BOOL,,物料盘摆盘方式false:水平摆盘true:堆叠摆盘),,coil,9410,负极片摆盘方式
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,忽略电池清洁false:使用true:忽略),,coil,9460,
1 Name DataType InitValue Comment Attribute DeviceType Address
2 COIL_SYS_START_CMD BOOL coil 9010
3 COIL_SYS_STOP_CMD BOOL coil 9020
4 COIL_SYS_RESET_CMD BOOL coil 9030
5 COIL_SYS_HAND_CMD BOOL coil 9040
6 COIL_SYS_AUTO_CMD BOOL coil 9050
7 COIL_SYS_INIT_CMD BOOL coil 9060
8 COIL_UNILAB_SEND_MSG_SUCC_CMD BOOL coil 9700
9 COIL_UNILAB_REC_MSG_SUCC_CMD BOOL coil 9710 unilab_rec_msg_succ_cmd
10 COIL_SYS_START_STATUS BOOL coil 9210
11 COIL_SYS_STOP_STATUS BOOL coil 9220
12 COIL_SYS_RESET_STATUS BOOL coil 9230
13 COIL_SYS_HAND_STATUS BOOL coil 9240
14 COIL_SYS_AUTO_STATUS BOOL coil 9250
15 COIL_SYS_INIT_STATUS BOOL coil 9260
16 COIL_REQUEST_REC_MSG_STATUS BOOL coil 9500
17 COIL_REQUEST_SEND_MSG_STATUS BOOL coil 9510 request_send_msg_status
18 REG_MSG_ELECTROLYTE_USE_NUM INT16 hold_register 17000
19 REG_MSG_ELECTROLYTE_NUM INT16 hold_register 17002 unilab_send_msg_electrolyte_num
20 REG_MSG_ELECTROLYTE_VOLUME INT16 hold_register 17004 unilab_send_msg_electrolyte_vol
21 REG_MSG_ASSEMBLY_TYPE INT16 hold_register 17006 unilab_send_msg_assembly_type
22 REG_MSG_ASSEMBLY_PRESSURE INT16 hold_register 17008 unilab_send_msg_assembly_pressure
23 REG_DATA_ASSEMBLY_COIN_CELL_NUM INT16 hold_register 16000 data_assembly_coin_cell_num
24 REG_DATA_OPEN_CIRCUIT_VOLTAGE FLOAT32 hold_register 16002 data_open_circuit_voltage
25 REG_DATA_AXIS_X_POS FLOAT32 hold_register 16004
26 REG_DATA_AXIS_Y_POS FLOAT32 hold_register 16006
27 REG_DATA_AXIS_Z_POS FLOAT32 hold_register 16008
28 REG_DATA_POLE_WEIGHT FLOAT32 hold_register 16010 data_pole_weight
29 REG_DATA_ASSEMBLY_PER_TIME FLOAT32 hold_register 16012 data_assembly_time
30 REG_DATA_ASSEMBLY_PRESSURE INT16 hold_register 16014 data_assembly_pressure
31 REG_DATA_ELECTROLYTE_VOLUME INT16 hold_register 16016 data_electrolyte_volume
32 REG_DATA_COIN_NUM INT16 hold_register 16018 data_coin_num
33 REG_DATA_ELECTROLYTE_CODE STRING hold_register 16020 data_electrolyte_code()
34 REG_DATA_COIN_CELL_CODE STRING hold_register 16030 data_coin_cell_code()
35 REG_DATA_STACK_VISON_CODE STRING hold_register 18004 data_stack_vision_code()
36 REG_DATA_GLOVE_BOX_PRESSURE FLOAT32 hold_register 16050 data_glove_box_pressure
37 REG_DATA_GLOVE_BOX_WATER_CONTENT FLOAT32 hold_register 16052 data_glove_box_water_content
38 REG_DATA_GLOVE_BOX_O2_CONTENT FLOAT32 hold_register 16054 data_glove_box_o2_content
39 UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM BOOL coil 9720
40 UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM BOOL coil 9520
41 REG_MSG_ELECTROLYTE_NUM_USED INT16 hold_register 17496
42 REG_DATA_ELECTROLYTE_USE_NUM INT16 hold_register 16000
43 UNILAB_SEND_FINISHED_CMD BOOL coil 9730
44 UNILAB_RECE_FINISHED_CMD BOOL coil 9530
45 REG_DATA_ASSEMBLY_TYPE INT16 hold_register 16018 ASSEMBLY_TYPE7or8
46 COIL_ALUMINUM_FOIL BOOL 使用铝箔垫 coil 9340
47 REG_MSG_NE_PLATE_MATRIX INT16 负极片矩阵点位 hold_register 17440
48 REG_MSG_SEPARATOR_PLATE_MATRIX INT16 隔膜矩阵点位 hold_register 17450
49 REG_MSG_TIP_BOX_MATRIX INT16 移液枪头矩阵点位 hold_register 17480
50 REG_MSG_NE_PLATE_NUM INT16 负极片盘数 hold_register 17443
51 REG_MSG_SEPARATOR_PLATE_NUM INT16 隔膜盘数 hold_register 17453
52 REG_MSG_PRESS_MODE BOOL 压制模式(false:压力检测模式,True:距离模式) coil 9360 电池压制模式
53
54 BOOL 视觉对位(false:使用,true:忽略) coil 9300 视觉对位
55 BOOL 复检(false:使用,true:忽略) coil 9310 视觉复检
56 BOOL 手套箱_左仓(false:使用,true:忽略) coil 9320 手套箱左仓
57 BOOL 手套箱_右仓(false:使用,true:忽略) coil 9420 手套箱右仓
58 BOOL 真空检知(false:使用,true:忽略) coil 9350 真空检知
59 BOOL 电解液添加模式(false:单次滴液,true:二次滴液) coil 9370 滴液模式
60 BOOL 正极片称重(false:使用,true:忽略) coil 9380 正极片称重
61 BOOL 正负极片组装方式(false:正装,true:倒装) coil 9390 正负极反装
62 BOOL 压制清洁(false:使用,true:忽略) coil 9400 压制清洁
63 BOOL 物料盘摆盘方式(false:水平摆盘,true:堆叠摆盘) coil 9410 负极片摆盘方式
64 REG_MSG_BATTERY_CLEAN_IGNORE BOOL 忽略电池清洁(false:使用,true:忽略) coil 9460

View File

@@ -0,0 +1,159 @@
Name,DataType,Comment,DeviceType,Address,,
COIL_SYS_START_CMD,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8010,,
COIL_SYS_STOP_CMD,BOOL,<EFBFBD>豸ֹͣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8020,,
COIL_SYS_RESET_CMD,BOOL,<EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8030,,
COIL_SYS_HAND_CMD,BOOL,<EFBFBD><EFBFBD>ֶ<EFBFBD>ģʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8040,,
COIL_SYS_AUTO_CMD,BOOL,<EFBFBD><EFBFBD>Զ<EFBFBD>ģʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8050,,
COIL_SYS_INIT_CMD,BOOL,<EFBFBD><EFBFBD><EFBFBD>ʼ<EFBFBD><EFBFBD>ģʽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8060,,
COIL_SYS_STOP_STATUS,BOOL,<EFBFBD><EFBFBD><EFBFBD>ͣ<EFBFBD><EFBFBD>,coil,8220,,
,,,,,,
,BOOL,UNILAB<EFBFBD><EFBFBD><EFBFBD>͵<EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8720,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD>,coil,8520,,
REG_MSG_ELECTROLYTE_NUM,WORD,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һʹ<EFBFBD><EFBFBD>ƿ<EFBFBD><EFBFBD>,hold_register,496,,
,WORD,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD>̾<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʼλ0<EFBFBD><EFBFBD>,hold_register,440,,
,WORD,<EFBFBD><EFBFBD>Ĥ<EFBFBD>̾<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʼλ0<EFBFBD><EFBFBD>,hold_register,450,,
,WORD,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD>_<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ͼ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʼλ0<EFBFBD><EFBFBD>,hold_register,460,,
,WORD,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD>_<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>վ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʼλ0<EFBFBD><EFBFBD>,hold_register,430,,
,WORD,g_<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һƿ<EFBFBD><EFBFBD>_<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>޸˸׾<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʼλ0<EFBFBD><EFBFBD>,hold_register,470,,
,WORD,<EFBFBD><EFBFBD>Һǹͷ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʼλ0<EFBFBD><EFBFBD>,hold_register,480,,
,WORD,<EFBFBD><EFBFBD><EFBFBD>ø<EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,443,,
,WORD,<EFBFBD><EFBFBD><EFBFBD>ø<EFBFBD>Ĥ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,453,,
,,,,,,
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,UNILAB<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8700,,
COIL_REQUEST_REC_MSG_STATUS,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8500,,
REG_MSG_ELECTROLYTE_USE_NUM,INT16,<EFBFBD><EFBFBD>ƿ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һʹ<EFBFBD>ô<EFBFBD><EFBFBD><EFBFBD>,hold_register,11000,,
REG_MSG_ELECTROLYTE_VOLUME,INT16,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ȡ<EFBFBD><EFBFBD>,hold_register,11004,,
REG_MSG_ASSEMBLY_PRESSURE,INT16,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,11008,,
REG_DATA_ELECTROLYTE_CODE,STRING,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ά<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>к<EFBFBD>,hold_register,10020,,
,BOOL,<EFBFBD>Ӿ<EFBFBD><EFBFBD><EFBFBD>λ<EFBFBD><EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8300,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD>죨false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8310,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>_<EFBFBD><EFBFBD><EFBFBD>֣<EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8320,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>_<EFBFBD>Ҳ֣<EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8420,,
,BOOL,<EFBFBD><EFBFBD>е<EFBFBD>ְ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>̣<EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8330,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ϣ<EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8340,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD>ռ<EFBFBD>֪<EFBFBD><EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8350,,
,BOOL,ѹ<EFBFBD><EFBFBD>ģʽ<EFBFBD><EFBFBD>false:ѹ<><D1B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ģʽ<C4A3><CABD>True:<3A><><EFBFBD><EFBFBD>ģʽ<C4A3><CABD>,coil,8360,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ģʽ<EFBFBD><EFBFBD>false:<3A><><EFBFBD>ε<EFBFBD>Һ<EFBFBD><D2BA>true:<3A><><EFBFBD>ε<EFBFBD>Һ<EFBFBD><D2BA>,coil,8370,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD>أ<EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8380,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD>ʽ<EFBFBD><EFBFBD>false:<3A><>װ<EFBFBD><D7B0>true:<3A><>װ<EFBFBD><D7B0>,coil,8390,,
,BOOL,ѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ࣨfalse:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8400,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>̰<EFBFBD><EFBFBD>̷<EFBFBD>ʽ<EFBFBD><EFBFBD>false:ˮƽ<CBAE><C6BD><EFBFBD>̣<EFBFBD>true:<3A>ѵ<EFBFBD><D1B5><EFBFBD><EFBFBD>̣<EFBFBD>,coil,8410,,
COIL_SYS_UNILAB_INTERACT ,BOOL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Unilab<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>false:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,coil,8450,,
,BOOL,<EFBFBD><EFBFBD><EFBFBD>Ե<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ࣨfalse:ʹ<>ã<EFBFBD>true:<3A><><EFBFBD>ԣ<EFBFBD>,colil,8460,,
,,,,,,
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,UNILAB<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8510,,
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,UNILAB<EFBFBD><EFBFBD><EFBFBD>ܲ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,8710,,
REG_DATA_POLE_WEIGHT,FLOAT32,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,10010,,
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD>ŵ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װʱ<EFBFBD><EFBFBD>,hold_register,10012,,
REG_DATA_ASSEMBLY_PRESSURE,INT16,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,10014,,
REG_DATA_ELECTROLYTE_VOLUME,INT16,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ע<EFBFBD><EFBFBD>,hold_register,10016,,
REG_DATA_ASSEMBLY_TYPE,INT16,<EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD>ѵ<EFBFBD><EFBFBD><EFBFBD>ʽ(7/8),hold_register,10018,,
REG_DATA_ELECTROLYTE_CODE,STRING,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ<EFBFBD><EFBFBD>ά<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>к<EFBFBD>,hold_register,10020,,
REG_DATA_COIN_CELL_CODE,STRING,<EFBFBD><EFBFBD><EFBFBD>ض<EFBFBD>ά<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>к<EFBFBD>,hold_register,10030,,
REG_DATA_STACK_VISON_CODE,STRING,<EFBFBD><EFBFBD><EFBFBD>϶ѵ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͼƬ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,10040,,
REG_DATA_ELECTROLYTE_USE_NUM,INT16,<EFBFBD><EFBFBD>ǰ<EFBFBD>缫Һ<EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10000,,
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD>ص<EFBFBD>ѹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,10002,,
,INT,<EFBFBD><EFBFBD>е<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȡ<EFBFBD><EFBFBD><EFBFBD>ϼĴ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1-<2D><><EFBFBD><EFBFBD><EFBFBD>ǡ<EFBFBD>2-<2D><><EFBFBD>桢3-<2D><><EFBFBD><EFBFBD>Ƭ<EFBFBD><C6AC>4-<2D><>Ĥ<EFBFBD><C4A4>5-<2D><><EFBFBD><EFBFBD>Ƭ<EFBFBD><C6AC>6-ƽ<>桢7-<2D><><EFBFBD>桢8-<2D><><EFBFBD><EFBFBD><EFBFBD>ǣ<EFBFBD>,hold_register,10060,,
,,,,,,
,INT,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10062,PLC<EFBFBD><EFBFBD>ַ,1223-<2D><><EFBFBD><EFBFBD>
,INT,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD>Ĥ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10064,,
,INT,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Һ״̬<EFBFBD>루R<EFBFBD><EFBFBD>,hold_register,10066,,
,REAL,<EFBFBD><EFBFBD>·<EFBFBD><EFBFBD>ѹOK<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ֵ<EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10068,,
,REAL,<EFBFBD><EFBFBD>·<EFBFBD><EFBFBD>ѹOK<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ֵ<EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10070,,
,INT,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10072,,
,INT,<EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,10074,,
,REAL,10mm<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,520,HMI<EFBFBD><EFBFBD>ַ,
,REAL,12mm<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,522,,
,REAL,16mm<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,524,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,526,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,528,,
,REAL,ƽ<EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,530,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,532,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,534,,
,REAL,<EFBFBD><EFBFBD>Ʒ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,536,,
,REAL,<EFBFBD><EFBFBD>Ʒ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>NG<EFBFBD><EFBFBD>ʣ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD><EFBFBD>,hold_register,538,,
,,,,,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>10mm<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,540,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>12mm<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,542,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>16mm<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ƭ<EFBFBD><EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,544,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,546,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ǻ<EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,548,,
,REAL,<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ƽ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,550,,
,REAL,<EFBFBD><EFBFBD><EFBFBD>ø<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ǻ<EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,552,,
,REAL,<EFBFBD><EFBFBD><EFBFBD>õ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,554,,
,REAL,<EFBFBD><EFBFBD><EFBFBD>ó<EFBFBD>Ʒ<EFBFBD><EFBFBD><EFBFBD>غ<EFBFBD><EFBFBD>ȣ<EFBFBD>W<EFBFBD><EFBFBD>,hold_register,556,,
,,,,,,
,,,,,,
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ѹ<EFBFBD><EFBFBD>,hold_register,10050,,
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ˮ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,10052,,
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,hold_register,10054,,
,,,,,,
,BOOL,<EFBFBD>쳣100-ϵͳ<CFB5>,coil,1000,,
,BOOL,<EFBFBD>쳣101-<2D><>ͣ,coil,1010,,
,BOOL,<EFBFBD>쳣111-<2D><><EFBFBD><EFBFBD><EFBFBD>伱ͣ,coil,1110,,
,BOOL,<EFBFBD>쳣112-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ڹ<EFBFBD>դ<EFBFBD>ڵ<EFBFBD>,coil,1120,,
,BOOL,<EFBFBD>쳣160-<2D><>Һǹͷȱ<CDB7><C8B1>,coil,1600,,
,BOOL,<EFBFBD>쳣161-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȱ<EFBFBD><C8B1>,coil,1610,,
,BOOL,<EFBFBD>쳣162-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȱ<EFBFBD><C8B1>,coil,1620,,
,BOOL,<EFBFBD>쳣163-<2D><><EFBFBD><EFBFBD>Ƭȱ<C6AC><C8B1>,coil,1630,,
,BOOL,<EFBFBD>쳣164-<2D><>Ĥȱ<C4A4><C8B1>,coil,1640,,
,BOOL,<EFBFBD>쳣165-<2D><><EFBFBD><EFBFBD>Ƭȱ<C6AC><C8B1>,coil,1650,,
,BOOL,<EFBFBD>쳣166-ƽ<><C6BD>ȱ<EFBFBD><C8B1>,coil,1660,,
,BOOL,<EFBFBD>쳣167-<2D><><EFBFBD><EFBFBD>ȱ<EFBFBD><C8B1>,coil,1670,,
,BOOL,<EFBFBD>쳣168-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȱ<EFBFBD><C8B1>,coil,1680,,
,BOOL,<EFBFBD>쳣169-<2D><>Ʒ<EFBFBD><C6B7><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,1690,,
,BOOL,<EFBFBD>쳣201-<2D>ŷ<EFBFBD><C5B7><EFBFBD>01<30>,coil,2010,,
,BOOL,<EFBFBD>쳣202-<2D>ŷ<EFBFBD><C5B7><EFBFBD>02<30>,coil,2020,,
,BOOL,<EFBFBD>쳣203-<2D>ŷ<EFBFBD><C5B7><EFBFBD>03<30>,coil,2030,,
,BOOL,<EFBFBD>쳣204-<2D>ŷ<EFBFBD><C5B7><EFBFBD>04<30>,coil,2040,,
,BOOL,<EFBFBD>쳣205-<2D>ŷ<EFBFBD><C5B7><EFBFBD>05<30>,coil,2050,,
,BOOL,<EFBFBD>쳣206-<2D>ŷ<EFBFBD><C5B7><EFBFBD>06<30>,coil,2060,,
,BOOL,<EFBFBD>쳣207-<2D>ŷ<EFBFBD><C5B7><EFBFBD>07<30>,coil,2070,,
,BOOL,<EFBFBD>쳣208-<2D>ŷ<EFBFBD><C5B7><EFBFBD>08<30>,coil,2080,,
,BOOL,<EFBFBD>쳣209-<2D>ŷ<EFBFBD><C5B7><EFBFBD>09<30>,coil,2090,,
,BOOL,<EFBFBD>쳣210-<2D>ŷ<EFBFBD><C5B7><EFBFBD>10<31>,coil,2100,,
,BOOL,<EFBFBD>쳣211-<2D>ŷ<EFBFBD><C5B7><EFBFBD>11<31>,coil,2110,,
,BOOL,<EFBFBD>쳣212-<2D>ŷ<EFBFBD><C5B7><EFBFBD>12<31>,coil,2120,,
,BOOL,<EFBFBD>쳣213-<2D>ŷ<EFBFBD><C5B7><EFBFBD>13<31>,coil,2130,,
,BOOL,<EFBFBD>쳣214-<2D>ŷ<EFBFBD><C5B7><EFBFBD>14<31>,coil,2140,,
,BOOL,<EFBFBD>쳣250-<2D><><EFBFBD><EFBFBD>Ԫ<EFBFBD><D4AA><EFBFBD>,coil,2500,,
,BOOL,<EFBFBD>쳣251-<2D><>ҺǹͨѶ<CDA8>,coil,2510,,
,BOOL,<EFBFBD>쳣252-<2D><>Һǹ<D2BA><C7B9><EFBFBD><EFBFBD>,coil,2520,,
,BOOL,<EFBFBD>쳣256-<2D><>צ<EFBFBD>,coil,2560,,
,BOOL,<EFBFBD>쳣262-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>δ֪<CEB4><D6AA>λ<EFBFBD><CEBB><EFBFBD><EFBFBD>,coil,2620,,
,BOOL,<EFBFBD>쳣263-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>X<EFBFBD><58>Y<EFBFBD><59>Z<EFBFBD><5A><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,2630,,
,BOOL,<EFBFBD>쳣264-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӿ<EFBFBD><D3BE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,2640,,
,BOOL,<EFBFBD>쳣265-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1#<23><><EFBFBD><EFBFBD>ȡ<EFBFBD><C8A1>ʧ<EFBFBD><CAA7>,coil,2650,,
,BOOL,<EFBFBD>쳣266-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2#<23><><EFBFBD><EFBFBD>ȡ<EFBFBD><C8A1>ʧ<EFBFBD><CAA7>,coil,2660,,
,BOOL,<EFBFBD>쳣267-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>3#<23><><EFBFBD><EFBFBD>ȡ<EFBFBD><C8A1>ʧ<EFBFBD><CAA7>,coil,2670,,
,BOOL,<EFBFBD>쳣268-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>4#<23><><EFBFBD><EFBFBD>ȡ<EFBFBD><C8A1>ʧ<EFBFBD><CAA7>,coil,2680,,
,BOOL,<EFBFBD>쳣269-RB<52><42><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʧ<EFBFBD><CAA7>,coil,2690,,
,BOOL,<EFBFBD>쳣280-RB<52><42>ײ<EFBFBD>,coil,2800,,
,BOOL,<EFBFBD>쳣290-<2D>Ӿ<EFBFBD>ϵͳͨѶ<CDA8>,coil,2900,,
,BOOL,<EFBFBD>쳣291-<2D>Ӿ<EFBFBD><D3BE><EFBFBD>λNG<4E>,coil,2910,,
,BOOL,<EFBFBD>쳣292-ɨ<><C9A8>ǹͨѶ<CDA8>,coil,2920,,
,BOOL,<EFBFBD>쳣310-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3100,,
,BOOL,<EFBFBD>쳣311-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3110,,
,BOOL,<EFBFBD>쳣312-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3120,,
,BOOL,<EFBFBD>쳣313-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3130,,
,BOOL,<EFBFBD>쳣340-<2D><>·<EFBFBD><C2B7>ѹ<EFBFBD><D1B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3400,,
,BOOL,<EFBFBD>쳣342-<2D><>·<EFBFBD><C2B7>ѹ<EFBFBD><D1B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3420,,
,BOOL,<EFBFBD>쳣344-<2D><>·<EFBFBD><C2B7>ѹ<EFBFBD><D1B9>ѹ<EFBFBD><D1B9><EFBFBD><EFBFBD><EFBFBD>,coil,3440,,
,BOOL,<EFBFBD>쳣350-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3500,,
,BOOL,<EFBFBD>쳣352-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3520,,
,BOOL,<EFBFBD>쳣354-<2D><>ϴ<EFBFBD>޳<EFBFBD><DEB3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3540,,
,BOOL,<EFBFBD>쳣356-<2D><>ϴ<EFBFBD>޳<EFBFBD><DEB3><EFBFBD>ѹ<EFBFBD><D1B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3560,,
,BOOL,<EFBFBD>쳣360-<2D><><EFBFBD><EFBFBD>Һƿ<D2BA><C6BF>λ<EFBFBD><CEBB><EFBFBD><EFBFBD><EFBFBD>,coil,3600,,
,BOOL,<EFBFBD>쳣362-<2D><>Һǹͷ<C7B9>ж<EFBFBD>λ<EFBFBD><CEBB><EFBFBD><EFBFBD><EFBFBD>,coil,3620,,
COIL ALARM_364_SERVO_DRIVE_ERROR,BOOL,<EFBFBD>쳣364-<2D>Լ<EFBFBD>ƿ<EFBFBD><C6BF>צ<EFBFBD><D7A6><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3640,,
COIL ALARM_367_SERVO_DRIVER_ERROR,BOOL,<EFBFBD>쳣366-<2D>Լ<EFBFBD>ƿ<EFBFBD><C6BF>צ<EFBFBD><D7A6><EFBFBD><EFBFBD><EFBFBD>,coil,3660,,
COIL ALARM_370_SERVO_MODULE_ERROR,BOOL,<EFBFBD>쳣370-ѹ<><D1B9>ģ<EFBFBD><EFBFBD><E9B4B5><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>,coil,3700,,
,,,,,,
,,,,,,
,,,,,,
,,,,,,
,,,,,,
,,,,,,
,,,,,,
COIL + <20><><EFBFBD><EFBFBD>ģ<EFBFBD><C4A3>/<2F><><EFBFBD><EFBFBD> + <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>д+<2B>»<EFBFBD><C2BB>߷ָ<DFB7><D6B8><EFBFBD>--<2D><><EFBFBD><EFBFBD>boolֵ,,,,,,
REG + <20><><EFBFBD><EFBFBD>ģ<EFBFBD><C4A3>/<2F><><EFBFBD><EFBFBD> + <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>д+<2B>»<EFBFBD><C2BB>߷ָ<DFB7><D6B8><EFBFBD>--<2D><><EFBFBD>ԼĴ<D4BC><C4B4><EFBFBD>,,,,,,
1 Name DataType Comment DeviceType Address
2 COIL_SYS_START_CMD BOOL 设备启动命令 coil 8010
3 COIL_SYS_STOP_CMD BOOL 设备停止命令 coil 8020
4 COIL_SYS_RESET_CMD BOOL 设备复位命令 coil 8030
5 COIL_SYS_HAND_CMD BOOL 设备手动模式命令 coil 8040
6 COIL_SYS_AUTO_CMD BOOL 设备自动模式命令 coil 8050
7 COIL_SYS_INIT_CMD BOOL 设备初始化模式命令 coil 8060
8 COIL_SYS_STOP_STATUS BOOL 设备暂停中 coil 8220
9
10 BOOL UNILAB发送电解液瓶数完毕 coil 8720
11 BOOL 设备请求接受电解液瓶数 coil 8520
12 REG_MSG_ELECTROLYTE_NUM WORD 电解液使用瓶数 hold_register 496
13 WORD 负极片盘矩阵点位(初始位0) hold_register 440
14 WORD 隔膜盘矩阵点位(初始位0) hold_register 450
15 WORD 电解液瓶盘_缓存上料矩阵点位(初始位0) hold_register 460
16 WORD 电解液瓶盘_缓存回收矩阵点位(初始位0) hold_register 430
17 WORD g_电解液瓶盘_手套箱无杆缸矩阵点位(初始位0) hold_register 470
18 WORD 移液枪头矩阵点位(初始位0) hold_register 480
19 WORD 设置负极片盘数 hold_register 443
20 WORD 设置隔膜盘数 hold_register 453
21
22 COIL_UNILAB_SEND_MSG_SUCC_CMD BOOL UNILAB发送配方完毕 coil 8700
23 COIL_REQUEST_REC_MSG_STATUS BOOL 设备请求接受配方 coil 8500
24 REG_MSG_ELECTROLYTE_USE_NUM INT16 单瓶电解液使用次数 hold_register 11000
25 REG_MSG_ELECTROLYTE_VOLUME INT16 电解液吸取量 hold_register 11004
26 REG_MSG_ASSEMBLY_PRESSURE INT16 电池组装压制力 hold_register 11008
27 REG_DATA_ELECTROLYTE_CODE STRING 电解液二维码序列号 hold_register 10020
28 BOOL 视觉对位(false:使用,true:忽略) coil 8300
29 BOOL 复检(false:使用,true:忽略) coil 8310
30 BOOL 手套箱_左仓(false:使用,true:忽略) coil 8320
31 BOOL 手套箱_右仓(false:使用,true:忽略) coil 8420
32 BOOL 机械手搬送料盘(false:使用,true:忽略) coil 8330
33 BOOL 铝箔物料(false:使用,true:忽略) coil 8340
34 BOOL 真空检知(false:使用,true:忽略) coil 8350
35 BOOL 压制模式(false:压力检测模式,True:距离模式) coil 8360
36 BOOL 电解液添加模式(false:单次滴液,true:二次滴液) coil 8370
37 BOOL 正极片称重(false:使用,true:忽略) coil 8380
38 BOOL 正负极片组装方式(false:正装,true:倒装) coil 8390
39 BOOL 压制清洁(false:使用,true:忽略) coil 8400
40 BOOL 物料盘摆盘方式(false:水平摆盘,true:堆叠摆盘) coil 8410
41 COIL_SYS_UNILAB_INTERACT BOOL 忽略Unilab交互(false:使用,true:忽略) coil 8450
42 BOOL 忽略电池清洁(false:使用,true:忽略) colil 8460
43
44 COIL_UNILAB_SEND_MSG_SUCC_CMD BOOL UNILAB发送配方完毕 coil 8510
45 COIL_UNILAB_REC_MSG_SUCC_CMD BOOL UNILAB接受测试数据完毕 coil 8710
46 REG_DATA_POLE_WEIGHT FLOAT32 当前电池正极片称重数据 hold_register 10010
47 REG_DATA_ASSEMBLY_PER_TIME FLOAT32 当前单颗电池组装时间 hold_register 10012
48 REG_DATA_ASSEMBLY_PRESSURE INT16 当前电池组装压制力 hold_register 10014
49 REG_DATA_ELECTROLYTE_VOLUME INT16 当前电解液加注量 hold_register 10016
50 REG_DATA_ASSEMBLY_TYPE INT16 组装参数:极片堆叠方式(7/8) hold_register 10018
51 REG_DATA_ELECTROLYTE_CODE STRING 电解液二维码序列号 hold_register 10020
52 REG_DATA_COIN_CELL_CODE STRING 电池二维码序列号 hold_register 10030
53 REG_DATA_STACK_VISON_CODE STRING 物料堆叠复检图片编码 hold_register 10040
54 REG_DATA_ELECTROLYTE_USE_NUM INT16 当前电极液组装电池数量(R) hold_register 10000
55 REG_DATA_OPEN_CIRCUIT_VOLTAGE FLOAT32 当前电池电压数据 hold_register 10002
56 INT 机械手吸取物料寄存器(1-正极壳、2-铝垫、3-正极片、4-隔膜、5-负极片、6-平垫、7-弹垫、8-负极壳) hold_register 10060
57
58 INT 当前负极片剩余盘数量(R) hold_register 10062 PLC地址 1223-新增
59 INT 当前隔膜神域盘数量(R) hold_register 10064
60 INT 电解液状态码(R) hold_register 10066
61 REAL 开路电压OK下限值(R) hold_register 10068
62 REAL 开路电压OK上限值(R) hold_register 10070
63 INT 当前进行组装电池数量(R) hold_register 10072
64 INT 当前完成组装电池数量(R) hold_register 10074
65 REAL 10mm正极片剩余物料数量(R) hold_register 520 HMI地址
66 REAL 12mm正极片剩余物料数量(R) hold_register 522
67 REAL 16mm正极片剩余物料数量(R) hold_register 524
68 REAL 铝箔剩余物料数量(R) hold_register 526
69 REAL 正极壳剩余物料数量(R) hold_register 528
70 REAL 平垫剩余物料数量(R) hold_register 530
71 REAL 负极壳剩余物料数量(R) hold_register 532
72 REAL 弹垫剩余物料数量(R) hold_register 534
73 REAL 成品电池剩余可容纳数量(R) hold_register 536
74 REAL 成品电池NG槽剩余可容纳数量(R) hold_register 538
75
76 REAL 设置10mm正极片厚度(W) hold_register 540
77 REAL 设置12mm正极片厚度(W) hold_register 542
78 REAL 设置16mm正极片厚度(W) hold_register 544
79 REAL 设置铝箔厚度(W) hold_register 546
80 REAL 设置正极壳厚度(W) hold_register 548
81 REAL 设置平垫厚度(W) hold_register 550
82 REAL 设置负极壳厚度(W) hold_register 552
83 REAL 设置弹垫厚度(W) hold_register 554
84 REAL 设置成品电池厚度(W) hold_register 556
85
86
87 REG_DATA_GLOVE_BOX_PRESSURE FLOAT32 手套箱压力 hold_register 10050
88 REG_DATA_GLOVE_BOX_WATER_CONTENT FLOAT32 手套箱水含量 hold_register 10052
89 REG_DATA_GLOVE_BOX_O2_CONTENT FLOAT32 手套箱氧含量 hold_register 10054
90
91 BOOL 异常100-系统异常 coil 1000
92 BOOL 异常101-急停 coil 1010
93 BOOL 异常111-手套箱急停 coil 1110
94 BOOL 异常112-手套箱内光栅遮挡 coil 1120
95 BOOL 异常160-移液枪头缺料 coil 1600
96 BOOL 异常161-正极壳缺料 coil 1610
97 BOOL 异常162-铝箔垫缺料 coil 1620
98 BOOL 异常163-正极片缺料 coil 1630
99 BOOL 异常164-隔膜缺料 coil 1640
100 BOOL 异常165-负极片缺料 coil 1650
101 BOOL 异常166-平垫缺料 coil 1660
102 BOOL 异常167-弹垫缺料 coil 1670
103 BOOL 异常168-负极壳缺料 coil 1680
104 BOOL 异常169-成品电池满料 coil 1690
105 BOOL 异常201-伺服轴01异常 coil 2010
106 BOOL 异常202-伺服轴02异常 coil 2020
107 BOOL 异常203-伺服轴03异常 coil 2030
108 BOOL 异常204-伺服轴04异常 coil 2040
109 BOOL 异常205-伺服轴05异常 coil 2050
110 BOOL 异常206-伺服轴06异常 coil 2060
111 BOOL 异常207-伺服轴07异常 coil 2070
112 BOOL 异常208-伺服轴08异常 coil 2080
113 BOOL 异常209-伺服轴09异常 coil 2090
114 BOOL 异常210-伺服轴10异常 coil 2100
115 BOOL 异常211-伺服轴11异常 coil 2110
116 BOOL 异常212-伺服轴12异常 coil 2120
117 BOOL 异常213-伺服轴13异常 coil 2130
118 BOOL 异常214-伺服轴14异常 coil 2140
119 BOOL 异常250-其他元件异常 coil 2500
120 BOOL 异常251-移液枪通讯异常 coil 2510
121 BOOL 异常252-移液枪报警 coil 2520
122 BOOL 异常256-电爪异常 coil 2560
123 BOOL 异常262-RB报警:未知点位错误 coil 2620
124 BOOL 异常263-RB报警:X、Y、Z参数超限制 coil 2630
125 BOOL 异常264-RB报警:视觉参数误差过大 coil 2640
126 BOOL 异常265-RB报警:1#吸嘴取料失败 coil 2650
127 BOOL 异常266-RB报警:2#吸嘴取料失败 coil 2660
128 BOOL 异常267-RB报警:3#吸嘴取料失败 coil 2670
129 BOOL 异常268-RB报警:4#吸嘴取料失败 coil 2680
130 BOOL 异常269-RB报警:取物料盘失败 coil 2690
131 BOOL 异常280-RB碰撞异常 coil 2800
132 BOOL 异常290-视觉系统通讯异常 coil 2900
133 BOOL 异常291-视觉对位NG异常 coil 2910
134 BOOL 异常292-扫码枪通讯异常 coil 2920
135 BOOL 异常310-开电移载吸嘴吸真空异常 coil 3100
136 BOOL 异常311-开电移载吸嘴破真空异常 coil 3110
137 BOOL 异常312-称重移载吸嘴吸真空异常 coil 3120
138 BOOL 异常313-称重移载吸嘴破真空异常 coil 3130
139 BOOL 异常340-开路电压吸嘴移载气缸异常 coil 3400
140 BOOL 异常342-开路电压吸嘴升降气缸异常 coil 3420
141 BOOL 异常344-开路电压旋压气缸异常 coil 3440
142 BOOL 异常350-称重吸嘴移载气缸异常 coil 3500
143 BOOL 异常352-称重吸嘴升降气缸异常 coil 3520
144 BOOL 异常354-清洗无尘布移载气缸异常 coil 3540
145 BOOL 异常356-清洗无尘布压紧气缸异常 coil 3560
146 BOOL 异常360-电解液瓶定位气缸异常 coil 3600
147 BOOL 异常362-移液枪头盒定位气缸异常 coil 3620
148 COIL ALARM_364_SERVO_DRIVE_ERROR BOOL 异常364-试剂瓶夹爪升降气缸异常 coil 3640
149 COIL ALARM_367_SERVO_DRIVER_ERROR BOOL 异常366-试剂瓶夹爪气缸异常 coil 3660
150 COIL ALARM_370_SERVO_MODULE_ERROR BOOL 异常370-压制模块吹气气缸异常 coil 3700
151
152
153
154
155
156
157
158 COIL + 功能模块/类型 + 具体描述(大写+下划线分隔)--针对bool值
159 REG + 功能模块/类型 + 具体描述(大写+下划线分隔)--针对寄存器

View File

@@ -0,0 +1,130 @@
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
,,,,,coil,8320,
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
,BOOL,,,,coil,8300,
,BOOL,,,,coil,8310,
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
,BOOL,,,,coil,8350,
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
,BOOL,,,,coil,8380,
,BOOL,,,,coil,8390,
,BOOL,,,,coil,8400,
,BOOL,,,,coil,8410,
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警未知点位错误
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警X、Y、Z参数超限制
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警视觉参数误差过大
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警1#吸嘴取料失败
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警2#吸嘴取料失败
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警3#吸嘴取料失败
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警4#吸嘴取料失败
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警取物料盘失败
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
1 Name DataType InitValue Comment Attribute DeviceType Address
2 COIL_SYS_START_CMD BOOL coil 8010
3 COIL_SYS_STOP_CMD BOOL coil 8020
4 COIL_SYS_RESET_CMD BOOL coil 8030
5 COIL_SYS_HAND_CMD BOOL coil 8040
6 COIL_SYS_AUTO_CMD BOOL coil 8050
7 COIL_SYS_INIT_CMD BOOL coil 8060
8 COIL_UNILAB_SEND_MSG_SUCC_CMD BOOL coil 8700
9 COIL_UNILAB_REC_MSG_SUCC_CMD BOOL coil 8710 unilab_rec_msg_succ_cmd
10 COIL_SYS_START_STATUS BOOL coil 8210
11 COIL_SYS_STOP_STATUS BOOL coil 8220
12 COIL_SYS_RESET_STATUS BOOL coil 8230
13 COIL_SYS_HAND_STATUS BOOL coil 8240
14 COIL_SYS_AUTO_STATUS BOOL coil 8250
15 COIL_SYS_INIT_STATUS BOOL coil 8260
16 COIL_REQUEST_REC_MSG_STATUS BOOL coil 8500
17 COIL_REQUEST_SEND_MSG_STATUS BOOL coil 8510 request_send_msg_status
18 REG_MSG_ELECTROLYTE_USE_NUM INT16 hold_register 11000
19 REG_MSG_ELECTROLYTE_NUM INT16 hold_register 11002 unilab_send_msg_electrolyte_num
20 REG_MSG_ELECTROLYTE_VOLUME INT16 hold_register 11004 unilab_send_msg_electrolyte_vol
21 REG_MSG_ASSEMBLY_TYPE INT16 hold_register 11006 unilab_send_msg_assembly_type
22 REG_MSG_ASSEMBLY_PRESSURE INT16 hold_register 11008 unilab_send_msg_assembly_pressure
23 REG_DATA_ASSEMBLY_COIN_CELL_NUM INT16 hold_register 10000 data_assembly_coin_cell_num
24 REG_DATA_OPEN_CIRCUIT_VOLTAGE FLOAT32 hold_register 10002 data_open_circuit_voltage
25 REG_DATA_AXIS_X_POS FLOAT32 hold_register 10004
26 REG_DATA_AXIS_Y_POS FLOAT32 hold_register 10006
27 REG_DATA_AXIS_Z_POS FLOAT32 hold_register 10008
28 REG_DATA_POLE_WEIGHT FLOAT32 hold_register 10010 data_pole_weight
29 REG_DATA_ASSEMBLY_PER_TIME FLOAT32 hold_register 10012 data_assembly_time
30 REG_DATA_ASSEMBLY_PRESSURE INT16 hold_register 10014 data_assembly_pressure
31 REG_DATA_ELECTROLYTE_VOLUME INT16 hold_register 10016 data_electrolyte_volume
32 REG_DATA_COIN_NUM INT16 hold_register 10018 data_coin_num
33 REG_DATA_ELECTROLYTE_CODE STRING hold_register 10020 data_electrolyte_code()
34 REG_DATA_COIN_CELL_CODE STRING hold_register 10030 data_coin_cell_code()
35 REG_DATA_STACK_VISON_CODE STRING hold_register 12004 data_stack_vision_code()
36 REG_DATA_GLOVE_BOX_PRESSURE FLOAT32 hold_register 10050 data_glove_box_pressure
37 REG_DATA_GLOVE_BOX_WATER_CONTENT FLOAT32 hold_register 10052 data_glove_box_water_content
38 REG_DATA_GLOVE_BOX_O2_CONTENT FLOAT32 hold_register 10054 data_glove_box_o2_content
39 UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM BOOL coil 8720
40 UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM BOOL coil 8520
41 REG_MSG_ELECTROLYTE_NUM_USED INT16 hold_register 496
42 REG_DATA_ELECTROLYTE_USE_NUM INT16 hold_register 10000
43 UNILAB_SEND_FINISHED_CMD BOOL coil 8730
44 UNILAB_RECE_FINISHED_CMD BOOL coil 8530
45 REG_DATA_ASSEMBLY_TYPE INT16 hold_register 10018 ASSEMBLY_TYPE7or8
46 REG_UNILAB_INTERACT BOOL coil 8450
47 coil 8320
48 COIL_ALUMINUM_FOIL BOOL coil 8340
49 REG_MSG_NE_PLATE_MATRIX INT16 hold_register 440
50 REG_MSG_SEPARATOR_PLATE_MATRIX INT16 hold_register 450
51 REG_MSG_TIP_BOX_MATRIX INT16 hold_register 480
52 REG_MSG_NE_PLATE_NUM INT16 hold_register 443
53 REG_MSG_SEPARATOR_PLATE_NUM INT16 hold_register 453
54 REG_MSG_PRESS_MODE BOOL coil 8360
55 BOOL coil 8300
56 BOOL coil 8310
57 COIL_GB_L_IGNORE_CMD BOOL coil 8320
58 COIL_GB_R_IGNORE_CMD BOOL coil 8420
59 BOOL coil 8350
60 COIL_ELECTROLYTE_DUAL_DROP_MODE BOOL coil 8370
61 BOOL coil 8380
62 BOOL coil 8390
63 BOOL coil 8400
64 BOOL coil 8410
65 REG_MSG_DUAL_DROP_FIRST_VOLUME INT16 hold_register 4001
66 COIL_DUAL_DROP_SUCTION_TIMING BOOL coil 8430
67 COIL_DUAL_DROP_START_TIMING BOOL coil 8470
68 REG_MSG_BATTERY_CLEAN_IGNORE BOOL coil 8460
69 COIL_ALARM_100_SYSTEM_ERROR BOOL coil 1000 异常100-系统异常
70 COIL_ALARM_101_EMERGENCY_STOP BOOL coil 1010 异常101-急停
71 COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP BOOL coil 1110 异常111-手套箱急停
72 COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED BOOL coil 1120 异常112-手套箱内光栅遮挡
73 COIL_ALARM_160_PIPETTE_TIP_SHORTAGE BOOL coil 1600 异常160-移液枪头缺料
74 COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE BOOL coil 1610 异常161-正极壳缺料
75 COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE BOOL coil 1620 异常162-铝箔垫缺料
76 COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE BOOL coil 1630 异常163-正极片缺料
77 COIL_ALARM_164_SEPARATOR_SHORTAGE BOOL coil 1640 异常164-隔膜缺料
78 COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE BOOL coil 1650 异常165-负极片缺料
79 COIL_ALARM_166_FLAT_WASHER_SHORTAGE BOOL coil 1660 异常166-平垫缺料
80 COIL_ALARM_167_SPRING_WASHER_SHORTAGE BOOL coil 1670 异常167-弹垫缺料
81 COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE BOOL coil 1680 异常168-负极壳缺料
82 COIL_ALARM_169_FINISHED_BATTERY_FULL BOOL coil 1690 异常169-成品电池满料
83 COIL_ALARM_201_SERVO_AXIS_01_ERROR BOOL coil 2010 异常201-伺服轴01异常
84 COIL_ALARM_202_SERVO_AXIS_02_ERROR BOOL coil 2020 异常202-伺服轴02异常
85 COIL_ALARM_203_SERVO_AXIS_03_ERROR BOOL coil 2030 异常203-伺服轴03异常
86 COIL_ALARM_204_SERVO_AXIS_04_ERROR BOOL coil 2040 异常204-伺服轴04异常
87 COIL_ALARM_205_SERVO_AXIS_05_ERROR BOOL coil 2050 异常205-伺服轴05异常
88 COIL_ALARM_206_SERVO_AXIS_06_ERROR BOOL coil 2060 异常206-伺服轴06异常
89 COIL_ALARM_207_SERVO_AXIS_07_ERROR BOOL coil 2070 异常207-伺服轴07异常
90 COIL_ALARM_208_SERVO_AXIS_08_ERROR BOOL coil 2080 异常208-伺服轴08异常
91 COIL_ALARM_209_SERVO_AXIS_09_ERROR BOOL coil 2090 异常209-伺服轴09异常
92 COIL_ALARM_210_SERVO_AXIS_10_ERROR BOOL coil 2100 异常210-伺服轴10异常
93 COIL_ALARM_211_SERVO_AXIS_11_ERROR BOOL coil 2110 异常211-伺服轴11异常
94 COIL_ALARM_212_SERVO_AXIS_12_ERROR BOOL coil 2120 异常212-伺服轴12异常
95 COIL_ALARM_213_SERVO_AXIS_13_ERROR BOOL coil 2130 异常213-伺服轴13异常
96 COIL_ALARM_214_SERVO_AXIS_14_ERROR BOOL coil 2140 异常214-伺服轴14异常
97 COIL_ALARM_250_OTHER_COMPONENT_ERROR BOOL coil 2500 异常250-其他元件异常
98 COIL_ALARM_251_PIPETTE_COMM_ERROR BOOL coil 2510 异常251-移液枪通讯异常
99 COIL_ALARM_252_PIPETTE_ALARM BOOL coil 2520 异常252-移液枪报警
100 COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR BOOL coil 2560 异常256-电爪异常
101 COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR BOOL coil 2620 异常262-RB报警:未知点位错误
102 COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR BOOL coil 2630 异常263-RB报警:X、Y、Z参数超限制
103 COIL_ALARM_264_RB_VISION_PARAM_ERROR BOOL coil 2640 异常264-RB报警:视觉参数误差过大
104 COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL BOOL coil 2650 异常265-RB报警:1#吸嘴取料失败
105 COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL BOOL coil 2660 异常266-RB报警:2#吸嘴取料失败
106 COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL BOOL coil 2670 异常267-RB报警:3#吸嘴取料失败
107 COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL BOOL coil 2680 异常268-RB报警:4#吸嘴取料失败
108 COIL_ALARM_269_RB_TRAY_PICK_FAIL BOOL coil 2690 异常269-RB报警:取物料盘失败
109 COIL_ALARM_280_RB_COLLISION_ERROR BOOL coil 2800 异常280-RB碰撞异常
110 COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR BOOL coil 2900 异常290-视觉系统通讯异常
111 COIL_ALARM_291_VISION_ALIGNMENT_NG BOOL coil 2910 异常291-视觉对位NG异常
112 COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR BOOL coil 2920 异常292-扫码枪通讯异常
113 COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3100 异常310-开电移载吸嘴吸真空异常
114 COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3110 异常311-开电移载吸嘴破真空异常
115 COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3120 异常312-称重移载吸嘴吸真空异常
116 COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3130 异常313-称重移载吸嘴破真空异常
117 COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3400 异常340-开路电压吸嘴移载气缸异常
118 COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3420 异常342-开路电压吸嘴升降气缸异常
119 COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR BOOL coil 3440 异常344-开路电压旋压气缸异常
120 COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3500 异常350-称重吸嘴移载气缸异常
121 COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3520 异常352-称重吸嘴升降气缸异常
122 COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR BOOL coil 3540 异常354-清洗无尘布移载气缸异常
123 COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR BOOL coil 3560 异常356-清洗无尘布压紧气缸异常
124 COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR BOOL coil 3600 异常360-电解液瓶定位气缸异常
125 COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR BOOL coil 3620 异常362-移液枪头盒定位气缸异常
126 COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR BOOL coil 3640 异常364-试剂瓶夹爪升降气缸异常
127 COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR BOOL coil 3660 异常366-试剂瓶夹爪气缸异常
128 COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR BOOL coil 3700 异常370-压制模块吹气气缸异常
129 COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR BOOL coil 1510 异常151-电解液瓶定位在籍异常
130 COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR BOOL coil 1520 异常152-电解液瓶盖在籍异常

View File

@@ -0,0 +1,2 @@
Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code
20251224_172304,-5.537573695435827e-37,-48.45097351074219,1.372190511464448e+16,3820,30,7,b'\x00\x00d\x00eaoR',b'\x00\x00\x01\x00\x00\x00\r\n'
1 Time open_circuit_voltage pole_weight assembly_time assembly_pressure electrolyte_volume coin_num electrolyte_code coin_cell_code
2 20251224_172304 -5.537573695435827e-37 -48.45097351074219 1.372190511464448e+16 3820 30 7 b'\x00\x00d\x00eaoR' b'\x00\x00\x01\x00\x00\x00\r\n'

View File

@@ -0,0 +1,2 @@
Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code
20251225_105600,5.566961054206384e-37,-53149746331648.0,3271557120.0,3658,10,7,b'\x00\x00d\x00eaoR',b'\x00\x00\x01\x00\x00\x00\r\n'
1 Time open_circuit_voltage pole_weight assembly_time assembly_pressure electrolyte_volume coin_num electrolyte_code coin_cell_code
2 20251225_105600 5.566961054206384e-37 -53149746331648.0 3271557120.0 3658 10 7 b'\x00\x00d\x00eaoR' b'\x00\x00\x01\x00\x00\x00\r\n'

View File

@@ -0,0 +1,2 @@
Time,open_circuit_voltage,pole_weight,assembly_time,assembly_pressure,electrolyte_volume,coin_num,electrolyte_code,coin_cell_code
20251229_161836,-5.537573695435827e-37,8.919000478163591e+20,-3.806253867691382e-29,3544,20,7,b'\x00\x00d\x00eaoR',b'\x00\x00\x01\x00\x00\x00\r\n'
1 Time open_circuit_voltage pole_weight assembly_time assembly_pressure electrolyte_volume coin_num electrolyte_code coin_cell_code
2 20251229_161836 -5.537573695435827e-37 8.919000478163591e+20 -3.806253867691382e-29 3544 20 7 b'\x00\x00d\x00eaoR' b'\x00\x00\x01\x00\x00\x00\r\n'

Some files were not shown because too many files have changed in this diff Show More